Skip to content

Commit 2b12522

Browse files
authored
Merge pull request #5 from pomponchik/develop
0.0.5
2 parents 4cf69a7 + 2390b16 commit 2b12522

File tree

5 files changed

+161
-36
lines changed

5 files changed

+161
-36
lines changed

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,15 @@ With the `@superfunction` decorator, you no longer need to call special methods
250250

251251
If you use it as a regular function, a regular function will be created "under the hood" based on the template and then called:
252252

253+
To call a superfunction like a regular function, you need to use a special tilde syntax:
254+
253255
```python
254-
my_superfunction()
256+
~my_superfunction()
255257
#> so, it's just usual function!
256258
```
257259

260+
Yes, the tilde syntax simply means putting the `~` symbol in front of the function name when calling it.
261+
258262
If you use `asyncio.run` or the `await` keyword when calling, the async version of the function will be automatically generated and called:
259263

260264
```python
@@ -273,15 +277,22 @@ list(my_superfunction())
273277

274278
How does it work? In fact, `my_superfunction` returns some kind of intermediate object that can be both a coroutine and a generator and an ordinary function. Depending on how it is handled, it lazily code-generates the desired version of the function from a given template and uses it.
275279

276-
Separately, it is worth considering how the superfunction works in the normal function mode. The point is that we need to somehow distinguish a call wrapped with an `await` statement or iteration from a call in which we use a function as a regular function. To do this, a special trick is used by default: assigning a finalizer to reset the reference counter to a variable. When the reference count is zero, the normal (synchronous) implementation of the function is automatically called. However, this imposes 2 restrictions:
280+
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:
277281

278-
- You cannot use the return values from this function in any way. This works in the coroutine function mode, but not in the regular mode. If you try to save the result of a function call to a variable, the reference counter to the returned object will not reset while this variable exists, and accordingly the function will not actually be called.
279-
- 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.
282+
```python
283+
@superfunction(tilde_syntax=False)
284+
```
280285

281-
To get around both of these problems, you can use a special syntactic trick: put the `~` symbol before calling the function. Like this:
286+
In this case, the superfunction can be called in exactly the same way as a regular function:
282287

283288
```python
284-
~my_superfunction()
289+
my_superfunction()
290+
#> so, it's just usual function!
285291
```
286292

287-
In this case, the behavior of the superfunction will be completely indistinguishable from the behavior of a regular function. Return expressions and exceptions will work exactly as you expect them to.
293+
However, it is not completely free. 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:
294+
295+
- 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 reset while this variable exists, and accordingly the function will not actually be called.
296+
- 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.
297+
298+
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.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transfunctions"
7-
version = "0.0.4"
7+
version = "0.0.5"
88
authors = [
99
{ name="Evgeniy Blinov", email="zheni-b@yandex.ru" },
1010
]
@@ -32,6 +32,7 @@ classifiers = [
3232
'License :: OSI Approved :: MIT License',
3333
'Intended Audience :: Developers',
3434
'Topic :: Software Development :: Libraries',
35+
'Topic :: Software Development :: Code Generators',
3536
'Framework :: AsyncIO',
3637
]
3738
keywords = [
@@ -40,6 +41,7 @@ keywords = [
4041
'async to sync',
4142
'code generation',
4243
'ast manipulation',
44+
'metaprogramming',
4345
'magic',
4446
]
4547

tests/units/decorators/test_superfunction.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import sys
23
from asyncio import run
34
from contextlib import redirect_stdout
45

@@ -22,7 +23,7 @@
2223
"""
2324

2425

25-
def test_just_sync_call():
26+
def test_just_sync_call_without_breackets():
2627
@superfunction
2728
def function():
2829
with sync_context:
@@ -32,12 +33,44 @@ def function():
3233
with generator_context:
3334
yield from [1, 2, 3]
3435

36+
buffer = io.StringIO()
37+
with redirect_stdout(buffer):
38+
~function()
39+
assert buffer.getvalue() == "1\n"
40+
41+
42+
def test_just_sync_call_without_tilde_syntax():
43+
@superfunction(tilde_syntax=False)
44+
def function():
45+
with sync_context:
46+
print(1)
47+
with async_context:
48+
print(2)
49+
with generator_context:
50+
yield from [1, 2, 3]
51+
3552
buffer = io.StringIO()
3653
with redirect_stdout(buffer):
3754
function()
3855
assert buffer.getvalue() == "1\n"
3956

4057

58+
def test_just_sync_call_with_tilde_syntax():
59+
@superfunction(tilde_syntax=True)
60+
def function():
61+
with sync_context:
62+
print(1)
63+
with async_context:
64+
print(2)
65+
with generator_context:
66+
yield from [1, 2, 3]
67+
68+
buffer = io.StringIO()
69+
with redirect_stdout(buffer):
70+
~function()
71+
assert buffer.getvalue() == "1\n"
72+
73+
4174
def test_just_async_call():
4275
@superfunction
4376
def function():
@@ -84,7 +117,7 @@ def function(a, b):
84117

85118
buffer = io.StringIO()
86119
with redirect_stdout(buffer):
87-
function(1, 2)
120+
~function(1, 2)
88121
assert buffer.getvalue() == "1\n"
89122

90123

@@ -248,3 +281,67 @@ def template():
248281

249282
with pytest.raises(WrongDecoratorSyntaxError, match=full_match('The @superfunction decorator cannot be used in conjunction with other decorators.')):
250283
~template()
284+
285+
286+
def test_pass_coroutine_function_to_decorator():
287+
with pytest.raises(ValueError, match=full_match("Only regular or generator functions can be used as a template for @superfunction. You can't use async functions.")):
288+
@superfunction
289+
async def function_maker():
290+
return 4
291+
292+
293+
def test_pass_not_function_to_decorator():
294+
with pytest.raises(ValueError, match=full_match("Only regular or generator functions can be used as a template for @superfunction.")):
295+
superfunction(1)
296+
297+
298+
def test_try_to_pass_lambda_to_decorator():
299+
with pytest.raises(ValueError, match=full_match("Only regular or generator functions can be used as a template for @superfunction. Don't use lambdas here.")):
300+
superfunction(lambda x: x)
301+
302+
303+
def test_choose_tilde_syntax_off_and_use_tilde():
304+
@superfunction(tilde_syntax=False)
305+
def function():
306+
pass
307+
308+
with pytest.raises(NotImplementedError, match=full_match('The syntax with ~ is disabled for this superfunction. Call it with simple breackets.')):
309+
~function()
310+
311+
312+
def test_call_superfunction_without_tilde_syntax_whet_it_is_on_by_default():
313+
exception_message = None
314+
def temporary_hook(unraisable):
315+
nonlocal exception_message
316+
exception_message = str(unraisable.exc_value)
317+
old_hook = sys.unraisablehook
318+
sys.unraisablehook = temporary_hook
319+
320+
@superfunction
321+
def function():
322+
pass
323+
324+
function()
325+
326+
assert 'The tilde-syntax is enabled for the "function" function. Call it like this: ~function().' == exception_message
327+
328+
sys.unraisablehook = old_hook
329+
330+
331+
def test_call_superfunction_without_tilde_syntax_whet_it_is_on():
332+
exception_message = None
333+
def temporary_hook(unraisable):
334+
nonlocal exception_message
335+
exception_message = str(unraisable.exc_value)
336+
old_hook = sys.unraisablehook
337+
sys.unraisablehook = temporary_hook
338+
339+
@superfunction(tilde_syntax=True)
340+
def function():
341+
pass
342+
343+
function()
344+
345+
assert 'The tilde-syntax is enabled for the "function" function. Call it like this: ~function().' == exception_message
346+
347+
sys.unraisablehook = old_hook

transfunctions/decorators/superfunction.py

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from ast import NodeTransformer, Return, AST
44
from inspect import currentframe
55
from functools import wraps
6-
from typing import Dict, Any, Optional, Union, List
6+
from typing import Dict, Any, Optional, Union, List, Callable
77
from collections.abc import Coroutine
88

99
if sys.version_info <= (3, 10): # pragma: no cover
@@ -23,13 +23,14 @@
2323
CoroutineClass: TypeAlias = Coroutine[Any, Any, None]
2424

2525
class UsageTracer(CoroutineClass):
26-
def __init__(self, args, kwargs, transformer) -> None:
26+
def __init__(self, args, kwargs, transformer, tilde_syntax: bool) -> None:
2727
self.flags: Dict[str, bool] = {}
2828
self.args = args
2929
self.kwargs = kwargs
3030
self.transformer = transformer
31-
self.coroutine = self.async_sleep_option(self.flags, args, kwargs, transformer)
32-
self.finalizer = weakref.finalize(self, self.sync_sleep_option, self.flags, args, kwargs, transformer, self.coroutine)
31+
self.tilde_syntax = tilde_syntax
32+
self.coroutine = self.async_option(self.flags, args, kwargs, transformer)
33+
self.finalizer = weakref.finalize(self, self.sync_option, self.flags, args, kwargs, transformer, self.coroutine, tilde_syntax)
3334

3435
def __iter__(self):
3536
self.flags['used'] = True
@@ -42,8 +43,12 @@ def __await__(self) -> Any: # pragma: no cover
4243
return self.coroutine.__await__()
4344

4445
def __invert__(self):
45-
result = self.finalizer()
46-
return result
46+
if not self.tilde_syntax:
47+
raise NotImplementedError('The syntax with ~ is disabled for this superfunction. Call it with simple breackets.')
48+
49+
self.flags['used'] = True
50+
self.coroutine.close()
51+
return self.transformer.get_usual_function()(*(self.args), **(self.kwargs))
4752

4853
def send(self, value: Any) -> Any:
4954
return self.coroutine.send(value)
@@ -55,37 +60,45 @@ def close(self) -> None: # pragma: no cover
5560
pass
5661

5762
@staticmethod
58-
def sync_sleep_option(flags: Dict[str, bool], args, kwargs, transformer, wrapped_coroutine: CoroutineClass) -> None:
63+
def sync_option(flags: Dict[str, bool], args, kwargs, transformer, wrapped_coroutine: CoroutineClass, tilde_syntax: bool) -> None:
5964
if not flags.get('used', False):
6065
wrapped_coroutine.close()
61-
return transformer.get_usual_function()(*args, **kwargs)
66+
if not tilde_syntax:
67+
return transformer.get_usual_function()(*args, **kwargs)
68+
else:
69+
raise NotImplementedError(f'The tilde-syntax is enabled for the "{transformer.function.__name__}" function. Call it like this: ~{transformer.function.__name__}().')
6270

6371
@staticmethod
64-
async def async_sleep_option(flags: Dict[str, bool], args, kwargs, transformer) -> None:
72+
async def async_option(flags: Dict[str, bool], args, kwargs, transformer) -> None:
6573
flags['used'] = True
6674
return await transformer.get_async_function()(*args, **kwargs)
6775

6876

6977
not_display(UsageTracer)
7078

71-
def superfunction(function):
72-
class NoReturns(NodeTransformer):
73-
def visit_Return(self, node: Return) -> Optional[Union[AST, List[AST]]]:
74-
raise WrongTransfunctionSyntaxError('A superfunction cannot contain a return statement.')
79+
def superfunction(*args: Callable, tilde_syntax: bool = True):
80+
def decorator(function):
81+
class NoReturns(NodeTransformer):
82+
def visit_Return(self, node: Return) -> Optional[Union[AST, List[AST]]]:
83+
raise WrongTransfunctionSyntaxError('A superfunction cannot contain a return statement.')
84+
85+
transformer = FunctionTransformer(
86+
function,
87+
currentframe().f_back.f_lineno,
88+
'superfunction',
89+
extra_transformers=[
90+
#NoReturns(),
91+
],
92+
)
7593

76-
transformer = FunctionTransformer(
77-
function,
78-
currentframe().f_back.f_lineno,
79-
'superfunction',
80-
extra_transformers=[
81-
#NoReturns(),
82-
],
83-
)
94+
@wraps(function)
95+
def wrapper(*args, **kwargs):
96+
return UsageTracer(args, kwargs, transformer, tilde_syntax)
8497

85-
@wraps(function)
86-
def wrapper(*args, **kwargs):
87-
return UsageTracer(args, kwargs, transformer)
98+
wrapper.__is_superfunction__ = True
8899

89-
wrapper.__is_superfunction__ = True
100+
return wrapper
90101

91-
return wrapper
102+
if args:
103+
return decorator(args[0])
104+
return decorator

transfunctions/transformer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ def visit_FunctionDef(self, node: FunctionDef) -> Optional[Union[AST, List[AST]]
136136
raise WrongDecoratorSyntaxError(f"The @{decorator_name} decorator can only be used with the '@' symbol. Don't use it as a regular function. Also, don't rename it.")
137137

138138
for decorator in node.decorator_list:
139+
if isinstance(decorator, Call):
140+
decorator = decorator.func
139141
if decorator.id != decorator_name:
140142
raise WrongDecoratorSyntaxError(f'The @{decorator_name} decorator cannot be used in conjunction with other decorators.')
141143
else:

0 commit comments

Comments
 (0)