PyperCache 0.1.1__tar.gz → 0.1.3__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.
Files changed (57) hide show
  1. pypercache-0.1.3/PKG-INFO +295 -0
  2. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/__init__.py +1 -1
  3. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/models/apimodel.py +4 -2
  4. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/query/json_injester.py +0 -25
  5. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/typing_cast.py +4 -2
  6. pypercache-0.1.3/PyperCache.egg-info/PKG-INFO +295 -0
  7. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache.egg-info/SOURCES.txt +1 -1
  8. pypercache-0.1.3/README.md +241 -0
  9. pypercache-0.1.3/docs/CACHE.md +222 -0
  10. pypercache-0.1.3/docs/DOCS.md +164 -0
  11. pypercache-0.1.3/docs/QUERY.md +228 -0
  12. pypercache-0.1.3/docs/STORAGE.md +326 -0
  13. {pypercache-0.1.1 → pypercache-0.1.3}/pyproject.toml +1 -1
  14. pypercache-0.1.1/PKG-INFO +0 -95
  15. pypercache-0.1.1/PyperCache.egg-info/PKG-INFO +0 -95
  16. pypercache-0.1.1/README.md +0 -41
  17. pypercache-0.1.1/docs/CACHE.md +0 -237
  18. pypercache-0.1.1/docs/QUERY.md +0 -240
  19. pypercache-0.1.1/docs/README.md +0 -148
  20. pypercache-0.1.1/docs/STORAGE.md +0 -370
  21. {pypercache-0.1.1 → pypercache-0.1.3}/LICENSE +0 -0
  22. {pypercache-0.1.1 → pypercache-0.1.3}/MANIFEST.in +0 -0
  23. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/core/__init__.py +0 -0
  24. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/core/cache.py +0 -0
  25. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/core/cache_record.py +0 -0
  26. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/core/request_logger.py +0 -0
  27. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/py.typed +0 -0
  28. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/query/__init__.py +0 -0
  29. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/storage/__init__.py +0 -0
  30. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/storage/backends.py +0 -0
  31. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/storage/base.py +0 -0
  32. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/storage/chunked_dictionary.py +0 -0
  33. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/storage/factory.py +0 -0
  34. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/storage/sqlite_storage.py +0 -0
  35. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/__init__.py +0 -0
  36. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/collections.py +0 -0
  37. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/fs.py +0 -0
  38. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/patterns.py +0 -0
  39. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/profiling.py +0 -0
  40. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/sentinel.py +0 -0
  41. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache/utils/serialization.py +0 -0
  42. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache.egg-info/dependency_links.txt +0 -0
  43. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache.egg-info/not-zip-safe +0 -0
  44. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache.egg-info/requires.txt +0 -0
  45. {pypercache-0.1.1 → pypercache-0.1.3}/PyperCache.egg-info/top_level.txt +0 -0
  46. {pypercache-0.1.1 → pypercache-0.1.3}/pytest.ini +0 -0
  47. {pypercache-0.1.1 → pypercache-0.1.3}/setup.cfg +0 -0
  48. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_apimodel.py +0 -0
  49. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_cache.py +0 -0
  50. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_cache_record.py +0 -0
  51. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_json_injester.py +0 -0
  52. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_patterns.py +0 -0
  53. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_request_logger.py +0 -0
  54. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_sentinel.py +0 -0
  55. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_serialization.py +0 -0
  56. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_sqlite_storage.py +0 -0
  57. {pypercache-0.1.1 → pypercache-0.1.3}/tests/test_storage.py +0 -0
@@ -0,0 +1,295 @@
1
+ Metadata-Version: 2.4
2
+ Name: PyperCache
3
+ Version: 0.1.3
4
+ Summary: Durable file-backed caching for JSON-like data with pluggable storage backends
5
+ Author-email: Brandon Bahret <your.email@example.com>
6
+ Maintainer-email: Brandon Bahret <your.email@example.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Brandon Bahret
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ Project-URL: Homepage, https://github.com/BrandonBahret/PyperCache
29
+ Project-URL: Documentation, https://github.com/BrandonBahret/PyperCache/tree/master/docs
30
+ Project-URL: Source, https://github.com/BrandonBahret/PyperCache
31
+ Project-URL: Repository, https://github.com/BrandonBahret/PyperCache
32
+ Project-URL: Issues, https://github.com/BrandonBahret/PyperCache/issues
33
+ Project-URL: Changelog, https://github.com/BrandonBahret/PyperCache/blob/master/CHANGELOG.md
34
+ Keywords: cache,persistence,json,pickle,sqlite,storage
35
+ Classifier: Development Status :: 4 - Beta
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Operating System :: OS Independent
39
+ Classifier: Programming Language :: Python :: 3
40
+ Classifier: Programming Language :: Python :: 3.8
41
+ Classifier: Programming Language :: Python :: 3.9
42
+ Classifier: Programming Language :: Python :: 3.10
43
+ Classifier: Programming Language :: Python :: 3.11
44
+ Classifier: Programming Language :: Python :: 3.12
45
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
46
+ Classifier: Topic :: System :: Distributed Computing
47
+ Requires-Python: >=3.8
48
+ Description-Content-Type: text/markdown
49
+ License-File: LICENSE
50
+ Requires-Dist: lark>=1.1.0
51
+ Requires-Dist: jsonpickle>=3.0.0
52
+ Requires-Dist: msgpack>=1.0.0
53
+ Dynamic: license-file
54
+
55
+ # PyperCache
56
+
57
+ A Python library providing durable file-backed caching for JSON-like data with pluggable storage backends (pickle, JSON, chunked manifest, SQLite), optional TTL and staleness semantics, read-only query navigation, and append-only request logging.
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install pypercache
63
+ ```
64
+
65
+ Or install from source:
66
+
67
+ ```bash
68
+ git clone https://github.com/BrandonBahret/PyperCache.git
69
+ cd PyperCache
70
+ pip install .
71
+ ```
72
+
73
+ ## Quick Start
74
+
75
+ See the full documentation, examples, and API reference on GitHub:
76
+
77
+ https://github.com/BrandonBahret/PyperCache/tree/master/docs
78
+
79
+ ## Features
80
+
81
+ - **Pluggable Backends**: Choose storage by file extension (.pkl, .json, .manifest, .db)
82
+ - **TTL & Staleness**: Optional expiry and acceptable staleness windows
83
+ - **Typed Objects**: Decorate classes for automatic serialization/deserialization
84
+ - **Query Navigation**: Safe, read-only JSON path queries with filters
85
+ - **Request Logging**: Thread-safe JSONL audit trails
86
+
87
+ ## Testing
88
+
89
+ ```bash
90
+ pytest
91
+ ```
92
+
93
+ ## Example
94
+
95
+ The snippet below demonstrates every major feature in one pass: choosing a backend, TTL, typed objects, query navigation, and request logging.
96
+
97
+ ```python
98
+ import math
99
+ from PyperCache import Cache, RequestLogger
100
+ from PyperCache.models.apimodel import apimodel
101
+
102
+ # ── 1. Backend is chosen by file extension ──────────────────────────────────
103
+ cache = Cache(filepath="api-cache.db") # .pkl / .json / .manifest / .db
104
+ log = RequestLogger("api_requests.log")
105
+
106
+ # ── 2. Define a typed model ──────────────────────────────────────────────────
107
+ @apimodel
108
+ class SearchResult:
109
+ total: int
110
+ hits: list
111
+
112
+ # ── 3. Fetch-or-cache pattern ────────────────────────────────────────────────
113
+ KEY = "search:v1:python"
114
+
115
+ if not cache.is_data_fresh(KEY):
116
+ payload = {
117
+ "total": 3,
118
+ "hits": [
119
+ {"name": "Alice", "role": "staff", "score": 92},
120
+ {"name": "Bob", "role": "guest", "score": 74},
121
+ {"name": "Carol", "role": "staff", "score": 88},
122
+ ],
123
+ }
124
+ cache.store(KEY, payload, expiry=3600, cast=SearchResult)
125
+ log.log(uri="/api/search?q=python", status=200)
126
+
127
+ # ── 4. Retrieve a typed object ───────────────────────────────────────────────
128
+ result = cache.get_object(KEY) # SearchResult instance
129
+ print(result.total) # 3
130
+
131
+ # ── 5. Query without mutating the payload ───────────────────────────────────
132
+ q = cache.get(KEY).query
133
+
134
+ print(q.get("total")) # 3
135
+ print(q.get("hits?role=staff.name")) # [Alice, Carol]
136
+ print(q.get("hits?name*")) # ['Alice', 'Bob', 'Carol']
137
+ print(q.get("hits?role=staff", select_first=True)["name"]) # 'Alice'
138
+
139
+ # ── 6. Inspect the request log ───────────────────────────────────────────────
140
+ for entry in log.get_logs_from_last_seconds(60):
141
+ print(entry.data["uri"], entry.data["status"])
142
+ ```
143
+
144
+ ## Features
145
+
146
+ - **Four backends** — `.pkl`, `.json`, `.manifest`, `.db` (SQLite with write-behind batching)
147
+ - **TTL & staleness** — per-record expiry; `is_data_fresh` tells you whether to re-fetch
148
+ - **Typed round-trips** — `@Cache.cached` / `@apimodel` + `cast=` on store; `get_object()` on retrieval
149
+ - **Query navigation** — dotted paths, `?key=value` filters, `?key*` plucks, `?key` existence, `select_first`, defaults; all in memory over the loaded record
150
+ - **Request logging** — thread-safe JSONL audit trail with time-window reads
151
+
152
+ ---
153
+
154
+ ## Query navigation
155
+
156
+ `record.query` returns a `JsonInjester` — a lightweight, read-only selector language that runs in memory over the loaded payload. It never touches the storage backend.
157
+
158
+ ```python
159
+ q = cache.get("search:v1:python").query
160
+ ```
161
+
162
+ You can also instantiate it directly over any dict:
163
+
164
+ ```python
165
+ from PyperCache.query import JsonInjester
166
+ q = JsonInjester({"meta": {"total": 5}, "hits": [...]})
167
+ ```
168
+
169
+ ### Path navigation
170
+
171
+ Dot-separated keys walk the dict. Returns `UNSET` if any key along the path is absent.
172
+
173
+ ```python
174
+ q.get("meta.total") # 5
175
+ q.get("meta.page") # 1
176
+ q.get("meta.missing") # UNSET
177
+ q.has("meta.total") # True (shorthand for `get(...) is not UNSET`)
178
+ ```
179
+
180
+ Keys containing hyphens or other non-identifier characters must be wrapped in double quotes inside the selector string:
181
+
182
+ ```python
183
+ q.get('"content-type".value')
184
+ ```
185
+
186
+ ### `?key=value` — match filter
187
+
188
+ Returns every element in a list where the key equals the value. A tail path after the operator plucks a field from each matched element.
189
+
190
+ ```python
191
+ q.get("hits?role=staff")
192
+ # [{"name": "Alice", ...}, {"name": "Carol", ...}]
193
+
194
+ q.get("hits?role=staff.name")
195
+ # ["Alice", "Carol"]
196
+
197
+ q.get("hits?team.name=Engineering")
198
+ # all dicts where hits[i].team.name == "Engineering"
199
+ ```
200
+
201
+ Prefix the value with `#` to match numbers instead of strings:
202
+
203
+ ```python
204
+ q.get("hits?score=#92") # integer match
205
+ q.get("hits?ratio=#0.75") # float match
206
+ ```
207
+
208
+ No matches returns an empty list, not `UNSET`.
209
+
210
+ ### `?key*` — pluck
211
+
212
+ Extracts a field from every element in the list. Non-missing results are collected; missing ones are silently skipped. Plucks can be chained.
213
+
214
+ ```python
215
+ q.get("hits?name*")
216
+ # ["Alice", "Bob", "Carol"]
217
+
218
+ q.get("hits?team.name*")
219
+ # ["Engineering", "Marketing", "Engineering"]
220
+
221
+ q.get("hits?role*?label*")
222
+ # chained: pluck role objects, then pluck label from each
223
+ ```
224
+
225
+ On a dict cursor (rather than a list), pluck navigates to the key and returns its value or `UNSET`.
226
+
227
+ ### `?key` — exists filter
228
+
229
+ Does not extract values. On a list cursor, returns only elements that contain the key. On a dict cursor, returns the cursor unchanged if the key is present, or `UNSET` if absent.
230
+
231
+ ```python
232
+ # list cursor — filter to elements that have a "team" key
233
+ q.get("hits?team")
234
+
235
+ # dict cursor — gate on key presence
236
+ q.get("meta?total") # returns the meta dict (key exists)
237
+ q.get("meta?ghost") # UNSET
238
+ q.get("meta?ghost", default_value=0) # 0
239
+ ```
240
+
241
+ ### `select_first` and `default_value`
242
+
243
+ `select_first=True` unwraps the first element of a list result. Returns `UNSET` if the list is empty.
244
+
245
+ ```python
246
+ from PyperCache.query.json_injester import UNSET
247
+
248
+ first = q.get("hits?role=staff", select_first=True)
249
+ print(first["name"]) # "Alice"
250
+
251
+ empty = q.get("hits?role=contractor", select_first=True)
252
+ print(empty is UNSET) # True
253
+ ```
254
+
255
+ `default_value` is returned when the path is missing or resolves to `None`. Falsy non-`None` values (`False`, `0`, `""`) pass through unchanged.
256
+
257
+ ```python
258
+ q.get("meta.missing", default_value=0) # 0
259
+ q.get("flags.debug", default_value=False) # False (returned as-is, not default)
260
+ ```
261
+
262
+ ### `cast`
263
+
264
+ When the result is a dict, `cast` passes it to the given type before returning.
265
+
266
+ ```python
267
+ q.get("hits?role=staff", select_first=True, cast=StaffMember)
268
+ # StaffMember instance
269
+ ```
270
+
271
+ ### Known limitations
272
+
273
+ `JsonInjester` is intentionally scoped and simple. A few things it does not do:
274
+
275
+ - **Integer list indexing** — `"hits.0.name"` is not supported. Use a filter or pluck to reach list elements.
276
+ - **Cross-key queries** — `record.query` operates on a single loaded payload. It does not scan multiple records or touch the backend.
277
+ - **Non-ASCII keys** — unquoted non-ASCII key names raise a parse error. Wrap them in double quotes: `'"héros".name'`.
278
+
279
+ For the complete selector reference see [QUERY.md](QUERY.md).
280
+
281
+ ---
282
+
283
+ ## Documentation
284
+
285
+ | Topic | File |
286
+ |---|---|
287
+ | `Cache`, `CacheRecord`, TTL, typed objects | [CACHE.md](CACHE.md) |
288
+ | `JsonInjester` / `record.query` selector syntax | [QUERY.md](QUERY.md) |
289
+ | Storage backends, `RequestLogger`, SQLite internals | [STORAGE.md](STORAGE.md) |
290
+
291
+ Full docs and examples: https://github.com/BrandonBahret/PyperCache/tree/master/docs
292
+
293
+ ## License
294
+
295
+ MIT
@@ -15,7 +15,7 @@ from PyperCache.core.cache import Cache
15
15
  from PyperCache.core.cache_record import CacheRecord
16
16
  from PyperCache.core.request_logger import LogRecord, RequestLogger
17
17
 
18
- __version__ = "0.1.1"
18
+ __version__ = "0.1.3"
19
19
 
20
20
  __all__ = [
21
21
  "Cache",
@@ -8,14 +8,16 @@ This module provides a light-weight decorator that:
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
- from typing import Any
11
+ from typing import Any, TypeVar
12
12
 
13
13
  from ..utils.patterns import ClassRepository
14
14
  from ..query.json_injester import JsonInjester
15
15
  from ..utils.typing_cast import instantiate_type
16
16
 
17
17
 
18
- def apimodel(cls: type) -> type:
18
+ T = TypeVar("T")
19
+
20
+ def apimodel(cls: T) -> T:
19
21
  """Decorator that makes a simple model from annotated fields.
20
22
 
21
23
  The generated constructor accepts a single positional ``data`` dict.
@@ -9,31 +9,6 @@ from lark import Lark, Transformer
9
9
  from ..utils.sentinel import UNSET
10
10
  from ..utils.typing_cast import instantiate_type
11
11
 
12
- # # ---------------------------------------------------------------------------
13
- # # Sentinel
14
- # # ---------------------------------------------------------------------------
15
-
16
- # class UNSET:
17
- # """Sentinel type representing a missing or unresolved value.
18
-
19
- # Used instead of None so that None can be a legitimate return value
20
- # from a query.
21
- # """
22
-
23
- # _instance: Optional[UNSET] = None
24
-
25
- # def __new__(cls) -> UNSET:
26
- # # Singleton — there is only ever one UNSET instance.
27
- # if cls._instance is None:
28
- # cls._instance = super().__new__(cls)
29
- # return cls._instance
30
-
31
- # def __repr__(self) -> str:
32
- # return "JsonInjest.UNSET"
33
-
34
-
35
- # # Convenience singleton so callers can write `is UNSET` rather than `== UNSET`.
36
- # UNSET = UNSET()
37
12
 
38
13
  T = TypeVar("T")
39
14
 
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
4
  import typing
5
- from typing import Any
5
+ from typing import Any, Type
6
6
 
7
7
 
8
+ T = typing.TypeVar("T")
9
+
8
10
  def _is_generic_alias(obj: Any) -> bool:
9
11
  try:
10
12
  from typing import get_origin
@@ -14,7 +16,7 @@ def _is_generic_alias(obj: Any) -> bool:
14
16
  return hasattr(obj, "__origin__") and hasattr(obj, "__args__")
15
17
 
16
18
 
17
- def instantiate_type(target_type: Any, data: Any) -> Any:
19
+ def instantiate_type(target_type: Type[T], data: Any) -> T:
18
20
  """Instantiate or cast *data* into *target_type*.
19
21
 
20
22
  Supports simple classes (preferring ``from_dict``), dataclasses, and
@@ -0,0 +1,295 @@
1
+ Metadata-Version: 2.4
2
+ Name: PyperCache
3
+ Version: 0.1.3
4
+ Summary: Durable file-backed caching for JSON-like data with pluggable storage backends
5
+ Author-email: Brandon Bahret <your.email@example.com>
6
+ Maintainer-email: Brandon Bahret <your.email@example.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Brandon Bahret
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ Project-URL: Homepage, https://github.com/BrandonBahret/PyperCache
29
+ Project-URL: Documentation, https://github.com/BrandonBahret/PyperCache/tree/master/docs
30
+ Project-URL: Source, https://github.com/BrandonBahret/PyperCache
31
+ Project-URL: Repository, https://github.com/BrandonBahret/PyperCache
32
+ Project-URL: Issues, https://github.com/BrandonBahret/PyperCache/issues
33
+ Project-URL: Changelog, https://github.com/BrandonBahret/PyperCache/blob/master/CHANGELOG.md
34
+ Keywords: cache,persistence,json,pickle,sqlite,storage
35
+ Classifier: Development Status :: 4 - Beta
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Operating System :: OS Independent
39
+ Classifier: Programming Language :: Python :: 3
40
+ Classifier: Programming Language :: Python :: 3.8
41
+ Classifier: Programming Language :: Python :: 3.9
42
+ Classifier: Programming Language :: Python :: 3.10
43
+ Classifier: Programming Language :: Python :: 3.11
44
+ Classifier: Programming Language :: Python :: 3.12
45
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
46
+ Classifier: Topic :: System :: Distributed Computing
47
+ Requires-Python: >=3.8
48
+ Description-Content-Type: text/markdown
49
+ License-File: LICENSE
50
+ Requires-Dist: lark>=1.1.0
51
+ Requires-Dist: jsonpickle>=3.0.0
52
+ Requires-Dist: msgpack>=1.0.0
53
+ Dynamic: license-file
54
+
55
+ # PyperCache
56
+
57
+ A Python library providing durable file-backed caching for JSON-like data with pluggable storage backends (pickle, JSON, chunked manifest, SQLite), optional TTL and staleness semantics, read-only query navigation, and append-only request logging.
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install pypercache
63
+ ```
64
+
65
+ Or install from source:
66
+
67
+ ```bash
68
+ git clone https://github.com/BrandonBahret/PyperCache.git
69
+ cd PyperCache
70
+ pip install .
71
+ ```
72
+
73
+ ## Quick Start
74
+
75
+ See the full documentation, examples, and API reference on GitHub:
76
+
77
+ https://github.com/BrandonBahret/PyperCache/tree/master/docs
78
+
79
+ ## Features
80
+
81
+ - **Pluggable Backends**: Choose storage by file extension (.pkl, .json, .manifest, .db)
82
+ - **TTL & Staleness**: Optional expiry and acceptable staleness windows
83
+ - **Typed Objects**: Decorate classes for automatic serialization/deserialization
84
+ - **Query Navigation**: Safe, read-only JSON path queries with filters
85
+ - **Request Logging**: Thread-safe JSONL audit trails
86
+
87
+ ## Testing
88
+
89
+ ```bash
90
+ pytest
91
+ ```
92
+
93
+ ## Example
94
+
95
+ The snippet below demonstrates every major feature in one pass: choosing a backend, TTL, typed objects, query navigation, and request logging.
96
+
97
+ ```python
98
+ import math
99
+ from PyperCache import Cache, RequestLogger
100
+ from PyperCache.models.apimodel import apimodel
101
+
102
+ # ── 1. Backend is chosen by file extension ──────────────────────────────────
103
+ cache = Cache(filepath="api-cache.db") # .pkl / .json / .manifest / .db
104
+ log = RequestLogger("api_requests.log")
105
+
106
+ # ── 2. Define a typed model ──────────────────────────────────────────────────
107
+ @apimodel
108
+ class SearchResult:
109
+ total: int
110
+ hits: list
111
+
112
+ # ── 3. Fetch-or-cache pattern ────────────────────────────────────────────────
113
+ KEY = "search:v1:python"
114
+
115
+ if not cache.is_data_fresh(KEY):
116
+ payload = {
117
+ "total": 3,
118
+ "hits": [
119
+ {"name": "Alice", "role": "staff", "score": 92},
120
+ {"name": "Bob", "role": "guest", "score": 74},
121
+ {"name": "Carol", "role": "staff", "score": 88},
122
+ ],
123
+ }
124
+ cache.store(KEY, payload, expiry=3600, cast=SearchResult)
125
+ log.log(uri="/api/search?q=python", status=200)
126
+
127
+ # ── 4. Retrieve a typed object ───────────────────────────────────────────────
128
+ result = cache.get_object(KEY) # SearchResult instance
129
+ print(result.total) # 3
130
+
131
+ # ── 5. Query without mutating the payload ───────────────────────────────────
132
+ q = cache.get(KEY).query
133
+
134
+ print(q.get("total")) # 3
135
+ print(q.get("hits?role=staff.name")) # [Alice, Carol]
136
+ print(q.get("hits?name*")) # ['Alice', 'Bob', 'Carol']
137
+ print(q.get("hits?role=staff", select_first=True)["name"]) # 'Alice'
138
+
139
+ # ── 6. Inspect the request log ───────────────────────────────────────────────
140
+ for entry in log.get_logs_from_last_seconds(60):
141
+ print(entry.data["uri"], entry.data["status"])
142
+ ```
143
+
144
+ ## Features
145
+
146
+ - **Four backends** — `.pkl`, `.json`, `.manifest`, `.db` (SQLite with write-behind batching)
147
+ - **TTL & staleness** — per-record expiry; `is_data_fresh` tells you whether to re-fetch
148
+ - **Typed round-trips** — `@Cache.cached` / `@apimodel` + `cast=` on store; `get_object()` on retrieval
149
+ - **Query navigation** — dotted paths, `?key=value` filters, `?key*` plucks, `?key` existence, `select_first`, defaults; all in memory over the loaded record
150
+ - **Request logging** — thread-safe JSONL audit trail with time-window reads
151
+
152
+ ---
153
+
154
+ ## Query navigation
155
+
156
+ `record.query` returns a `JsonInjester` — a lightweight, read-only selector language that runs in memory over the loaded payload. It never touches the storage backend.
157
+
158
+ ```python
159
+ q = cache.get("search:v1:python").query
160
+ ```
161
+
162
+ You can also instantiate it directly over any dict:
163
+
164
+ ```python
165
+ from PyperCache.query import JsonInjester
166
+ q = JsonInjester({"meta": {"total": 5}, "hits": [...]})
167
+ ```
168
+
169
+ ### Path navigation
170
+
171
+ Dot-separated keys walk the dict. Returns `UNSET` if any key along the path is absent.
172
+
173
+ ```python
174
+ q.get("meta.total") # 5
175
+ q.get("meta.page") # 1
176
+ q.get("meta.missing") # UNSET
177
+ q.has("meta.total") # True (shorthand for `get(...) is not UNSET`)
178
+ ```
179
+
180
+ Keys containing hyphens or other non-identifier characters must be wrapped in double quotes inside the selector string:
181
+
182
+ ```python
183
+ q.get('"content-type".value')
184
+ ```
185
+
186
+ ### `?key=value` — match filter
187
+
188
+ Returns every element in a list where the key equals the value. A tail path after the operator plucks a field from each matched element.
189
+
190
+ ```python
191
+ q.get("hits?role=staff")
192
+ # [{"name": "Alice", ...}, {"name": "Carol", ...}]
193
+
194
+ q.get("hits?role=staff.name")
195
+ # ["Alice", "Carol"]
196
+
197
+ q.get("hits?team.name=Engineering")
198
+ # all dicts where hits[i].team.name == "Engineering"
199
+ ```
200
+
201
+ Prefix the value with `#` to match numbers instead of strings:
202
+
203
+ ```python
204
+ q.get("hits?score=#92") # integer match
205
+ q.get("hits?ratio=#0.75") # float match
206
+ ```
207
+
208
+ No matches returns an empty list, not `UNSET`.
209
+
210
+ ### `?key*` — pluck
211
+
212
+ Extracts a field from every element in the list. Non-missing results are collected; missing ones are silently skipped. Plucks can be chained.
213
+
214
+ ```python
215
+ q.get("hits?name*")
216
+ # ["Alice", "Bob", "Carol"]
217
+
218
+ q.get("hits?team.name*")
219
+ # ["Engineering", "Marketing", "Engineering"]
220
+
221
+ q.get("hits?role*?label*")
222
+ # chained: pluck role objects, then pluck label from each
223
+ ```
224
+
225
+ On a dict cursor (rather than a list), pluck navigates to the key and returns its value or `UNSET`.
226
+
227
+ ### `?key` — exists filter
228
+
229
+ Does not extract values. On a list cursor, returns only elements that contain the key. On a dict cursor, returns the cursor unchanged if the key is present, or `UNSET` if absent.
230
+
231
+ ```python
232
+ # list cursor — filter to elements that have a "team" key
233
+ q.get("hits?team")
234
+
235
+ # dict cursor — gate on key presence
236
+ q.get("meta?total") # returns the meta dict (key exists)
237
+ q.get("meta?ghost") # UNSET
238
+ q.get("meta?ghost", default_value=0) # 0
239
+ ```
240
+
241
+ ### `select_first` and `default_value`
242
+
243
+ `select_first=True` unwraps the first element of a list result. Returns `UNSET` if the list is empty.
244
+
245
+ ```python
246
+ from PyperCache.query.json_injester import UNSET
247
+
248
+ first = q.get("hits?role=staff", select_first=True)
249
+ print(first["name"]) # "Alice"
250
+
251
+ empty = q.get("hits?role=contractor", select_first=True)
252
+ print(empty is UNSET) # True
253
+ ```
254
+
255
+ `default_value` is returned when the path is missing or resolves to `None`. Falsy non-`None` values (`False`, `0`, `""`) pass through unchanged.
256
+
257
+ ```python
258
+ q.get("meta.missing", default_value=0) # 0
259
+ q.get("flags.debug", default_value=False) # False (returned as-is, not default)
260
+ ```
261
+
262
+ ### `cast`
263
+
264
+ When the result is a dict, `cast` passes it to the given type before returning.
265
+
266
+ ```python
267
+ q.get("hits?role=staff", select_first=True, cast=StaffMember)
268
+ # StaffMember instance
269
+ ```
270
+
271
+ ### Known limitations
272
+
273
+ `JsonInjester` is intentionally scoped and simple. A few things it does not do:
274
+
275
+ - **Integer list indexing** — `"hits.0.name"` is not supported. Use a filter or pluck to reach list elements.
276
+ - **Cross-key queries** — `record.query` operates on a single loaded payload. It does not scan multiple records or touch the backend.
277
+ - **Non-ASCII keys** — unquoted non-ASCII key names raise a parse error. Wrap them in double quotes: `'"héros".name'`.
278
+
279
+ For the complete selector reference see [QUERY.md](QUERY.md).
280
+
281
+ ---
282
+
283
+ ## Documentation
284
+
285
+ | Topic | File |
286
+ |---|---|
287
+ | `Cache`, `CacheRecord`, TTL, typed objects | [CACHE.md](CACHE.md) |
288
+ | `JsonInjester` / `record.query` selector syntax | [QUERY.md](QUERY.md) |
289
+ | Storage backends, `RequestLogger`, SQLite internals | [STORAGE.md](STORAGE.md) |
290
+
291
+ Full docs and examples: https://github.com/BrandonBahret/PyperCache/tree/master/docs
292
+
293
+ ## License
294
+
295
+ MIT
@@ -33,8 +33,8 @@ PyperCache/utils/sentinel.py
33
33
  PyperCache/utils/serialization.py
34
34
  PyperCache/utils/typing_cast.py
35
35
  docs/CACHE.md
36
+ docs/DOCS.md
36
37
  docs/QUERY.md
37
- docs/README.md
38
38
  docs/STORAGE.md
39
39
  tests/test_apimodel.py
40
40
  tests/test_cache.py