cacheprop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cacheprop/__init__.py +73 -0
- cacheprop-0.1.0.dist-info/METADATA +96 -0
- cacheprop-0.1.0.dist-info/RECORD +8 -0
- cacheprop-0.1.0.dist-info/WHEEL +5 -0
- cacheprop-0.1.0.dist-info/licenses/LICENSE +10 -0
- cacheprop-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_cacheprop.py +290 -0
cacheprop/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Callable, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class cacheprop[T]:
|
|
6
|
+
"""
|
|
7
|
+
Decorator that converts a method with a single self argument into a
|
|
8
|
+
property cached on the instance.
|
|
9
|
+
|
|
10
|
+
A cacheprop can be made out of an existing method:
|
|
11
|
+
(e.g. ``url = cacheprop(get_absolute_url)``).
|
|
12
|
+
|
|
13
|
+
Also provided generic type so that we can accomodate for type checking
|
|
14
|
+
for properties that return different types, ie general types
|
|
15
|
+
|
|
16
|
+
Works with both sync and async methods.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name = None
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def func(instance):
|
|
23
|
+
raise TypeError(
|
|
24
|
+
"Cannot use cacheprop instance without calling " "__set_name__() on it."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def __init__(self, func: Callable[[Any], T]):
|
|
28
|
+
self.real_func = func
|
|
29
|
+
self._is_async = inspect.iscoroutinefunction(func)
|
|
30
|
+
self.__doc__ = getattr(func, "__doc__")
|
|
31
|
+
self.__annotations__ = getattr(func, "__annotations__", {})
|
|
32
|
+
|
|
33
|
+
def __set_name__(self, owner, name):
|
|
34
|
+
if self.name is None:
|
|
35
|
+
self.name = name
|
|
36
|
+
self.func = self.real_func # type: ignore[method-assign]
|
|
37
|
+
elif name != self.name:
|
|
38
|
+
raise TypeError(
|
|
39
|
+
"Cannot assign the same cacheprop to two different names "
|
|
40
|
+
"(%r and %r)." % (self.name, name)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __get__(self, instance, cls=None) -> T:
|
|
44
|
+
"""
|
|
45
|
+
Call the function and put the return value in instance.__dict__ so that
|
|
46
|
+
subsequent attribute access on the instance returns the cached value
|
|
47
|
+
instead of calling cacheprop.__get__().
|
|
48
|
+
"""
|
|
49
|
+
if instance is None:
|
|
50
|
+
return self # type: ignore[return-value]
|
|
51
|
+
cache_key = f"_cached_{self.name}"
|
|
52
|
+
if self.name is not None and cache_key in instance.__dict__:
|
|
53
|
+
if self._is_async:
|
|
54
|
+
|
|
55
|
+
async def _cache_and_return():
|
|
56
|
+
return instance.__dict__[cache_key] # type: ignore[index]
|
|
57
|
+
|
|
58
|
+
return _cache_and_return()
|
|
59
|
+
return instance.__dict__[cache_key]
|
|
60
|
+
res = self.func(instance)
|
|
61
|
+
if self._is_async:
|
|
62
|
+
|
|
63
|
+
async def _cache_and_return():
|
|
64
|
+
result = await res
|
|
65
|
+
instance.__dict__[cache_key] = result # type: ignore[index]
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
return _cache_and_return() # type: ignore[return-value]
|
|
69
|
+
instance.__dict__[self.name] = res
|
|
70
|
+
return res
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["cacheprop"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cacheprop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed cached property decorator with async support
|
|
5
|
+
Author-email: "Nikola T." <git@nikola.aleeas.com>
|
|
6
|
+
License-Expression: 0BSD
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/elrik/cacheprop
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/elrik/cacheprop.git
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# cacheprop
|
|
17
|
+
|
|
18
|
+
[](https://pypi.python.org/pypi/cacheprop)
|
|
19
|
+
|
|
20
|
+
A typed cached property decorator with async support.
|
|
21
|
+
|
|
22
|
+
## Why?
|
|
23
|
+
|
|
24
|
+
- Makes caching of time or computation-expensive properties quick and easy.
|
|
25
|
+
- Provides proper type checking via PEP 695 generic syntax (`class cacheprop[T]`).
|
|
26
|
+
- Works with both sync and async methods — the return value is automatically awaited for async functions.
|
|
27
|
+
- Preserves `__doc__` and `__annotations__` from the wrapped function.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install cacheprop
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires **Python 3.12+** (uses PEP 695 type parameter syntax).
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### Sync methods
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from cacheprop import cacheprop
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MyClass:
|
|
46
|
+
@cacheprop
|
|
47
|
+
def expensive(self) -> str:
|
|
48
|
+
return heavy_computation()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
obj = MyClass()
|
|
52
|
+
print(obj.expensive) # calls heavy_computation(), caches result
|
|
53
|
+
print(obj.expensive) # returns cached value, no call
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Async methods
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
class MyAsyncClass:
|
|
60
|
+
@cacheprop
|
|
61
|
+
async def fetch_data(self) -> dict:
|
|
62
|
+
return await http_get("https://example.com/api")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
obj = MyAsyncClass()
|
|
67
|
+
data1 = await obj.fetch_data # awaits and caches
|
|
68
|
+
data2 = await obj.fetch_data # returns cached value, no await needed
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Reusing an existing function
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
def get_absolute_url(self):
|
|
75
|
+
return f"/items/{self.id}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Item:
|
|
79
|
+
url = cacheprop(get_absolute_url)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Type checking
|
|
83
|
+
|
|
84
|
+
The generic parameter `T` lets type checkers infer the return type:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
@cacheprop
|
|
88
|
+
def size(self) -> int:
|
|
89
|
+
return len(self.data)
|
|
90
|
+
|
|
91
|
+
reveal_type(obj.size) # int (not object or Any)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
0BSD
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cacheprop/__init__.py,sha256=lnElEP_9XBvn6ghe2F2mrAqkV4voEPzl8KO4Z9zyAgQ,2423
|
|
2
|
+
cacheprop-0.1.0.dist-info/licenses/LICENSE,sha256=Sc9mCostqbVaA-dHC_EzdbLanw7O4r4z4v52w5t1M64,654
|
|
3
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
tests/test_cacheprop.py,sha256=aqfxavb86RpkXmBFY1yj7gD2XjX1KmfK3k66fbj4WRQ,7510
|
|
5
|
+
cacheprop-0.1.0.dist-info/METADATA,sha256=R-bB-zMatFUFX8tnPVqwFqgCu76QNTBAgVfWq9XvvyI,2182
|
|
6
|
+
cacheprop-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
cacheprop-0.1.0.dist-info/top_level.txt,sha256=VlJJ-wFg2nMClFd-JhKJdC7e7xK7l4lcYzTiC2eUipw,16
|
|
8
|
+
cacheprop-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Copyright (C) by the authors of this software
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without
|
|
4
|
+
fee is hereby granted.
|
|
5
|
+
|
|
6
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE
|
|
7
|
+
INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
|
|
8
|
+
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
9
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
|
10
|
+
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_cacheprop.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ..cacheprop import cacheprop
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestCacheprop:
|
|
9
|
+
def test_unassigned_raises_type_error(self):
|
|
10
|
+
"""
|
|
11
|
+
Using cacheprop without assigning to a class should raise TypeError
|
|
12
|
+
"""
|
|
13
|
+
call_count = 0
|
|
14
|
+
|
|
15
|
+
def my_func(self):
|
|
16
|
+
nonlocal call_count
|
|
17
|
+
call_count += 1
|
|
18
|
+
return 42
|
|
19
|
+
|
|
20
|
+
prop = cacheprop(my_func)
|
|
21
|
+
with pytest.raises(TypeError) as excinfo:
|
|
22
|
+
prop.__get__(object())
|
|
23
|
+
assert (
|
|
24
|
+
str(excinfo.value) == "Cannot use cacheprop instance without calling "
|
|
25
|
+
"__set_name__() on it."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def test_reassign_same_cacheprop_raises(self):
|
|
29
|
+
"""
|
|
30
|
+
Assigning the same cacheprop to two different class names raises TypeError
|
|
31
|
+
"""
|
|
32
|
+
call_count = 0
|
|
33
|
+
|
|
34
|
+
def my_func(self):
|
|
35
|
+
nonlocal call_count
|
|
36
|
+
call_count += 1
|
|
37
|
+
return 42
|
|
38
|
+
|
|
39
|
+
prop = cacheprop(my_func)
|
|
40
|
+
|
|
41
|
+
class A:
|
|
42
|
+
attr_a = prop
|
|
43
|
+
|
|
44
|
+
with pytest.raises(TypeError) as excinfo:
|
|
45
|
+
|
|
46
|
+
class B:
|
|
47
|
+
attr_b = prop
|
|
48
|
+
|
|
49
|
+
assert (
|
|
50
|
+
str(excinfo.value)
|
|
51
|
+
== "Cannot assign the same cacheprop to two different names "
|
|
52
|
+
"('attr_a' and 'attr_b')."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_class_access_returns_descriptor(self):
|
|
56
|
+
"""
|
|
57
|
+
Accessing cacheprop from the class (not instance) returns self
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
class MyClass:
|
|
61
|
+
@cacheprop
|
|
62
|
+
def my_prop(self):
|
|
63
|
+
return 42
|
|
64
|
+
|
|
65
|
+
assert isinstance(MyClass.my_prop, cacheprop)
|
|
66
|
+
|
|
67
|
+
def test_basic_caching_behavior(self):
|
|
68
|
+
"""
|
|
69
|
+
cacheprop should cache the result on the instance
|
|
70
|
+
"""
|
|
71
|
+
call_count = 0
|
|
72
|
+
|
|
73
|
+
class MyClass:
|
|
74
|
+
@cacheprop
|
|
75
|
+
def my_prop(self):
|
|
76
|
+
nonlocal call_count
|
|
77
|
+
call_count += 1
|
|
78
|
+
return 42
|
|
79
|
+
|
|
80
|
+
instance = MyClass()
|
|
81
|
+
assert instance.my_prop == 42
|
|
82
|
+
assert instance.my_prop == 42
|
|
83
|
+
assert call_count == 1
|
|
84
|
+
|
|
85
|
+
def test_docstring_preserved(self):
|
|
86
|
+
"""
|
|
87
|
+
cacheprop should copy __doc__ from the wrapped function
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def my_func(self):
|
|
91
|
+
"""My docstring"""
|
|
92
|
+
return 42
|
|
93
|
+
|
|
94
|
+
prop = cacheprop(my_func)
|
|
95
|
+
assert prop.__doc__ == "My docstring"
|
|
96
|
+
|
|
97
|
+
def test_annotations_preserved(self):
|
|
98
|
+
"""
|
|
99
|
+
cacheprop should copy __annotations__ from the wrapped function
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def my_func(self) -> int:
|
|
103
|
+
return 42
|
|
104
|
+
|
|
105
|
+
prop = cacheprop(my_func)
|
|
106
|
+
assert prop.__annotations__ == {"return": int}
|
|
107
|
+
|
|
108
|
+
def test_different_instances_independent(self):
|
|
109
|
+
"""
|
|
110
|
+
Each instance should have its own cached value
|
|
111
|
+
"""
|
|
112
|
+
call_counts = {"a": 0, "b": 0}
|
|
113
|
+
|
|
114
|
+
class MyClass:
|
|
115
|
+
@cacheprop
|
|
116
|
+
def my_prop(self):
|
|
117
|
+
label = "a" if self.label == "a" else "b"
|
|
118
|
+
call_counts[label] += 1
|
|
119
|
+
return 42
|
|
120
|
+
|
|
121
|
+
a = MyClass()
|
|
122
|
+
a.label = "a"
|
|
123
|
+
b = MyClass()
|
|
124
|
+
b.label = "b"
|
|
125
|
+
|
|
126
|
+
assert a.my_prop == 42
|
|
127
|
+
assert b.my_prop == 42
|
|
128
|
+
assert call_counts["a"] == 1
|
|
129
|
+
assert call_counts["b"] == 1
|
|
130
|
+
|
|
131
|
+
def test_cached_in_instance_dict(self):
|
|
132
|
+
"""
|
|
133
|
+
The cached value should be stored in the instance's __dict__
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
class MyClass:
|
|
137
|
+
@cacheprop
|
|
138
|
+
def my_prop(self):
|
|
139
|
+
return 42
|
|
140
|
+
|
|
141
|
+
instance = MyClass()
|
|
142
|
+
assert "my_prop" not in instance.__dict__
|
|
143
|
+
_ = instance.my_prop
|
|
144
|
+
assert "my_prop" in instance.__dict__
|
|
145
|
+
assert instance.__dict__["my_prop"] == 42
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_async_method_returns_coroutine(self):
|
|
149
|
+
"""
|
|
150
|
+
Using cacheprop on an async method should return a coroutine
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
class MyClass:
|
|
154
|
+
@cacheprop
|
|
155
|
+
async def my_async_prop(self):
|
|
156
|
+
return 42
|
|
157
|
+
|
|
158
|
+
instance = MyClass()
|
|
159
|
+
result = instance.my_async_prop
|
|
160
|
+
assert asyncio.iscoroutine(result)
|
|
161
|
+
assert 42 == await result
|
|
162
|
+
result.close()
|
|
163
|
+
|
|
164
|
+
def test_cached_value_returned_from_dict(self):
|
|
165
|
+
"""
|
|
166
|
+
Once cached, the value should be returned directly from __dict__
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
class MyClass:
|
|
170
|
+
@cacheprop
|
|
171
|
+
def my_prop(self):
|
|
172
|
+
return 42
|
|
173
|
+
|
|
174
|
+
instance = MyClass()
|
|
175
|
+
instance.__dict__["my_prop"] = 99
|
|
176
|
+
assert instance.my_prop == 99
|
|
177
|
+
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_async_method_caches_result(self):
|
|
180
|
+
"""
|
|
181
|
+
Async cacheprop should only call the function once
|
|
182
|
+
"""
|
|
183
|
+
call_count = 0
|
|
184
|
+
|
|
185
|
+
class MyClass:
|
|
186
|
+
@cacheprop
|
|
187
|
+
async def my_async_prop(self):
|
|
188
|
+
nonlocal call_count
|
|
189
|
+
call_count += 1
|
|
190
|
+
return 42
|
|
191
|
+
|
|
192
|
+
instance = MyClass()
|
|
193
|
+
r1 = await instance.my_async_prop
|
|
194
|
+
r2 = await instance.my_async_prop
|
|
195
|
+
assert r1 == 42
|
|
196
|
+
assert r2 == 42
|
|
197
|
+
assert call_count == 1
|
|
198
|
+
|
|
199
|
+
def test_async_method_docstring_preserved(self):
|
|
200
|
+
"""
|
|
201
|
+
cacheprop should copy __doc__ from async function
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
async def my_async_func(self):
|
|
205
|
+
"""Async docstring"""
|
|
206
|
+
return 42
|
|
207
|
+
|
|
208
|
+
prop = cacheprop(my_async_func)
|
|
209
|
+
assert prop.__doc__ == "Async docstring"
|
|
210
|
+
|
|
211
|
+
def test_async_method_annotations_preserved(self):
|
|
212
|
+
"""
|
|
213
|
+
cacheprop should copy __annotations__ from async function
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
async def my_async_func(self) -> int:
|
|
217
|
+
return 42
|
|
218
|
+
|
|
219
|
+
prop = cacheprop(my_async_func)
|
|
220
|
+
assert prop.__annotations__ == {"return": int}
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_async_method_different_instances_independent(self):
|
|
224
|
+
"""
|
|
225
|
+
Each instance should have its own cached async value
|
|
226
|
+
"""
|
|
227
|
+
call_counts = {"a": 0, "b": 0}
|
|
228
|
+
|
|
229
|
+
class MyClass:
|
|
230
|
+
@cacheprop
|
|
231
|
+
async def my_async_prop(self):
|
|
232
|
+
label = "a" if self.label == "a" else "b"
|
|
233
|
+
call_counts[label] += 1
|
|
234
|
+
return 42
|
|
235
|
+
|
|
236
|
+
a = MyClass()
|
|
237
|
+
a.label = "a"
|
|
238
|
+
b = MyClass()
|
|
239
|
+
b.label = "b"
|
|
240
|
+
|
|
241
|
+
r1 = await a.my_async_prop
|
|
242
|
+
r2 = await b.my_async_prop
|
|
243
|
+
assert r1 == 42
|
|
244
|
+
assert r2 == 42
|
|
245
|
+
assert call_counts["a"] == 1
|
|
246
|
+
assert call_counts["b"] == 1
|
|
247
|
+
|
|
248
|
+
@pytest.mark.asyncio
|
|
249
|
+
async def test_async_method_cached_in_instance_dict(self):
|
|
250
|
+
"""
|
|
251
|
+
The cached async value should be stored in the instance's __dict__
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
class MyClass:
|
|
255
|
+
@cacheprop
|
|
256
|
+
async def my_async_prop(self):
|
|
257
|
+
return 42
|
|
258
|
+
|
|
259
|
+
instance = MyClass()
|
|
260
|
+
assert "my_async_prop" not in instance.__dict__
|
|
261
|
+
await instance.my_async_prop
|
|
262
|
+
assert "_cached_my_async_prop" in instance.__dict__
|
|
263
|
+
assert instance.__dict__["_cached_my_async_prop"] == 42
|
|
264
|
+
|
|
265
|
+
def test_returns(self):
|
|
266
|
+
class A:
|
|
267
|
+
_a = 0
|
|
268
|
+
|
|
269
|
+
@cacheprop
|
|
270
|
+
def val(self):
|
|
271
|
+
self._a += 1
|
|
272
|
+
return self._a
|
|
273
|
+
|
|
274
|
+
a: A = A()
|
|
275
|
+
assert a.val == 1
|
|
276
|
+
assert a.val == 1
|
|
277
|
+
|
|
278
|
+
@pytest.mark.asyncio
|
|
279
|
+
async def test_async_returns(self):
|
|
280
|
+
class A:
|
|
281
|
+
_a = 0
|
|
282
|
+
|
|
283
|
+
@cacheprop
|
|
284
|
+
async def val(self):
|
|
285
|
+
self._a += 1
|
|
286
|
+
return self._a
|
|
287
|
+
|
|
288
|
+
a: A = A()
|
|
289
|
+
assert await a.val == 1
|
|
290
|
+
assert await a.val == 1
|