From 323e46e174e1b35791fe2a5dd2c98b15b349e01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=82=85=E7=AB=8B=E4=B8=9A=EF=BC=88Chris=20Fu=EF=BC=89?= <17433201@qq.com> Date: Fri, 6 Sep 2024 18:37:22 +0800 Subject: [PATCH 1/3] Rework almost everything --- .gitignore | 4 + SPEC.md | 185 -------- deferrer/__init__.py | 5 +- deferrer/__public__.py | 9 + deferrer/{_support => }/_code_location.py | 0 deferrer/{_defer/_sugarful.py => _defer.py} | 134 +++++- deferrer/_defer/__init__.py | 1 - deferrer/_defer/_mixed.py | 49 -- deferrer/_defer/_mixed_test.py | 186 -------- deferrer/_defer/_sugarful_test.py | 216 --------- .../_sugarful_test__used_in_global_scope.py | 8 - deferrer/_defer/_sugarless.py | 111 ----- deferrer/_defer/_sugarless_test.py | 243 ---------- .../_sugarless_test__used_in_global_scope.py | 8 - deferrer/_defer_scope.py | 218 +++++++++ deferrer/_deferred_actions.py | 60 +++ deferrer/_frame.py | 136 ++++++ deferrer/{_support => }/_opcode.py | 0 deferrer/_scope/__init__.py | 1 - deferrer/_scope/_context.py | 78 ---- deferrer/_scope/_context_test.py | 41 -- deferrer/_scope/_core.py | 37 -- deferrer/_scope/_core_test.py | 43 -- deferrer/_scope/_iterable.py | 46 -- deferrer/_scope/_iterable_test.py | 32 -- deferrer/_support/__init__.py | 4 - deferrer/_support/_deferred_calls.py | 92 ---- deferrer/_support/_frame.py | 95 ---- deferrer_tests/defer.py | 426 ++++++++++++++++++ deferrer_tests/defer_scope.py | 110 +++++ .../samples/sugarful_with_defer_scope.py | 5 + .../samples/sugarful_without_defer_scope.py | 4 + .../samples/sugarless_with_defer_scope.py | 8 + .../samples/sugarless_without_defer_scope.py | 4 + pyproject.toml | 15 +- setup.py | 5 + 36 files changed, 1114 insertions(+), 1505 deletions(-) delete mode 100644 SPEC.md create mode 100644 deferrer/__public__.py rename deferrer/{_support => }/_code_location.py (100%) rename deferrer/{_defer/_sugarful.py => _defer.py} (52%) delete mode 100644 deferrer/_defer/__init__.py delete mode 100644 deferrer/_defer/_mixed.py delete mode 100644 deferrer/_defer/_mixed_test.py delete mode 100644 deferrer/_defer/_sugarful_test.py delete mode 100644 deferrer/_defer/_sugarful_test__used_in_global_scope.py delete mode 100644 deferrer/_defer/_sugarless.py delete mode 100644 deferrer/_defer/_sugarless_test.py delete mode 100644 deferrer/_defer/_sugarless_test__used_in_global_scope.py create mode 100644 deferrer/_defer_scope.py create mode 100644 deferrer/_deferred_actions.py create mode 100644 deferrer/_frame.py rename deferrer/{_support => }/_opcode.py (100%) delete mode 100644 deferrer/_scope/__init__.py delete mode 100644 deferrer/_scope/_context.py delete mode 100644 deferrer/_scope/_context_test.py delete mode 100644 deferrer/_scope/_core.py delete mode 100644 deferrer/_scope/_core_test.py delete mode 100644 deferrer/_scope/_iterable.py delete mode 100644 deferrer/_scope/_iterable_test.py delete mode 100644 deferrer/_support/__init__.py delete mode 100644 deferrer/_support/_deferred_calls.py delete mode 100644 deferrer/_support/_frame.py create mode 100644 deferrer_tests/defer.py create mode 100644 deferrer_tests/defer_scope.py create mode 100644 deferrer_tests/samples/sugarful_with_defer_scope.py create mode 100644 deferrer_tests/samples/sugarful_without_defer_scope.py create mode 100644 deferrer_tests/samples/sugarless_with_defer_scope.py create mode 100644 deferrer_tests/samples/sugarless_without_defer_scope.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 3df8576..975c0e9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ # Coverage reports. /.coverage /coverage.* + +# Building artifacts. +/dist +*.egg-info \ No newline at end of file diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index dda299a..0000000 --- a/SPEC.md +++ /dev/null @@ -1,185 +0,0 @@ -# Specifications - -## `defer` can be used in two forms - -There are two ways to use `defer`. One is called _sugarful_ and the other is called _sugarless_. - -A typical _sugarful_ usage is like - -```python ->>> from deferrer import defer - ->>> def f(): -... defer and print(0) -... print(1) - ->>> f() -1 -0 - -``` - -A typical _sugarless_ usage is like - -```python ->>> from deferrer import defer - ->>> def f(): -... defer(print)(0) -... print(1) - ->>> f() -1 -0 - -``` - -You may use either of them, or mix them up. - -```python ->>> from deferrer import defer - ->>> def f(): -... defer and print(0) -... defer(print)(1) -... print(2) - ->>> f() -2 -1 -0 - -``` - -## `defer` can only be used in functions - -The implementation of `defer` relies on the fact that the local scope, along with some temporary objects we put in it, will eventually be released when the function ends. - -It is never the same case for a global scope or a class scope, whereas a global scope will nearly never get disposed and objects in a class scope are copied into the class and therefore retained. - -As a prevention, when `defer` is (incorrectly) used in a global scope or a class scope, a `RuntimeError` is raised. - -```python ->>> from deferrer import defer - ->>> defer and print() -Traceback (most recent call last): - ... -RuntimeError: ... - ->>> defer(print)() -Traceback (most recent call last): - ... -RuntimeError: ... - ->>> class C: -... defer and print() -Traceback (most recent call last): - ... -RuntimeError: ... - ->>> class C: -... defer(print)() -Traceback (most recent call last): - ... -RuntimeError: ... - -``` - -## A deferred function’s arguments are evaluated when the defer statement is evaluated - -(Paragraph title is borrowed from [The Go Blog](https://go.dev/blog/defer-panic-and-recover)) - -```python ->>> from deferrer import defer - ->>> def f(): -... x = 0 -... defer and print(x) -... x = 1 -... defer and print(x) -... x = 2 -... print(x) - ->>> f() -2 -1 -0 - -``` - -```python ->>> from deferrer import defer - ->>> def f(): -... x = 0 -... defer(print)(x) -... x = 1 -... defer(print)(x) -... x = 2 -... print(x) - ->>> f() -2 -1 -0 - -``` - -If you don't want this behavior, you may try embedded functions with non-local variables. - -```python ->>> from deferrer import defer - ->>> def f(): -... x = 0 -... -... @defer -... def _(): -... print(x) -... -... x = 1 -... -... @defer -... def _(): -... print(x) -... -... x = 2 -... print(x) - ->>> f() -2 -2 -2 - -``` - -## `defer_scope` can be used to explicitly declare where the deferred actions should be drained - -```python ->>> from deferrer import defer, defer_scope - ->>> with defer_scope(): -... __ = defer and print(0) # If the result is not used, it will be printed. -... print(1) -1 -0 - -``` - -## `defer_scope` can be used to wrap an iterable and drain deferred actions when each loop ends - -```python ->>> from deferrer import defer, defer_scope - ->>> for i in defer_scope(range(3)): -... __ = defer and print(-i) # If the result is not used, it will be printed. -... print(i) -0 -0 -1 --1 -2 --2 - -``` diff --git a/deferrer/__init__.py b/deferrer/__init__.py index 4540f2f..d79993b 100644 --- a/deferrer/__init__.py +++ b/deferrer/__init__.py @@ -1,4 +1,3 @@ -__version__ = "0.1.7" +__version__ = "0.2.0" -from ._defer import defer as defer -from ._scope import defer_scope as defer_scope +from .__public__ import * diff --git a/deferrer/__public__.py b/deferrer/__public__.py new file mode 100644 index 0000000..9dbedaa --- /dev/null +++ b/deferrer/__public__.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +__all__ = [ + "defer", + "defer_scope", +] + +from ._defer import defer +from ._defer_scope import defer_scope diff --git a/deferrer/_support/_code_location.py b/deferrer/_code_location.py similarity index 100% rename from deferrer/_support/_code_location.py rename to deferrer/_code_location.py diff --git a/deferrer/_defer/_sugarful.py b/deferrer/_defer.py similarity index 52% rename from deferrer/_defer/_sugarful.py rename to deferrer/_defer.py index b43088c..d3c863e 100644 --- a/deferrer/_defer/_sugarful.py +++ b/deferrer/_defer.py @@ -1,57 +1,71 @@ from __future__ import annotations -__all__ = [ - "Defer", - "defer", -] +__all__ = ["defer"] +from collections.abc import Callable from types import CellType, FunctionType -from typing import Any, cast +from typing import Any, Final, Literal, cast, final from warnings import warn -from .._scope import DeferScope -from .._support import AnyDeferredCall, Opcode, get_caller_frame, get_code_location +from ._defer_scope import ensure_deferred_actions +from ._deferred_actions import DeferredAction +from ._opcode import Opcode +from ._code_location import get_code_location +from ._frame import get_outer_frame _MISSING = cast("Any", object()) +@final class Defer: """ - Provides `defer` functionality in a sugarful way. + Provides `defer` functionality in both sugarful and sugarless ways. Examples -------- - >>> def f(): + >>> def f_0(): ... defer and print(0) ... defer and print(1) ... print(2) ... defer and print(3) ... defer and print(4) - >>> f() + >>> f_0() 2 4 3 1 0 - """ - __slots__ = () + >>> def f_1(): + ... defer(print)(0) + ... defer(print)(1) + ... print(2) + ... defer(print)(3) + ... defer(print)(4) + + >>> f_1() + 2 + 4 + 3 + 1 + 0 + """ @staticmethod - def __bool__() -> bool: + def __bool__() -> Literal[False]: """ **DO NOT INVOKE** - This method is only meant to be called during `defer and ...`. + This method is only meant to be used during `defer and ...`. - If called in other ways, the return value will always be `False` + If used in other ways, the return value will always be `False` and a warning will be emitted. """ - frame = get_caller_frame() + frame = get_outer_frame() - # The usage is `defer and ...` and the corresponding instructions should be: + # The usage is `defer and ...` and the typical instructions should be like: # ``` # LOAD_GLOBAL ? (defer) # COPY @@ -60,7 +74,7 @@ def __bool__() -> bool: # # ``` # The current instruction is at the line prefixed by "-->", and the "" part - # stands for the `...` part in `defer and ...`. + # stands for the RHS part in `defer and ...`. code = frame.f_code code_bytes = code.co_code i_code_byte = frame.f_lasti @@ -146,12 +160,90 @@ def __bool__() -> bool: new_function = FunctionType( code=dummy_code, globals=global_scope, closure=dummy_closure ) - deferred_call = AnyDeferredCall(new_function) + deferred_call = _DeferredCall(new_function) - deferred_calls = DeferScope.get_deferred_calls(frame) - deferred_calls.append(deferred_call) + deferred_actions = ensure_deferred_actions(frame) + deferred_actions.append(deferred_call) return False + @staticmethod + def __call__[**P](callable: Callable[P, Any], /) -> Callable[P, None]: + """ + Converts a callable into a deferred callable. + + Return value of the given callable will always be ignored. + """ + + frame = get_outer_frame() + code_location = get_code_location(frame) + + deferred_callable = _DeferredCallable(callable, code_location) + + deferred_actions = ensure_deferred_actions(frame) + deferred_actions.append(deferred_callable) + + return deferred_callable + defer = Defer() + + +@final +class _DeferredCall(DeferredAction): + def __init__(self, body: Callable[[], Any], /) -> None: + self._body: Final = body + + def perform(self, /) -> None: + self._body() + + +@final +class _DeferredCallable[**P](DeferredAction): + _body: Final[Callable[..., Any]] + _code_location: Final[str] + + _args_and_kwargs: tuple[tuple[Any, ...], dict[str, Any]] | None + + def __init__(self, body: Callable[P, Any], /, code_location: str) -> None: + self._body = body + self._code_location = code_location + + self._args_and_kwargs = None + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: + if self._args_and_kwargs is not None: + raise RuntimeError("`defer(...)` gets further called more than once.") + + self._args_and_kwargs = (args, kwargs) + + def perform(self, /) -> None: + body = self._body + args_and_kwargs = self._args_and_kwargs + + if args_and_kwargs is not None: + args, kwargs = args_and_kwargs + body(*args, **kwargs) + return + + try: + body() + except Exception as e: + if isinstance(e, TypeError): + traceback = e.__traceback__ + assert traceback is not None + + if traceback.tb_next is None: + # This `TypeError` was raised on function call, which means that + # there was a signature error. + # It is typically because a deferred callable with at least one + # required argument doesn't ever get further called with appropriate + # arguments. + code_location = self._code_location + message = ( + f"`defer(...)` has never got further called ({code_location})." + ) + warn(message) + return + + raise e diff --git a/deferrer/_defer/__init__.py b/deferrer/_defer/__init__.py deleted file mode 100644 index ad079ef..0000000 --- a/deferrer/_defer/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ._mixed import * diff --git a/deferrer/_defer/_mixed.py b/deferrer/_defer/_mixed.py deleted file mode 100644 index 8385990..0000000 --- a/deferrer/_defer/_mixed.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -__all__ = [ - "Defer", - "defer", -] - -from . import _sugarful, _sugarless - - -class Defer(_sugarful.Defer, _sugarless.Defer): - """ - Provides `defer` functionality in both sugarful and sugarless ways. - - Examples - -------- - >>> def f_0(): - ... defer and print(0) - ... defer and print(1) - ... print(2) - ... defer and print(3) - ... defer and print(4) - - >>> f_0() - 2 - 4 - 3 - 1 - 0 - - >>> def f_1(): - ... defer(print)(0) - ... defer(print)(1) - ... print(2) - ... defer(print)(3) - ... defer(print)(4) - - >>> f_1() - 2 - 4 - 3 - 1 - 0 - """ - - __slots__ = () - - -defer = Defer() diff --git a/deferrer/_defer/_mixed_test.py b/deferrer/_defer/_mixed_test.py deleted file mode 100644 index ef8a7b7..0000000 --- a/deferrer/_defer/_mixed_test.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -from typing import Any, cast - -import pytest - -from ._mixed import * - -_MISSING = cast("Any", object()) - - -class FunctionalityTests: - @staticmethod - def test_0() -> None: - x: int = _MISSING - nums: list[int] = _MISSING - - def f() -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer(nums.append)(4) - assert nums == [x] - - x = 0 - f() - assert nums == [0, 4, 3, 2, 1] - - x = -1 - f() - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_1() -> None: - nums: list[int] = _MISSING - - def f(x: int) -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer(nums.append)(4) - assert nums == [x] - - f(0) - assert nums == [0, 4, 3, 2, 1] - - f(-1) - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_2() -> None: - nums: list[int] = _MISSING - - def f(x: int = 0) -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer(nums.append)(4) - assert nums == [x] - - f() - assert nums == [0, 4, 3, 2, 1] - - f(0) - assert nums == [0, 4, 3, 2, 1] - - f(-1) - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_3() -> None: - x: int = _MISSING - nums: list[int] = _MISSING - - def f() -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - raise - - defer and nums.append(3) - defer(nums.append)(4) - - x = 0 - with pytest.raises(Exception): - f() - assert nums == [0, 2, 1] - - x = -1 - with pytest.raises(Exception): - f() - assert nums == [-1, 2, 1] - - @staticmethod - def test_4() -> None: - x: int = _MISSING - - def f() -> list[int]: - nums: list[int] = [] - assert nums == [] - - defer and nums.append(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer(nums.append)(4) - assert nums == [x] - - return nums - - x = 0 - nums = f() - assert nums == [0, 4, 3, 2, 1] - - x = -1 - nums = f() - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_5() -> None: - result: int = _MISSING - - def f() -> None: - x = 0 - - def f_0() -> None: - nonlocal result - result = x - - defer and f_0() - - def f_1() -> None: - nonlocal x - x = 1 - - defer and f_1() - - def f_2() -> None: - nonlocal x - x = 2 - - defer and f_2() - - f() - assert result == 1 diff --git a/deferrer/_defer/_sugarful_test.py b/deferrer/_defer/_sugarful_test.py deleted file mode 100644 index 0eeb66a..0000000 --- a/deferrer/_defer/_sugarful_test.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import annotations - -__all__ = [] - -from typing import Any, cast - -import pytest - -from ._sugarful import * - -_MISSING = cast("Any", object()) - - -class FunctionalityTests: - @staticmethod - def test_0() -> None: - x: int = _MISSING - nums: list[int] = _MISSING - - def f() -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer and nums.append(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer and nums.append(4) - assert nums == [x] - - x = 0 - f() - assert nums == [0, 4, 3, 2, 1] - - x = -1 - f() - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_1() -> None: - nums: list[int] = _MISSING - - def f(x: int) -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer and nums.append(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer and nums.append(4) - assert nums == [x] - - f(0) - assert nums == [0, 4, 3, 2, 1] - - f(-1) - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_2() -> None: - nums: list[int] = _MISSING - - def f(x: int = 0) -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer and nums.append(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer and nums.append(4) - assert nums == [x] - - f() - assert nums == [0, 4, 3, 2, 1] - - f(0) - assert nums == [0, 4, 3, 2, 1] - - f(-1) - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_3() -> None: - x: int = _MISSING - nums: list[int] = _MISSING - - def f() -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer and nums.append(1) - defer and nums.append(2) - assert nums == [] - - nums.append(x) - raise - - defer and nums.append(3) - defer and nums.append(4) - - x = 0 - with pytest.raises(Exception): - f() - assert nums == [0, 2, 1] - - x = -1 - with pytest.raises(Exception): - f() - assert nums == [-1, 2, 1] - - @staticmethod - def test_4() -> None: - x: int = _MISSING - - def f() -> list[int]: - nums: list[int] = [] - assert nums == [] - - defer and nums.append(1) - defer and nums.append(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer and nums.append(3) - defer and nums.append(4) - assert nums == [x] - - return nums - - x = 0 - nums = f() - assert nums == [0, 4, 3, 2, 1] - - x = -1 - nums = f() - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_5() -> None: - result: int = _MISSING - - def f() -> None: - x = 0 - - def f_0() -> None: - nonlocal result - result = x - - defer and f_0() - - def f_1() -> None: - nonlocal x - x = 1 - - defer and f_1() - - def f_2() -> None: - nonlocal x - x = 2 - - defer and f_2() - - f() - assert result == 1 - - -class UsageTests: - @staticmethod - def test__should_raise__used_in_global_scope() -> None: - with pytest.raises(RuntimeError): - from . import _sugarful_test__used_in_global_scope as _ - - @staticmethod - def test__should_raise__used_in_class_scope() -> None: - with pytest.raises(RuntimeError): - - class _: - defer and print() - - @staticmethod - def test__should_warn__unsupported_bool_call() -> None: - def f_0() -> None: - defer or print() - - with pytest.warns(): - f_0() - - def f_1() -> None: - __ = bool(defer) - - with pytest.warns(): - f_1() diff --git a/deferrer/_defer/_sugarful_test__used_in_global_scope.py b/deferrer/_defer/_sugarful_test__used_in_global_scope.py deleted file mode 100644 index de6989f..0000000 --- a/deferrer/_defer/_sugarful_test__used_in_global_scope.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -__all__ = [] - -from ._sugarful import defer - -# The following line should cause a `RuntimeError`. -defer and print() diff --git a/deferrer/_defer/_sugarless.py b/deferrer/_defer/_sugarless.py deleted file mode 100644 index b6e74b7..0000000 --- a/deferrer/_defer/_sugarless.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -__all__ = [ - "Defer", - "defer", -] - -from collections.abc import Callable -from typing import Any, Final, override -from warnings import warn - -from .._scope import DeferScope -from .._support import DeferredCall, get_caller_frame, get_code_location - - -class Defer: - """ - Provides `defer` functionality in a sugarless way. - - Examples - -------- - >>> def f(): - ... defer(print)(0) - ... defer(print)(1) - ... print(2) - ... defer(print)(3) - ... defer(print)(4) - - >>> f() - 2 - 4 - 3 - 1 - 0 - """ - - @staticmethod - def __call__[**P](callable: Callable[P, Any], /) -> Callable[P, None]: - """ - Converts a callable into a deferred callable. - - Return value of the given callable will always be ignored. - """ - - frame = get_caller_frame() - code_location = get_code_location(frame) - - deferred_callable = _DeferredCallable(callable, code_location) - - deferred_calls = DeferScope.get_deferred_calls(frame) - deferred_calls.append(deferred_callable) - - return deferred_callable - - __slots__ = () - - -defer = Defer() - - -class _DeferredCallable[**P](DeferredCall): - __slots__ = ("_body", "_code_location", "_args_and_kwargs") - - _body: Final[Callable[[], Any]] - _code_location: Final[str] - - _args_and_kwargs: tuple[tuple[Any, ...], dict[str, Any]] | None - - def __init__(self, body: Callable[P, Any], /, code_location: str) -> None: - self._body = body - self._code_location = code_location - - self._args_and_kwargs = None - - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None: - if self._args_and_kwargs is not None: - raise RuntimeError("`defer(...)` gets further called more than once.") - - self._args_and_kwargs = (args, kwargs) - - @override - def invoke(self, /) -> None: - body = self._body - args_and_kwargs = self._args_and_kwargs - - if args_and_kwargs is not None: - args, kwargs = args_and_kwargs - body(*args, **kwargs) - return - - try: - body() - except Exception as e: - if isinstance(e, TypeError): - traceback = e.__traceback__ - assert traceback is not None - - if traceback.tb_next is None: - # This `TypeError` was raised on function call, which means that - # there was a signature error. - # It is typically because a deferred callable with at least one - # required argument doesn't ever get further called with appropriate - # arguments. - code_location = self._code_location - message = ( - f"`defer(...)` has never got further called ({code_location})." - ) - warn(message) - return - - raise e diff --git a/deferrer/_defer/_sugarless_test.py b/deferrer/_defer/_sugarless_test.py deleted file mode 100644 index febd661..0000000 --- a/deferrer/_defer/_sugarless_test.py +++ /dev/null @@ -1,243 +0,0 @@ -from __future__ import annotations - -__all__ = [] - -from typing import Any, cast - -import pytest - -from ._sugarless import * - -_MISSING = cast("Any", object()) - - -class FunctionalityTests: - @staticmethod - def test_0() -> None: - x: int = _MISSING - nums: list[int] = _MISSING - - def f() -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer(nums.append)(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer(nums.append)(3) - defer(nums.append)(4) - assert nums == [x] - - x = 0 - f() - assert nums == [0, 4, 3, 2, 1] - - x = -1 - f() - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_1() -> None: - nums: list[int] = _MISSING - - def f(x: int) -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer(nums.append)(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer(nums.append)(3) - defer(nums.append)(4) - assert nums == [x] - - f(0) - assert nums == [0, 4, 3, 2, 1] - - f(-1) - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_2() -> None: - nums: list[int] = _MISSING - - def f(x: int = 0) -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer(nums.append)(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer(nums.append)(3) - defer(nums.append)(4) - assert nums == [x] - - f() - assert nums == [0, 4, 3, 2, 1] - - f(0) - assert nums == [0, 4, 3, 2, 1] - - f(-1) - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_3() -> None: - x: int = _MISSING - nums: list[int] = _MISSING - - def f() -> None: - nonlocal nums - - nums = [] - assert nums == [] - - defer(nums.append)(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - raise - - defer(nums.append)(3) - defer(nums.append)(4) - - x = 0 - with pytest.raises(Exception): - f() - assert nums == [0, 2, 1] - - x = -1 - with pytest.raises(Exception): - f() - assert nums == [-1, 2, 1] - - @staticmethod - def test_4() -> None: - x: int = _MISSING - - def f() -> list[int]: - nums: list[int] = [] - assert nums == [] - - defer(nums.append)(1) - defer(nums.append)(2) - assert nums == [] - - nums.append(x) - assert nums == [x] - - defer(nums.append)(3) - defer(nums.append)(4) - assert nums == [x] - - return nums - - x = 0 - nums = f() - assert nums == [0, 4, 3, 2, 1] - - x = -1 - nums = f() - assert nums == [-1, 4, 3, 2, 1] - - @staticmethod - def test_5() -> None: - result: int = _MISSING - - def f() -> None: - x = 0 - - def f_0() -> None: - nonlocal result - result = x - - defer(f_0)() - - def f_1() -> None: - nonlocal x - x = 1 - - defer(f_1)() - - def f_2() -> None: - nonlocal x - x = 2 - - defer(f_2)() - - f() - assert result == 1 - - -class UsageTests: - @staticmethod - def test__should_raise__used_in_global_scope() -> None: - with pytest.raises(RuntimeError): - from . import _sugarless_test__used_in_global_scope as _ - - @staticmethod - def test__should_raise__used_in_class_scope() -> None: - with pytest.raises(RuntimeError): - - class _: - defer(print)() - - @staticmethod - def test__should_warn__not_further_called() -> None: - def f_0() -> None: - def inner() -> None: - pass - - defer(inner) # pyright: ignore[reportUnusedCallResult] - - # This should work because `f_0.inner` can be called with no argument. - f_0() - - def f_1() -> None: - def inner(x: int) -> None: ... - - defer(inner) # pyright: ignore[reportUnusedCallResult] - - # This should not work because `f_1.inner` cannot be called with no argument. - with pytest.warns(UserWarning, match="has never got further called"): - f_1() - - @staticmethod - def test__should_raise__called_more_than_once() -> None: - def f() -> None: - stub = defer(lambda: None) - stub() - stub() - - with pytest.raises(RuntimeError): - f() - - @staticmethod - def test__should_warn__type_error_created_by_user() -> None: - def f() -> None: - @defer - def _() -> None: - raise TypeError() - - # The `TypeError` should be raised as a warning because it is created by user. - with pytest.warns(match="TypeError"): - f() diff --git a/deferrer/_defer/_sugarless_test__used_in_global_scope.py b/deferrer/_defer/_sugarless_test__used_in_global_scope.py deleted file mode 100644 index 6ea66c7..0000000 --- a/deferrer/_defer/_sugarless_test__used_in_global_scope.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -__all__ = [] - -from ._sugarless import defer - -# The following line should cause a `RuntimeError`. -defer(print)() diff --git a/deferrer/_defer_scope.py b/deferrer/_defer_scope.py new file mode 100644 index 0000000..88db82d --- /dev/null +++ b/deferrer/_defer_scope.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from types import TracebackType + +__all__ = [ + "_DeferScopeContextManager", + "defer_scope", + "ensure_deferred_actions", +] + +import operator +from collections.abc import Callable, Iterable, Iterator +from contextlib import AbstractContextManager +from functools import update_wrapper +from types import FrameType +from typing import Any, Final, Generic, ParamSpec, TypeVar, cast, final, overload + +from ._deferred_actions import DeferredActions +from ._frame import get_outer_frame, is_class_frame, is_global_frame + +_Wrapped_t = TypeVar("_Wrapped_t") + +_P = ParamSpec("_P") +_R = TypeVar("_R") + +_E = TypeVar("_E") + +_LOCAL_KEY = cast("Any", object()) + + +@overload +def defer_scope() -> AbstractContextManager: ... +@overload +def defer_scope(wrapped: Callable[_P, _R], /) -> Callable[_P, _R]: ... +@overload +def defer_scope(wrapped: Iterable[_E], /) -> Iterable[_E]: ... + + +def defer_scope(wrapped: Any = None, /) -> Any: + if wrapped is None: + return _DeferScopeContextManager() + else: + wrapper = _DeferScopeWrapper(wrapped) + wrapper = update_wrapper(wrapper, wrapped) + return wrapper + + +def ensure_deferred_actions(frame: FrameType) -> DeferredActions: + for recorder in ( + _context_deferred_actions_recorder, + _callable_deferred_actions_recorder, + ): + deferred_actions = recorder.find(frame) + if deferred_actions is not None: + return deferred_actions + + local_scope = frame.f_locals + + deferred_actions = local_scope.get(_LOCAL_KEY) + if deferred_actions is not None: + return deferred_actions + + if is_global_frame(frame): + raise RuntimeError("cannot inject deferred actions into global scope") + + if is_class_frame(frame): + raise RuntimeError("cannot inject deferred actions into class scope") + + deferred_actions = DeferredActions() + local_scope[_LOCAL_KEY] = deferred_actions + + return deferred_actions + + +@final +class _DeferScopeContextManager(AbstractContextManager): + _frame: FrameType | None = None + _deferred_actions: DeferredActions | None = None + + def __enter__(self, /) -> Any: + frame = get_outer_frame() + assert self._frame is None + self._frame = frame + + deferred_actions = DeferredActions() + assert self._deferred_actions is None + self._deferred_actions = deferred_actions + + _context_deferred_actions_recorder.add(frame, deferred_actions) + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + /, + ) -> None: + frame = self._frame + assert frame is not None + del self._frame + + deferred_actions = self._deferred_actions + assert deferred_actions is not None + del self._deferred_actions + + _context_deferred_actions_recorder.remove(frame, deferred_actions) + + return None + + +@final +class _DeferScopeWrapper(Generic[_Wrapped_t]): + def __init__(self, wrapped: _Wrapped_t, /) -> None: + self._wrapped: Final = wrapped + + def __call__( + self: _DeferScopeWrapper[Callable[_P, _R]], + /, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> _R: + wrapped = self._wrapped + + frame = get_outer_frame() + deferred_actions = DeferredActions() + + _callable_deferred_actions_recorder.add(frame, deferred_actions) + try: + result = wrapped(*args, **kwargs) + finally: + _callable_deferred_actions_recorder.remove(frame, deferred_actions) + + return result + + def __iter__(self: _DeferScopeWrapper[Iterable[_E]], /) -> Iterator[_E]: + frame = get_outer_frame() + wrapped = self._wrapped + + @iter + @operator.call + def _() -> Iterable[_E]: + for element in wrapped: + deferred_actions = DeferredActions() + + _context_deferred_actions_recorder.add(frame, deferred_actions) + try: + yield element + finally: + _context_deferred_actions_recorder.remove(frame, deferred_actions) + + iterator = _ + return iterator + + +class _CallableDeferredActionsRecorder: + _internal_dict: Final[dict[FrameType, DeferredActions]] + + def __init__(self, /) -> None: + self._internal_dict = {} + + def add(self, outer_frame: FrameType, deferred_actions: DeferredActions, /) -> None: + __ = self._internal_dict.setdefault(outer_frame, deferred_actions) + assert __ is deferred_actions + + def remove( + self, outer_frame: FrameType, deferred_actions: DeferredActions, / + ) -> None: + __ = self._internal_dict.pop(outer_frame) + assert __ is deferred_actions + + def find(self, frame: FrameType, /) -> DeferredActions | None: + outer_frame = frame.f_back + assert outer_frame is not None + deferred_actions = self._internal_dict.get(outer_frame) + return deferred_actions + + +_callable_deferred_actions_recorder = _CallableDeferredActionsRecorder() + + +class _ContextDeferredActionsRecorder: + _internal_dict: Final[dict[FrameType, list[DeferredActions]]] + + def __init__(self, /) -> None: + self._internal_dict = {} + + def add(self, frame: FrameType, deferred_actions: DeferredActions, /) -> None: + internal_dict = self._internal_dict + + deferred_actions_list = internal_dict.get(frame) + if deferred_actions_list is None: + deferred_actions_list = [] + internal_dict[frame] = deferred_actions_list + + deferred_actions_list.append(deferred_actions) + + def remove(self, frame: FrameType, deferred_actions: DeferredActions, /) -> None: + internal_dict = self._internal_dict + + deferred_actions_list = internal_dict[frame] + __ = deferred_actions_list.pop() + assert __ is deferred_actions + + if len(deferred_actions_list) == 0: + del internal_dict[frame] + + def find(self, frame: FrameType, /) -> DeferredActions | None: + deferred_actions_list = self._internal_dict.get(frame) + if deferred_actions_list is None: + return None + + deferred_actions = deferred_actions_list[-1] + return deferred_actions + + +_context_deferred_actions_recorder = _ContextDeferredActionsRecorder() diff --git a/deferrer/_deferred_actions.py b/deferrer/_deferred_actions.py new file mode 100644 index 0000000..6179fcb --- /dev/null +++ b/deferrer/_deferred_actions.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +__all__ = [ + "DeferredAction", + "DeferredActions", +] + +from abc import ABC, abstractmethod +from typing import Final, final + + +class DeferredAction(ABC): + """ + An object that stands for an action that is meant to be performed + later. + """ + + @abstractmethod + def perform(self, /) -> None: ... + + +@final +class DeferredActions: + """ + A list-like object that holds `DeferredAction` objects. + + When a `DeferredActions` object is being disposed, all + `DeferredAction` objects it holds will get performed in a FILO + order. + """ + + _internal_list: Final[list[DeferredAction]] + + def __init__(self, /) -> None: + self._internal_list = [] + + def append(self, deferred_call: DeferredAction, /) -> None: + self._internal_list.append(deferred_call) + + def drain(self, /) -> None: + exceptions: list[Exception] = [] + + internal_list = self._internal_list + while len(internal_list) > 0: + deferred_call = internal_list.pop() + + try: + deferred_call.perform() + except Exception as e: + exceptions.append(e) + + n_exceptions = len(exceptions) + if n_exceptions == 0: + return + + exception_group = ExceptionGroup("deferred exception(s)", exceptions) + raise exception_group + + def __del__(self, /) -> None: + self.drain() diff --git a/deferrer/_frame.py b/deferrer/_frame.py new file mode 100644 index 0000000..7c8f76b --- /dev/null +++ b/deferrer/_frame.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +__all__ = [ + "get_outer_frame", + "is_class_frame", + "is_global_frame", +] + +import sys +from collections.abc import Sequence +from types import FrameType + +from ._opcode import Opcode + + +def get_outer_frame() -> FrameType: + """ + Returns the frame of the caller of caller. + + Examples + -------- + >>> def foo(): # L0 + ... def inner(): + ... frame = get_outer_frame() + ... print ( + ... frame.f_code.co_name, + ... frame.f_lineno - frame.f_code.co_firstlineno, + ... ) + ... inner() # L7 + + >>> foo() + foo 7 + """ + + frame = sys._getframe(2) # pyright: ignore[reportPrivateUsage] + return frame + + +def is_global_frame(frame: FrameType, /) -> bool: + """ + Detects if the given frame is a global frame. + + Examples + -------- + >>> import sys + + >>> print(is_global_frame(sys._getframe())) + True + + >>> class C: + ... print(is_global_frame(sys._getframe())) + False + + >>> def f(): + ... print(is_global_frame(sys._getframe())) + + >>> f() + False + """ + + return frame.f_locals is frame.f_globals + + +def is_class_frame(frame: FrameType, /) -> bool: + """ + Detects if the given frame is a class frame. + + Examples + -------- + >>> import sys + + >>> print(is_class_frame(sys._getframe())) + False + + >>> class C: + ... print(is_class_frame(sys._getframe())) + True + + >>> def f(): + ... print(is_class_frame(sys._getframe())) + + >>> f() + False + + >>> def fake_class(): + ... global __name__, __module__, __qualname__ + ... if False: __name__, __module__, __qualname__ + ... print(is_class_frame(sys._getframe())) + + >>> fake_class() + False + """ + + # Typical class code will begin like: + # + # RESUME + # LOAD_NAME "__name__" + # STORE "__module__" + # LOAD_CONST {qualname} + # STORE "__qualname__" + # ... + + code = frame.f_code + + names = code.co_names + if not _sequence_has_prefix(names, ("__name__", "__module__", "__qualname__")): + return False + + code_bytes = code.co_code + # In some cases (e.g. embedded class), there may be a COPY_FREE_VARS instruction. + if _sequence_has_prefix(code_bytes, (Opcode.COPY_FREE_VARS,)): + code_bytes = code_bytes[2:] + if not _sequence_has_prefix( + code_bytes, + ( + Opcode.RESUME, + 0, + Opcode.LOAD_NAME, + 0, # __name__ + Opcode.STORE_NAME, + 1, # __module__ + Opcode.LOAD_CONST, + 0, + Opcode.STORE_NAME, + 2, # __qualname__ + ), + ): + return False + + return True + + +def _sequence_has_prefix( + sequence: Sequence[object], prefix: Sequence[object], / +) -> bool: + return tuple(sequence[: len(prefix)]) == tuple(prefix) diff --git a/deferrer/_support/_opcode.py b/deferrer/_opcode.py similarity index 100% rename from deferrer/_support/_opcode.py rename to deferrer/_opcode.py diff --git a/deferrer/_scope/__init__.py b/deferrer/_scope/__init__.py deleted file mode 100644 index 6eb8646..0000000 --- a/deferrer/_scope/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ._core import * diff --git a/deferrer/_scope/_context.py b/deferrer/_scope/_context.py deleted file mode 100644 index 97da310..0000000 --- a/deferrer/_scope/_context.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -__all__ = ["DeferScope"] - -from contextlib import AbstractContextManager -from types import FrameType, TracebackType -from typing import Any, Final, cast, override - -from .._support import DeferredCalls, get_caller_frame - -_MISSING = cast("Any", object) - - -class DeferScope(AbstractContextManager): - _internal_dict: Final[dict[FrameType, list[DeferredCalls]]] = {} - - @staticmethod - def get_deferred_calls(frame: FrameType, /) -> DeferredCalls: - internal_dict = DeferScope._internal_dict - - deferred_calls_list = internal_dict.get(frame) - if deferred_calls_list is None: - deferred_calls = DeferredCalls.ensure_in_frame(frame) - return deferred_calls - - deferred_calls = deferred_calls_list[-1] - return deferred_calls - - __slots__ = ("_frame",) - - _frame: Final[FrameType] - - def __init__(self, /, frame: FrameType = _MISSING) -> None: - if frame is _MISSING: - frame = get_caller_frame() - - self._frame = frame - - _deferred_calls: DeferredCalls | None = None - - @override - def __enter__(self, /) -> None: - assert self._deferred_calls is None - - deferred_calls = DeferredCalls() - self._deferred_calls = deferred_calls - - internal_dict = self._internal_dict - frame = self._frame - - deferred_calls_list = internal_dict.get(frame) - if deferred_calls_list is None: - deferred_calls_list = [] - internal_dict[frame] = deferred_calls_list - - deferred_calls_list.append(deferred_calls) - - @override - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - /, - ) -> None: - internal_dict = DeferScope._internal_dict - frame = self._frame - - deferred_calls_list = internal_dict[frame] - - deferred_calls = deferred_calls_list.pop() - deferred_calls.drain() - - if len(deferred_calls_list) == 0: - del internal_dict[frame] - - assert deferred_calls is self._deferred_calls - self._deferred_calls = None diff --git a/deferrer/_scope/_context_test.py b/deferrer/_scope/_context_test.py deleted file mode 100644 index 92cfe01..0000000 --- a/deferrer/_scope/_context_test.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -__all__ = [] - -import pytest - -from ._context import * -from .._defer import defer - - -class FunctionalityTests: - @staticmethod - def test_0() -> None: - def f() -> None: - with DeferScope(): - raise ValueError("xxx") - - with pytest.raises(ValueError, match="xxx"): - f() - - @staticmethod - def test_1() -> None: - nums: list[int] = [] - - with DeferScope(): - defer(nums.append)(0) - defer and nums.append(1) - - with DeferScope(): - defer(nums.append)(2) - defer and nums.append(3) - - nums.append(4) - - defer(nums.append)(5) - defer and nums.append(6) - - defer(nums.append)(7) - defer and nums.append(8) - - assert nums == [4, 6, 5, 3, 2, 8, 7, 1, 0] diff --git a/deferrer/_scope/_core.py b/deferrer/_scope/_core.py deleted file mode 100644 index 2ed467a..0000000 --- a/deferrer/_scope/_core.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -__all__ = [ - "DeferScope", - "DeferScopeIterable", - "defer_scope", -] - -from collections.abc import Iterable -from contextlib import AbstractContextManager -from typing import Any, cast, overload - -from ._context import DeferScope -from ._iterable import DeferScopeIterable -from .._support import get_caller_frame - -_MISSING = cast("Any", object()) - - -@overload -def defer_scope() -> AbstractContextManager: ... - - -@overload -def defer_scope[E](iterable: Iterable[E], /) -> Iterable[E]: ... - - -def defer_scope( - wrapped: Iterable[Any] = _MISSING, / -) -> AbstractContextManager | Iterable[Any]: - frame = get_caller_frame() - - if wrapped is _MISSING: - return DeferScope(frame=frame) - - if isinstance(wrapped, Iterable): - return DeferScopeIterable(wrapped, frame=frame) diff --git a/deferrer/_scope/_core_test.py b/deferrer/_scope/_core_test.py deleted file mode 100644 index e1d3414..0000000 --- a/deferrer/_scope/_core_test.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -__all__ = [] - -from ._core import * -from .._defer import defer - - -class FunctionalityTests: - @staticmethod - def test_0() -> None: - nums: list[int] = [] - - with defer_scope(): - defer(nums.append)(0) - defer and nums.append(1) - - with DeferScope(): - defer(nums.append)(2) - defer and nums.append(3) - - nums.append(4) - - defer(nums.append)(5) - defer and nums.append(6) - - defer(nums.append)(7) - defer and nums.append(8) - - assert nums == [4, 6, 5, 3, 2, 8, 7, 1, 0] - - @staticmethod - def test_1() -> None: - nums: list[int] = [] - - for i in defer_scope([0, 5, 10]): - defer(nums.append)(i) - defer and nums.append(i + 1) - nums.append(i + 2) - defer(nums.append)(i + 3) - defer and nums.append(i + 4) - - assert nums == [2, 4, 3, 1, 0, 7, 9, 8, 6, 5, 12, 14, 13, 11, 10] diff --git a/deferrer/_scope/_iterable.py b/deferrer/_scope/_iterable.py deleted file mode 100644 index 1fc0465..0000000 --- a/deferrer/_scope/_iterable.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -__all__ = ["DeferScopeIterable"] - -import operator -from collections.abc import Iterable, Iterator -from types import FrameType -from typing import Any, Final, Generic, TypeVar, cast, override - -from ._context import DeferScope -from .._support import get_caller_frame - -_E_co = TypeVar("_E_co", covariant=True) - -_MISSING = cast("Any", object()) - - -class DeferScopeIterable(Iterable[_E_co], Generic[_E_co]): - __slots__ = ("_wrapped", "_frame") - - _wrapped: Final[Iterable[_E_co]] - _frame: Final[FrameType] - - def __init__( - self, wrapped: Iterable[_E_co], /, frame: FrameType = _MISSING - ) -> None: - if frame is _MISSING: - frame = get_caller_frame() - - self._wrapped = wrapped - self._frame = frame - - @override - def __iter__(self, /) -> Iterator[_E_co]: - frame = self._frame - wrapped = self._wrapped - - @iter - @operator.call - def _() -> Iterable[_E_co]: - for e in iter(wrapped): - with DeferScope(frame): - yield e - - iterator = _ - return iterator diff --git a/deferrer/_scope/_iterable_test.py b/deferrer/_scope/_iterable_test.py deleted file mode 100644 index 6b7bd02..0000000 --- a/deferrer/_scope/_iterable_test.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -__all__ = [] - -import pytest - -from ._iterable import * -from .._defer import defer - - -class FuntionalityTests: - @staticmethod - def test_0() -> None: - def f() -> None: - for _ in DeferScopeIterable(range(10)): - raise ValueError("xxx") - - with pytest.raises(ValueError, match="xxx"): - f() - - @staticmethod - def test_1() -> None: - nums: list[int] = [] - - for i in DeferScopeIterable([0, 5, 10]): - defer(nums.append)(i) - defer and nums.append(i + 1) - nums.append(i + 2) - defer(nums.append)(i + 3) - defer and nums.append(i + 4) - - assert nums == [2, 4, 3, 1, 0, 7, 9, 8, 6, 5, 12, 14, 13, 11, 10] diff --git a/deferrer/_support/__init__.py b/deferrer/_support/__init__.py deleted file mode 100644 index 3c5404f..0000000 --- a/deferrer/_support/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ._code_location import * -from ._deferred_calls import * -from ._frame import * -from ._opcode import * diff --git a/deferrer/_support/_deferred_calls.py b/deferrer/_support/_deferred_calls.py deleted file mode 100644 index a60692b..0000000 --- a/deferrer/_support/_deferred_calls.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -__all__ = [ - "AnyDeferredCall", - "DeferredCall", - "DeferredCalls", -] - -from abc import ABC, abstractmethod -from collections.abc import Callable -from types import FrameType -from typing import Any, Final, cast, override -from warnings import warn - -from ._frame import is_class_frame, is_global_frame - - -class DeferredCall(ABC): - __slots__ = () - - @abstractmethod - def invoke(self, /) -> None: ... - - -class AnyDeferredCall(DeferredCall): - """ - A type-erasing implementation of `DeferredCall`. - """ - - __slots__ = ("_body",) - - _body: Final[Callable[[], Any]] - - def __init__(self, body: Callable[[], Any]) -> None: - self._body = body - - @override - def invoke(self) -> None: - self._body() - - -class DeferredCalls: - """ - A list-like object that holds deferred calls and automatically runs - them in a reversed order when it is being disposed. - """ - - # The type of this key is `object` instead of `str`, so it will never conflict with - # anything in a local scope. - _key: Final[Any] = object() - - @staticmethod - def ensure_in_frame(frame: FrameType, /) -> DeferredCalls: - if is_global_frame(frame): - raise RuntimeError("cannot be used in global scope") - - if is_class_frame(frame): - raise RuntimeError("cannot be used in class scope") - - key = DeferredCalls._key - - local_scope = frame.f_locals - - instance = cast("DeferredCalls | None", local_scope.get(key)) - if instance is None: - instance = DeferredCalls() - local_scope[key] = instance - - return instance - - __slots__ = ("_internal_list",) - - _internal_list: list[DeferredCall] - - def __init__(self, /) -> None: - self._internal_list = [] - - def append(self, deferred_call: DeferredCall, /) -> None: - self._internal_list.append(deferred_call) - - def drain(self, /) -> None: - internal_list = self._internal_list - while len(internal_list) > 0: - deferred_call = internal_list.pop() - - try: - deferred_call.invoke() - except Exception as e: - warn(repr(e)) - - def __del__(self, /) -> None: - self.drain() diff --git a/deferrer/_support/_frame.py b/deferrer/_support/_frame.py deleted file mode 100644 index b96b6fc..0000000 --- a/deferrer/_support/_frame.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -__all__ = [ - "get_caller_frame", - "is_class_frame", - "is_global_frame", -] - -import sys -from collections.abc import Sequence -from types import FrameType -from typing import override - -from ._opcode import Opcode - - -def get_caller_frame() -> FrameType: - """ - Returns the frame of the caller of caller. - - Examples - -------- - >>> def foo(): # L0 - ... def mocked_defer(): - ... frame = get_caller_frame() - ... return ( - ... frame.f_code.co_name, - ... frame.f_lineno - frame.f_code.co_firstlineno, - ... ) - ... print(*mocked_defer()) # L7 - - >>> foo() - foo 7 - """ - - frame = sys._getframe(2) # pyright: ignore[reportPrivateUsage] - return frame - - -def is_global_frame(frame: FrameType, /) -> bool: - """ - Detects if the given frame is a global frame. - """ - - return frame.f_locals is frame.f_globals - - -def is_class_frame(frame: FrameType, /) -> bool: - """ - Detects if the given frame is a class frame. - """ - - if ( - True - and _sequence_has_prefix(frame.f_code.co_consts, _CLASS_CODE_PREFIX_CONSTS) - and _sequence_has_prefix(frame.f_code.co_names, _CLASS_CODE_PREFIX_NAMES) - and _sequence_has_prefix(frame.f_code.co_code, _CLASS_CODE_PREFIX_BYTES) - ): - return True - - return False - - -def _sequence_has_prefix( - sequence: Sequence[object], prefix: Sequence[object], / -) -> bool: - return tuple(sequence[: len(prefix)]) == tuple(prefix) - - -class _AnyStr: - """ - An object that equals any `str`. - """ - - @override - def __eq__(self, other: object, /) -> bool: - return isinstance(other, str) - - -_CLASS_CODE_PREFIX_CONSTS = (_AnyStr(),) -_CLASS_CODE_PREFIX_NAMES = ("__name__", "__module__", "__qualname__") -_CLASS_CODE_PREFIX_BYTES = bytes( - [ - Opcode.RESUME, - 0, - Opcode.LOAD_NAME, - 0, # __name__ - Opcode.STORE_NAME, - 1, # __module__ - Opcode.LOAD_CONST, - 0, - Opcode.STORE_NAME, - 2, # __qualname__ - ] -) diff --git a/deferrer_tests/defer.py b/deferrer_tests/defer.py new file mode 100644 index 0000000..9b57e77 --- /dev/null +++ b/deferrer_tests/defer.py @@ -0,0 +1,426 @@ +from __future__ import annotations + + +def defer_is_not_allowed_in_module_level() -> None: + """ + For `defer` to work, the local scope where `defer` gets used need to + eventually get disposed with everything in it released at the same + time. + + If `defer` is used in module level, the local scope is the global + scope and will never get disposed in time. + + Therefore, an exception is raised to prevent such usages. + """ + + import pytest + + with pytest.raises(Exception) as exc_info: + from deferrer_tests.samples import sugarful_without_defer_scope as _ + assert not exc_info.errisinstance(ImportError) + + with pytest.raises(Exception): + from deferrer_tests.samples import sugarless_without_defer_scope as _ + assert not exc_info.errisinstance(ImportError) + + +def defer_is_not_allowed_in_class_level() -> None: + """ + For `defer` to work, the local scope where `defer` gets used need to + eventually get disposed with everything in it released at the same + time. + + If `defer` is used in class level, everything in the local scope + will get copied into the class and will never get released in time. + + Therefore, an exception is raised to prevent such usages. + """ + + import pytest + + from deferrer import defer + + with pytest.raises(Exception): + + class _: + defer and print() + + with pytest.raises(Exception): + + class _: + defer(print)() + + +def defer_can_be_used_in_sugarful_form() -> None: + """ + `defer` can be used like `defer and {expression}`. + """ + + from deferrer import defer + + nums = [] + + def f() -> None: + nums.clear() + assert nums == [] + + defer and nums.append(0) + assert nums == [] + + nums.append(1) + assert nums == [1] + + defer and nums.append(2) + assert nums == [1] + + f() + assert nums == [1, 2, 0] + + +def defer_can_be_used_in_sugarless_form() -> None: + """ + `defer` can be used like `defer(function)(*args, **kwargs)`. + """ + + from deferrer import defer + + nums = [] + + def f() -> None: + nums.clear() + assert nums == [] + + defer(nums.append)(0) + assert nums == [] + + nums.append(1) + assert nums == [1] + + defer(nums.append)(2) + assert nums == [1] + + f() + assert nums == [1, 2, 0] + + +def defer_can_be_used_in_mixed_forms() -> None: + """ + Both forms can work together. + """ + + from deferrer import defer + + nums = [] + + def f() -> None: + nums.clear() + assert nums == [] + + defer and nums.append(0) + assert nums == [] + + defer(nums.append)(1) + assert nums == [] + + nums.append(2) + assert nums == [2] + + defer(nums.append)(3) + assert nums == [2] + + defer and nums.append(4) + assert nums == [2] + + f() + assert nums == [2, 4, 3, 1, 0] + + +def there_will_be_warnings_for_unsupported_bool_conversions() -> None: + """ + `defer.__bool__()` is only meant to be indirectly called during + `defer and ...`. + + If used in another situation, a warning will be emitted. + """ + + import pytest + + from deferrer import defer + + with pytest.warns(): + __ = bool(defer) + + with pytest.warns(): + defer or print() + + +def there_will_be_warnings_for_left_out_deferred_calls() -> None: + """ + `defer(function)` need to be further called with arguments of + `function` to work correctly. + + If it doesn't get further called, a warning will be emitted. + + Note + ---- + If `function` can be called with no argument, the deferred call is + allowed not to be further called. + """ + + import pytest + + from deferrer import defer + + def f() -> None: + __ = defer(lambda __: None) + + with pytest.warns(): + f() + + +def deferred_call_cannot_be_further_called_more_than_once() -> None: + """ + If a deferred call is accidentally further called more than once, + an exception will be raised. The previous deferred calls, including + the first further call of this deferred call, will all take effect. + """ + + import pytest + + from deferrer import defer + + nums = [] + + def f() -> None: + nums.clear() + assert nums == [] + + # This will take effect later. + defer(nums.append)(0) + assert nums == [] + + nums.append(1) + assert nums == [1] + + deferred = defer(nums.append) + # This will also take effect later. + deferred(2) + assert nums == [1] + # This will cause an exception. + deferred(3) + + with pytest.raises(Exception): + f() + assert nums == [1, 2, 0] + + +def deferred_call_with_no_argument_is_allowed_not_to_be_further_called() -> None: + """ + If a function can be called with no argument, its deferred version + is allowed not to be further called. + + Such usage is not recommended though. + """ + + from deferrer import defer + + nums = [] + + def f() -> None: + nums.clear() + assert nums == [] + + __ = defer(lambda *args: nums.append(0)) + assert nums == [] + + nums.append(1) + assert nums == [1] + + f() + assert nums == [1, 0] + + +def user_typeerror_during_deferred_call_should_not_be_silenced() -> None: + """ + If a deferred call doesn't get further called, we will try to invoke + it with no argument. When it is not callable with no argument, a + `TypeError` will be raised at that point. We will catch that + `TypeError` and won't re-raise it. + + It is important that `TypeError`s raised by user should not get + silenced with the same reason. + """ + + import sys + from typing import cast + + import pytest + + from deferrer import defer + + def f() -> None: + def raise_type_error() -> None: + raise TypeError + + __ = defer(raise_type_error) + + with pytest.raises(Exception): + e = cast("BaseException | None", None) + + def unraisablehook(args: sys.UnraisableHookArgs, /) -> None: + nonlocal e + e = args.exc_value + + old_unraisablehook = sys.unraisablehook + sys.unraisablehook = unraisablehook + try: + f() + finally: + sys.unraisablehook = old_unraisablehook + + if e is not None: + raise e + + +def defer_can_be_used_as_function_decorator() -> None: + """ + The typical usage is - + + ``` + @defer + def _(): + ... + ``` + """ + + from deferrer import defer + + nums = [] + + def f() -> None: + nums.clear() + assert nums == [] + + @defer + def _() -> None: + nums.append(0) + + assert nums == [] + + nums.append(1) + assert nums == [1] + + f() + assert nums == [1, 0] + + +def deferred_exceptions_are_grouped_and_unraisable() -> None: + """ + If any exceptions are raised in deferred actions, they are grouped + as an `ExceptionGroup`. + + Due to the fact that the deferred actions are performed during + disposal of local scope, the `ExceptionGroup` will be unraisable. + """ + + import sys + from typing import cast + + from deferrer import defer + + def f() -> None: + def do_raise(): + raise RuntimeError + + defer and do_raise() + defer and do_raise() + + e = cast("BaseException | None", None) + + def unraisablehook(args: sys.UnraisableHookArgs, /) -> None: + nonlocal e + e = args.exc_value + + old_unraisablehook = sys.unraisablehook + sys.unraisablehook = unraisablehook + f() + sys.unraisablehook = old_unraisablehook + + assert isinstance(e, ExceptionGroup) + e_0, e_1 = e.exceptions + assert isinstance(e_0, RuntimeError) + assert isinstance(e_1, RuntimeError) + + +def variables_are_evaluated_when_defer_expression_is_evaluated() -> None: + from deferrer import defer + + nums = [] + + def f() -> None: + nums.clear() + assert nums == [] + + i = 0 + + # Equivalent to `defer and nums.append(0)`. + defer and nums.append(i) + assert nums == [] + + i = 1 + nums.append(i) + assert nums == [1] + + i = 2 + # Equivalent to `defer and nums.append(2)`. + defer and nums.append(i) + assert nums == [1] + + f() + assert nums == [1, 2, 0] + + +def deferred_function_can_write_nonlocal_variables() -> None: + from deferrer import defer + + a = int() + b = int() + c = int() + + def f() -> None: + nonlocal a, b, c + + a = 0 + b = 0 + c = 0 + + def b_to_a() -> None: + nonlocal a + a = b + + defer and b_to_a() + assert a == 0 + assert b == 0 + assert c == 0 + + def c_to_b() -> None: + nonlocal b + b = c + + defer and c_to_b() + assert a == 0 + assert b == 0 + assert c == 0 + + c = 1 + assert a == 0 + assert b == 0 + assert c == 1 + + # deferred: b = c + # deferred: a = b + + f() + assert a == 1 + assert b == 1 + assert c == 1 diff --git a/deferrer_tests/defer_scope.py b/deferrer_tests/defer_scope.py new file mode 100644 index 0000000..a74ef61 --- /dev/null +++ b/deferrer_tests/defer_scope.py @@ -0,0 +1,110 @@ +from __future__ import annotations + + +def defer_scope_can_be_used_to_provide_context_manager() -> None: + """ + If `defer_scope` is called with no argument, it provides a context + manager that holds all deferred actions. When the context manager + exists, the deferred actions it holds get performed. + """ + + from deferrer import defer, defer_scope + + nums = [] + + with defer_scope() as _scope_0: + defer and nums.append(0) + assert nums == [] + + # `defer_scope` can be nested. + with defer_scope() as _scope_1: + defer and nums.append(1) + assert nums == [] + + nums.append(2) + assert nums == [2] + + defer and nums.append(3) + assert nums == [2] + + # Exits `_scope_1`. + assert nums == [2, 3, 1] + + defer and nums.append(4) + assert nums == [2, 3, 1] + + # Exits `_scope_0`. + assert nums == [2, 3, 1, 4, 0] + + +def defer_is_allowed_to_be_used_in_module_level_with_defer_scope() -> None: + """ + Inside a `defer_scope()` context, `defer` is allowed to be used in + module level. + """ + + from deferrer_tests.samples import sugarless_with_defer_scope as _ + + +def defer_is_allowed_to_be_used_in_class_level_with_defer_scope() -> None: + """ + Inside a `defer_scope()` context, `defer` is allowed to be used in + class level. + """ + + from deferrer import defer, defer_scope + + class _: + with defer_scope(): + + @defer + def _() -> None: + pass + + +def defer_scope_can_be_used_to_wrap_functions() -> None: + """ + When called with a function as the argument, `defer_scope(...)` + ensures that `defer` should be usable in the function (even in + Python prior to 3.12). + """ + + from deferrer import defer, defer_scope + + nums = [] + + @defer_scope + def f() -> None: + nums.clear() + assert nums == [] + + defer and nums.append(0) + assert nums == [] + + nums.append(1) + assert nums == [1] + + defer and nums.append(2) + assert nums == [1] + + f() + assert nums == [1, 2, 0] + + +def defer_scope_can_be_used_to_wrap_iterables() -> None: + """ + When called with an iterable as the argument, `defer_scope(...)` + returns a similar iterable with one distinct `defer_scope` context + for each iteration. + """ + + from deferrer import defer, defer_scope + + nums = [] + + for i in defer_scope([1, 2, 3]): + defer and nums.append(-1) + nums.append(i) + defer and nums.append(0) + + assert nums == [1, 0, -1, 2, 0, -1, 3, 0, -1] diff --git a/deferrer_tests/samples/sugarful_with_defer_scope.py b/deferrer_tests/samples/sugarful_with_defer_scope.py new file mode 100644 index 0000000..8ee11e7 --- /dev/null +++ b/deferrer_tests/samples/sugarful_with_defer_scope.py @@ -0,0 +1,5 @@ +from deferrer import defer, defer_scope + +# `defer` is allowed in a `defer_scope()` context. +with defer_scope(): + defer and 1 diff --git a/deferrer_tests/samples/sugarful_without_defer_scope.py b/deferrer_tests/samples/sugarful_without_defer_scope.py new file mode 100644 index 0000000..9c4e415 --- /dev/null +++ b/deferrer_tests/samples/sugarful_without_defer_scope.py @@ -0,0 +1,4 @@ +from deferrer import defer + +# The following line will cause a `RuntimeError`. +defer and 1 diff --git a/deferrer_tests/samples/sugarless_with_defer_scope.py b/deferrer_tests/samples/sugarless_with_defer_scope.py new file mode 100644 index 0000000..5f38b5c --- /dev/null +++ b/deferrer_tests/samples/sugarless_with_defer_scope.py @@ -0,0 +1,8 @@ +from deferrer import defer, defer_scope + +# `defer` is allowed in a `defer_scope()` context. +with defer_scope(): + + @defer + def _() -> None: + pass diff --git a/deferrer_tests/samples/sugarless_without_defer_scope.py b/deferrer_tests/samples/sugarless_without_defer_scope.py new file mode 100644 index 0000000..9951e4a --- /dev/null +++ b/deferrer_tests/samples/sugarless_without_defer_scope.py @@ -0,0 +1,4 @@ +from deferrer import defer + +# The following line will cause a `RuntimeError`. +defer(lambda: None)() diff --git a/pyproject.toml b/pyproject.toml index b28c751..8b84546 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,16 +8,19 @@ name = "deferrer" authors = [{ name = "Chris Fu", email = "17433201@qq.com" }] description = "Fancy `defer` in python 3.12" classifiers = [ + "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Intended Audience :: Developers", - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", ] readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.12, <3.13" dynamic = ["version"] +keywords = ["defer", "sugar"] [project.urls] @@ -30,6 +33,7 @@ profile = "black" case_sensitive = true combine_as_imports = true +project = ["deferrer"] reverse_relative = true star_first = true treat_all_comments_as_code = true @@ -133,7 +137,7 @@ reportUntypedClassDecorator = "none" reportUntypedFunctionDecorator = "none" reportUntypedNamedTuple = "warning" reportCallInDefaultInitializer = "warning" -reportImplicitOverride = "warning" +reportImplicitOverride = "none" reportImplicitStringConcatenation = "warning" reportImportCycles = "none" reportMissingSuperCall = "none" @@ -147,14 +151,15 @@ reportUnusedCallResult = "error" [tool.pytest.ini_options] -python_files = ["*_test.py"] -python_classes = ["*Tests"] +python_files = ["*_tests/*.py"] +python_classes = ["*"] +python_functions = ["*"] addopts = [ "--doctest-modules", "--doctest-glob=*.md", "--cov", "--cov-report=xml", - "--ignore-glob=*_test__*.py", + "--ignore-glob=**/samples/*", ] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a87804e --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from setuptools import setup + +setup(packages=["deferrer"]) From 3a2b189a958506506f1c6b28b79a6c5f6646dfcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=82=85=E7=AB=8B=E4=B8=9A=EF=BC=88Chris=20Fu=EF=BC=89?= <17433201@qq.com> Date: Fri, 6 Sep 2024 18:44:15 +0800 Subject: [PATCH 2/3] Make Pyright happy --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a87804e..8465e88 100644 --- a/setup.py +++ b/setup.py @@ -2,4 +2,4 @@ from setuptools import setup -setup(packages=["deferrer"]) +__ = setup(packages=["deferrer"]) From d401a90cac1ca31aefc6f0dab705d51515a70bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=82=85=E7=AB=8B=E4=B8=9A=EF=BC=88Chris=20Fu=EF=BC=89?= <17433201@qq.com> Date: Fri, 6 Sep 2024 18:53:35 +0800 Subject: [PATCH 3/3] Stop using `setup.py` --- pyproject.toml | 4 ++++ setup.py | 5 ----- 2 files changed, 4 insertions(+), 5 deletions(-) delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml index 8b84546..bbe185f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,5 +163,9 @@ addopts = [ ] +[tool.setuptools] +packages = ["deferrer"] + + [tool.setuptools.dynamic] version = { attr = "deferrer.__version__" } diff --git a/setup.py b/setup.py deleted file mode 100644 index 8465e88..0000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from setuptools import setup - -__ = setup(packages=["deferrer"])