From 5fbe96d1aae0ae73a46930a9e62b57542575d687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Apr 2026 05:01:58 +0300 Subject: [PATCH 1/8] Add DeepWiki badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7c24154..cd3d3aa 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ [![PyPI version](https://badge.fury.io/py/transfunctions.svg)](https://badge.fury.io/py/transfunctions) [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mutating/transfunctions) From 85ca0766734f09603fb9a7bc0df65f145c922a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Apr 2026 05:02:23 +0300 Subject: [PATCH 2/8] Bump version to 0.0.13 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2654ab5..0d30b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "transfunctions" -version = "0.0.12" +version = "0.0.13" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Say NO to Python fragmentation on sync and async' readme = "README.md" From 763abcdf0280fa66aebfc28b13540e916abf1eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Apr 2026 05:03:18 +0300 Subject: [PATCH 3/8] Update getsources to version 0.0.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0d30b68..c132741 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ readme = "README.md" requires-python = ">=3.8" dependencies = [ 'displayhooks>=0.0.6', - 'getsources>=0.0.3', + 'getsources>=0.0.4', 'typing_extensions ; python_version <= "3.10"', ] classifiers = [ From 8bde8e806969ad854d6b0748ea2f0a39e81d3751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Apr 2026 05:03:59 +0300 Subject: [PATCH 4/8] Add 'Typing :: Typed' classifier to pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c132741..0c605d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Code Generators', 'Framework :: AsyncIO', + 'Typing :: Typed', ] keywords = [ 'async', From 0ee3251e406affceb796a4eae45d8bcdd45c357a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 16:06:11 +0300 Subject: [PATCH 5/8] Add a lot of docstrings --- tests/documentation/test_readme.py | 5 + .../decorators/test_superfunction_typing.py | 86 +++ .../decorators/test_transfunction_typing.py | 70 +++ tests/units/decorators/test_superfunction.py | 317 +++++++++++ tests/units/decorators/test_transfunction.py | 530 ++++++++++++++++++ tests/units/test_universal_namespace.py | 42 ++ 6 files changed, 1050 insertions(+) diff --git a/tests/documentation/test_readme.py b/tests/documentation/test_readme.py index 221796f..be39a85 100644 --- a/tests/documentation/test_readme.py +++ b/tests/documentation/test_readme.py @@ -11,6 +11,11 @@ def test_quick_start(): + """ + Ensure a decorated template generates usual, async, and generator callables that share common code and retain only their matching marker block. + + Each generated callable is executed in the way required for its type, including awaiting the async function and iterating the generator, and stdout is checked for the common prefix plus the type-specific message. + """ @transfunction def template(): print('so, ', end='') # noqa: T201 diff --git a/tests/typing/decorators/test_superfunction_typing.py b/tests/typing/decorators/test_superfunction_typing.py index 4e07e44..3cecc3c 100644 --- a/tests/typing/decorators/test_superfunction_typing.py +++ b/tests/typing/decorators/test_superfunction_typing.py @@ -30,6 +30,11 @@ @pytest.mark.mypy_testing def test_superfunction_deduced_return_type_sync() -> None: + """ + Ensure mypy infers a synchronous superfunction tilde call as the template's annotated return type. + + The check uses reveal_type on the sync call path and expects int, focusing on static return type deduction rather than runtime behavior. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -42,6 +47,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_deduced_return_type_async() -> None: + """ + Locks down that async use of a @superfunction exposes the template's annotated return type. + + The check treats the decorated function call as a coroutine passed to asyncio.run and expects the revealed result type to remain int, while still allowing the forwarded positional argument. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -54,6 +64,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_fail_on_incorrect_arg_type_sync() -> None: + """ + Reject a synchronous tilde call to a superfunction when the first positional argument has the wrong type. + + The keyword argument is valid, so the checked failure is that None is not accepted where the original template requires a float. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -66,6 +81,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_fail_on_incorrect_kwarg_type_sync() -> None: + """ + A @superfunction preserves keyword-only argument types for static checking in the synchronous tilde call path. + + The check verifies that mypy rejects passing None to an int parameter named kwarg. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -78,6 +98,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_on_correct_args_types_sync() -> None: + """ + A superfunction preserves its original parameter types for valid synchronous tilde calls. + + The check calls the decorated function with a float positional argument and an int keyword-only argument, matching the template signature, and expects static type checking to accept the call. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -90,6 +115,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_fail_on_incorrect_arg_type_async() -> None: + """ + Ensure a superfunction keeps its ParamSpec when type checked through async-style invocation. + + The call is wrapped in asyncio.run, passes None for a parameter annotated as float, and supplies a valid keyword-only int so the expected mypy failure is limited to the first positional argument. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -102,6 +132,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_fail_on_incorrect_kwarg_type_async() -> None: + """ + Ensure async superfunction calls preserve keyword-only parameter types for static checking. + + The test exercises coroutine consumption with asyncio.run and expects mypy to reject passing None to an int keyword-only argument. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -114,6 +149,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_on_correct_args_types_async() -> None: + """ + Ensures @superfunction keeps the template ParamSpec when called through async usage. + + The type check should accept asyncio.run on the decorated function with the required float positional argument and keyword-only int argument, with no expected mypy errors. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -126,6 +166,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_fail_on_missing_args_sync() -> None: + """ + Ensure type checking reports a missing-argument error for sync tilde calls to a superfunction without its required positional argument. + + The runtime TypeError is suppressed so the test can verify the inline type-checking expectation rather than fail during execution. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -140,6 +185,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_superfunction_param_spec_fail_on_extra_args_sync() -> None: + """ + Track the xfailed extra-positional-argument case for sync tilde superfunction calls. + + The call uses unary ~ with the valid first argument, the keyword-only argument, and one additional positional value under suppress(TypeError). + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -154,6 +204,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_superfunction_param_spec_fail_on_extra_kwargs_sync() -> None: + """ + Reject unexpected keyword arguments on synchronous tilde calls to a superfunction. + + The decorated function allows a float positional argument and the keyword-only kwarg, so passing kwarg2 should be a static call-shape error. Runtime TypeError is suppressed because the assertion is about typing. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -167,6 +222,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_superfunction_param_spec_fail_on_missing_args_async() -> None: + """ + Mypy rejects asyncio.run calls to a superfunction that omit a required positional argument. + + Runtime TypeError is suppressed so the test can keep the focus on the preserved ParamSpec call-shape error for async-style consumption. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -181,6 +241,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_superfunction_param_spec_fail_on_extra_args_async() -> None: + """ + Track the xfailed extra-positional-argument case for async-style superfunction calls. + + The call is wrapped in asyncio.run under suppress(TypeError), with a valid first argument, the keyword-only argument, and one additional positional value. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -195,6 +260,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_superfunction_param_spec_fail_on_extra_kwargs_async() -> None: + """ + Async-style superfunction calls reject unexpected keyword arguments. + + The checked call is wrapped in asyncio.run under suppress(TypeError), supplies the valid positional and keyword-only arguments, then adds one extra keyword. The typing failure is expected to be about that unexpected keyword. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -209,6 +279,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail # it shouldn't work because typed_superfunction is a generator function, gut it's not returning a generator object according to it's typing. def test_simple_using_of_generator_function_with_simple_yield_from() -> None: + """ + Calling a typed superfunction in generator mode is expected to fail static typing when its generator branch uses native yield from. + + This locks down the known limitation that raw generator syntax makes the decorated template look like a generator function to mypy, even though list(...) is the intended generator-mode use at runtime. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -225,6 +300,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.xfail @pytest.mark.mypy_testing def test_wrong_using_of_generator_function_with_simple_yield_from() -> None: + """ + Generator-style use of a superfunction still enforces the original positional argument type. + + This xfailed typing case checks that consuming the generator form does not hide that None is invalid for a float parameter, even when the template uses a plain yield from branch. + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -239,6 +319,11 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_simple_using_of_generator_function_with_yield_from_it_marker_function() -> None: + """ + A @superfunction generator_context branch using yield_from_it type-checks when the generated function is consumed with list(...). + + The template body has separate sync_context, async_context, and generator_context blocks. The generator block calls yield_from_it with integer values, and the check calls the decorated object itself with a valid original argument and wraps that call in list(...). + """ @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -255,6 +340,7 @@ def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.xfail @pytest.mark.mypy_testing def test_using_of_generator_function_with_yield_from_it_marker_function_with_wrong_return_value() -> None: + """Documents the typing gap where yield_from_it can yield strings from a superfunction annotated to return int.""" @superfunction def typed_superfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: diff --git a/tests/typing/decorators/test_transfunction_typing.py b/tests/typing/decorators/test_transfunction_typing.py index f49d297..c4f0b6f 100644 --- a/tests/typing/decorators/test_transfunction_typing.py +++ b/tests/typing/decorators/test_transfunction_typing.py @@ -14,6 +14,11 @@ @pytest.mark.mypy_testing def test_transfunction_deduced_return_type_sync(): + """ + Verify that @transfunction preserves the annotated return type for a generated synchronous function. + + The type check calls the regular function returned by get_usual_function() and confirms that a template annotated to return int is inferred as int. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -27,6 +32,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_deduced_return_type_async(): + """ + Verify that the decorated object's get_async_function() method preserves the template return type after asyncio.run(...). + + The template is a regular @transfunction body with sync_context and async_context blocks, not an async def. The type check calls get_async_function() as a method on the decorated object, invokes the returned async callable, and consumes it through asyncio.run expecting the annotated int result. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -39,6 +49,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_param_spec_fail_on_incorrect_arg_type_sync(): + """ + A transfunction-generated synchronous callable preserves the original positional argument type. + + The check passes None where a float is required while keeping the keyword-only argument valid, so the expected type failure is isolated to the first positional argument. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -51,6 +66,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_param_spec_fail_on_incorrect_kwarg_type_sync(): + """ + @transfunction preserves keyword-only parameter types on the callable returned by get_usual_function(). + + The check calls get_usual_function()(...) with a valid positional argument and an invalid keyword-only value, so the expected type failure is isolated to that keyword-only int parameter. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -63,6 +83,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_param_spec_fail_on_missing_args_sync(): + """ + The callable returned by a @transfunction object's get_usual_function() still requires the template's positional arguments. + + The template body uses with sync_context and with async_context blocks. The check calls get_usual_function() on the decorated object, then invokes that generated sync function with no arguments while suppressing runtime TypeError. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -77,6 +102,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_transfunction_param_spec_fail_on_extra_args_sync(): + """ + Reject extra positional arguments on the callable returned by get_usual_function(). + + The xfailed check calls the generated sync function with the required float, the valid keyword-only int, and one unexpected positional value while suppressing runtime TypeError. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -91,6 +121,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_transfunction_param_spec_fail_on_extra_kwargs_sync(): + """ + Reject unexpected keywords on the callable returned by get_usual_function(). + + The xfailed check calls the generated sync function with valid arguments plus an unsupported keyword while suppressing runtime TypeError. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -104,6 +139,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_param_spec_on_correct_args_types_sync(): + """ + A sync transfunction preserves the template ParamSpec so correctly typed arguments are accepted. + + This checks the positive mypy case for get_usual_function: a valid float positional argument and keyword-only int argument should type-check without an expected-error marker. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -116,6 +156,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_param_spec_fail_on_incorrect_arg_type_async(): + """ + The async callable returned by get_async_function() preserves the template's positional parameter type. + + The check runs the generated coroutine with None where a float is required, while keeping the keyword-only argument valid. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -128,6 +173,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_param_spec_fail_on_incorrect_kwarg_type_async(): + """ + The async callable returned by get_async_function() preserves the template's keyword-only parameter type. + + The check runs the generated coroutine with a valid float and None for the keyword-only int argument. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -140,6 +190,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing def test_transfunction_param_spec_fail_on_missing_args_async(): + """ + The async callable returned by get_async_function() still requires the template's positional argument. + + Runtime TypeError is suppressed so the inline mypy expectation can check the missing-argument error. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -153,6 +208,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_transfunction_param_spec_fail_on_extra_args_async(): + """ + Document the current xfailed gap for extra positional arguments in the async-labeled transfunction case. + + The body calls get_usual_function() inside asyncio.run with one unexpected positional value, so this test does not actually cover get_async_function(). + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -167,6 +227,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_transfunction_param_spec_fail_on_extra_kwargs_async(): + """ + Document the current xfailed gap for unexpected keywords in the async-labeled transfunction case. + + The body calls get_usual_function() inside asyncio.run with an extra keyword, so this test does not actually cover get_async_function(). + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: @@ -181,6 +246,11 @@ def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 @pytest.mark.mypy_testing @pytest.mark.xfail def test_transfunction_param_spec_on_correct_args_types_async(): + """ + Exercise the positive ParamSpec case for get_async_function(). + + The generated async callable is run with a valid float positional argument and keyword-only int argument, and the call is expected to type-check successfully. + """ @transfunction def typed_transfunction(arg: float, *, kwarg: int = 0) -> int: # noqa: ARG001 with sync_context: diff --git a/tests/units/decorators/test_superfunction.py b/tests/units/decorators/test_superfunction.py index 073252a..da99e7a 100644 --- a/tests/units/decorators/test_superfunction.py +++ b/tests/units/decorators/test_superfunction.py @@ -36,6 +36,11 @@ global_variable = 123 def test_just_sync_call_without_breackets(): + """ + A bare @superfunction call evaluated with unary ~ runs only the sync_context branch. + + The template has sync and async branches that print different values plus a generator branch that would yield values. The check redirects stdout around ~function() and expects only the sync branch output. + """ @superfunction def function(): with sync_context: @@ -52,6 +57,11 @@ def function(): def test_just_sync_call_without_tilde_syntax(): + """ + A superfunction with tilde syntax disabled runs its sync branch when called with ordinary function-call syntax. + + The check discards the call result and verifies that only the sync marker code produces output, while async and generator marker branches stay inactive. + """ @superfunction(tilde_syntax=False) def function(): with sync_context: @@ -68,6 +78,11 @@ def function(): def test_just_sync_call_with_tilde_syntax(): + """ + Applying tilde to a superfunction call with tilde_syntax=True executes only the sync context. + + The stdout assertion distinguishes the sync branch from the async and generator branches. + """ @superfunction(tilde_syntax=True) def function(): with sync_context: @@ -84,6 +99,11 @@ def function(): def test_just_async_call(): + """ + Calling a superfunction as a coroutine runs only its async context branch. + + The assertion checks captured stdout after asyncio.run to confirm that the sync and generator branches were not executed. + """ @superfunction def function(): with sync_context: @@ -100,6 +120,11 @@ def function(): def test_just_generator_iteration(): + """ + Iterating a bare superfunction call selects and runs only its generator context. + + The test converts the call result to a list and checks that it yields [1, 2, 3] while captured stdout stays empty, proving the sync and async contexts did not execute. + """ @superfunction def function(): with sync_context: @@ -118,6 +143,11 @@ def function(): def test_just_sync_call_with_arguments(): + """ + A tilde call to a superfunction with positional arguments runs the synchronous branch with those arguments. + + The captured output confirms that only the sync path contributes and that the first positional argument is used. + """ @superfunction def function(a, b): with sync_context: @@ -134,6 +164,11 @@ def function(a, b): def test_just_async_call_with_arguments(): + """ + Calling a parameterized superfunction as an async function runs only its async context with the original positional arguments. + + The captured output confirms the async branch receives the second argument while the sync and generator branches are skipped. + """ @superfunction def function(a, b): with sync_context: @@ -150,6 +185,11 @@ def function(a, b): def test_just_generator_with_arguments_iteration(): + """ + Calling a superfunction with arguments and iterating the result selects its generator behavior. + + The generated iterator receives the original arguments and yields values from them, while the sync and async branches remain inactive. + """ @superfunction def function(a, b): with sync_context: @@ -168,6 +208,11 @@ def function(a, b): def test_tilda_syntax_for_function_call_without_arguments(): + """ + A no-argument default superfunction call returns its plain synchronous result when the call is evaluated with unary tilde. + + This checks the simplest tilde syntax path: no argument binding, alternate contexts, side effects, or exception handling are involved. + """ @superfunction def function(): return 124 @@ -176,6 +221,8 @@ def function(): def test_tilda_syntax_for_function_call_with_arguments(): + """ + Using tilde syntax on a superfunction call forwards positional arguments, default parameters, and keyword overrides to the regular synchronous function.""" @superfunction def function(a, b, c=4, d=3): return 1 + a + b + c + d @@ -184,6 +231,11 @@ def function(a, b, c=4, d=3): def test_tilda_syntax_for_function_call_without_arguments_raise_exception(): + """ + Ensure a zero-argument superfunction propagates a ValueError when called with the default tilde syntax. + + The check invokes the function as ~function() and verifies that the original error message is preserved. + """ @superfunction def function(): raise ValueError('some text') @@ -193,6 +245,11 @@ def function(): def test_tilda_syntax_for_function_call_with_arguments_raise_exception(): + """ + Unary tilde calls on a default superfunction pass positional and keyword arguments through and propagate the raised exception. + + The case uses mixed explicit and default arguments, then checks that the original exception type and message are preserved instead of being hidden by the call syntax. + """ @superfunction def function(a, b, c=4, d=3): # noqa: ARG001 raise ValueError('some text') @@ -202,6 +259,11 @@ def function(a, b, c=4, d=3): # noqa: ARG001 def test_return_value_from_async_simple_superfunction(): + """ + An async call to a no-argument superfunction returns the value from a plain body return. + + The check runs the decorated function as a coroutine and expects the returned value to be 1. + """ @superfunction def function(): return 1 @@ -210,6 +272,11 @@ def function(): def test_return_awaited_value_from_async_simple_superfunction(): + """ + Awaiting a zero-argument superfunction returns the value produced by await_it on an inner coroutine. + + The check runs the decorated function through the async entry path and compares the result with a sentinel value from the coroutine. + """ async def another_one(): return 1 @@ -221,6 +288,11 @@ def function(): def test_return_value_from_async_superfunction_with_arguments(): + """ + A @superfunction call used as an async coroutine returns its template result after binding positional, keyword, and default arguments. + + The check runs the decorated function through asyncio.run with one positional argument, one keyword override, and one default value, proving the async path forwards arguments and propagates the return value. + """ @superfunction def function(a, b=5, c=10): return a + b + c @@ -229,6 +301,7 @@ def function(a, b=5, c=10): def test_return_awaited_value_from_async_superfunction_with_arguments(): + """Async superfunctions return the awaited result while forwarding positional arguments, keyword overrides, and defaults correctly.""" async def another_one(a, b, c): return a + b + c @@ -240,6 +313,11 @@ def function(a, b=5, c=10): def test_call_superfunction_with_tilda_multiple_times(): + """ + A regular superfunction can be invoked with the default tilde syntax repeatedly. + + Each check uses a fresh tilde call on the same decorated function and expects the original scalar return value every time. + """ @superfunction def function(): return 4 @@ -250,6 +328,11 @@ def function(): def test_async_call_superfunction_multiple_times(): + """ + A superfunction returns the same value across repeated independent async calls. + + The test uses separate calls through the async entry point to confirm repeatability without relying on reusing or re-awaiting the same coroutine object. + """ @superfunction def function(): return 4 @@ -260,6 +343,11 @@ def function(): def test_generator_call_superfunction_multiple_times(): + """ + A generator @superfunction call can be repeated, producing the same yielded values each time. + + Each check calls the wrapper anew and consumes that fresh generator, so the test is about repeatable generator creation rather than reusing one iterator. + """ @superfunction def function(): yield 4 @@ -270,6 +358,11 @@ def function(): def test_combine_with_other_decorator_before(): + """ + Rejects a superfunction template when another decorator is applied before superfunction. + + The check uses tilde generation so the regular synchronous path must still enforce the direct decorator-stacking restriction. + """ def other_decorator(function): return function @@ -283,6 +376,11 @@ def template(): def test_combine_with_other_decorator_after(): + """ + Stacking another decorator outside @superfunction is rejected when regular superfunction use is generated. + + The function can be defined, but applying tilde syntax must raise WrongDecoratorSyntaxError, confirming decorator-list validation happens during lazy regular-function creation. + """ def other_decorator(function): return function @@ -296,6 +394,11 @@ def template(): def test_pass_coroutine_function_to_decorator(): + """ + Rejects an async function used directly as a @superfunction template during decoration. + + The check verifies that coroutine templates fail before the decorated function is called and that the error identifies regular or generator functions as the only allowed template forms. + """ with pytest.raises(ValueError, match=match("Only regular or generator functions can be used as a template for @superfunction. You can't use async functions.")): @superfunction async def function_maker(): @@ -303,16 +406,31 @@ async def function_maker(): def test_pass_not_function_to_decorator(): + """ + Rejects direct use of superfunction when the template argument is not a regular or generator function. + + This locks down the generic validation path for invalid decorator input, before any wrapping behavior can occur. + """ with pytest.raises(ValueError, match=match("Only regular or generator functions can be used as a template for @superfunction.")): superfunction(1) def test_try_to_pass_lambda_to_decorator(): + """ + Rejects a lambda passed directly as a superfunction template. + + The check confirms that lambda templates fail immediately with ValueError before any generated function is created or invoked. + """ with pytest.raises(ValueError, match=match("Only regular or generator functions can be used as a template for @superfunction. Don't use lambdas here.")): superfunction(lambda x: x) def test_choose_tilde_syntax_off_and_use_tilde(): + """ + Disabled tilde syntax rejects calls made with unary tilde. + + The test decorates an empty superfunction with tilde_syntax=False and checks that using the tilde call form raises NotImplementedError with the disabled-tilde error message. + """ @superfunction(tilde_syntax=False) def function(): pass @@ -322,6 +440,11 @@ def function(): def test_call_superfunction_without_tilde_syntax_whet_it_is_on_by_default(): + """ + Bare @superfunction callables reject plain function() calls while tilde syntax is enabled by default. + + The call leaves the tracer unused, so the test checks the resulting finalizer failure through the unraisable-exception hook rather than expecting the call itself to raise. + """ exception_message = None def temporary_hook(unraisable): nonlocal exception_message @@ -341,6 +464,11 @@ def function(): def test_call_superfunction_without_tilde_syntax_whet_it_is_on(): + """ + Plain calls to a tilde-enabled superfunction are rejected instead of running synchronously. + + The test checks the unraisable exception path because this misuse is reported when the unused call result is finalized. + """ exception_message = None def temporary_hook(unraisable): nonlocal exception_message @@ -360,6 +488,11 @@ def function(): def test_there_is_exception_if_not_tilde_mode_and_in_function_is_empty_return_in_common_block(): + """ + Non-tilde superfunctions reject a bare return in the common body at decoration time. + + The check defines the decorated function inside the exception assertion, so the failure must happen during validation rather than during a later call. + """ with pytest.raises(WrongTransfunctionSyntaxError, match=match('A superfunction cannot contain a return statement.')): @superfunction(tilde_syntax=False) def function(): @@ -367,6 +500,11 @@ def function(): def test_there_is_exception_if_not_tilde_mode_and_in_function_is_return_true_in_common_block(): + """ + Non-tilde superfunctions reject a value-returning return in the common body. + + The error is raised while the decorator is applied, before the function can be called. + """ with pytest.raises(WrongTransfunctionSyntaxError, match=match('A superfunction cannot contain a return statement.')): @superfunction(tilde_syntax=False) def function(): @@ -374,6 +512,11 @@ def function(): def test_there_is_exception_if_not_tilde_mode_and_in_function_is_empty_return_in_sync_block(): + """ + Decorating a no-tilde superfunction raises WrongTransfunctionSyntaxError for a bare return inside sync_context. + + The failure is checked during decoration, before the generated function is ever called. + """ with pytest.raises(WrongTransfunctionSyntaxError, match=match('A superfunction cannot contain a return statement.')): @superfunction(tilde_syntax=False) def function(): @@ -382,6 +525,11 @@ def function(): def test_there_is_exception_if_not_tilde_mode_and_in_function_is_return_true_in_sync_block(): + """ + Non-tilde superfunctions reject value-returning returns inside a sync_context block. + + The error is expected while applying the decorator, before the function is ever called, because the sync block belongs to the normal generated function path where return values are not allowed. + """ with pytest.raises(WrongTransfunctionSyntaxError, match=match('A superfunction cannot contain a return statement.')): @superfunction(tilde_syntax=False) def function(): @@ -390,6 +538,11 @@ def function(): def test_there_are_no_exceptions_if_not_tilde_mode_and_in_function_is_empty_return_in_async_block(): + """ + Non-tilde superfunctions allow a bare return inside an async-only block. + + This locks down that definition-time validation succeeds when the return is confined to async context, without checking execution or returned values. + """ @superfunction(tilde_syntax=False) def function(): with async_context: @@ -397,6 +550,11 @@ def function(): def test_there_are_no_exceptions_if_not_tilde_mode_and_in_function_is_return_true_in_async_block(): + """ + Plain-call superfunctions may be decorated when their only return value is inside an async_context block. + + The test checks definition-time validation only: the decorated function is not called, and no runtime async behavior is asserted. + """ @superfunction(tilde_syntax=False) def function(): with async_context: @@ -404,6 +562,11 @@ def function(): def test_there_are_no_exceptions_if_not_tilde_mode_and_in_function_is_empty_return_in_generator_block(): + """ + Allow non-tilde superfunctions to decorate a template with a bare return inside generator_context. + + The check is decoration-only: it locks down that the return restriction does not reject code removed from the generated sync function. + """ @superfunction(tilde_syntax=False) def function(): with generator_context: @@ -411,6 +574,11 @@ def function(): def test_there_are_no_exceptions_if_not_tilde_mode_and_in_function_is_return_true_in_generator_block(): + """ + Non-tilde superfunctions allow a valued return inside a generator_context block without raising during decoration. + + This locks down that the allowed return is specific to the generator block, even when the return carries a truthy value. + """ @superfunction(tilde_syntax=False) def function(): with generator_context: @@ -418,6 +586,11 @@ def function(): def test_async_function_with_all_content_in_generator_context(): + """ + Awaiting a superfunction with only generator-context content returns None. + + The async path should discard the generator-only block entirely, including its return value, leaving the generated coroutine to complete without an explicit result. + """ @superfunction def function(): with generator_context: @@ -427,6 +600,11 @@ def function(): def test_async_function_with_all_content_in_sync_context(): + """ + Async dispatch drops sync-only template content and completes with None. + + The test uses a superfunction whose only meaningful body is inside sync_context, then runs it as a coroutine and checks that the sync return value is ignored. + """ @superfunction def function(): with sync_context: @@ -436,6 +614,7 @@ def function(): def test_usual_tilde_function_with_all_content_in_generator_context(): + """A tilde call ignores generator_context-only content and returns None when no synchronous body remains.""" @superfunction def function(): with generator_context: @@ -445,6 +624,10 @@ def function(): def test_usual_tilde_function_with_all_content_in_async_context(): + """Ensure a usual tilde call returns None when a superfunction has only async-context content. + + This confirms that the synchronous variant ignores async-only content rather than running it. + """ @superfunction def function(): with async_context: @@ -454,6 +637,11 @@ def function(): def test_basic_yield_from_it(): + """ + @superfunction yields each item provided through yield_from_it inside generator_context. + + The check uses list(function()) to select the generated iterator behavior and confirms the literal iterable is forwarded as [1, 2, 3]. + """ @superfunction def function(): with generator_context: @@ -463,6 +651,11 @@ def function(): def test_yield_from_it_with_function_call(): + """ + A superfunction generator expands yield_from_it(helper()) into the iterable returned by helper. + + This checks that the marker accepts a call expression and that iterating the decorated function uses the generator variant. + """ def some_other_function(): return [1, 2, 3] @@ -476,6 +669,11 @@ def function(): def test_await_it_with_two_arguments(): + """ + Reject await_it with two positional arguments in a superfunction async branch. + + The invalid marker is detected when the lazily generated async form is executed, so the check runs the template through asyncio.run and expects the single-positional-argument syntax error. + """ async def another_function(): return None @@ -489,6 +687,11 @@ def template(): def test_await_it_without_arguments(): + """ + Rejects a zero-argument await_it marker when a superfunction is invoked asynchronously. + + The check confirms that lazy async generation raises the marker syntax error instead of treating await_it() as a valid await expression. + """ @superfunction def template(): with async_context: @@ -499,6 +702,11 @@ def template(): def test_await_it_with_one_usual_and_one_named_arguments(): + """ + await_it rejects a positional awaitable combined with any keyword argument in a superfunction async branch. + + The test forces the async path and checks that this invalid marker shape raises the marker syntax error for using anything other than one positional argument. + """ async def another_function(): return None @@ -512,6 +720,7 @@ def template(): def test_yield_from_it_with_two_arguments(): + """yield_from_it rejects multiple positional arguments in a generator-only superfunction template with a marker syntax error.""" @superfunction def template(): with generator_context: @@ -522,6 +731,11 @@ def template(): def test_yield_from_it_without_arguments(): + """ + yield_from_it without an argument is rejected in a superfunction generator context. + + The check forces the generated iterator path so validation happens during lazy generator variant selection, covering the zero-argument marker case. + """ @superfunction def template(): with generator_context: @@ -532,6 +746,11 @@ def template(): def test_yield_from_it_with_one_usual_and_one_named_arguments(): + """ + yield_from_it rejects keyword arguments even when it receives one positional iterable. + + The check is exercised by iterating the superfunction, which requests generator code generation and should raise WrongMarkerSyntaxError. + """ @superfunction def template(): with generator_context: @@ -542,6 +761,11 @@ def template(): def test_string_literal_default_value_for_usual_function_with_tilde(): + """ + Preserve a string literal default when a superfunction is called through tilde syntax. + + Calling the synchronous variant without arguments should still use the original default value. + """ @superfunction def function(string='kek'): return string @@ -550,6 +774,11 @@ def function(string='kek'): def test_int_literal_default_value_for_usual_function_with_tilde(): + """ + Preserve an integer literal default when invoking a superfunction's usual branch with tilde syntax. + + The check omits the argument and verifies that the synchronous call uses the generated function's default value. + """ @superfunction def function(number=123): return number @@ -558,6 +787,7 @@ def function(number=123): def test_list_literal_default_value_for_usual_function_with_tilde(): + """Usual tilde calls to a superfunction reuse the same list literal default across invocations.""" @superfunction def function(number, lst=[]): # noqa: B006 lst.append(number) @@ -568,6 +798,11 @@ def function(number, lst=[]): # noqa: B006 def test_list_literal_default_value_it_the_same_for_all_types_of_functions_when_usual_one_is_with_tilde(): + """ + Mutable list defaults are shared across the usual, async, and generator variants of one superfunction when the usual variant is called with tilde syntax. + + Each mode observes the accumulated mutations from the previous calls, proving that the generated variants do not receive separate default lists. + """ @superfunction def function(number, lst=[]): # noqa: B006 lst.append(number) @@ -589,6 +824,7 @@ def function(number, lst=[]): # noqa: B006 def test_string_literal_default_value_for_async_function(): + """Async superfunction calls preserve string literal positional defaults when no argument is supplied.""" @superfunction def function(string='kek'): return string @@ -597,6 +833,11 @@ def function(string='kek'): def test_int_literal_default_value_for_async_function(): + """ + Async superfunctions preserve an int literal default when called without arguments. + + The check consumes the generated coroutine with asyncio.run and expects the default value to be returned. + """ @superfunction def function(number=123): return number @@ -605,6 +846,11 @@ def function(number=123): def test_list_literal_default_value_for_async_function(): + """ + Async superfunctions preserve normal Python identity semantics for mutable default arguments. + + The coroutine path is checked by running the same superfunction twice with a list literal default and confirming that appended values accumulate across invocations. + """ @superfunction def function(number, lst=[]): # noqa: B006 lst.append(number) @@ -615,6 +861,7 @@ def function(number, lst=[]): # noqa: B006 def test_string_literal_default_value_for_generator_function(): + """A superfunction used as a generator preserves a string literal default argument when called without arguments.""" @superfunction def function(string='kek'): yield string @@ -623,6 +870,11 @@ def function(string='kek'): def test_int_literal_default_value_for_generator_function(): + """ + A superfunction generator preserves an integer literal positional default when called without arguments. + + The check consumes the decorated function with list(...) and expects the yielded value to be 123, confirming generator mode uses the original default value. + """ @superfunction def function(number=123): yield number @@ -631,6 +883,11 @@ def function(number=123): def test_list_literal_default_value_for_generator_function(): + """ + Generator-style superfunctions preserve and reuse a list literal default across calls. + + The test consumes two calls with list(...) so generator mode is selected and confirms the second result includes the mutation from the first call. + """ @superfunction def function(number, lst=[]): # noqa: B006 lst.append(number) @@ -641,6 +898,11 @@ def function(number, lst=[]): # noqa: B006 def test_nonlocal_variable_default_value_for_usual_function_with_tilde(): + """ + A usual superfunction call via tilde preserves a default argument value captured from an enclosing local variable. + + The check calls the generated ordinary function without an explicit argument and expects it to return the value that was in scope when the decorated function was defined. + """ variable = 123 @superfunction @@ -651,6 +913,11 @@ def function(number=variable): def test_global_variable_default_value_for_usual_function_with_tilde(): + """ + A usual superfunction call with unary tilde preserves a default argument evaluated from a module-level global. + + The wrapped function returns that parameter directly, so calling it without an argument checks that generated usual-function execution keeps the original default value intact. + """ @superfunction def function(number=global_variable): return number @@ -659,6 +926,11 @@ def function(number=global_variable): def test_resetted_global_variable_default_value_for_usual_function_with_tilde(): + """ + A usual superfunction call through tilde keeps a default argument captured from a local name that shadows a global. + + The test verifies that calling without an explicit argument returns the local default value, not the module-level global value. + """ global_variable = 'kek' @superfunction @@ -669,6 +941,7 @@ def function(number=global_variable): def test_nonlocal_variable_default_value_for_usual_function_without_tilde(): + """A usual function can use a nonlocal variable as a default value without tilde syntax.""" container = [] variable = 123 @@ -682,6 +955,11 @@ def function(number=variable): def test_global_variable_default_value_for_usual_function_without_tilde(): + """ + A no-tilde superfunction keeps a module global used as a normal function default. + + The test calls the usual function without passing that argument and checks that its side effect receives the global value. + """ container = [] @superfunction(tilde_syntax=False) @@ -694,6 +972,11 @@ def function(number=global_variable): def test_resetted_global_variable_default_value_for_usual_function_without_tilde(): + """ + A non-tilde superfunction call uses a default value captured from an enclosing local variable. + + The local variable named global_variable shadows the module global, and the side effect records that local default when the decorated function is called without arguments. + """ container = [] global_variable = 'kek' @@ -707,6 +990,11 @@ def function(number=global_variable): def test_nonlocal_variable_default_value_for_async_function(): + """ + Preserves a default parameter value captured from an enclosing local variable when an async superfunction is executed. + + The test checks that calling the decorated function through the coroutine path returns the nonlocal default value when no explicit argument is supplied. + """ variable = 123 @superfunction @@ -717,6 +1005,11 @@ def function(number=variable): def test_global_variable_default_value_for_async_function(): + """ + Async superfunction calls preserve parameter defaults that were bound from module globals. + + The check calls the decorated function through asyncio.run without arguments and expects the global-backed default value to be returned. + """ @superfunction def function(number=global_variable): return number @@ -725,6 +1018,11 @@ def function(number=global_variable): def test_resetted_global_variable_default_value_for_async_function(): + """ + Preserves a locally captured default argument when the superfunction is invoked as async. + + The default comes from a local variable that shadows a module global, so the generated async function must return the local value when called without an explicit argument. + """ global_variable = 'kek' @superfunction @@ -735,6 +1033,10 @@ def function(number=global_variable): def test_nonlocal_variable_default_value_for_generator_function(): + """Generator superfunctions preserve default arguments captured from an enclosing local variable. + + The check calls the generated generator without arguments and confirms iteration yields the captured default value. + """ variable = 123 @superfunction @@ -745,6 +1047,11 @@ def function(number=variable): def test_global_variable_default_value_for_generator_function(): + """ + Generator superfunctions preserve module-level global default arguments when called without an explicit value. + + The call is consumed with list() to exercise the generator path selected by normal iteration, and the yielded value must match the original global default. + """ @superfunction def function(number=global_variable): yield number @@ -753,6 +1060,11 @@ def function(number=global_variable): def test_resetted_global_variable_default_value_for_generator_function(): + """ + Generator superfunctions preserve a default value captured from a local variable that shadows a module global. + + The call is made without arguments and then iterated, so the yielded value must come from the original default rather than from a later global-name lookup. + """ global_variable = 'kek' @superfunction @@ -763,6 +1075,11 @@ def function(number=global_variable): def test_use_decorator_without_at(): + """ + Reject calling superfunction directly instead of applying it with decorator syntax. + + The wrapping call may succeed initially, but forcing the resulting sync, async, or generator form must raise the decorator-syntax error. + """ def template(): pass diff --git a/tests/units/decorators/test_transfunction.py b/tests/units/decorators/test_transfunction.py index d70031e..6d51f15 100644 --- a/tests/units/decorators/test_transfunction.py +++ b/tests/units/decorators/test_transfunction.py @@ -96,6 +96,11 @@ def null_indentation_function(): def test_result_is_transformer(): + """ + @transfunction turns a regular template function into a FunctionTransformer as soon as it is decorated. + + This checks the decorated object directly, before any derived sync, async, or generator function is requested. + """ @transfunction def function(): pass @@ -113,6 +118,11 @@ def function(): ], ) def test_direct_call_or_transformer(args, kwargs): + """ + Transfunction-decorated templates reject direct calls and require generating a usable function variant first. + + The check covers empty, positional, mixed, and keyword-only calls so the error is tied to direct transformer execution, not argument binding. + """ @transfunction def function_maker(*args, **kwargs): pass @@ -122,6 +132,11 @@ def function_maker(*args, **kwargs): def test_pass_coroutine_function_to_decorator(): + """ + @transfunction rejects an async template function at decoration time. + + The test defines an async function under the decorator and checks that the async-template ValueError is raised before any transformed function is requested. + """ with pytest.raises(ValueError, match=match("Only regular or generator functions can be used as a template for @transfunction. You can't use async functions.")): @transfunction async def function_maker(): @@ -129,11 +144,17 @@ async def function_maker(): def test_pass_not_function_to_decorator(): + """Passing a non-function object to @transfunction raises the generic template-type ValueError immediately, before any function generation is attempted.""" with pytest.raises(ValueError, match=match("Only regular or generator functions can be used as a template for @transfunction.")): transfunction(1) def test_create_usual_function_without_any_markers(): + """ + Create a normal function from a transfunction template with no marker blocks. + + A template containing only ordinary body code should still produce a real Python function whose call result matches that body. + """ @transfunction def function_maker(): return 4 @@ -145,6 +166,11 @@ def function_maker(): def test_create_usual_function_with_parameters_without_any_markers(): + """ + A marker-free transfunction template with parameters and a default can generate a regular function that preserves argument handling. + + The generated function is checked as a real function and returns the template body result when called with positional arguments plus the keyword default. + """ @transfunction def function_maker(a, b, c=3): return a + b + c @@ -156,24 +182,44 @@ def function_maker(a, b, c=3): def test_null_indentation_usual_function(): + """ + A zero-indentation transfunction template can generate a regular function from its sync branch. + + The generated function should ignore the async and generator branches in the same template and return the sync result. + """ function = null_indentation_function.get_usual_function() assert function() == 1 def test_null_indentation_async_function(): + """ + A zero-indentation transfunction template can generate an async function that keeps only the async branch. + + The generated coroutine is run and its result confirms that the async branch was selected over the sync and generator alternatives. + """ function = null_indentation_function.get_async_function() assert run(function()) == 2 def test_null_indentation_generator_function(): + """ + Top-level transfunction templates can generate a generator function that keeps only the generator branch. + + This locks down the null-indented case by consuming the generated function and checking that it yields 1, 2, and 3 while the sync and async branches are excluded. + """ function = null_indentation_function.get_generator_function() assert [x for x in function()] == [1, 2, 3] def test_create_async_function_without_any_markers(): + """ + Async functions can be created from plain transfunction templates without markers. + + The generated function is expected to be recognized as a coroutine function and to preserve the template's return value when awaited. + """ @transfunction def function_maker(): return 4 @@ -185,6 +231,11 @@ def function_maker(): def test_create_async_function_with_parameters_without_any_markers(): + """ + An unmarked parameterized template can be generated as an async coroutine function. + + It preserves positional arguments, the defaulted keyword parameter, and the template's return calculation when invoked. + """ @transfunction def function_maker(a, b, c=3): return a + b + c @@ -196,11 +247,21 @@ def function_maker(a, b, c=3): def test_try_to_pass_lambda_to_decorator(): + """ + Rejects using an inline lambda as the transfunction template. + + The test calls the decorator constructor directly because lambdas cannot use decorator syntax, and verifies the lambda-specific ValueError rather than the generic invalid-template error. + """ with pytest.raises(ValueError, match=match("Only regular or generator functions can be used as a template for @transfunction. Don't use lambdas here.")): transfunction(lambda x: x) def test_create_generator_function_without_any_markers(): + """ + A marker-free zero-argument transfunction template that is already a generator can be materialized as a generator function. + + The generated function should be recognized as a generator function and yield the original sequence unchanged. + """ @transfunction def generator_maker(): yield 1 @@ -214,6 +275,11 @@ def generator_maker(): def test_create_generator_function_with_parameters_without_any_markers(): + """ + A marker-free transfunction generator with parameters can be converted into a generator function. + + The generated function should still be recognized as a generator function and should yield values from both positional and keyword arguments. + """ @transfunction def generator_maker(a, b, c=3): yield a @@ -227,6 +293,11 @@ def generator_maker(a, b, c=3): def test_traceback_is_working_in_simple_usual_function(): + """ + Generated usual functions preserve traceback locations for exceptions raised in the template body. + + This covers the plain synchronous case: a ValueError raised by the decorated template should propagate from the generated usual function, with the final traceback frame pointing back to the original raise line. + """ @transfunction def make(): raise ValueError('message') @@ -244,6 +315,11 @@ def make(): def test_traceback_is_working_in_simple_async_function(): + """ + Generated async transfunctions preserve traceback location for exceptions raised in the original template body. + + The check runs the generated coroutine and verifies the final traceback frame still maps to the template raise line. + """ @transfunction def make(): raise ValueError('message') @@ -261,6 +337,11 @@ def make(): def test_traceback_is_working_in_simple_generator_function(): + """ + Generated generator functions preserve traceback location for exceptions raised in the template body. + + The template is generator-shaped only because of a trailing unreachable yield, so the check forces iteration and verifies the final traceback frame still points at the original raise line. + """ @transfunction def make(): raise ValueError('message') @@ -279,6 +360,11 @@ def make(): def test_traceback_is_working_in_usual_function_with_marker(): + """ + Generated usual functions preserve traceback locations for errors raised inside sync_context markers. + + This checks that removing the marker wrapper still leaves the final traceback frame pointing at the original template raise line by both line number and source text. + """ @transfunction def make(): with sync_context: @@ -297,6 +383,11 @@ def make(): def test_traceback_is_working_in_simple_async_function_with_marker(): + """ + Generated async functions preserve tracebacks for exceptions raised inside async context marker blocks. + + The test checks that a propagated exception still reports the original template raise line as the final traceback frame. + """ @transfunction def make(): with async_context: @@ -315,6 +406,11 @@ def make(): def test_traceback_is_working_in_simple_generator_function_with_marker(): + """ + Exceptions raised inside a generator marker keep the original template traceback location. + + The generated generator is forced to run by iteration, and the traceback is checked against the template line that raises the error. + """ @transfunction def make(): with generator_context: @@ -334,6 +430,11 @@ def make(): def test_try_to_use_transfunction_decorator_without_at_sign(): + """ + Using the transfunction decorator without @ syntax is rejected. + + This locks down the guard that prevents treating the decorator as an ordinary helper call when it must be applied through decorator syntax. + """ def function(): with generator_context: raise ValueError('message') @@ -346,6 +447,10 @@ def function(): def test_double_use_of_decorator(): + """Using @transfunction twice on the same function is rejected at decoration time. + + The test defines a function with two stacked transfunction decorators and expects the duplicate-decorator error before any transformed function is requested. + """ with pytest.raises(DualUseOfDecoratorError, match=match("You cannot use the 'transfunction' decorator twice for the same function.")): @transfunction @transfunction @@ -354,6 +459,11 @@ def make(): def test_read_closures_with_usual_function(): + """ + Generated usual functions preserve read access to closed-over local variables. + + This covers the simple synchronous case where a transfunction template captures a value from its enclosing scope and the regular function produced from it returns that captured value. + """ nonlocal_variable = 1 @transfunction @@ -367,6 +477,11 @@ def make(): def test_read_closures_with_usual_function_with_arguments(): + """ + Converting a template to a usual function preserves read access to closure values while accepting runtime arguments. + + The generated function is checked across repeated calls with different positional arguments to confirm that closure reconstruction and parameter binding work together. + """ nonlocal_variable = 1 @transfunction @@ -381,6 +496,11 @@ def make(some_number): def test_read_closures_with_async_function(): + """ + Generated async functions preserve read-only access to closure variables from the template scope. + + The template captures a local value, is converted to an async function, and the resulting coroutine is run without arguments to verify it returns that captured value. + """ nonlocal_variable = 1 @transfunction @@ -394,6 +514,11 @@ def make(): def test_read_closures_with_async_function_with_arguments(): + """ + Generated async functions can read closed-over values while binding positional arguments. + + The test awaits the generated function with two different arguments to verify that both the captured value and the call argument are used. + """ nonlocal_variable = 1 @transfunction @@ -408,6 +533,7 @@ def make(some_number): def test_read_closures_with_generator_function(): + """A generated generator function preserves read access to a closed-over local value.""" nonlocal_variable = 1 @transfunction @@ -421,6 +547,11 @@ def make(): def test_read_closures_with_generator_function_with_arguments(): + """ + A generated generator function preserves closure reads while accepting call arguments. + + The converted function is consumed with different argument values to confirm each yielded result combines the closed-over outer value with the value passed at runtime. + """ nonlocal_variable = 1 @transfunction @@ -435,6 +566,11 @@ def make(some_number): def test_read_globals_with_usual_function(): + """ + Generated usual functions preserve access to globals read by the template. + + The check converts a parameterless transfunction with get_usual_function() and verifies that the resulting callable returns the module-level value it references. + """ @transfunction def make(): return SOME_GLOBAL @@ -445,6 +581,11 @@ def make(): def test_read_globals_with_usual_function_with_arguments(): + """ + Generated regular functions can read module globals while preserving positional arguments. + + The converted usual function should combine the module-level value with each supplied argument across repeated calls, showing that both global lookup and argument binding survive template conversion. + """ @transfunction def make(some_number): #nonlocal nonlocal_variable @@ -457,6 +598,11 @@ def make(some_number): def test_read_globals_with_async_function(): + """ + Generated async functions preserve access to globals read by the template. + + The template has no parameters or context markers, so the check isolates global name resolution after get_async_function() converts the body and the coroutine is run. + """ @transfunction def make(): return SOME_GLOBAL @@ -467,6 +613,11 @@ def make(): def test_read_globals_with_async_function_with_arguments(): + """ + Generated async functions can read template globals while using caller-supplied arguments. + + The check awaits the converted function with different argument values to lock down that global lookup is preserved and arguments are evaluated per call. + """ @transfunction def make(some_number): return SOME_GLOBAL + some_number @@ -478,6 +629,11 @@ def make(some_number): def test_read_globals_with_generator_function(): + """ + Generated generator functions preserve access to globals read from yielded expressions. + + The check iterates the converted generator and verifies it yields the module-level value from the template function's original namespace. + """ @transfunction def make(): yield SOME_GLOBAL @@ -488,6 +644,11 @@ def make(): def test_read_globals_with_generator_function_with_arguments(): + """ + Generated generator functions preserve module global lookup while binding call arguments dynamically. + + The test consumes separate generator calls with different arguments to confirm each yielded value combines the same global with the current argument. + """ @transfunction def make(some_number): yield SOME_GLOBAL + some_number @@ -499,6 +660,11 @@ def make(some_number): def test_write_nonlocal_variable_from_usual_function_without_arguments(): + """ + Generated no-argument regular functions preserve writes to nonlocal variables in the original closure. + + The check calls the converted function and verifies that the enclosing value is updated, proving the nonlocal binding targets the original closure cell. + """ nonlocal_variable = 1 @transfunction @@ -513,6 +679,7 @@ def make(): def test_write_nonlocal_variable_from_usual_function_with_arguments(): + """A transformed ordinary function with arguments can update a nonlocal variable.""" nonlocal_variable = 1 @transfunction @@ -527,6 +694,11 @@ def make(number): def test_write_nonlocal_variable_from_async_function_without_arguments(): + """ + A no-argument transfunction can generate an async function that updates a nonlocal variable. + + The generated coroutine is executed and the enclosing variable is checked afterward, proving that assignment writes through to the original closure cell. + """ nonlocal_variable = 1 @transfunction @@ -541,6 +713,11 @@ def make(): def test_write_nonlocal_variable_from_async_function_with_arguments(): + """ + Generated async transfunctions preserve nonlocal writes from templates with arguments. + + Running the generated coroutine should update the original enclosing variable using the supplied argument, confirming that async conversion keeps the closure binding writable. + """ nonlocal_variable = 1 @transfunction @@ -555,6 +732,11 @@ def make(number): def test_write_nonlocal_variable_from_generator_function_without_arguments(): + """ + Generated no-argument generator functions preserve writable nonlocal closure variables. + + The test consumes the generator returned by get_generator_function() so the nonlocal update in the generator body executes and changes the enclosing value. + """ nonlocal_variable = 1 @transfunction @@ -570,6 +752,11 @@ def make(): def test_write_nonlocal_variable_from_generator_function_with_arguments(): + """ + Generated generator functions can mutate nonlocal closure variables using call arguments. + + The test exhausts a generated generator with an argument and checks that the enclosing variable changes by that value, proving the write reaches the original closure cell. + """ nonlocal_variable = 1 @transfunction @@ -585,6 +772,11 @@ def make(number): def test_write_global_variable_from_usual_function_without_arguments(): + """ + A no-argument generated regular function can write to the template's module global. + + The check verifies that calling the generated function increments the original global value and restores the module state afterward. + """ @transfunction def make(): global SOME_GLOBAL # noqa: PLW0603 @@ -601,6 +793,11 @@ def make(): def test_write_global_variable_from_usual_function_with_arguments(): + """ + A usual function generated from a transfunction can write to a module global using its positional argument. + + The test checks that the generated callable mutates the original global value by the argument amount, then restores the global so the state change does not leak. + """ @transfunction def make(number): global SOME_GLOBAL # noqa: PLW0603 @@ -617,6 +814,11 @@ def make(number): def test_write_global_variable_from_async_function_without_arguments(): + """ + A zero-argument async transfunction preserves writes to module globals. + + The generated coroutine should honor a global declaration and mutate the original module-level variable when run. + """ @transfunction def make(): global SOME_GLOBAL # noqa: PLW0603 @@ -633,6 +835,11 @@ def make(): def test_write_global_variable_from_async_function_with_arguments(): + """ + Generated async functions preserve writable access to module-level globals while forwarding call arguments. + + The check runs a coroutine generated from a regular transfunction template, passes an increment value, and verifies that the global changes by that value. + """ @transfunction def make(number): global SOME_GLOBAL # noqa: PLW0603 @@ -649,6 +856,11 @@ def make(number): def test_write_global_variable_from_generator_function_without_arguments(): + """ + A no-argument generator function produced by a transfunction can write back to a module-level global variable. + + The check exhausts the generated generator and verifies the global value changes, while preserving the original global afterward. + """ @transfunction def make(): global SOME_GLOBAL # noqa: PLW0603 @@ -666,6 +878,11 @@ def make(): def test_write_global_variable_from_generator_function_with_arguments(): + """ + Generated generator functions can write to template module globals using call arguments. + + The generator is consumed to run the side effect, then the assertion checks that the original global was incremented by the argument value. + """ @transfunction def make(number): global SOME_GLOBAL # noqa: PLW0603 @@ -683,6 +900,11 @@ def make(number): def test_module_name(): + """ + Generated transfunction variants keep the module name of functions defined beside the template. + + The check compares module metadata for sync, async, and generator outputs against a local ordinary function without executing them. + """ @transfunction def template(): pass @@ -701,6 +923,11 @@ def usual_function(): def test_it_works_with_simple_usual_method(): + """ + A transfunction-decorated instance method yields a usual function bound to that instance. + + The check calls the generated function without passing self and expects it to read instance state. + """ class SomeClass: some_value = 1 @transfunction @@ -714,6 +941,11 @@ def template(self): def test_it_works_with_simple_usual_method_with_parameters(): + """ + A transfunction-decorated instance method exposes a usual generated function as a bound method with parameters intact. + + The generated function is called through an instance with one explicit argument; it should receive self automatically, use the default parameter value, and read the instance attribute when computing the result. + """ class SomeClass: some_value = 1 @transfunction @@ -727,6 +959,7 @@ def template(self, a, b=5): def test_it_works_with_simple_async_method(): + """A transfunction-decorated instance method can generate an async bound method that reads instance state.""" class SomeClass: some_value = 1 @transfunction @@ -740,6 +973,11 @@ def template(self): def test_it_works_with_simple_async_method_with_parameters(): + """ + Accessing a transfunction on an instance can produce an async bound method that receives self and preserves its parameters. + + The generated coroutine should use the instance state, accept the provided positional argument, apply the default argument value, and return the template expression result when awaited. + """ class SomeClass: some_value = 1 @transfunction @@ -753,6 +991,7 @@ def template(self, a, b=5): def test_it_works_with_simple_generator_method(): + """Decorating a simple generator method preserves its generator behavior and yielded values.""" class SomeClass: some_value = 1 @transfunction @@ -766,6 +1005,11 @@ def template(self): def test_it_works_with_simple_generator_method_with_parameters(): + """ + A transfunction-decorated generator method can generate a bound generator that applies self, explicit parameters, and default parameters correctly. + + The check calls the generated generator with one provided argument while relying on the method default for another, confirming that binding and default handling work together. + """ class SomeClass: some_value = 1 @transfunction @@ -779,6 +1023,11 @@ def template(self, a, b=5): def test_combine_with_other_decorator_before(): + """ + Rejects a transfunction template that already has another source-level decorator. + + The other decorator is applied before transfunction wrapping, and the syntax error is expected when the usual function is requested. + """ def other_decorator(function): return function @@ -792,6 +1041,11 @@ def template(): def test_combine_with_other_decorator_after(): + """ + Reject a template that is wrapped by another decorator after @transfunction. + + The check is triggered when the usual function is requested, and the extra decorator is invalid even when it leaves the template unchanged. + """ def other_decorator(function): return function @@ -805,6 +1059,7 @@ def template(): def test_create_empty_usual_function_without_arguments(): + """A zero-argument transfunction with only async-only content still creates a regular function that returns None.""" @transfunction def template(): with async_context: @@ -816,6 +1071,11 @@ def template(): def test_create_empty_usual_function_with_arguments(): + """ + Generating a usual callable from an async-only template preserves its arguments and returns None. + + The discarded async_context body contains a return expression using both arguments, so the check ensures that unselected async code is removed rather than evaluated. + """ @transfunction def template(a, b): with async_context: @@ -827,6 +1087,11 @@ def template(a, b): def test_create_empty_async_function_without_arguments(): + """ + Generating an async function from a zero-argument template with only sync-only content succeeds. + + The sync-only body is excluded from the async variant, leaving a valid empty coroutine that completes with None when awaited. + """ @transfunction def template(): with sync_context: @@ -838,6 +1103,11 @@ def template(): def test_create_empty_async_function_with_arguments(): + """ + Generating an async function from an argument-taking template with only sync-only content yields a callable coroutine that returns None. + + The generated async function must still accept the template arguments even though no async body remains. + """ @transfunction def template(a, b): with sync_context: @@ -849,6 +1119,11 @@ def template(a, b): def test_other_context_managers_with_empty_parentness_are_working_in_usual_function_without_arguments(): + """ + A zero-argument transfunction template preserves and executes a top-level ordinary context manager call with empty parentheses in a generated usual function. + + The check confirms that the value yielded by the local context manager is bound by the with statement and returned unchanged. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -864,6 +1139,11 @@ def template(): def test_other_context_managers_with_empty_parentness_are_working_in_usual_function_with_arguments(): + """ + A generated usual function preserves a no-argument ordinary context manager while still binding template arguments. + + The result must combine the context manager's yielded value with the supplied arguments. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -879,6 +1159,11 @@ def template(a, b): def test_other_context_managers_with_not_empty_parentness_are_working_in_usual_function_without_arguments(): + """ + Non-marker context managers with arguments are preserved in usual functions generated from no-argument templates. + + The generated function should execute the context manager normally and return the value bound by its as-target. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -894,6 +1179,11 @@ def template(): def test_other_context_managers_with_not_empty_parentness_are_working_in_usual_function_with_arguments(): + """ + Ensure @transfunction keeps an ordinary argument-bearing context manager intact in a generated usual function. + + The context manager should still receive its call argument, bind its yielded value through as, and let that value combine with the generated function's own positional arguments. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -909,6 +1199,11 @@ def template(a, b): def test_other_context_managers_with_empty_parentness_are_working_in_async_function_without_arguments(): + """ + Normal context managers called with empty parentheses keep working when a no-argument transfunction is converted to an async function. + + The coroutine should preserve the with-block binding and return the value yielded by the ordinary context manager. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -924,6 +1219,11 @@ def template(): def test_other_context_managers_with_empty_parentness_are_working_in_async_function_with_arguments(): + """ + Async functions generated from argument-taking transfunctions preserve ordinary empty-argument context manager blocks. + + The generated coroutine should keep the context manager active, bind its yielded value, preserve positional arguments, and return the result computed from both sources. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -939,6 +1239,11 @@ def template(a, b): def test_other_context_managers_with_not_empty_parentness_are_working_in_async_function_without_arguments(): + """ + A generated async function from a zero-argument template preserves an ordinary context manager call with arguments. + + The awaited result should be the value yielded by that context manager. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -954,6 +1259,11 @@ def template(): def test_other_context_managers_with_not_empty_parentness_are_working_in_async_function_with_arguments(): + """ + Generated async functions preserve ordinary context manager calls with their own arguments. + + This checks that a transfunction template containing a non-marker with block can become an async function, pass through the template arguments, and return the value produced inside the context manager when awaited. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -969,6 +1279,11 @@ def template(a, b): def test_other_context_managers_with_empty_parentness_are_working_in_generator_function_without_arguments(): + """ + A no-argument transfunction preserves an ordinary no-argument context manager in generated generator output. + + The check ensures the context value is bound inside the with block and yielded by the generated generator. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -984,6 +1299,11 @@ def template(): def test_other_context_managers_with_empty_parentness_are_working_in_generator_function_with_arguments(): + """ + Preserves a zero-argument ordinary context manager and generated function arguments in a generator transfunction. + + The generated function should keep the context manager call and its `as` binding intact while yielding a value computed from the bound context value and the positional arguments. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -999,6 +1319,11 @@ def template(a, b): def test_other_context_managers_with_not_empty_parentness_are_working_in_generator_function_without_arguments(): + """ + Generated generator functions preserve ordinary context manager calls with arguments in zero-argument transfunction templates. + + This locks down that non-marker with blocks remain usable common code and that the yielded value from the context manager is produced when the generated function is iterated. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1014,6 +1339,11 @@ def template(): def test_other_context_managers_with_not_empty_parentness_are_working_in_generator_function_with_arguments(): + """ + Generated generator functions preserve ordinary context manager calls with arguments in shared template code. + + The check also verifies that the generated generator forwards its own call arguments into the preserved with block and yields the value computed from both sources. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1029,6 +1359,11 @@ def template(a, b): def test_other_context_managers_into_context_marker_with_empty_parentness_are_working_in_usual_function_without_arguments(): + """ + A zero-argument transfunction preserves and executes a normal context manager nested inside a sync context marker when generating a usual function. + + The generated function should enter the nested context manager, bind its yielded value, and return it. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -1045,6 +1380,11 @@ def template(): def test_other_context_managers_into_context_marker_with_empty_parentness_are_working_in_usual_function_with_arguments(): + """ + A usual function generated from a transfunction preserves a zero-argument context manager nested inside a sync context marker. + + The check covers a template with positional arguments, where the nested context manager contributes its yielded value and the generated function combines it with the supplied arguments. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -1061,6 +1401,11 @@ def template(a, b): def test_other_context_managers_into_context_marker_with_not_empty_parentness_are_working_in_usual_function_without_arguments(): + """ + Generates a usual function that preserves an argument-taking context manager nested inside a sync context marker. + + The template itself takes no arguments; the returned value proves the marker block was retained for the usual-function variant, the marker wrapper was removed, and the nested context manager still provided the value used by the return statement. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1077,6 +1422,11 @@ def template(): def test_other_context_managers_into_context_marker_with_not_empty_parentness_are_working_in_usual_function_with_arguments(): + """ + A generated usual function preserves an argument-taking context manager nested inside sync_context while binding template arguments. + + The result must combine the nested manager's yielded value with the supplied arguments. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1093,6 +1443,11 @@ def template(a, b): def test_other_context_managers_into_context_marker_with_empty_parentness_are_working_in_async_function_without_arguments(): + """ + A generated async function preserves a no-argument ordinary context manager nested inside async_context. + + The awaited result should come from the nested manager's yielded value. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -1109,6 +1464,11 @@ def template(): def test_other_context_managers_into_context_marker_with_empty_parentness_are_working_in_async_function_with_arguments(): + """ + Ensure async function extraction preserves an ordinary context manager nested inside an async_context marker when the template has arguments. + + The generated coroutine should remove only the marker wrapper, keep the inner with binding, and return the context manager value combined with the supplied arguments. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -1125,6 +1485,11 @@ def template(a, b): def test_other_context_managers_into_context_marker_with_not_empty_parentness_are_working_in_async_function_without_arguments(): + """ + Generating an async function keeps a normal context manager nested inside the async marker. + + The marker wrapper is removed, but the inner context manager call with its positional argument still runs, so the coroutine returns the value yielded by that manager. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1141,6 +1506,11 @@ def template(): def test_other_context_managers_into_context_marker_with_not_empty_parentness_are_working_in_async_function_with_arguments(): + """ + Async transfunctions preserve nested regular context managers with arguments and as targets inside async-context marker blocks. + + The generated coroutine is called with template arguments and must combine them with the value yielded by the nested context manager, proving the conversion does not disturb that nested context. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1157,6 +1527,11 @@ def template(a, b): def test_other_context_managers_into_context_marker_with_empty_parentness_are_working_in_generator_function_without_arguments(): + """ + Generated generator functions keep ordinary nested context managers inside generator-only marker blocks. + + This covers the zero-argument case: the generator marker is removed, the inner context manager still runs, and its yielded value is emitted by the generated generator. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -1173,6 +1548,11 @@ def template(): def test_other_context_managers_into_context_marker_with_empty_parentness_are_working_in_generator_function_with_arguments(): + """ + Generator templates preserve nested ordinary context managers inside generator-only marker blocks. + + This checks that an empty-argument context manager call is entered, its yielded value is bound with an `as` target, and the generated generator can combine that value with call arguments. + """ @contextmanager def context_manager_with_parentnes(): yield 123 @@ -1189,6 +1569,11 @@ def template(a, b): def test_other_context_managers_into_context_marker_with_not_empty_parentness_are_working_in_generator_function_without_arguments(): + """ + Generator transfunctions with no template arguments preserve regular context manager calls inside generator_context. + + The generated generator should execute the nested manager with its positional argument, bind the value it yields, and yield that value to the caller. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1205,6 +1590,11 @@ def template(): def test_other_context_managers_into_context_marker_with_not_empty_parentness_are_working_in_generator_function_with_arguments(): + """ + Generator templates preserve nested context managers with arguments inside generator-only blocks. + + Checks that the generated generator enters the ordinary context manager, binds its yielded value, and combines it with the generator call arguments. + """ @contextmanager def context_manager_with_parentnes(c): yield 123 + c @@ -1221,6 +1611,11 @@ def template(a, b): def test_basic_yield_from_it(): + """ + Generated generator functions yield every value supplied through a basic yield_from_it marker. + + This locks down the simplest valid path: a no-argument transfunction template uses yield_from_it with a literal iterable, is converted with get_generator_function(), and produces the iterable values when consumed. + """ @transfunction def template(): with generator_context: @@ -1232,6 +1627,11 @@ def template(): def test_yield_from_it_with_function_call(): + """ + Allow yield_from_it() in generator_context to delegate to the iterable returned by a helper call. + + The generated generator function is checked by iterating it and comparing the collected values from the helper. + """ def some_other_function(): return [1, 2, 3] @@ -1246,6 +1646,11 @@ def template(): def test_await_it_with_two_arguments(): + """ + get_async_function() rejects await_it markers with two positional arguments inside async_context. + + The test checks that this is reported during async function generation as a marker syntax error, preserving the rule that await_it accepts exactly one awaited expression. + """ async def another_function(): return None @@ -1259,6 +1664,11 @@ def template(): def test_await_it_without_arguments(): + """ + await_it() without an argument inside async_context is rejected when generating the async function. + + The template can be decorated, but requesting the async version must fail because await_it needs exactly one positional value to await. + """ @transfunction def template(): with async_context: @@ -1269,6 +1679,11 @@ def template(): def test_await_it_with_one_usual_and_one_named_arguments(): + """ + await_it rejects a marker call that combines one positional coroutine expression with a keyword argument in async_context. + + The check is made when the generated async function is requested, so the test locks down lazy validation during transformation rather than template definition. + """ async def another_function(): return None @@ -1282,6 +1697,11 @@ def template(): def test_yield_from_it_with_two_arguments(): + """ + yield_from_it with more than one positional argument is rejected when building a generator function. + + The invalid marker appears inside generator_context, so the check covers generator-function generation rather than template definition or iteration. + """ @transfunction def template(): with generator_context: @@ -1292,6 +1712,11 @@ def template(): def test_yield_from_it_without_arguments(): + """ + Reject yield_from_it markers that are called without an argument. + + The error is raised when the generator function is requested, confirming that marker arity is validated during template generation. + """ @transfunction def template(): with generator_context: @@ -1302,6 +1727,11 @@ def template(): def test_yield_from_it_with_one_usual_and_one_named_arguments(): + """ + Reject yield_from_it calls that combine one positional iterable with a keyword argument. + + The template is validated when its generated function is requested, so the expected failure is a marker syntax error rather than normal generator execution. + """ @transfunction def template(): with generator_context: @@ -1312,6 +1742,11 @@ def template(): def test_string_literal_default_value_for_usual_function(): + """ + Preserve a string literal default when a transfunction template is converted to a usual function. + + The generated usual function is called without arguments to confirm it returns the template's default value. + """ @transfunction def template(string='kek'): return string @@ -1322,6 +1757,11 @@ def template(string='kek'): def test_int_literal_default_value_for_usual_function(): + """ + Decorating a regular function preserves an integer literal default argument. + + The test checks that omitting the argument uses the declared integer default instead of requiring an explicit value. + """ @transfunction def template(number=123): return number @@ -1332,6 +1772,11 @@ def template(number=123): def test_list_literal_default_value_for_usual_function(): + """ + A usual function generated from a transfunction preserves shared mutable list default semantics. + + Calling it repeatedly without providing the list argument should reuse the same default list, so mutations from earlier calls are visible later. + """ @transfunction def template(number, lst=[]): # noqa: B006 lst.append(number) @@ -1344,6 +1789,11 @@ def template(number, lst=[]): # noqa: B006 def test_list_literal_default_value_it_the_same_for_all_types_of_functions(): + """ + Mutable list literal defaults are shared across all function variants generated from one transfunction. + + The test calls the usual, async, and generator functions without passing the list and checks that each call sees the cumulative appended values from earlier calls. + """ @transfunction def template(number, lst=[]): # noqa: B006 lst.append(number) @@ -1371,6 +1821,11 @@ def template(number, lst=[]): # noqa: B006 def test_string_literal_default_value_for_async_function(): + """ + @transfunction preserves a string literal default value when generating an async function. + + The generated coroutine is called without passing that argument, and its resolved result must come from the original default. + """ @transfunction def template(string='kek'): return string @@ -1381,6 +1836,11 @@ def template(string='kek'): def test_int_literal_default_value_for_async_function(): + """ + Async transfunctions preserve integer literal default values. + + This covers an async function parameter whose default is an integer literal, ensuring the decorated callable keeps that default value intact. + """ @transfunction def template(number=123): return number @@ -1391,6 +1851,11 @@ def template(number=123): def test_list_literal_default_value_for_async_function(): + """ + Generated async functions preserve a mutable list literal default. + + The test checks that repeated awaited calls without an explicit list argument share the same default list and accumulate appended values. + """ @transfunction def template(number, lst=[]): # noqa: B006 lst.append(number) @@ -1403,6 +1868,11 @@ def template(number, lst=[]): # noqa: B006 def test_string_literal_default_value_for_generator_function(): + """ + Preserve a string literal default argument when building a generator function. + + The generated function is called without arguments and its yielded values are collected to confirm the default string is used. + """ @transfunction def template(string='kek'): yield string @@ -1413,6 +1883,11 @@ def template(string='kek'): def test_int_literal_default_value_for_generator_function(): + """ + Materialized generator transfunctions preserve integer literal default arguments. + + Calling the generated generator without arguments should yield the default integer value. + """ @transfunction def template(number=123): yield number @@ -1423,6 +1898,11 @@ def template(number=123): def test_list_literal_default_value_for_generator_function(): + """ + Preserve a list literal default across calls to a generated generator function. + + The generator should yield the accumulated list contents, showing that the same default list instance is reused after mutation. + """ @transfunction def template(number, lst=[]): # noqa: B006 lst.append(number) @@ -1435,6 +1915,11 @@ def template(number, lst=[]): # noqa: B006 def test_nonlocal_variable_default_value_for_usual_function(): + """ + get_usual_function preserves a default argument value captured from an enclosing local variable. + + Calling the generated usual function without arguments should pass that preserved default into the body. + """ container = [] variable = 123 @@ -1449,6 +1934,11 @@ def template(number=variable): def test_global_variable_default_value_for_usual_function(): + """ + A generated ordinary function preserves a default argument value bound from a module global. + + The test calls the generated function without an argument and checks the captured default through the function's side effect on a local container. + """ container = [] @transfunction @@ -1462,6 +1952,11 @@ def template(number=SOME_GLOBAL): def test_resetted_global_variable_default_value_for_usual_function(): + """ + A generated usual function keeps the template's captured default value when it came from a local variable shadowing a global. + + The check calls the generated function without arguments and verifies that the side effect uses the local default value, not the module global. + """ container = [] SOME_GLOBAL = 'kek' # noqa: N806 @@ -1476,6 +1971,11 @@ def template(number=SOME_GLOBAL): def test_nonlocal_variable_default_value_for_async_function(): + """ + Preserves an enclosing-scope parameter default when generating an async function. + + The generated coroutine is called without an explicit argument, so the returned value proves the nonlocal default was captured and used. + """ variable = 123 @transfunction @@ -1488,6 +1988,11 @@ def template(number=variable): def test_global_variable_default_value_for_async_function(): + """ + Async functions generated from a transfunction preserve module-level globals used as default parameter values. + + The generated coroutine is called without the parameter, so the check isolates default resolution through the template's global namespace rather than an explicit argument override. + """ @transfunction def template(number=SOME_GLOBAL): return number @@ -1498,6 +2003,11 @@ def template(number=SOME_GLOBAL): def test_resetted_global_variable_default_value_for_async_function(): + """ + Generated async functions keep a default value evaluated from a local name that shadows a module global. + + Calling the coroutine without an argument should use the local default, not the same-named global. + """ SOME_GLOBAL = 'kek' # noqa: N806 @transfunction @@ -1510,6 +2020,11 @@ def template(number=SOME_GLOBAL): def test_nonlocal_variable_default_value_for_generator_function(): + """ + Generated generator functions preserve default argument values evaluated from the template's enclosing scope. + + The test defines a generator template whose parameter default comes from a local variable, builds the generator function, calls it without arguments, and verifies that iteration yields that captured default value. + """ variable = 123 @transfunction @@ -1522,6 +2037,11 @@ def template(number=variable): def test_global_variable_default_value_for_generator_function(): + """ + A generated generator preserves a module-level global used as a parameter default. + + The check converts a transfunction template with get_generator_function(), calls the generated generator without arguments, and verifies that iteration yields the default value from the global. + """ @transfunction def template(number=SOME_GLOBAL): yield number @@ -1532,6 +2052,11 @@ def template(number=SOME_GLOBAL): def test_resetted_global_variable_default_value_for_generator_function(): + """ + Generated generator functions keep evaluated default values from shadowed local names. + + This checks that a template default captured from a test-local variable is reused after generator conversion, so calling the generated function without arguments yields the local value rather than the same-named module global. + """ SOME_GLOBAL = 'kek' # noqa: N806 @transfunction @@ -1544,6 +2069,11 @@ def template(number=SOME_GLOBAL): def test_use_decorator_without_at(): + """ + Manually wrapping a template with transfunction() without @ syntax cannot generate any function variant. + + The test checks that the usual, async, and generator generation methods all reject the transformer with the decorator syntax error. + """ def template(): pass diff --git a/tests/units/test_universal_namespace.py b/tests/units/test_universal_namespace.py index 910151e..cd7abd4 100644 --- a/tests/units/test_universal_namespace.py +++ b/tests/units/test_universal_namespace.py @@ -8,6 +8,7 @@ some_global = 321 def test_set_something_and_get(): + """A fresh universal namespace treats unknown names as missing and returns values assigned directly into it.""" def function(): pass @@ -24,6 +25,11 @@ def function(): def test_get_nonlocal(): + """ + Resolve a requested name from the supplied frame's local variables. + + This locks down that lookup can use a caller-local value even when the wrapped function is empty and does not close over that name. + """ some_nonlocal = 123 # noqa: F841 def function(): @@ -37,6 +43,11 @@ def function(): def test_get_global(): + """ + Resolves missing names from the wrapped function's module globals. + + The check uses a module-level sentinel without local or builtin shadowing, so the lookup path being locked down is specifically the global fallback. + """ def function(): pass @@ -48,6 +59,11 @@ def function(): def test_get_nonlocal_with_name_as_global(): + """ + Captured frame locals take precedence over globals with the same name. + + This locks down that UniversalNamespaceAroundFunction returns the nonlocal value when a requested identifier exists both in the captured frame and in the wrapped function's global namespace. + """ some_global = 123 # noqa: F841 def function(): @@ -61,6 +77,11 @@ def function(): def test_get_builtin(): + """ + Resolves a missing namespace name from Python builtins. + + The test uses a temporary unique builtin value so the assertion specifically verifies the final builtin fallback after namespace assignments, frame locals, and function globals do not provide the name. + """ builtins.some_name = 1234 def function(): @@ -76,6 +97,7 @@ def function(): def test_get_nonlocal_with_name_as_builtin(): + """Frame-local names take precedence over builtins with the same name when resolving through the universal namespace.""" builtins.some_name = 1234 some_name = 123 # noqa: F841 @@ -93,6 +115,11 @@ def function(): def test_get_global_with_name_as_builtin(): + """ + Return the original function module global when the same name also exists in builtins. + + The check creates a temporary builtin with the same name as a module global and verifies that namespace lookup still resolves to the module value. + """ builtins.some_global = 1234 def function(): @@ -108,6 +135,11 @@ def function(): def test_set_value_with_same_name_as_nonlocal(): + """ + Assigned namespace values take precedence over same-named captured frame locals. + + This covers the case where an explicit write records a value for a name that is also visible through the captured frame; later lookup must return the written value. + """ some_nonlocal = 123 # noqa: F841 def function(): @@ -123,6 +155,11 @@ def function(): def test_set_value_with_same_name_as_global(): + """ + Assigned namespace values shadow globals with the same name. + + This checks that a value stored directly in the universal namespace is returned on lookup even when the wrapped function's module globals already contain that name. + """ def function(): pass @@ -136,6 +173,11 @@ def function(): def test_set_value_with_same_name_as_builtin(): + """ + Explicit namespace assignments take precedence over same-named builtins. + + The check creates a temporary builtin, assigns a different value under that name in the namespace, and verifies lookup returns the assigned value. + """ builtins.some_builtin = 1234 def function(): From e8eb187099974f25ae94898361ef827ddc65d162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 16:16:34 +0300 Subject: [PATCH 6/8] Update README.md with improved wording and grammar for clarity and consistency --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cd3d3aa..1eefd75 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ You can also quickly try out this and other packages without having to install u ## The problem -Since the `asyncio` module appeared in Python more than 10 years ago, many well-known libraries have gained asynchronous counterparts. A lot of the code in the Python ecosystem has been [duplicated](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself), and you probably know many such examples. +Since the `asyncio` module appeared in Python more than 10 years ago, many well-known libraries have developed asynchronous counterparts. A lot of the code in the Python ecosystem has been [duplicated](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself), and you probably know many such examples. The reason for this problem is that the Python community has chosen a syntax-based approach to asynchrony. There are new keywords in the language, such as `async` and `await`. Their use makes the code so-called "[multicolored](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/)": functions become “red” or “blue”, and depending on the color, the rules for calling them are different. You can only call blue functions from red ones, but not vice versa. @@ -101,7 +101,7 @@ def template(): print('something') ``` -Executing this code will actually return to us not a function, but a special object that can *produce* functions: +Executing this code will actually give us not a function, but a special object that can *produce* functions: ```python print(template) @@ -126,7 +126,7 @@ run(async_function()) #> something ``` -That's more interesting. In fact, we transferred all the contents from the original function to the generated async function. The content itself has not changed in any way, that is, we got a function that would look something like this: +That's more interesting. In fact, all the contents from the original function are included in the generated async function. The content itself has not changed in any way, that is, we got a function that would look something like this: ```python async def template(): @@ -148,7 +148,7 @@ def template(): yield ``` -The `get_usual_function` method will return a function that will contain a common part (the first `print`) and a part highlighted using the context manager as related to ordinary functions. It will look something like this: +The `get_usual_function` method returns a function that contains a common part (the first `print`) and a part highlighted using the context manager as related to ordinary functions. It will look something like this: ```python def template(): @@ -214,7 +214,7 @@ from transfunctions import ( ) ``` -Make sure that the generated functions do not include keywords that are not related to this type of function. For example, you cannot generate a regular function using the `get_usual_function` method from such a template: +Make sure that the generated functions do not include keywords that are not related to this type of function. For example, you cannot generate a regular function from such a template using the `get_usual_function` method: ```python from asyncio import sleep @@ -224,12 +224,12 @@ def template(): await_it(sleep(5)) ``` -Regular or generator functions cannot use the `await` keyword, so you will get an exception when you try to generate such a function. The same applies to the `yield` and `yield from` keywords. You cannot use them outside of code blocks that relate *only* to generator functions. Please note that not in all such cases, the `transfunctions` library will offer you an informative exception. Here you'd better rely on your own knowledge of `Python` syntax. However, even if such an exception is provided, it will only be raised when trying to generate a function of the type in which this syntax is inappropriate. At the template definition stage, you won't get an exception telling you that something went wrong, because the code generation here is lazy and the code is not analyzed for correctness in any way before you request it. +Regular or generator functions cannot use the `await` keyword, so you will get an exception when you try to generate such a function. The same applies to the `yield` and `yield from` keywords. You cannot use them outside of code blocks that relate *only* to generator functions. Please note that not in all such cases, the `transfunctions` library will offer you an informative exception. You'll need to rely on your knowledge of Python syntax in these cases. However, even if such an exception is provided, it will only be raised when trying to generate a function of the type in which this syntax is inappropriate. At the template definition stage, you won't get an exception telling you that something went wrong, because the code generation here is lazy and the code is not analyzed for correctness in any way before you request it. ## Superfunctions -Superfunctions are the most powerful feature of the library. They allow you to completely "put under the hood" all the machinery for selecting the desired type of function based on the template function. The selection is completely automatic. +Superfunctions are the most powerful feature of the library. They completely hide the machinery for selecting the desired type of function based on the template function. The selection is completely automatic. Let's take a look at the sample code: @@ -254,7 +254,7 @@ def my_superfunction(): yield ``` -With the `@superfunction` decorator, you no longer need to call special methods for code generation. You can use the resulting function right away, and it will behave differently depending on how you use it. +With the `@superfunction` decorator, the code generation happens automatically. You can use the resulting function right away, and it will behave differently depending on how you use it. If you use it as a regular function, a regular function will be created "under the hood" based on the template and then called: @@ -283,7 +283,7 @@ list(my_superfunction()) #> so, it's a generator function! ``` -How does it work? In fact, `my_superfunction` returns some kind of intermediate object that can behave as a coroutine, a generator, or a regular callable. Depending on how it is handled, it lazily code-generates the desired version of the function from a given template and uses it. +How does it work? In fact, calling `my_superfunction` gives you an object that can behave as a coroutine, a generator, or a regular callable. Depending on how it is handled, it lazily code-generates the desired version of the function from a given template and uses it. By default, a superfunction is called as a regular function using tilde syntax, but there is another mode. To enable it, use the appropriate flag in the decorator: @@ -301,7 +301,7 @@ my_superfunction() However, it comes with trade-offs. The fact is that this mode uses a special trick with a reference counter, a special mechanism inside the interpreter that cleans up memory. When there is no reference to an object, the interpreter deletes it, and you can link your callback to this process. It is inside such a callback that the contents of your function are actually executed. This imposes some restrictions on you: - You cannot use the return values from this function in any way. If you try to save the result of a function call to a variable, the reference counter to the returned object will not drop to zero while this variable exists, and accordingly the function will not actually be called. -- Exceptions will not work normally inside this function. Rather, they can be picked up and intercepted in [`sys.unraisablehook`](https://docs.python.org/3/library/sys.html#sys.unraisablehook), but they will not go up the stack above this function. This is due to a feature of CPython: exceptions that occur inside callbacks for finalizing objects are completely escaped. +- Exceptions will not work normally inside this function. Rather, they can be picked up and intercepted in [`sys.unraisablehook`](https://docs.python.org/3/library/sys.html#sys.unraisablehook), but they will not go up the stack above this function. This is a CPython limitation: exceptions raised inside object finalizer callbacks can't propagate normally. This mode is well suited for functions such as logging or sending statistics from your code: simple functions from which no exceptions or return values are expected. In all other cases, I recommend using the tilde syntax. @@ -313,9 +313,9 @@ Typing is the most difficult problem we faced when developing this library. In m There are 2 main difficulties in developing typing here: - Code generation creates code at runtime that is not in the source files of your project. Whereas most type analyzers look at your code statically, at what is actually present in your files. -- We mix several types of syntax in a single template function, but the static analyzer does not know that this is a template and part of the code will be deleted from here. In its opinion, this is the final function that will continue to be used in your project. +- We put different syntax blocks in a single template function, but the static analyzer does not know that this is a template and part of the code will be deleted from here. In its opinion, this is the final function that will continue to be used in your project. -As you can see, typing in Python is not well suited for metaprogramming. However, in this project, almost all the problems with typing turned out to be solved in one way or another. The main reason why this is so is that we mostly *remove* code from functions, but hardly *add* it there during code generation. In other words, we almost never encounter the problem of how to type the *added* code. This makes the solution to most typing problems accessible. However, we were not able to completely hide all the typing problems under the hood, but you should still be aware of some of them if you use `mypy` or another analyzer. +As you can see, typing in Python is not well suited for metaprogramming. However, in this project, almost all the problems with typing turned out to be solved in one way or another. The main reason is that we mostly *remove* code from functions, but hardly *add* it there during code generation. In other words, we almost never encounter the problem of how to type the *added* code. This makes the solution to most typing problems accessible. However, we were not able to completely hide all the typing problems under the hood, but you should still be aware of some of them if you use `mypy` or another analyzer. If you use the keyword `yield from`, you need to call the function `yield_from_it` instead: From 4529dc2594e556e174de7dbc0db2e292d0771a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 16:20:25 +0300 Subject: [PATCH 7/8] Update displayhooks version to 0.0.7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0c605d5..2ab5314 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ description = 'Say NO to Python fragmentation on sync and async' readme = "README.md" requires-python = ">=3.8" dependencies = [ - 'displayhooks>=0.0.6', + 'displayhooks>=0.0.7', 'getsources>=0.0.4', 'typing_extensions ; python_version <= "3.10"', ] From 94dbe3e6136d8f3244bc6baa367c455e59ebc98c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 1 Jul 2026 21:20:36 +0300 Subject: [PATCH 8/8] Make some tests more powerful --- tests/units/decorators/test_superfunction.py | 13 ++-- tests/units/decorators/test_transfunction.py | 74 ++++++++++++++------ tests/units/test_universal_namespace.py | 21 +++--- 3 files changed, 69 insertions(+), 39 deletions(-) diff --git a/tests/units/decorators/test_superfunction.py b/tests/units/decorators/test_superfunction.py index da99e7a..f381b0d 100644 --- a/tests/units/decorators/test_superfunction.py +++ b/tests/units/decorators/test_superfunction.py @@ -901,15 +901,18 @@ def test_nonlocal_variable_default_value_for_usual_function_with_tilde(): """ A usual superfunction call via tilde preserves a default argument value captured from an enclosing local variable. - The check calls the generated ordinary function without an explicit argument and expects it to return the value that was in scope when the decorated function was defined. + The check rebinds the enclosing name after decoration, then calls the generated ordinary function without an explicit argument and expects the original object to be returned unchanged. """ - variable = 123 + original_variable = object() + variable = original_variable @superfunction def function(number=variable): return number - assert ~function() == variable + variable = object() + + assert ~function() is original_variable def test_global_variable_default_value_for_usual_function_with_tilde(): @@ -1063,7 +1066,7 @@ def test_resetted_global_variable_default_value_for_generator_function(): """ Generator superfunctions preserve a default value captured from a local variable that shadows a module global. - The call is made without arguments and then iterated, so the yielded value must come from the original default rather than from a later global-name lookup. + The local shadowing name is deleted before iteration, so the yielded value must come from the original default rather than from a later global-name lookup. """ global_variable = 'kek' @@ -1071,6 +1074,8 @@ def test_resetted_global_variable_default_value_for_generator_function(): def function(number=global_variable): yield number + del global_variable + assert list(function()) == ['kek'] diff --git a/tests/units/decorators/test_transfunction.py b/tests/units/decorators/test_transfunction.py index 6d51f15..ba6b5ce 100644 --- a/tests/units/decorators/test_transfunction.py +++ b/tests/units/decorators/test_transfunction.py @@ -21,6 +21,7 @@ from transfunctions.transformer import FunctionTransformer SOME_GLOBAL = 777 +SOME_GLOBAL_OBJECT = object() """ Что нужно проверить: @@ -462,9 +463,9 @@ def test_read_closures_with_usual_function(): """ Generated usual functions preserve read access to closed-over local variables. - This covers the simple synchronous case where a transfunction template captures a value from its enclosing scope and the regular function produced from it returns that captured value. + This covers the simple synchronous case where a transfunction template captures an object from its enclosing scope and the regular function produced from it returns that exact object. """ - nonlocal_variable = 1 + nonlocal_variable = object() @transfunction def make(): @@ -473,7 +474,7 @@ def make(): function = make.get_usual_function() - assert function() == 1 + assert function() is nonlocal_variable def test_read_closures_with_usual_function_with_arguments(): @@ -569,15 +570,15 @@ def test_read_globals_with_usual_function(): """ Generated usual functions preserve access to globals read by the template. - The check converts a parameterless transfunction with get_usual_function() and verifies that the resulting callable returns the module-level value it references. + The check converts a parameterless transfunction with get_usual_function() and verifies that the resulting callable returns the exact module-level object it references. """ @transfunction def make(): - return SOME_GLOBAL + return SOME_GLOBAL_OBJECT function = make.get_usual_function() - assert function() == SOME_GLOBAL + assert function() is SOME_GLOBAL_OBJECT def test_read_globals_with_usual_function_with_arguments(): @@ -1162,11 +1163,17 @@ def test_other_context_managers_with_not_empty_parentness_are_working_in_usual_f """ Non-marker context managers with arguments are preserved in usual functions generated from no-argument templates. - The generated function should execute the context manager normally and return the value bound by its as-target. + The generated function should execute the context manager normally, including both enter and exit paths, and return the value bound by its as-target. """ + events = [] + @contextmanager def context_manager_with_parentnes(c): - yield 123 + c + events.append('enter') + try: + yield 123 + c + finally: + events.append('exit') @transfunction def template(): @@ -1176,6 +1183,7 @@ def template(): function = template.get_usual_function() assert function() == 127 + assert events == ['enter', 'exit'] def test_other_context_managers_with_not_empty_parentness_are_working_in_usual_function_with_arguments(): @@ -1202,11 +1210,17 @@ def test_other_context_managers_with_empty_parentness_are_working_in_async_funct """ Normal context managers called with empty parentheses keep working when a no-argument transfunction is converted to an async function. - The coroutine should preserve the with-block binding and return the value yielded by the ordinary context manager. + The coroutine should preserve the with-block binding, run both context manager paths, and return the value yielded by the ordinary context manager. """ + events = [] + @contextmanager def context_manager_with_parentnes(): - yield 123 + events.append('enter') + try: + yield 123 + finally: + events.append('exit') @transfunction def template(): @@ -1216,6 +1230,7 @@ def template(): function = template.get_async_function() assert run(function()) == 123 + assert events == ['enter', 'exit'] def test_other_context_managers_with_empty_parentness_are_working_in_async_function_with_arguments(): @@ -1262,20 +1277,24 @@ def test_other_context_managers_with_not_empty_parentness_are_working_in_async_f """ Generated async functions preserve ordinary context manager calls with their own arguments. - This checks that a transfunction template containing a non-marker with block can become an async function, pass through the template arguments, and return the value produced inside the context manager when awaited. + This checks that a transfunction template containing a non-marker with block can become an async function and pass template arguments into the preserved context manager call when awaited. """ + context_manager_arguments = [] + @contextmanager - def context_manager_with_parentnes(c): - yield 123 + c + def context_manager_with_parentnes(a, b): + context_manager_arguments.append((a, b)) + yield 123 + a + b @transfunction def template(a, b): - with context_manager_with_parentnes(4) as something: - return something + a + b + with context_manager_with_parentnes(a, b) as something: + return something function = template.get_async_function() - assert run(function(1, 2)) == 130 + assert run(function(1, 2)) == 126 + assert context_manager_arguments == [(1, 2)] def test_other_context_managers_with_empty_parentness_are_working_in_generator_function_without_arguments(): @@ -1446,21 +1465,29 @@ def test_other_context_managers_into_context_marker_with_empty_parentness_are_wo """ A generated async function preserves a no-argument ordinary context manager nested inside async_context. - The awaited result should come from the nested manager's yielded value. + The awaited result should come from the nested manager's yielded value, and the nested manager should run enter, body, and exit in order. """ + events = [] + @contextmanager def context_manager_with_parentnes(): - yield 123 + events.append('enter') + try: + yield 123 + finally: + events.append('exit') @transfunction def template(): with async_context: # noqa: SIM117 with context_manager_with_parentnes() as something: + events.append('body') return something function = template.get_async_function() assert run(function()) == 123 + assert events == ['enter', 'body', 'exit'] def test_other_context_managers_into_context_marker_with_empty_parentness_are_working_in_async_function_with_arguments(): @@ -1918,10 +1945,10 @@ def test_nonlocal_variable_default_value_for_usual_function(): """ get_usual_function preserves a default argument value captured from an enclosing local variable. - Calling the generated usual function without arguments should pass that preserved default into the body. + Calling the generated usual function without arguments should pass that exact preserved object into the body. """ container = [] - variable = 123 + variable = object() @transfunction def template(number=variable): @@ -1930,7 +1957,8 @@ def template(number=variable): function = template.get_usual_function() function() - assert container == [variable] + assert len(container) == 1 + assert container[0] is variable def test_global_variable_default_value_for_usual_function(): @@ -2006,12 +2034,12 @@ def test_resetted_global_variable_default_value_for_async_function(): """ Generated async functions keep a default value evaluated from a local name that shadows a module global. - Calling the coroutine without an argument should use the local default, not the same-named global. + Calling the coroutine without a keyword-only argument should use the local default, not the same-named global or a copied positional default. """ SOME_GLOBAL = 'kek' # noqa: N806 @transfunction - def template(number=SOME_GLOBAL): + def template(*, number=SOME_GLOBAL): return number function = template.get_async_function() diff --git a/tests/units/test_universal_namespace.py b/tests/units/test_universal_namespace.py index cd7abd4..cf09312 100644 --- a/tests/units/test_universal_namespace.py +++ b/tests/units/test_universal_namespace.py @@ -28,9 +28,9 @@ def test_get_nonlocal(): """ Resolve a requested name from the supplied frame's local variables. - This locks down that lookup can use a caller-local value even when the wrapped function is empty and does not close over that name. + This locks down that lookup returns the exact caller-local object even when the wrapped function is empty and does not close over that name. """ - some_nonlocal = 123 # noqa: F841 + some_nonlocal = object() def function(): pass @@ -39,7 +39,7 @@ def function(): namespace = UniversalNamespaceAroundFunction(function, frame) - assert namespace['some_nonlocal'] == 123 + assert namespace['some_nonlocal'] is some_nonlocal def test_get_global(): @@ -76,13 +76,14 @@ def function(): assert namespace['some_global'] == 123 -def test_get_builtin(): +def test_get_builtin(monkeypatch): """ Resolves a missing namespace name from Python builtins. The test uses a temporary unique builtin value so the assertion specifically verifies the final builtin fallback after namespace assignments, frame locals, and function globals do not provide the name. """ - builtins.some_name = 1234 + builtin_value = object() + monkeypatch.setattr(builtins, 'some_name', builtin_value, raising=False) def function(): pass @@ -91,9 +92,7 @@ def function(): namespace = UniversalNamespaceAroundFunction(function, frame) - assert namespace['some_name'] == 1234 - - del builtins.some_name + assert namespace['some_name'] is builtin_value def test_get_nonlocal_with_name_as_builtin(): @@ -172,13 +171,13 @@ def function(): assert namespace['some_global'] == 12345 -def test_set_value_with_same_name_as_builtin(): +def test_set_value_with_same_name_as_builtin(monkeypatch): """ Explicit namespace assignments take precedence over same-named builtins. The check creates a temporary builtin, assigns a different value under that name in the namespace, and verifies lookup returns the assigned value. """ - builtins.some_builtin = 1234 + monkeypatch.setattr(builtins, 'some_builtin', 1234, raising=False) def function(): pass @@ -190,5 +189,3 @@ def function(): namespace['some_builtin'] = 12345 assert namespace['some_builtin'] == 12345 - - del builtins.some_builtin