pycacheable 0.1.1__tar.gz → 0.2.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.
- {pycacheable-0.1.1/src/pycacheable.egg-info → pycacheable-0.2.0}/PKG-INFO +33 -17
- {pycacheable-0.1.1 → pycacheable-0.2.0}/README.md +18 -13
- {pycacheable-0.1.1 → pycacheable-0.2.0}/pyproject.toml +21 -6
- pycacheable-0.2.0/src/pycacheable/backend_memory.py +79 -0
- pycacheable-0.2.0/src/pycacheable/backend_sqlite.py +99 -0
- pycacheable-0.2.0/src/pycacheable/cache_base.py +40 -0
- pycacheable-0.2.0/src/pycacheable/cacheable.py +105 -0
- pycacheable-0.2.0/src/pycacheable/serializers.py +50 -0
- {pycacheable-0.1.1 → pycacheable-0.2.0/src/pycacheable.egg-info}/PKG-INFO +33 -17
- {pycacheable-0.1.1 → pycacheable-0.2.0}/src/pycacheable.egg-info/SOURCES.txt +2 -0
- pycacheable-0.2.0/src/pycacheable.egg-info/requires.txt +6 -0
- pycacheable-0.2.0/tests/test_memory_cache.py +143 -0
- pycacheable-0.2.0/tests/test_serializers.py +111 -0
- pycacheable-0.2.0/tests/test_sqlite_cache.py +175 -0
- pycacheable-0.1.1/src/pycacheable/backend_memory.py +0 -48
- pycacheable-0.1.1/src/pycacheable/backend_sqlite.py +0 -70
- pycacheable-0.1.1/src/pycacheable/cache_base.py +0 -19
- pycacheable-0.1.1/src/pycacheable/cacheable.py +0 -53
- pycacheable-0.1.1/src/pycacheable.egg-info/requires.txt +0 -1
- pycacheable-0.1.1/tests/test_memory_cache.py +0 -49
- pycacheable-0.1.1/tests/test_sqlite_cache.py +0 -58
- {pycacheable-0.1.1 → pycacheable-0.2.0}/LICENSE +0 -0
- {pycacheable-0.1.1 → pycacheable-0.2.0}/setup.cfg +0 -0
- {pycacheable-0.1.1 → pycacheable-0.2.0}/src/pycacheable/__init__.py +0 -0
- {pycacheable-0.1.1 → pycacheable-0.2.0}/src/pycacheable/hashing.py +0 -0
- {pycacheable-0.1.1 → pycacheable-0.2.0}/src/pycacheable.egg-info/dependency_links.txt +0 -0
- {pycacheable-0.1.1 → pycacheable-0.2.0}/src/pycacheable.egg-info/top_level.txt +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycacheable
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Decorator de cache para funções e métodos Python, com backends InMemory e SQLite, TTL configurável
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Decorator de cache para funções e métodos Python, com backends InMemory e SQLite, TTL configurável, hash estável de parâmetros e serialização segura.
|
|
5
5
|
Author-email: Leonardo Pinho <contato@leonardopinho.com>
|
|
6
|
+
Maintainer-email: Leonardo Pinho <contato@leonardopinho.com>
|
|
6
7
|
License: MIT License
|
|
7
8
|
|
|
8
9
|
Copyright (c) 2025 Leonardo Pinho
|
|
@@ -28,27 +29,37 @@ License: MIT License
|
|
|
28
29
|
Project-URL: Homepage, https://github.com/leonardopinho/pycacheable
|
|
29
30
|
Project-URL: Repository, https://github.com/leonardopinho/pycacheable
|
|
30
31
|
Project-URL: Issues, https://github.com/leonardopinho/pycacheable/issues
|
|
31
|
-
|
|
32
|
+
Project-URL: Changelog, https://github.com/leonardopinho/pycacheable/blob/main/CHANGES.md
|
|
33
|
+
Project-URL: Documentation, https://github.com/leonardopinho/pycacheable/blob/main/README.md
|
|
34
|
+
Project-URL: Benchmarks, https://github.com/leonardopinho/pycacheable/tree/main/benchmarks
|
|
35
|
+
Keywords: cache,decorator,sqlite,python,performance,memoization,caching
|
|
32
36
|
Classifier: Development Status :: 4 - Beta
|
|
33
37
|
Classifier: Intended Audience :: Developers
|
|
34
38
|
Classifier: License :: OSI Approved :: MIT License
|
|
35
39
|
Classifier: Programming Language :: Python :: 3
|
|
40
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
36
41
|
Classifier: Programming Language :: Python :: 3.9
|
|
37
42
|
Classifier: Programming Language :: Python :: 3.10
|
|
38
43
|
Classifier: Programming Language :: Python :: 3.11
|
|
39
44
|
Classifier: Programming Language :: Python :: 3.12
|
|
40
45
|
Classifier: Topic :: Software Development :: Libraries
|
|
41
46
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
47
|
+
Classifier: Topic :: Utilities
|
|
48
|
+
Classifier: Topic :: Database
|
|
42
49
|
Classifier: Operating System :: OS Independent
|
|
43
50
|
Requires-Python: >=3.9
|
|
44
51
|
Description-Content-Type: text/markdown
|
|
45
52
|
License-File: LICENSE
|
|
46
|
-
|
|
53
|
+
Provides-Extra: dev
|
|
54
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
55
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
56
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
57
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
47
58
|
Dynamic: license-file
|
|
48
59
|
|
|
49
60
|
# PyCacheable
|
|
50
61
|
|
|
51
|
-
Decorator de cache para métodos e funções Python com backends em memória e SQLite — serialização automática, hash estável de parâmetros, suporte a instância/estado e arquitetura plugável.
|
|
62
|
+
Decorator de cache para métodos e funções Python com backends em memória e SQLite — serialização automática com estratégia JSON-first + pickle fallback, hash estável de parâmetros, suporte a instância/estado e arquitetura plugável.
|
|
52
63
|
|
|
53
64
|
---
|
|
54
65
|
|
|
@@ -74,6 +85,9 @@ A biblioteca fornece:
|
|
|
74
85
|
- `InMemoryCache`: cache volátil em memória com LRU + TTL.
|
|
75
86
|
- `SQLiteCache`: cache persistente em disco (SQLite) com TTL, ideal para entre execuções ou processos;
|
|
76
87
|
- Logs claros de fluxo: HIT / MISS / EXPIRE — permitindo entender se o cache está funcionando;
|
|
88
|
+
- Serialização segura:
|
|
89
|
+
- JSON-first para estruturas simples (seguro, sem risco de RCE)
|
|
90
|
+
- Pickle fallback para objetos complexos (flexível)
|
|
77
91
|
- Métodos auxiliares:
|
|
78
92
|
- `.cache_clear()`, `.cache_info()` no wrapper para inspeção/manutenção;
|
|
79
93
|
|
|
@@ -110,11 +124,12 @@ u2 = repo.get_user(42) # HIT → retorna cache, consulta não é executada
|
|
|
110
124
|
|
|
111
125
|
## Benefícios
|
|
112
126
|
|
|
113
|
-
- Menor latência em chamadas repetidas (hit quase instantâneo).
|
|
114
|
-
- Menor carga no banco/serviço, menos I/O repetido.
|
|
115
|
-
- Persistência local (via SQLite) permite cache entre reinícios/processos.
|
|
116
|
-
- Transparente para o usuário da função — apenas aplicar o decorator.
|
|
127
|
+
- Menor latência em chamadas repetidas (hit quase instantâneo).
|
|
128
|
+
- Menor carga no banco/serviço, menos I/O repetido.
|
|
129
|
+
- Persistência local (via SQLite) permite cache entre reinícios/processos.
|
|
130
|
+
- Transparente para o usuário da função — apenas aplicar o decorator.
|
|
117
131
|
- Logs e métricas ajudam a monitorar impacto real.
|
|
132
|
+
- Serialização segura com JSON-first (sem risco de arbitrary code execution).
|
|
118
133
|
|
|
119
134
|
---
|
|
120
135
|
|
|
@@ -129,10 +144,11 @@ u2 = repo.get_user(42) # HIT → retorna cache, consulta não é executada
|
|
|
129
144
|
|
|
130
145
|
## Considerações e limites
|
|
131
146
|
|
|
132
|
-
- O cache evita reexecuções **somente** se os parâmetros para o método forem os mesmos e serializáveis.
|
|
133
|
-
- Se o método depende de estados mutáveis fora dos parâmetros (ex.: `self.some_state`), você deve usar `include_self=True` ou custom `key_fn`.
|
|
134
|
-
- TTL é usado para expiração — resultados podem ficar
|
|
135
|
-
- Embora o backend SQLite seja persistente, ele **não substitui** um cache distribuído (ex.: Redis) em cenários multi
|
|
147
|
+
- O cache evita reexecuções **somente** se os parâmetros para o método forem os mesmos e serializáveis.
|
|
148
|
+
- Se o método depende de estados mutáveis fora dos parâmetros (ex.: `self.some_state`), você deve usar `include_self=True` ou custom `key_fn`.
|
|
149
|
+
- TTL é usado para expiração — resultados podem ficar "stale" se parâmetros ou contexto mudarem sem mudar a chave.
|
|
150
|
+
- Embora o backend SQLite seja persistente, ele **não substitui** um cache distribuído (ex.: Redis) em cenários multi-processo/semi-distribuídos.
|
|
151
|
+
- Pickle fallback mantém compatibilidade com objetos complexos, mas use com dados confiáveis.
|
|
136
152
|
|
|
137
153
|
---
|
|
138
154
|
|
|
@@ -142,11 +158,11 @@ Veja resultados reais que medem MISS vs HIT:
|
|
|
142
158
|
|
|
143
159
|
| Backend | MISS (s) | HIT (s) | Speedup | Calls |
|
|
144
160
|
|----------|-----------|----------|----------|--------|
|
|
145
|
-
| RAW | 0.
|
|
146
|
-
| InMemory | 0.
|
|
147
|
-
| SQLite | 0.
|
|
161
|
+
| RAW | 0.4410 | — | — | — |
|
|
162
|
+
| InMemory | 0.4043 | 0.000043 | ~9,494x | 1 |
|
|
163
|
+
| SQLite | 0.4001 | 0.000763 | ~524x | 1 |
|
|
148
164
|
|
|
149
|
-
O cache reduz o tempo de execução de ~0.
|
|
165
|
+
O cache reduz o tempo de execução de ~0.4 s para ~0.00004 s — um **speedup superior a 9.000×**.
|
|
150
166
|
|
|
151
167
|
---
|
|
152
168
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PyCacheable
|
|
2
2
|
|
|
3
|
-
Decorator de cache para métodos e funções Python com backends em memória e SQLite — serialização automática, hash estável de parâmetros, suporte a instância/estado e arquitetura plugável.
|
|
3
|
+
Decorator de cache para métodos e funções Python com backends em memória e SQLite — serialização automática com estratégia JSON-first + pickle fallback, hash estável de parâmetros, suporte a instância/estado e arquitetura plugável.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -26,6 +26,9 @@ A biblioteca fornece:
|
|
|
26
26
|
- `InMemoryCache`: cache volátil em memória com LRU + TTL.
|
|
27
27
|
- `SQLiteCache`: cache persistente em disco (SQLite) com TTL, ideal para entre execuções ou processos;
|
|
28
28
|
- Logs claros de fluxo: HIT / MISS / EXPIRE — permitindo entender se o cache está funcionando;
|
|
29
|
+
- Serialização segura:
|
|
30
|
+
- JSON-first para estruturas simples (seguro, sem risco de RCE)
|
|
31
|
+
- Pickle fallback para objetos complexos (flexível)
|
|
29
32
|
- Métodos auxiliares:
|
|
30
33
|
- `.cache_clear()`, `.cache_info()` no wrapper para inspeção/manutenção;
|
|
31
34
|
|
|
@@ -62,11 +65,12 @@ u2 = repo.get_user(42) # HIT → retorna cache, consulta não é executada
|
|
|
62
65
|
|
|
63
66
|
## Benefícios
|
|
64
67
|
|
|
65
|
-
- Menor latência em chamadas repetidas (hit quase instantâneo).
|
|
66
|
-
- Menor carga no banco/serviço, menos I/O repetido.
|
|
67
|
-
- Persistência local (via SQLite) permite cache entre reinícios/processos.
|
|
68
|
-
- Transparente para o usuário da função — apenas aplicar o decorator.
|
|
68
|
+
- Menor latência em chamadas repetidas (hit quase instantâneo).
|
|
69
|
+
- Menor carga no banco/serviço, menos I/O repetido.
|
|
70
|
+
- Persistência local (via SQLite) permite cache entre reinícios/processos.
|
|
71
|
+
- Transparente para o usuário da função — apenas aplicar o decorator.
|
|
69
72
|
- Logs e métricas ajudam a monitorar impacto real.
|
|
73
|
+
- Serialização segura com JSON-first (sem risco de arbitrary code execution).
|
|
70
74
|
|
|
71
75
|
---
|
|
72
76
|
|
|
@@ -81,10 +85,11 @@ u2 = repo.get_user(42) # HIT → retorna cache, consulta não é executada
|
|
|
81
85
|
|
|
82
86
|
## Considerações e limites
|
|
83
87
|
|
|
84
|
-
- O cache evita reexecuções **somente** se os parâmetros para o método forem os mesmos e serializáveis.
|
|
85
|
-
- Se o método depende de estados mutáveis fora dos parâmetros (ex.: `self.some_state`), você deve usar `include_self=True` ou custom `key_fn`.
|
|
86
|
-
- TTL é usado para expiração — resultados podem ficar
|
|
87
|
-
- Embora o backend SQLite seja persistente, ele **não substitui** um cache distribuído (ex.: Redis) em cenários multi
|
|
88
|
+
- O cache evita reexecuções **somente** se os parâmetros para o método forem os mesmos e serializáveis.
|
|
89
|
+
- Se o método depende de estados mutáveis fora dos parâmetros (ex.: `self.some_state`), você deve usar `include_self=True` ou custom `key_fn`.
|
|
90
|
+
- TTL é usado para expiração — resultados podem ficar "stale" se parâmetros ou contexto mudarem sem mudar a chave.
|
|
91
|
+
- Embora o backend SQLite seja persistente, ele **não substitui** um cache distribuído (ex.: Redis) em cenários multi-processo/semi-distribuídos.
|
|
92
|
+
- Pickle fallback mantém compatibilidade com objetos complexos, mas use com dados confiáveis.
|
|
88
93
|
|
|
89
94
|
---
|
|
90
95
|
|
|
@@ -94,11 +99,11 @@ Veja resultados reais que medem MISS vs HIT:
|
|
|
94
99
|
|
|
95
100
|
| Backend | MISS (s) | HIT (s) | Speedup | Calls |
|
|
96
101
|
|----------|-----------|----------|----------|--------|
|
|
97
|
-
| RAW | 0.
|
|
98
|
-
| InMemory | 0.
|
|
99
|
-
| SQLite | 0.
|
|
102
|
+
| RAW | 0.4410 | — | — | — |
|
|
103
|
+
| InMemory | 0.4043 | 0.000043 | ~9,494x | 1 |
|
|
104
|
+
| SQLite | 0.4001 | 0.000763 | ~524x | 1 |
|
|
100
105
|
|
|
101
|
-
O cache reduz o tempo de execução de ~0.
|
|
106
|
+
O cache reduz o tempo de execução de ~0.4 s para ~0.00004 s — um **speedup superior a 9.000×**.
|
|
102
107
|
|
|
103
108
|
---
|
|
104
109
|
|
|
@@ -1,36 +1,51 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pycacheable"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Decorator de cache para funções e métodos Python, com backends InMemory e SQLite, TTL configurável
|
|
5
|
-
readme = "README.md"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Decorator de cache para funções e métodos Python, com backends InMemory e SQLite, TTL configurável, hash estável de parâmetros e serialização segura."
|
|
5
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "Leonardo Pinho", email = "contato@leonardopinho.com" }
|
|
8
8
|
]
|
|
9
|
+
maintainers = [
|
|
10
|
+
{ name = "Leonardo Pinho", email = "contato@leonardopinho.com" }
|
|
11
|
+
]
|
|
9
12
|
license = { file = "LICENSE" }
|
|
10
13
|
requires-python = ">=3.9"
|
|
11
|
-
keywords = ["cache", "decorator", "sqlite", "python", "performance", "memoization", "
|
|
14
|
+
keywords = ["cache", "decorator", "sqlite", "python", "performance", "memoization", "caching"]
|
|
12
15
|
classifiers = [
|
|
13
16
|
"Development Status :: 4 - Beta",
|
|
14
17
|
"Intended Audience :: Developers",
|
|
15
18
|
"License :: OSI Approved :: MIT License",
|
|
16
19
|
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
21
|
"Programming Language :: Python :: 3.9",
|
|
18
22
|
"Programming Language :: Python :: 3.10",
|
|
19
23
|
"Programming Language :: Python :: 3.11",
|
|
20
24
|
"Programming Language :: Python :: 3.12",
|
|
21
25
|
"Topic :: Software Development :: Libraries",
|
|
22
26
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Topic :: Utilities",
|
|
28
|
+
"Topic :: Database",
|
|
23
29
|
"Operating System :: OS Independent",
|
|
24
30
|
]
|
|
25
31
|
|
|
26
|
-
dependencies = [
|
|
27
|
-
|
|
32
|
+
dependencies = []
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"build>=1.2.0",
|
|
37
|
+
"twine>=5.0.0",
|
|
38
|
+
"ruff>=0.5.0",
|
|
39
|
+
"pytest>=8.0.0",
|
|
28
40
|
]
|
|
29
41
|
|
|
30
42
|
[project.urls]
|
|
31
43
|
Homepage = "https://github.com/leonardopinho/pycacheable"
|
|
32
44
|
Repository = "https://github.com/leonardopinho/pycacheable"
|
|
33
45
|
Issues = "https://github.com/leonardopinho/pycacheable/issues"
|
|
46
|
+
Changelog = "https://github.com/leonardopinho/pycacheable/blob/main/CHANGES.md"
|
|
47
|
+
Documentation = "https://github.com/leonardopinho/pycacheable/blob/main/README.md"
|
|
48
|
+
Benchmarks = "https://github.com/leonardopinho/pycacheable/tree/main/benchmarks"
|
|
34
49
|
|
|
35
50
|
[build-system]
|
|
36
51
|
requires = ["setuptools>=68.0", "wheel"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Cache em memória com LRU e TTL."""
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from typing import Any, Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .cache_base import CacheBackend
|
|
8
|
+
from .serializers import SafeSerializer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryCache(CacheBackend):
|
|
12
|
+
"""Cache em memória com LRU e TTL, thread-safe."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, max_entries: int = 1024, serializer=None):
|
|
15
|
+
"""
|
|
16
|
+
Args:
|
|
17
|
+
max_entries: Máximo de itens antes de evicção LRU
|
|
18
|
+
serializer: Instância de serializer (padrão: SafeSerializer)
|
|
19
|
+
"""
|
|
20
|
+
self._store: OrderedDict[str, Tuple[float, bytes]] = OrderedDict()
|
|
21
|
+
self._lock = threading.RLock()
|
|
22
|
+
self._max_entries = max_entries
|
|
23
|
+
self._serializer = serializer or SafeSerializer()
|
|
24
|
+
|
|
25
|
+
def get(self, key: str) -> Tuple[bool, Any, str]:
|
|
26
|
+
with self._lock:
|
|
27
|
+
entry = self._store.get(key)
|
|
28
|
+
now = time.time()
|
|
29
|
+
|
|
30
|
+
if not entry:
|
|
31
|
+
return False, None, "MISS"
|
|
32
|
+
|
|
33
|
+
expire_at, payload = entry
|
|
34
|
+
|
|
35
|
+
# Verifica expiração
|
|
36
|
+
if expire_at and expire_at < now:
|
|
37
|
+
self._store.pop(key, None)
|
|
38
|
+
return False, None, "EXPIRE"
|
|
39
|
+
|
|
40
|
+
# Move para fim (LRU)
|
|
41
|
+
self._store.move_to_end(key)
|
|
42
|
+
|
|
43
|
+
# Desserializa
|
|
44
|
+
try:
|
|
45
|
+
value = self._serializer.loads(payload)
|
|
46
|
+
return True, value, "HIT"
|
|
47
|
+
except Exception:
|
|
48
|
+
# Se falhar na desserialização, remove do cache
|
|
49
|
+
self._store.pop(key, None)
|
|
50
|
+
return False, None, "DESERIALIZE_ERROR"
|
|
51
|
+
|
|
52
|
+
def set(self, key: str, value: Any, ttl_seconds: Optional[int]) -> None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
expire_at = (time.time() + ttl_seconds) if ttl_seconds else 0.0
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
payload = self._serializer.dumps(value)
|
|
58
|
+
except Exception:
|
|
59
|
+
# Se falhar na serialização, pula (não armazena)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
self._store[key] = (expire_at, payload)
|
|
63
|
+
self._store.move_to_end(key)
|
|
64
|
+
|
|
65
|
+
# Evicção LRU
|
|
66
|
+
while len(self._store) > self._max_entries:
|
|
67
|
+
self._store.popitem(last=False)
|
|
68
|
+
|
|
69
|
+
def clear(self) -> None:
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._store.clear()
|
|
72
|
+
|
|
73
|
+
def info(self) -> Dict[str, Any]:
|
|
74
|
+
with self._lock:
|
|
75
|
+
return {
|
|
76
|
+
"backend": "InMemoryCache",
|
|
77
|
+
"size": len(self._store),
|
|
78
|
+
"max_entries": self._max_entries,
|
|
79
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Cache persistente em SQLite com TTL."""
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .cache_base import CacheBackend
|
|
9
|
+
from .serializers import SafeSerializer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SQLiteCache(CacheBackend):
|
|
13
|
+
"""Cache persistente em SQLite com TTL, thread-safe."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, path: str, serializer=None):
|
|
16
|
+
"""
|
|
17
|
+
Args:
|
|
18
|
+
path: Caminho para arquivo SQLite
|
|
19
|
+
serializer: Instância de serializer (padrão: SafeSerializer)
|
|
20
|
+
"""
|
|
21
|
+
self._path = path
|
|
22
|
+
self._lock = threading.RLock()
|
|
23
|
+
self._serializer = serializer or SafeSerializer()
|
|
24
|
+
|
|
25
|
+
# Cria diretório se não existir
|
|
26
|
+
dir_path = os.path.dirname(os.path.abspath(path))
|
|
27
|
+
if dir_path:
|
|
28
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
# Inicializa banco
|
|
31
|
+
with self._conn() as conn:
|
|
32
|
+
conn.execute("""
|
|
33
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
34
|
+
k TEXT PRIMARY KEY,
|
|
35
|
+
expire_at REAL,
|
|
36
|
+
v BLOB
|
|
37
|
+
)
|
|
38
|
+
""")
|
|
39
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
40
|
+
|
|
41
|
+
def _conn(self):
|
|
42
|
+
"""Cria nova conexão (autocommit mode)."""
|
|
43
|
+
return sqlite3.connect(self._path, timeout=30, isolation_level=None)
|
|
44
|
+
|
|
45
|
+
def get(self, key: str) -> Tuple[bool, Any, str]:
|
|
46
|
+
with self._lock, self._conn() as conn:
|
|
47
|
+
row = conn.execute(
|
|
48
|
+
"SELECT expire_at, v FROM cache WHERE k=?", (key,)
|
|
49
|
+
).fetchone()
|
|
50
|
+
|
|
51
|
+
now = time.time()
|
|
52
|
+
|
|
53
|
+
if not row:
|
|
54
|
+
return False, None, "MISS"
|
|
55
|
+
|
|
56
|
+
expire_at, blob = row
|
|
57
|
+
|
|
58
|
+
# Verifica expiração
|
|
59
|
+
if expire_at and expire_at < now:
|
|
60
|
+
conn.execute("DELETE FROM cache WHERE k=?", (key,))
|
|
61
|
+
return False, None, "EXPIRE"
|
|
62
|
+
|
|
63
|
+
# Desserializa
|
|
64
|
+
try:
|
|
65
|
+
value = self._serializer.loads(blob)
|
|
66
|
+
return True, value, "HIT"
|
|
67
|
+
except Exception:
|
|
68
|
+
# Se falhar na desserialização, remove do cache
|
|
69
|
+
conn.execute("DELETE FROM cache WHERE k=?", (key,))
|
|
70
|
+
return False, None, "DESERIALIZE_ERROR"
|
|
71
|
+
|
|
72
|
+
def set(self, key: str, value: Any, ttl_seconds: Optional[int]) -> None:
|
|
73
|
+
expire_at = (time.time() + ttl_seconds) if ttl_seconds else 0.0
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
blob = self._serializer.dumps(value)
|
|
77
|
+
except Exception:
|
|
78
|
+
# Se falhar na serialização, pula
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
with self._lock, self._conn() as conn:
|
|
82
|
+
conn.execute(
|
|
83
|
+
"REPLACE INTO cache (k, expire_at, v) VALUES (?, ?, ?)",
|
|
84
|
+
(key, expire_at, sqlite3.Binary(blob))
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def clear(self) -> None:
|
|
88
|
+
with self._lock, self._conn() as conn:
|
|
89
|
+
conn.execute("DELETE FROM cache")
|
|
90
|
+
|
|
91
|
+
def info(self) -> Dict[str, Any]:
|
|
92
|
+
with self._lock, self._conn() as conn:
|
|
93
|
+
row = conn.execute("SELECT COUNT(*) FROM cache").fetchone()
|
|
94
|
+
size = int(row[0]) if row else 0
|
|
95
|
+
return {
|
|
96
|
+
"backend": "SQLiteCache",
|
|
97
|
+
"size": size,
|
|
98
|
+
"path": self._path,
|
|
99
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Interface abstrata para implementações de cache."""
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, Dict, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CacheBackend(ABC):
|
|
7
|
+
"""Interface clara e tipada para backends de cache."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def get(self, key: str) -> Tuple[bool, Any, str]:
|
|
11
|
+
"""
|
|
12
|
+
Recupera um valor do cache.
|
|
13
|
+
|
|
14
|
+
Retorna:
|
|
15
|
+
(hit: bool, value: Any, status: str)
|
|
16
|
+
- hit: True se encontrou, não expirou e desserializou com sucesso
|
|
17
|
+
- value: o valor cacheado (None se miss/expire/erro)
|
|
18
|
+
- status: "HIT", "MISS", "EXPIRE", ou "DESERIALIZE_ERROR"
|
|
19
|
+
"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def set(self, key: str, value: Any, ttl_seconds: Optional[int]) -> None:
|
|
24
|
+
"""Armazena um valor no cache com TTL opcional."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def clear(self) -> None:
|
|
29
|
+
"""Remove todos os itens do cache."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def info(self) -> Dict[str, Any]:
|
|
34
|
+
"""Retorna informações sobre o estado do cache."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Alias para compatibilidade com código existente
|
|
39
|
+
CacheBase = CacheBackend
|
|
40
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Decorator de cache com backends plugáveis."""
|
|
2
|
+
import functools
|
|
3
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from .backend_sqlite import SQLiteCache
|
|
6
|
+
from .cache_base import CacheBackend
|
|
7
|
+
from .hashing import _build_key_from_call
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cacheable(
|
|
11
|
+
ttl: Optional[int] = 300,
|
|
12
|
+
backend: Optional[CacheBackend] = None,
|
|
13
|
+
*,
|
|
14
|
+
include_self: bool = False,
|
|
15
|
+
key_fn: Optional[Callable[[Callable, Tuple[Any, ...], Dict[str, Any]], str]] = None,
|
|
16
|
+
logger: Optional[Callable[[str], None]] = None,
|
|
17
|
+
backend_factory: Optional[Callable[..., CacheBackend]] = None,
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
Decorator de cache com backend configurável.
|
|
21
|
+
|
|
22
|
+
Ciclo de vida do backend (ordem de prioridade):
|
|
23
|
+
1. Se backend_factory fornecido: chamado por chamada (per-instance ou per-param)
|
|
24
|
+
2. Se wrapper.cache_backend foi atribuído externamente: usa esse override
|
|
25
|
+
3. Se backend fornecido: usa esse (compartilhado entre chamadas)
|
|
26
|
+
4. Padrão: cria SQLiteCache novo
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
ttl: Tempo de vida em segundos (None = sem expiração)
|
|
30
|
+
backend: Instância de CacheBackend compartilhada
|
|
31
|
+
include_self: Se True, inclui 'self' na chave (para estado de instância)
|
|
32
|
+
key_fn: Função customizada de geração de chave
|
|
33
|
+
logger: Função para log de eventos cache
|
|
34
|
+
backend_factory: Callable que retorna CacheBackend por chamada
|
|
35
|
+
|
|
36
|
+
Exemplo:
|
|
37
|
+
>>> @cacheable(ttl=60, backend=InMemoryCache())
|
|
38
|
+
... def get_user(user_id: int):
|
|
39
|
+
... return fetch_from_db(user_id)
|
|
40
|
+
|
|
41
|
+
>>> # Override backend em teste:
|
|
42
|
+
>>> get_user.cache_backend = test_backend
|
|
43
|
+
"""
|
|
44
|
+
# Resolve backend padrão uma única vez
|
|
45
|
+
default_backend = backend or SQLiteCache(path="./.cache/db.sqlite3")
|
|
46
|
+
|
|
47
|
+
def decorator(func: Callable):
|
|
48
|
+
@functools.wraps(func)
|
|
49
|
+
def wrapper(*args, **kwargs):
|
|
50
|
+
# Resolve qual backend usar (com fallback chain)
|
|
51
|
+
be = None
|
|
52
|
+
|
|
53
|
+
if backend_factory is not None:
|
|
54
|
+
try:
|
|
55
|
+
be = backend_factory(*args, **kwargs)
|
|
56
|
+
except TypeError:
|
|
57
|
+
# Fallback: tenta com first positional arg (self em métodos)
|
|
58
|
+
try:
|
|
59
|
+
be = backend_factory(args[0]) if args else None
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# Se factory falhou ou não existe, usa cache_backend ou padrão
|
|
64
|
+
if be is None:
|
|
65
|
+
be = getattr(wrapper, "cache_backend", default_backend)
|
|
66
|
+
|
|
67
|
+
# Gera chave estável
|
|
68
|
+
key = (
|
|
69
|
+
key_fn(func, args, kwargs)
|
|
70
|
+
if key_fn
|
|
71
|
+
else _build_key_from_call(func, args, kwargs, include_self)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Tenta obter do cache
|
|
75
|
+
hit, value, status = be.get(key)
|
|
76
|
+
if hit:
|
|
77
|
+
if logger:
|
|
78
|
+
logger(f"[CACHE {status}] {func.__qualname__} {key[:10]}…")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
# Cache miss: executa função
|
|
82
|
+
if logger:
|
|
83
|
+
logger(f"[CACHE {status}] {func.__qualname__} {key[:10]}… -> calling")
|
|
84
|
+
|
|
85
|
+
result = func(*args, **kwargs)
|
|
86
|
+
|
|
87
|
+
# Tenta armazenar no cache
|
|
88
|
+
try:
|
|
89
|
+
be.set(key, result, ttl)
|
|
90
|
+
if logger:
|
|
91
|
+
logger(f"[CACHE SET] {func.__qualname__} {key[:10]}… ttl={ttl}")
|
|
92
|
+
except Exception as e:
|
|
93
|
+
if logger:
|
|
94
|
+
logger(f"[CACHE ERROR SET] {func.__qualname__}: {e}")
|
|
95
|
+
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
# Atributos públicos para inspeção/controle
|
|
99
|
+
wrapper.cache_backend = default_backend
|
|
100
|
+
wrapper.cache_clear = lambda: wrapper.cache_backend.clear()
|
|
101
|
+
wrapper.cache_info = lambda: wrapper.cache_backend.info()
|
|
102
|
+
|
|
103
|
+
return wrapper
|
|
104
|
+
|
|
105
|
+
return decorator
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Estratégias de serialização thread-safe para cache."""
|
|
2
|
+
import json
|
|
3
|
+
import pickle
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SafeSerializer:
|
|
8
|
+
"""
|
|
9
|
+
Serializer híbrido: tenta JSON (seguro) primeiro, fallback para pickle.
|
|
10
|
+
|
|
11
|
+
Prefixos:
|
|
12
|
+
- 0x00: JSON (seguro, sem risco de code execution)
|
|
13
|
+
- 0x01: Pickle (flexível, suporta objetos complexos)
|
|
14
|
+
|
|
15
|
+
Exemplo:
|
|
16
|
+
>>> ser = SafeSerializer()
|
|
17
|
+
>>> data = ser.dumps({"user": 42})
|
|
18
|
+
>>> obj = ser.loads(data)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def dumps(self, obj: Any) -> bytes:
|
|
22
|
+
"""Serializa obj para bytes com prefixo de tipo."""
|
|
23
|
+
try:
|
|
24
|
+
# Tenta JSON primeiro
|
|
25
|
+
payload = json.dumps(
|
|
26
|
+
obj, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
27
|
+
).encode("utf-8")
|
|
28
|
+
return b"\x00" + payload
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
# Fallback para pickle
|
|
31
|
+
payload = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
|
32
|
+
return b"\x01" + payload
|
|
33
|
+
|
|
34
|
+
def loads(self, data: bytes) -> Any:
|
|
35
|
+
"""Desserializa bytes baseado no prefixo."""
|
|
36
|
+
if not data or len(data) < 1:
|
|
37
|
+
raise ValueError("Empty serialized data")
|
|
38
|
+
|
|
39
|
+
prefix = data[0:1]
|
|
40
|
+
payload = data[1:]
|
|
41
|
+
|
|
42
|
+
if prefix == b"\x00":
|
|
43
|
+
return json.loads(payload.decode("utf-8"))
|
|
44
|
+
elif prefix == b"\x01":
|
|
45
|
+
return pickle.loads(payload)
|
|
46
|
+
else:
|
|
47
|
+
prefix_hex = hex(prefix[0]) if prefix else "unknown"
|
|
48
|
+
raise ValueError(f"Unknown serializer prefix: {prefix_hex}")
|
|
49
|
+
|
|
50
|
+
|