diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2cf7da3 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +branch = True + +[report] +ignore_errors = True +include = side_effects/* diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 9497167..49c22d4 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -60,14 +60,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12"] # build LTS version, next version, HEAD django: ["32", "42", "50", "main"] exclude: - - python: "3.8" - django: "50" - - python: "3.8" - django: "main" - python: "3.9" django: "50" - python: "3.9" diff --git a/.gitignore b/.gitignore index 7ff0fc3..53657e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .coverage .tox .venv +.vscode *.egg-info *.lock *.pyc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index def9258..a4dbc43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,15 @@ repos: - # python code formatting - will amend files - - repo: https://github.com/ambv/black - rev: 23.10.1 - hooks: - - id: black - - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: "v0.1.5" + rev: "v0.2.1" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format # python static type checking - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.7.0 + rev: v1.8.0 hooks: - id: mypy args: diff --git a/.ruff.toml b/.ruff.toml index ccc1947..8ecd3b5 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,5 +1,5 @@ line-length = 88 -ignore = [ +lint.ignore = [ "D100", # Missing docstring in public module "D101", # Missing docstring in public class "D102", # Missing docstring in public method @@ -22,7 +22,7 @@ ignore = [ "D417", "D417", # Missing argument description in the docstring ] -select = [ +lint.select = [ "A", # flake8 builtins "C9", # mcabe "D", # pydocstyle @@ -34,13 +34,13 @@ select = [ "W", # pycodestype (warnings) ] -[isort] +[lint.isort] combine-as-imports = true -[mccabe] +[lint.mccabe] max-complexity = 8 -[per-file-ignores] +[lint.per-file-ignores] "*tests/*" = [ "D205", # 1 blank line required between summary line and description "D400", # First line should end with a period diff --git a/pyproject.toml b/pyproject.toml index 2eb42f0..1c91476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ classifiers = [ "Framework :: Django :: 5.0", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -26,11 +25,10 @@ classifiers = [ packages = [{ include = "side_effects" }] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" django = "^3.2 || ^4.0 || ^5.0 " [tool.poetry.dev-dependencies] -black = "*" coverage = "*" mypy = "*" pre-commit = "*" diff --git a/side_effects/decorators.py b/side_effects/decorators.py index 4abdb8a..c63ee5c 100644 --- a/side_effects/decorators.py +++ b/side_effects/decorators.py @@ -6,7 +6,6 @@ from django.http import HttpResponse from . import registry -from .settings import TEST_MODE def http_response_check(response: HttpResponse) -> bool: @@ -63,14 +62,7 @@ def inner_func(*args: Any, **kwargs: Any) -> Any: # if the exit test fails we go no further if not run_on_exit(return_value): return return_value - # when in TEST_MODE do not defer the side-effects, as they - # are never fired anyway. - registry_func = ( - registry.run_side_effects - if TEST_MODE - else registry.run_side_effects_on_commit - ) - registry_func(label, *args, return_value=return_value, **kwargs) + registry.run_side_effects(label, *args, return_value=return_value, **kwargs) return return_value return inner_func diff --git a/side_effects/registry.py b/side_effects/registry.py index f17a0e8..24afb88 100644 --- a/side_effects/registry.py +++ b/side_effects/registry.py @@ -31,11 +31,6 @@ def docstring(func: Callable) -> list[str] | None: return None -class SideEffectsTestFailure(Exception): - def __init__(self, label: str): - super().__init__(f"Side-effects for '{label}' aborted; TEST_MODE_FAIL=True") - - class SignatureMismatch(Exception): def __init__(self, func: Callable): super().__init__( @@ -116,52 +111,12 @@ def disable(self) -> None: def enable(self) -> None: self._suppress = False - 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: - """ - Run registered side-effects functions, or suppress as appropriate. - - If TEST_MODE is on, or the _suppress attr is True, then the side-effects - are not run, but the `suppressed_side_effect` signal is sent - this is - 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.is_suppressed: - 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. - - """ + """Run all registered side-effects functions.""" 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) + _run_func(func, *args, return_value=return_value, **kwargs) class disable_side_effects: @@ -177,8 +132,7 @@ class disable_side_effects: """ def __init__(self) -> None: - self.events = [] # type: List[str] - pass + self.events: list[str] = [] def __enter__(self) -> list[str]: _registry.suppressed_side_effect.connect(self.on_event, dispatch_uid="suppress") @@ -204,22 +158,20 @@ def run_side_effects( label: str, *args: Any, return_value: Any | None = None, **kwargs: Any ) -> None: """Run all of the side-effect functions registered for a label.""" - _registry.run_side_effects(label, *args, return_value=return_value, **kwargs) - - -def run_side_effects_on_commit( - label: str, *args: Any, return_value: Any | None = None, **kwargs: Any -) -> None: - """Run all of the side-effects after current transaction on_commit.""" - transaction.on_commit( - partial( - _registry.run_side_effects, - label, - *args, - return_value=return_value, - **kwargs, + # if the registry is suppressed we are inside disable_side_effects, + # so we send the signal and return early. + if _registry.is_suppressed: + _registry.suppressed_side_effect.send(Registry, label=label) + else: + transaction.on_commit( + partial( + _registry.run_side_effects, + label, + *args, + return_value=return_value, + **kwargs, + ) ) - ) def _run_func( @@ -239,7 +191,7 @@ def _run_func( 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: + if settings.ABORT_ON_ERROR: raise diff --git a/side_effects/settings.py b/side_effects/settings.py index fa74018..99f5046 100644 --- a/side_effects/settings.py +++ b/side_effects/settings.py @@ -17,9 +17,3 @@ def get_setting(setting_name: str, default_value: Any) -> Any: # In test mode no side-effects are run # Default = False TEST_MODE: bool = get_setting("SIDE_EFFECTS_TEST_MODE", False) - -# In FAIL test mode any call to run_side_effects will raise an Exception -# This is used to uncover any tests that are running side-effects when -# they shouldn't be. -# Default = False -TEST_MODE_FAIL: bool = get_setting("SIDE_EFFECTS_TEST_MODE_FAIL", False) diff --git a/side_effects/signals.py b/side_effects/signals.py new file mode 100644 index 0000000..6773189 --- /dev/null +++ b/side_effects/signals.py @@ -0,0 +1,7 @@ +from django.dispatch import Signal + +# if using the disable_side_effects context manager or decorator, +# then this signal is used to communicate details of events that +# would have fired, but have been suppressed. +# RemovedInDjango40Warning: providing_args=["label"] +suppressed_side_effect = Signal() diff --git a/tests/test_checks.py b/tests/test_checks.py index 0db3842..09d35f1 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -1,17 +1,19 @@ +from typing import Any + from django.test import TestCase from side_effects import checks, registry class SystemCheckTests(TestCase): - def test_multiple_functions(self): - def foo(): + def test_multiple_functions(self) -> None: + def foo() -> None: pass - def bar(): + def bar() -> None: pass - def baz(arg1): + def baz(arg1: Any) -> None: pass registry._registry.clear() diff --git a/tests/test_decorators.py b/tests/test_decorators.py index a741ef1..614931b 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,21 +1,20 @@ +from typing import Any from unittest import mock import pytest -from django.db import transaction from django.test import TestCase from side_effects import decorators, registry from side_effects.decorators import has_side_effects -from side_effects.registry import disable_side_effects class DecoratorTests(TestCase): """Tests for the decorators module.""" - def setUp(self): + def setUp(self) -> None: registry._registry.clear() - def test_http_response_check(self): + def test_http_response_check(self) -> None: """Test the HTTP response check rejects 4xx, 5xx status_codes.""" response = decorators.HttpResponse(status=200) self.assertTrue(decorators.http_response_check(response)) @@ -29,25 +28,25 @@ def test_http_response_check(self): self.assertTrue(decorators.http_response_check(response)) @mock.patch("side_effects.decorators.registry") - def test_has_side_effects(self, mock_registry): + def test_has_side_effects(self, mock_registry: mock.Mock) -> None: """Decorated functions should call run_side_effects.""" # call the decorator directly - then call the decorated function # as the action takes places post-function call. - def test_func(arg1: int): + def test_func(arg1: int) -> int: return arg1 * 2 func = decorators.has_side_effects("foo")(test_func) self.assertEqual(func(1), 2) - mock_registry.run_side_effects_on_commit.assert_called_with( - "foo", 1, return_value=2 - ) + 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): + def test_has_side_effects__run_on_exit_false( + self, mock_registry: mock.Mock + ) -> None: """Decorated functions should call run_side_effects.""" - def test_func(*args, **kwargs): + def test_func(*args: Any, **kwargs: Any) -> None: pass func = decorators.has_side_effects("foo", run_on_exit=lambda r: False)( @@ -57,10 +56,10 @@ def test_func(*args, **kwargs): 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): + def test_is_side_effect_of(self, mock_register: mock.Mock) -> Any: """Decorated functions should be added to the registry.""" - def test_func(arg1, arg2): + def test_func(arg1: Any, arg2: Any) -> None: return arg1 + arg2 # call the decorator directly - no need to call the decorated @@ -71,9 +70,9 @@ def test_func(arg1, arg2): self.assertEqual(func(1, 2), 3) @decorators.disable_side_effects() - def test_disable_side_effects(self, events): + def test_disable_side_effects(self, events: list[str]) -> None: # simple func that calls the side-effect 'foo' - def test_func(): + def test_func() -> None: registry.run_side_effects("foo") registry.register_side_effect("foo", test_func) @@ -84,121 +83,14 @@ def test_func(): self.assertEqual(events, ["foo", "foo"]) @mock.patch("side_effects.decorators.registry") - def test_disable_on_error(self, mock_registry): + def test_disable_on_error(self, mock_registry: mock.Mock) -> None: """Check that run_side_effects is not called on error.""" @has_side_effects("foo") - def foo(): + def foo() -> None: raise Exception("HELP") with pytest.raises(Exception): foo() assert mock_registry.run_side_effects.call_count == 0 - - def test_transaction_on_commit(self): - """Test the transaction awareness of the decorator.""" - - @has_side_effects("foo") - @transaction.atomic - def inner_func() -> None: - conn = transaction.get_connection() - assert conn.in_atomic_block - - # calling inner_func directly will trigger has_side_effects, which - # will defer the run_side_effects_on_commit call by passing it to - # the transaction.on_commit function. - with TestCase.captureOnCommitCallbacks() as callbacks: - inner_func() - assert len(callbacks) == 1 - assert callbacks[0].func == registry._registry.run_side_effects - assert callbacks[0].args == ("foo",) - assert callbacks[0].keywords == {"return_value": None} - - -@pytest.mark.django_db(transaction=True) -class TestDecoratorTransactions: - """ - Test the commit / rollback scenarios that impact side-effects. - - When using the has_side_effects decorator on a function, the - side-effects should only fire if the transaction within which the - decorated function is operating is committed. If the source - (decorated) function completes, but the calling function (outside - the decorated function) fails, then the transaction is aborted and - the side-effects should *not* fire. - - This test class is deliberately not parametrized as readability - trumps efficiency in this case. This is a hard one to follow. - - """ - - @has_side_effects("foo") - @transaction.atomic - def inner_commit(self) -> None: - """Run the side-effects as expected.""" - assert transaction.get_connection().in_atomic_block - - @has_side_effects("foo") - @transaction.atomic - def inner_rollback(self) -> None: - """Rollback the source (inner) function - side-effects should *not* fire.""" - raise Exception("Rolling back inner transaction") - - @transaction.atomic - def outer_commit(self) -> None: - """Commit the outer function - side-effects should fire.""" - self.inner_commit() - - @transaction.atomic - def outer_rollback(self) -> None: - """Rollback outer function - side-effects should *not* fire.""" - self.inner_commit() - raise Exception("Rolling back outer transaction") - - def test_inner_func_commit(self) -> None: - with disable_side_effects() as events: - self.inner_commit() - assert events == ["foo"] - - def test_outer_func_commit(self) -> None: - with disable_side_effects() as events: - self.outer_commit() - assert events == ["foo"] - - def test_inner_func_rollback(self) -> None: - with disable_side_effects() as events: - with pytest.raises(Exception): - self.inner_rollback() - assert events == [] - - def test_outer_func_rollback(self) -> None: - with disable_side_effects() as events: - with pytest.raises(Exception): - self.outer_rollback() - assert events == [] - - -class ContextManagerTests(TestCase): - @mock.patch("side_effects.registry._run_func") - def test_disable_side_effects(self, mock_func): - """Side-effects can be temporarily disabled.""" - - def test_func(): - pass - - registry._registry.clear() - registry.register_side_effect("foo", test_func) - - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 1) - - # shouldn't get another call inside the CM - with registry.disable_side_effects() as events: - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 1) - self.assertEqual(events, ["foo"]) - - # re-enabled - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 2) diff --git a/tests/test_display_side_effects.py b/tests/test_display_side_effects.py index 79473b0..8d19663 100644 --- a/tests/test_display_side_effects.py +++ b/tests/test_display_side_effects.py @@ -7,13 +7,13 @@ class SortEventsTests(TestCase): def test_sort_events(self) -> None: - def handler_zero(): + def handler_zero() -> None: """Docstring 0.""" - def handler_one(): + def handler_one() -> None: """Docstring 1.""" - def handler_two(): + def handler_two() -> None: """Docstring 2.""" events = { diff --git a/tests/test_registry.py b/tests/test_registry.py index 5f5c058..961a24c 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,3 +1,4 @@ +from typing import Any from unittest import mock from django.test import TestCase @@ -8,29 +9,29 @@ class RegistryFunctionTests(TestCase): """Test the free functions in the registry module.""" - def setUp(self): + def setUp(self) -> None: registry._registry.clear() - def test_fname(self): + def test_fname(self) -> None: self.assertEqual( # wait, what? registry.fname(registry.fname), "side_effects.registry.fname", ) - def test_docstring(self): - def test_func_no_docstring(arg1): + def test_docstring(self) -> None: + def test_func_no_docstring(arg1: Any) -> None: pass - def test_func_one_line(*args): + def test_func_one_line(*args: Any) -> None: """This is a one line docstring.""" return sum(args) - def test_func_one_line_2(): + def test_func_one_line_2() -> None: """This is also a one line docstring.""" pass - def test_func_multi_line(): + def test_func_multi_line() -> None: """ This is a multi-line docstring. @@ -51,20 +52,20 @@ def test_func_multi_line(): ["This is a multi-line docstring.", "", "It has multiple lines."], ) - def test_try_bind__with_return_value(self): - def foo1(return_value): + def test_try_bind__with_return_value(self) -> None: + def foo1(return_value: Any) -> None: pass - def foo2(arg1, return_value): + def foo2(arg1: Any, return_value: Any) -> None: pass - def foo3(*args, return_value): + def foo3(*args: Any, return_value: Any) -> None: pass - def foo4(return_value, **kwargs): + def foo4(return_value: Any, **kwargs: Any) -> None: pass - def foo5(arg1, **kwargs): + def foo5(arg1: Any, **kwargs: Any) -> None: pass self.assertTrue(registry.try_bind(foo1, return_value=1)) @@ -73,25 +74,25 @@ def foo5(arg1, **kwargs): self.assertTrue(registry.try_bind(foo4, bar="baz", return_value=1)) self.assertTrue(registry.try_bind(foo5, 1, return_value=1)) - def test_try_bind__without_return_value(self): - def foo1(): + def test_try_bind__without_return_value(self) -> None: + def foo1() -> None: pass - def foo2(arg1): + def foo2(arg1: Any) -> None: pass - def foo3(*args): + def foo3(*args: Any) -> None: pass self.assertFalse(registry.try_bind(foo1, return_value=1)) self.assertFalse(registry.try_bind(foo2, 1, return_value=1)) self.assertFalse(registry.try_bind(foo3, 1, 2, 3, return_value=1)) - def test_register_side_effect(self): - def test_func1(): + def test_register_side_effect(self) -> None: + def test_func1() -> None: pass - def test_func2(): + def test_func2() -> None: pass registry.register_side_effect("foo", test_func1) @@ -101,57 +102,29 @@ def test_func2(): registry.register_side_effect("foo", test_func1) self.assertTrue(registry._registry.contains("foo", test_func1)) - @mock.patch("side_effects.registry._run_func") - def test_run_side_effects(self, mock_func): - def test_func1(): - pass - - def test_func2(): - pass - - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 0) - - mock_func.reset_mock() - registry.register_side_effect("foo", test_func1) - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 1) - - mock_func.reset_mock() - registry.register_side_effect("foo", test_func2) - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 2) - - mock_func.reset_mock() - with mock.patch("side_effects.settings.TEST_MODE", True): - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 0) - with mock.patch("side_effects.settings.TEST_MODE", False): - registry.run_side_effects("foo") - self.assertEqual(mock_func.call_count, 2) - - @mock.patch("side_effects.registry.settings.TEST_MODE_FAIL", True) - def test_run_side_effects__test_mode_fail(self): - def test_func(): - pass + @mock.patch("side_effects.registry.settings.TEST_MODE", False) + def test_run_side_effects(self) -> None: + def test_func(x: list) -> None: + x.append("foo") + assert registry._registry.is_suppressed is False registry.register_side_effect("foo", test_func) - self.assertRaises( - registry.SideEffectsTestFailure, registry.run_side_effects, "foo" - ) + x: list[str] = [] + registry._registry.run_side_effects("foo", x) + assert x == ["foo"] - def test__run_func__no_return_value(self): + def test__run_func__no_return_value(self) -> None: """Test the _run_func function does not pass return_value if not required.""" - def test_func(): + def test_func() -> None: pass registry._run_func(test_func, return_value=None) - def test__run_func__with_return_value(self): + def test__run_func__with_return_value(self) -> None: """Test the _run_func function passes through the return_value if required.""" - def test_func(**kwargs): + def test_func(**kwargs: Any) -> None: assert "return_value" in kwargs # return_value not passed through, so fails @@ -159,10 +132,10 @@ def test_func(**kwargs): # self.assertRaises(KeyError, registry._run_func, test_func) registry._run_func(test_func, return_value=None) - def test__run_func__aborts_on_error(self): + def test__run_func__aborts_on_error(self) -> None: """Test the _run_func function handles ABORT_ON_ERROR correctly.""" - def test_func(): + def test_func() -> None: raise Exception("Pah") # error is logged, but not raised @@ -175,10 +148,10 @@ def test_func(): self.assertTrue(settings.ABORT_ON_ERROR) self.assertRaises(Exception, registry._run_func, test_func) - def test__run_func__signature_mismatch(self): + def test__run_func__signature_mismatch(self) -> None: """Test the _run_func function always raises SignatureMismatch.""" - def test_func(): + def test_func() -> None: raise Exception("Pah") with mock.patch.object(settings, "ABORT_ON_ERROR", False): @@ -190,10 +163,10 @@ def test_func(): class RegistryTests(TestCase): """Tests for the registry module.""" - def test_registry_add_contains(self): + def test_registry_add_contains(self) -> None: """Check that add and contains functions work together.""" - def test_func(): + def test_func() -> None: pass r = registry.Registry() @@ -201,8 +174,8 @@ def test_func(): r.add("foo", test_func) self.assertTrue(r.contains("foo", test_func)) - def test_by_label(self): - def test_func(): + def test_by_label(self) -> None: + def test_func() -> None: pass r = registry.Registry() @@ -211,8 +184,8 @@ def test_func(): self.assertEqual(r.by_label("foo"), {"foo": [test_func]}) self.assertEqual(r.by_label("bar"), {}) - def test_by_label_contains(self): - def test_func(): + def test_by_label_contains(self) -> None: + def test_func() -> None: pass r = registry.Registry() @@ -222,50 +195,3 @@ def test_func(): self.assertEqual(r.by_label_contains("fo"), {"foo": [test_func]}) self.assertEqual(r.by_label_contains("foo"), {"foo": [test_func]}) self.assertEqual(r.by_label_contains("food"), {}) - - @mock.patch("side_effects.registry._run_func") - def test__run_side_effects__no_return_value(self, mock_run): - """Test return_value is not passed""" - - def no_return_value(*args, **kwargz): - assert "return_value" not in kwargz - - r = registry.Registry() - r.add("foo", no_return_value) - r._run_side_effects("foo") - r._run_side_effects("foo", return_value=None) - - def test__run_side_effects__with_return_value(self): - """Test return_value is passed""" - r = registry.Registry() - - def has_return_value(*args, **kwargs): - assert "return_value" in kwargs - - r.add("foo", has_return_value) - r._run_side_effects("foo", return_value=None) - - def test_try_bind_all(self): - def foo1(return_value): - pass - - def foo2(arg1, return_value): - pass - - def foo3(*args, return_value): - pass - - def foo4(return_value, **kwargs): - pass - - def foo5(arg1, **kwargs): - pass - - r = registry.Registry() - r.add("foo", foo1) - r.add("foo", foo2) - r.add("foo", foo3) - r.add("foo", foo4) - r.add("foo", foo5) - r.try_bind_all("foo", 1) - self.assertRaises(registry.SignatureMismatch, r.try_bind_all, "foo", 1, 2) diff --git a/tests/test_run_side_effects.py b/tests/test_run_side_effects.py new file mode 100644 index 0000000..6cdc359 --- /dev/null +++ b/tests/test_run_side_effects.py @@ -0,0 +1,38 @@ +from django.test import TestCase + +from side_effects import registry + + +class TestRunSideEffects(TestCase): + """ + Test the run_side_effects function. + + This uses TestCase as we are using transaction.on_commit internally + and the easiest way to test this is with the captureOnCommitCallbacks + method. + + """ + + def test_run_side_effects__on_commit(self) -> None: + def foo() -> None: + pass + + registry._registry.clear() + registry.register_side_effect("foo", foo) + with self.captureOnCommitCallbacks() as callbacks: + registry.run_side_effects("foo") + assert callbacks[0].func == registry._registry.run_side_effects + assert callbacks[0].args == ("foo",) + assert callbacks[0].keywords == {"return_value": None} + + def test_run_side_effects__suppressed(self) -> None: + def foo() -> None: + pass + + registry._registry.clear() + registry.register_side_effect("foo", foo) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + with registry.disable_side_effects() as events: + registry.run_side_effects("foo") + assert events == ["foo"] + assert len(callbacks) == 0 diff --git a/tests/test_transactions.py b/tests/test_transactions.py new file mode 100644 index 0000000..b513a0c --- /dev/null +++ b/tests/test_transactions.py @@ -0,0 +1,127 @@ +import pytest +from django.core import mail +from django.db import transaction +from django.test import TestCase, TransactionTestCase + +from side_effects import registry +from side_effects.decorators import has_side_effects + + +def email_side_effect() -> None: + """Dummy function to simulate a side-effect.""" + mail.send_mail( + "Subject", + "message.", + "from@example.com", + ["to@example.com"], + fail_silently=False, + ) + + +class TestTransactionOnCommit(TestCase): + """ + Test the commit / rollback scenarios that impact side-effects. + + When using the has_side_effects decorator on a function, the + side-effects should only fire if the transaction within which the + decorated function is operating is committed. If the source + (decorated) function completes, but the calling function (outside + the decorated function) fails, then the transaction is aborted and + the side-effects should *not* fire. + + This test class is deliberately not parametrized as readability + trumps efficiency in this case. This is a hard one to follow. + + """ + + @has_side_effects("foo") + @transaction.atomic + def inner_commit(self) -> None: + """Run the side-effects as expected.""" + assert transaction.get_connection().in_atomic_block + + @has_side_effects("foo") + @transaction.atomic + def inner_rollback(self) -> None: + """Rollback the source (inner) function - side-effects should *not* fire.""" + raise Exception("Rolling back inner transaction") + + @transaction.atomic + def outer_commit(self) -> None: + """Commit the outer function - side-effects should fire.""" + self.inner_commit() + + @transaction.atomic + def outer_rollback(self) -> None: + """Rollback outer function - side-effects should *not* fire.""" + self.inner_commit() + raise Exception("Rolling back outer transaction") + + def test_inner_func_commit(self) -> None: + with self.captureOnCommitCallbacks() as callbacks: + self.inner_commit() + assert len(callbacks) == 1 + assert callbacks[0].func == registry._registry.run_side_effects + assert callbacks[0].args == ("foo", self) + assert callbacks[0].keywords == {"return_value": None} + + def test_outer_func_commit(self) -> None: + with self.captureOnCommitCallbacks() as callbacks: + self.outer_commit() + assert len(callbacks) == 1 + assert callbacks[0].func == registry._registry.run_side_effects + assert callbacks[0].args == ("foo", self) + assert callbacks[0].keywords == {"return_value": None} + + def test_inner_func_rollback(self) -> None: + with self.captureOnCommitCallbacks() as callbacks: + with pytest.raises(Exception): + self.inner_rollback() + assert callbacks == [] + + def test_outer_func_rollback(self) -> None: + with self.captureOnCommitCallbacks() as callbacks: + with pytest.raises(Exception): + self.outer_rollback() + assert callbacks == [] + + def test_on_commit__rollback(self) -> None: + registry._registry.clear() + registry.register_side_effect("foo", email_side_effect) + try: + mail.outbox = [] + with transaction.atomic(): + # defers the call to Registry.run_side_effects until + # the transaction is committed. + registry.run_side_effects("foo") + # this will cause the transaction to rollback, and + # therefore the side-effect should not fire. + raise Exception("Rolling back transaction") + except Exception: + assert len(mail.outbox) == 0 + + +class TestDisableSideEffectsOnCommit(TransactionTestCase): + """Integration test for the disable_side_effects context manager.""" + + def test_outbox_expected(self) -> None: + registry._registry.clear() + registry.register_side_effect("foo", email_side_effect) + with transaction.atomic(): + mail.outbox = [] + registry.run_side_effects("foo") + # we are still inside the transaction, so the side-effect + # should not have fired yet. + assert len(mail.outbox) == 0 + # now we are outside the transaction, so the side-effect should + # have fired. + assert len(mail.outbox) == 1 + + def test_disable_side_effects(self) -> None: + registry._registry.clear() + registry.register_side_effect("foo", email_side_effect) + with registry.disable_side_effects() as events: + mail.outbox = [] + registry.run_side_effects("foo") + assert events == ["foo"] + assert len(mail.outbox) == 0 diff --git a/tox.ini b/tox.ini index 4c663f9..a37926f 100644 --- a/tox.ini +++ b/tox.ini @@ -4,10 +4,10 @@ envlist = fmt, lint, mypy, django-checks, ; https://docs.djangoproject.com/en/5.0/releases/ - django32-py{38,39,310} - django40-py{38,39,310} - django41-py{38,39,310,311} - django42-py{38,39,310,311} + django32-py{39,310} + django40-py{39,310} + django41-py{39,310,311} + django42-py{39,310,311} django50-py{310,311,312} djangomain-py{311,312} @@ -35,12 +35,12 @@ commands = python manage.py makemigrations --dry-run --check --verbosity 3 [testenv:fmt] -description = Python source code formatting (black) +description = Python source code formatting (ruff) deps = - black + ruff commands = - black --check side_effects + ruff format side_effects [testenv:lint] description = Python source code linting (ruff)