Cachalot 2.0.0__tar.gz
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.
- cachalot-2.0.0/LICENSE +9 -0
- cachalot-2.0.0/PKG-INFO +156 -0
- cachalot-2.0.0/README.md +130 -0
- cachalot-2.0.0/pyproject.toml +50 -0
- cachalot-2.0.0/src/cachalot/__init__.py +312 -0
cachalot-2.0.0/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018, Radek Sprta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
cachalot-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: Cachalot
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Minimal persistent memoization cache
|
|
5
|
+
Project-URL: Homepage, https://radek-sprta.gitlab.io/Cachalot/
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/radek-sprta/Cachalot
|
|
7
|
+
Project-URL: Documentation, https://radek-sprta.gitlab.io/Cachalot/
|
|
8
|
+
Author-email: Radek Sprta <mail@radeksprta.eu>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cache,memoization
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: jsonpickle
|
|
24
|
+
Requires-Dist: tinydb>=4
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Cachalot [](https://badge.fury.io/py/cachalot) [](https://gitlab.com/radek-sprta/cachalot/commits/master) [](https://gitlab.com/radek-sprta/cachalot/commits/master)[](http://pepy.tech/project/cachalot)
|
|
28
|
+
|
|
29
|
+
Cachalot is a minimal persistent memoization cache. It provides a decorator, that stores function result for future use. Perfect for heavy computations and I/O operation (such as web requests). On backend, it uses TinyDB for storage.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
- Simple usage via decorator
|
|
33
|
+
- Persistent caching
|
|
34
|
+
- Optional in-memory caching
|
|
35
|
+
- Key expiration
|
|
36
|
+
- Maximum cache size, to prevent bloat
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
Cachalot requires Python 3.11 or newer to run.
|
|
40
|
+
|
|
41
|
+
**Python package**
|
|
42
|
+
|
|
43
|
+
You can easily install Cachalot using pip:
|
|
44
|
+
|
|
45
|
+
`pip3 install cachalot`
|
|
46
|
+
|
|
47
|
+
**Manual**
|
|
48
|
+
|
|
49
|
+
Alternatively, to get the latest development version, you can clone this repository and then manually install it:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
git clone git@gitlab.com:radek-sprta/cachalot.git
|
|
53
|
+
cd cachalot
|
|
54
|
+
pip install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
```python
|
|
59
|
+
from cachalot import Cache
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@Cache()
|
|
63
|
+
def expensive_function():
|
|
64
|
+
return expensive_calculation()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Advanced usage
|
|
68
|
+
```python
|
|
69
|
+
from cachalot import Cache
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@Cache(
|
|
73
|
+
path="cache.json",
|
|
74
|
+
timeout=3600,
|
|
75
|
+
size=5000,
|
|
76
|
+
filesize=1_000_000,
|
|
77
|
+
retry=True,
|
|
78
|
+
renew_on_read=True,
|
|
79
|
+
)
|
|
80
|
+
def expensive_function():
|
|
81
|
+
return expensive_calculation()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `path`: Path to the database file. Defaults to .cache.json. If None, the cache is kept in memory.
|
|
85
|
+
- `timeout`: How long should the data be cached in seconds. Defaults to 0 (infinite).
|
|
86
|
+
- `size`: Maximum number of keys cached. Defaults to 0 (infinite).
|
|
87
|
+
- `filesize`: Maximum size of database file in bytes. Defaults to 0 (infinite). Cannot be used with an in-memory cache.
|
|
88
|
+
- `retry`: Retry if the cached result is None. Defaults to False.
|
|
89
|
+
- `renew_on_read`: Renew the entry, i.e refresh the entry timestamp on reads. Defaults to True.
|
|
90
|
+
|
|
91
|
+
### Caveats
|
|
92
|
+
- Cache keys are the function's module and qualified name plus positional and keyword arguments (keyword order irrelevant). Defaults are not applied: `f(1)` and `f(1, b=2)` differ even if `2` is the default, and so do positional and keyword calls. For methods, `self` is intentionally excluded, so all instances share results.
|
|
93
|
+
- Functions with the same qualified name share a key: lambdas in the same scope, and functions made by the same factory (e.g. `make_scaler(2)` and `make_scaler(3)`). Closure state is not part of the key; use named top-level functions instead.
|
|
94
|
+
- Arguments are encoded with jsonpickle, which is not canonical: sets may encode in a different order between processes, causing misses.
|
|
95
|
+
- When `size` is exceeded, the least recently used entry is evicted. Use is the last read with `renew_on_read=True`, otherwise the last write.
|
|
96
|
+
- Results larger than `filesize` bytes are not cached (the entry is skipped, not stored).
|
|
97
|
+
- Cache objects on the same file within one process share one database. The file is still not locked; do not share it between concurrent processes or threads.
|
|
98
|
+
- Values are stored with jsonpickle; loading a cache file can execute code. Only use cache files from trusted sources.
|
|
99
|
+
|
|
100
|
+
### Manually deleting entries
|
|
101
|
+
If you want to manually invalidate an entry, you can calculate the hash of the function call and then pass it to the `remove` method.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
cache = Cache()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@cache
|
|
108
|
+
def expensive_function(arg):
|
|
109
|
+
return expensive_calculation(arg)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
key = cache.calculate_hash(expensive_function)("arg")
|
|
113
|
+
cache.remove(key)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For more information, see [documentation][documentation].
|
|
117
|
+
|
|
118
|
+
## Contributing
|
|
119
|
+
For information on how to contribute to the project, please check the [Contributor's Guide][contributing]
|
|
120
|
+
|
|
121
|
+
## Contact
|
|
122
|
+
[mail@radeksprta.eu](mailto:mail@radeksprta.eu)
|
|
123
|
+
|
|
124
|
+
[incoming+radek-sprta/cachalot@gitlab.com](mailto:incoming+radek-sprta/cachalot@gitlab.com)
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
MIT License
|
|
128
|
+
|
|
129
|
+
## Credits
|
|
130
|
+
This package was created with [Cookiecutter][cookiecutter] and the [python-cookiecutter][python-cookiecutter] project template. Inspired by [Cashier][cachier]
|
|
131
|
+
|
|
132
|
+
## Contributors ✨
|
|
133
|
+
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
|
134
|
+
|
|
135
|
+
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
|
136
|
+
<!-- prettier-ignore-start -->
|
|
137
|
+
<!-- markdownlint-disable -->
|
|
138
|
+
<table>
|
|
139
|
+
<tr>
|
|
140
|
+
<td align="center"><a href="https://gitlab.com/Evidlo"><img src="https://secure.gravatar.com/avatar/2670a8eba83e9233eb254fa48a12249f?s=80&d=identicon?s=100" width="100px;" alt=""/><br /><sub><b>Evan Widloski</b></sub></a><br /><a href="https://gitlab.com/radek-sprta/Cachalot/commits/master" title="Code">💻</a></td>
|
|
141
|
+
<td align="center"><a href="https://gitlab.com/sasa-tomic"><img src="https://assets.gitlab-static.net/uploads/-/system/user/avatar/4715393/avatar.png?s=100" width="100px;" alt=""/><br /><sub><b>Saša Tomić</b></sub></a><br /><a href="https://gitlab.com/radek-sprta/Cachalot/commits/master" title="Code">💻</a></td>
|
|
142
|
+
</tr>
|
|
143
|
+
</table>
|
|
144
|
+
|
|
145
|
+
<!-- markdownlint-restore -->
|
|
146
|
+
<!-- prettier-ignore-end -->
|
|
147
|
+
|
|
148
|
+
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
|
149
|
+
|
|
150
|
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
|
151
|
+
|
|
152
|
+
[cachier]: https://github.com/atmb4u/cashier
|
|
153
|
+
[contributing]: https://gitlab.com/radek-sprta/cachalot/blob/master/CONTRIBUTING.md
|
|
154
|
+
[cookiecutter]: https://github.com/audreyr/cookiecutter
|
|
155
|
+
[documentation]: https://radek-sprta.gitlab.io/cachalot
|
|
156
|
+
[python-cookiecutter]: https://gitlab.com/radek-sprta/python-cookiecutter
|
cachalot-2.0.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Cachalot [](https://badge.fury.io/py/cachalot) [](https://gitlab.com/radek-sprta/cachalot/commits/master) [](https://gitlab.com/radek-sprta/cachalot/commits/master)[](http://pepy.tech/project/cachalot)
|
|
2
|
+
|
|
3
|
+
Cachalot is a minimal persistent memoization cache. It provides a decorator, that stores function result for future use. Perfect for heavy computations and I/O operation (such as web requests). On backend, it uses TinyDB for storage.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- Simple usage via decorator
|
|
7
|
+
- Persistent caching
|
|
8
|
+
- Optional in-memory caching
|
|
9
|
+
- Key expiration
|
|
10
|
+
- Maximum cache size, to prevent bloat
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
Cachalot requires Python 3.11 or newer to run.
|
|
14
|
+
|
|
15
|
+
**Python package**
|
|
16
|
+
|
|
17
|
+
You can easily install Cachalot using pip:
|
|
18
|
+
|
|
19
|
+
`pip3 install cachalot`
|
|
20
|
+
|
|
21
|
+
**Manual**
|
|
22
|
+
|
|
23
|
+
Alternatively, to get the latest development version, you can clone this repository and then manually install it:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
git clone git@gitlab.com:radek-sprta/cachalot.git
|
|
27
|
+
cd cachalot
|
|
28
|
+
pip install .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
```python
|
|
33
|
+
from cachalot import Cache
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@Cache()
|
|
37
|
+
def expensive_function():
|
|
38
|
+
return expensive_calculation()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Advanced usage
|
|
42
|
+
```python
|
|
43
|
+
from cachalot import Cache
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@Cache(
|
|
47
|
+
path="cache.json",
|
|
48
|
+
timeout=3600,
|
|
49
|
+
size=5000,
|
|
50
|
+
filesize=1_000_000,
|
|
51
|
+
retry=True,
|
|
52
|
+
renew_on_read=True,
|
|
53
|
+
)
|
|
54
|
+
def expensive_function():
|
|
55
|
+
return expensive_calculation()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `path`: Path to the database file. Defaults to .cache.json. If None, the cache is kept in memory.
|
|
59
|
+
- `timeout`: How long should the data be cached in seconds. Defaults to 0 (infinite).
|
|
60
|
+
- `size`: Maximum number of keys cached. Defaults to 0 (infinite).
|
|
61
|
+
- `filesize`: Maximum size of database file in bytes. Defaults to 0 (infinite). Cannot be used with an in-memory cache.
|
|
62
|
+
- `retry`: Retry if the cached result is None. Defaults to False.
|
|
63
|
+
- `renew_on_read`: Renew the entry, i.e refresh the entry timestamp on reads. Defaults to True.
|
|
64
|
+
|
|
65
|
+
### Caveats
|
|
66
|
+
- Cache keys are the function's module and qualified name plus positional and keyword arguments (keyword order irrelevant). Defaults are not applied: `f(1)` and `f(1, b=2)` differ even if `2` is the default, and so do positional and keyword calls. For methods, `self` is intentionally excluded, so all instances share results.
|
|
67
|
+
- Functions with the same qualified name share a key: lambdas in the same scope, and functions made by the same factory (e.g. `make_scaler(2)` and `make_scaler(3)`). Closure state is not part of the key; use named top-level functions instead.
|
|
68
|
+
- Arguments are encoded with jsonpickle, which is not canonical: sets may encode in a different order between processes, causing misses.
|
|
69
|
+
- When `size` is exceeded, the least recently used entry is evicted. Use is the last read with `renew_on_read=True`, otherwise the last write.
|
|
70
|
+
- Results larger than `filesize` bytes are not cached (the entry is skipped, not stored).
|
|
71
|
+
- Cache objects on the same file within one process share one database. The file is still not locked; do not share it between concurrent processes or threads.
|
|
72
|
+
- Values are stored with jsonpickle; loading a cache file can execute code. Only use cache files from trusted sources.
|
|
73
|
+
|
|
74
|
+
### Manually deleting entries
|
|
75
|
+
If you want to manually invalidate an entry, you can calculate the hash of the function call and then pass it to the `remove` method.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
cache = Cache()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@cache
|
|
82
|
+
def expensive_function(arg):
|
|
83
|
+
return expensive_calculation(arg)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
key = cache.calculate_hash(expensive_function)("arg")
|
|
87
|
+
cache.remove(key)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For more information, see [documentation][documentation].
|
|
91
|
+
|
|
92
|
+
## Contributing
|
|
93
|
+
For information on how to contribute to the project, please check the [Contributor's Guide][contributing]
|
|
94
|
+
|
|
95
|
+
## Contact
|
|
96
|
+
[mail@radeksprta.eu](mailto:mail@radeksprta.eu)
|
|
97
|
+
|
|
98
|
+
[incoming+radek-sprta/cachalot@gitlab.com](mailto:incoming+radek-sprta/cachalot@gitlab.com)
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
MIT License
|
|
102
|
+
|
|
103
|
+
## Credits
|
|
104
|
+
This package was created with [Cookiecutter][cookiecutter] and the [python-cookiecutter][python-cookiecutter] project template. Inspired by [Cashier][cachier]
|
|
105
|
+
|
|
106
|
+
## Contributors ✨
|
|
107
|
+
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
|
108
|
+
|
|
109
|
+
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
|
110
|
+
<!-- prettier-ignore-start -->
|
|
111
|
+
<!-- markdownlint-disable -->
|
|
112
|
+
<table>
|
|
113
|
+
<tr>
|
|
114
|
+
<td align="center"><a href="https://gitlab.com/Evidlo"><img src="https://secure.gravatar.com/avatar/2670a8eba83e9233eb254fa48a12249f?s=80&d=identicon?s=100" width="100px;" alt=""/><br /><sub><b>Evan Widloski</b></sub></a><br /><a href="https://gitlab.com/radek-sprta/Cachalot/commits/master" title="Code">💻</a></td>
|
|
115
|
+
<td align="center"><a href="https://gitlab.com/sasa-tomic"><img src="https://assets.gitlab-static.net/uploads/-/system/user/avatar/4715393/avatar.png?s=100" width="100px;" alt=""/><br /><sub><b>Saša Tomić</b></sub></a><br /><a href="https://gitlab.com/radek-sprta/Cachalot/commits/master" title="Code">💻</a></td>
|
|
116
|
+
</tr>
|
|
117
|
+
</table>
|
|
118
|
+
|
|
119
|
+
<!-- markdownlint-restore -->
|
|
120
|
+
<!-- prettier-ignore-end -->
|
|
121
|
+
|
|
122
|
+
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
|
123
|
+
|
|
124
|
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
|
125
|
+
|
|
126
|
+
[cachier]: https://github.com/atmb4u/cashier
|
|
127
|
+
[contributing]: https://gitlab.com/radek-sprta/cachalot/blob/master/CONTRIBUTING.md
|
|
128
|
+
[cookiecutter]: https://github.com/audreyr/cookiecutter
|
|
129
|
+
[documentation]: https://radek-sprta.gitlab.io/cachalot
|
|
130
|
+
[python-cookiecutter]: https://gitlab.com/radek-sprta/python-cookiecutter
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "Cachalot"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = "Minimal persistent memoization cache"
|
|
5
|
+
authors = [{ name = "Radek Sprta", email = "mail@radeksprta.eu" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = ["memoization", "cache"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 5 - Production/Stable",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Natural Language :: English",
|
|
14
|
+
"Topic :: Database",
|
|
15
|
+
"Topic :: Software Development :: Libraries",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"tinydb>=4",
|
|
24
|
+
"jsonpickle",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://radek-sprta.gitlab.io/Cachalot/"
|
|
29
|
+
Repository = "https://gitlab.com/radek-sprta/Cachalot"
|
|
30
|
+
Documentation = "https://radek-sprta.gitlab.io/Cachalot/"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/cachalot"]
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"mkdocs>=1.6.1",
|
|
42
|
+
"mkdocs-cinder>=1.2.0",
|
|
43
|
+
"ruff>=0.16.10",
|
|
44
|
+
"pytest>=9.1.1",
|
|
45
|
+
"pytest-cov>=7.1.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
addopts = "--showlocals --verbose -r w --cov=cachalot --cov-report=term-missing"
|
|
50
|
+
norecursedirs = [".*", "build", "site", "dist"]
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Cachalot is a minimal persistent memoization cache, that uses TinyDB."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import hashlib
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import time
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from importlib import metadata
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import jsonpickle
|
|
16
|
+
import tinydb
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = metadata.version(__name__)
|
|
20
|
+
except metadata.PackageNotFoundError: # pragma: no cover
|
|
21
|
+
__version__ = "unknown"
|
|
22
|
+
|
|
23
|
+
# Not thread-safe
|
|
24
|
+
_file_databases: dict[str, tinydb.TinyDB] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Cache:
|
|
28
|
+
"""Offline cache for search results.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
path: Defaults to .cache.json. Path to the database file. If None, the
|
|
32
|
+
cache is kept in memory and the storage type is MemoryStorage.
|
|
33
|
+
timeout: Defaults to infinite. Period after which results should expire.
|
|
34
|
+
size: Defaults to infinite. Maximum number of cached results.
|
|
35
|
+
filesize: Defaults to infinite. Maximum size of database file in bytes.
|
|
36
|
+
Cannot be used with an in-memory cache.
|
|
37
|
+
retry: Defaults to False. Whether to retry when the cached result is None.
|
|
38
|
+
renew_on_read: Defaults to True. Whether to refresh timestamps on reads.
|
|
39
|
+
storage: Deprecated and ignored.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
path: str | None = ".cache.json",
|
|
46
|
+
timeout: int = 0,
|
|
47
|
+
size: int = 0,
|
|
48
|
+
filesize: int = 0,
|
|
49
|
+
retry: bool = False,
|
|
50
|
+
renew_on_read: bool = True,
|
|
51
|
+
storage: Any = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if path is None and filesize > 0:
|
|
54
|
+
raise ValueError("filesize cannot be used with an in-memory cache")
|
|
55
|
+
if storage is not None:
|
|
56
|
+
warnings.warn(
|
|
57
|
+
"storage is ignored and inferred from path",
|
|
58
|
+
DeprecationWarning,
|
|
59
|
+
stacklevel=2,
|
|
60
|
+
)
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self.size = size
|
|
63
|
+
self.filesize = filesize
|
|
64
|
+
self.retry = retry
|
|
65
|
+
self.renew_on_read = renew_on_read
|
|
66
|
+
self.uncached = object() # Sentinel object to check uncached functions
|
|
67
|
+
self.path = (
|
|
68
|
+
os.path.abspath(os.path.expanduser(path)) if path is not None else None
|
|
69
|
+
)
|
|
70
|
+
if self.path is None:
|
|
71
|
+
self.storage = tinydb.storages.MemoryStorage
|
|
72
|
+
self._memory_db = tinydb.TinyDB(storage=self.storage)
|
|
73
|
+
else:
|
|
74
|
+
self.storage = tinydb.storages.JSONStorage
|
|
75
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
76
|
+
self._open_file_db()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def db(self) -> tinydb.TinyDB:
|
|
80
|
+
"""Database shared by all caches on the same file."""
|
|
81
|
+
if self.path is None:
|
|
82
|
+
return self._memory_db
|
|
83
|
+
if self.path not in _file_databases:
|
|
84
|
+
_file_databases[self.path] = tinydb.TinyDB(self.path, storage=self.storage)
|
|
85
|
+
return _file_databases[self.path]
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
"""Close the database; other caches on the same file reopen it on next use."""
|
|
89
|
+
if self.path is None:
|
|
90
|
+
self._memory_db.close()
|
|
91
|
+
elif (database := _file_databases.pop(self.path, None)) is not None:
|
|
92
|
+
database.close()
|
|
93
|
+
|
|
94
|
+
def _open_file_db(self) -> None:
|
|
95
|
+
"""Replace the shared database of the file, closing the previous one."""
|
|
96
|
+
if (previous := _file_databases.get(self.path)) is not None:
|
|
97
|
+
previous.close()
|
|
98
|
+
_file_databases[self.path] = tinydb.TinyDB(self.path, storage=self.storage)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _arguments(
|
|
102
|
+
function: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
103
|
+
) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
|
104
|
+
"""Return the call arguments that identify a call.
|
|
105
|
+
|
|
106
|
+
Keyword arguments are sorted by key and self is dropped for methods,
|
|
107
|
+
so that calls differing only in keyword order are equal. Defaults are
|
|
108
|
+
not applied, so passing a default explicitly yields a different key.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
function: Function being called.
|
|
112
|
+
args: Positional arguments of the call.
|
|
113
|
+
kwargs: Keyword arguments of the call.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Positional arguments and keyword arguments sorted by key.
|
|
117
|
+
"""
|
|
118
|
+
if Cache._is_method(function):
|
|
119
|
+
args = args[1:]
|
|
120
|
+
return args, dict(sorted(kwargs.items()))
|
|
121
|
+
|
|
122
|
+
def calculate_hash(self, function: Callable[..., Any]) -> Callable[..., str]:
|
|
123
|
+
"""Calculate hash of a function call.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
function: Function to calculate hash of.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Function taking the call arguments and returning their hash.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
>>> cache.calculate_hash(len)('teststring')
|
|
133
|
+
'a3d600dd2d9a535ca47674474ebbefec'
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def wrapped(*args, **kwargs):
|
|
137
|
+
arguments = self._arguments(function, args, kwargs)
|
|
138
|
+
seed = f"{function.__module__}.{function.__qualname__}" + jsonpickle.encode(
|
|
139
|
+
arguments, keys=True
|
|
140
|
+
)
|
|
141
|
+
return hashlib.md5(seed.encode("utf8")).hexdigest()
|
|
142
|
+
|
|
143
|
+
return wrapped
|
|
144
|
+
|
|
145
|
+
def clear(self) -> None:
|
|
146
|
+
"""Clear the cache."""
|
|
147
|
+
try:
|
|
148
|
+
self.db.truncate()
|
|
149
|
+
except json.decoder.JSONDecodeError:
|
|
150
|
+
# Database is corrupted
|
|
151
|
+
self._recreate_db()
|
|
152
|
+
|
|
153
|
+
def expiry(self) -> float:
|
|
154
|
+
"""Return expiration time for cached results."""
|
|
155
|
+
return time.time() + self.timeout
|
|
156
|
+
|
|
157
|
+
def _get_with_sentinel(self, key: str) -> Any:
|
|
158
|
+
"""Private get implementation, that returns self.uncached sentinel
|
|
159
|
+
object, if key is not cached in the database. Exists so we do not
|
|
160
|
+
break the API.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
key: Key hash.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Cached object or self.uncached.
|
|
167
|
+
"""
|
|
168
|
+
self._remove_expired()
|
|
169
|
+
entry = tinydb.Query()
|
|
170
|
+
try:
|
|
171
|
+
value = self.db.get(entry.key == key)
|
|
172
|
+
if value:
|
|
173
|
+
if self.renew_on_read:
|
|
174
|
+
self.db.update(
|
|
175
|
+
{"time": self.expiry(), "used": time.time()},
|
|
176
|
+
entry.key == key,
|
|
177
|
+
)
|
|
178
|
+
return jsonpickle.decode(value["value"])
|
|
179
|
+
except json.decoder.JSONDecodeError:
|
|
180
|
+
# Database is corrupted
|
|
181
|
+
self._recreate_db()
|
|
182
|
+
return self.uncached
|
|
183
|
+
|
|
184
|
+
def get(self, key: str) -> Any:
|
|
185
|
+
"""Get entry from database.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
key: Key hash.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Cached object.
|
|
192
|
+
"""
|
|
193
|
+
value = self._get_with_sentinel(key)
|
|
194
|
+
return None if value is self.uncached else value
|
|
195
|
+
|
|
196
|
+
def insert(self, key: str, entry: Any) -> None:
|
|
197
|
+
"""Insert entry into cache.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
key: Key hash of the entry to store.
|
|
201
|
+
entry: Object to cache.
|
|
202
|
+
"""
|
|
203
|
+
document = {
|
|
204
|
+
"key": key,
|
|
205
|
+
"time": self.expiry(),
|
|
206
|
+
"used": time.time(),
|
|
207
|
+
"value": jsonpickle.encode(entry),
|
|
208
|
+
}
|
|
209
|
+
if self.filesize > 0:
|
|
210
|
+
alone = json.dumps({"_default": {"1": document}}).encode()
|
|
211
|
+
if len(alone) > self.filesize:
|
|
212
|
+
self.remove(key)
|
|
213
|
+
return
|
|
214
|
+
try:
|
|
215
|
+
self.db.upsert(document, tinydb.Query().key == key)
|
|
216
|
+
while 0 < self.size < len(self.db):
|
|
217
|
+
self._remove_oldest()
|
|
218
|
+
if self.filesize > 0:
|
|
219
|
+
while os.stat(self.path).st_size > self.filesize and len(self.db) > 0:
|
|
220
|
+
self._remove_oldest()
|
|
221
|
+
except json.decoder.JSONDecodeError:
|
|
222
|
+
# Database is corrupted
|
|
223
|
+
self._recreate_db()
|
|
224
|
+
self.db.upsert(document, tinydb.Query().key == key)
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _is_method(function: Callable[..., Any]) -> bool:
|
|
228
|
+
"""Check if function is actually a method.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
function: Function to check the state of.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if function is method, false otherwise.
|
|
235
|
+
"""
|
|
236
|
+
if inspect.ismethod(function) or "." not in function.__qualname__:
|
|
237
|
+
return False
|
|
238
|
+
return inspect.getfullargspec(function).args[:1] == ["self"]
|
|
239
|
+
|
|
240
|
+
def remove(self, key: str) -> None:
|
|
241
|
+
"""Delete key from cache.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
key: Hash key to delete from the cache.
|
|
245
|
+
"""
|
|
246
|
+
entry = tinydb.Query()
|
|
247
|
+
try:
|
|
248
|
+
self.db.remove(entry.key == key)
|
|
249
|
+
except json.decoder.JSONDecodeError:
|
|
250
|
+
# Database is corrupted
|
|
251
|
+
self._recreate_db()
|
|
252
|
+
|
|
253
|
+
def _remove_expired(self) -> None:
|
|
254
|
+
"""Remove old entries."""
|
|
255
|
+
if self.timeout < 1:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
entry = tinydb.Query()
|
|
259
|
+
now = time.time()
|
|
260
|
+
try:
|
|
261
|
+
self.db.remove(entry.time < now)
|
|
262
|
+
except json.decoder.JSONDecodeError:
|
|
263
|
+
# Database is corrupted
|
|
264
|
+
self._recreate_db()
|
|
265
|
+
|
|
266
|
+
def _recreate_db(self) -> None:
|
|
267
|
+
if self.path is None:
|
|
268
|
+
self._memory_db = tinydb.TinyDB(storage=self.storage)
|
|
269
|
+
return
|
|
270
|
+
self.close()
|
|
271
|
+
pathlib.Path(self.path).unlink(missing_ok=True)
|
|
272
|
+
self._open_file_db()
|
|
273
|
+
|
|
274
|
+
def _remove_oldest(self) -> None:
|
|
275
|
+
"""Remove least recently used entry."""
|
|
276
|
+
try:
|
|
277
|
+
oldest = min(self.db.all(), key=lambda doc: doc.get("used", 0))["key"]
|
|
278
|
+
self.remove(oldest)
|
|
279
|
+
except json.decoder.JSONDecodeError:
|
|
280
|
+
# Database is corrupted
|
|
281
|
+
self._recreate_db()
|
|
282
|
+
|
|
283
|
+
def __len__(self) -> int:
|
|
284
|
+
"""Return the length of cache."""
|
|
285
|
+
try:
|
|
286
|
+
return len(self.db)
|
|
287
|
+
except json.decoder.JSONDecodeError:
|
|
288
|
+
# Database is corrupted
|
|
289
|
+
self._recreate_db()
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
def __call__(self, function: Callable[..., Any]):
|
|
293
|
+
"""Decorator for caching function results.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
function: Function to decorate.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Cached function.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
@functools.wraps(function)
|
|
303
|
+
def wrapped(*args, **kwargs):
|
|
304
|
+
"""Cache function."""
|
|
305
|
+
key = self.calculate_hash(function)(*args, **kwargs)
|
|
306
|
+
result = self._get_with_sentinel(key)
|
|
307
|
+
if result is self.uncached or (result is None and self.retry):
|
|
308
|
+
result = function(*args, **kwargs)
|
|
309
|
+
self.insert(key, result)
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
return wrapped
|