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 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
+ [![PyPI](https://img.shields.io/pypi/v/cacheprop.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1,2 @@
1
+ cacheprop
2
+ tests
tests/__init__.py ADDED
File without changes
@@ -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