fromscratch-pykv 0.4.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.
- fromscratch_pykv-0.4.0.dist-info/METADATA +108 -0
- fromscratch_pykv-0.4.0.dist-info/RECORD +17 -0
- fromscratch_pykv-0.4.0.dist-info/WHEEL +4 -0
- fromscratch_pykv-0.4.0.dist-info/licenses/LICENSE +21 -0
- pykv/__init__.py +73 -0
- pykv/_version.py +24 -0
- pykv/atomic.py +66 -0
- pykv/batch.py +110 -0
- pykv/counters.py +137 -0
- pykv/errors.py +68 -0
- pykv/namespace.py +234 -0
- pykv/patterns.py +85 -0
- pykv/persistence.py +133 -0
- pykv/py.typed +0 -0
- pykv/store.py +173 -0
- pykv/ttl.py +264 -0
- pykv/types.py +102 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromscratch-pykv
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: An educational key-value store library built from scratch in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/robcox2910/pykv
|
|
6
|
+
Project-URL: Repository, https://github.com/robcox2910/pykv
|
|
7
|
+
Project-URL: Issues, https://github.com/robcox2910/pykv/issues
|
|
8
|
+
Project-URL: Documentation, https://robcox2910.github.io/pykv/
|
|
9
|
+
Author: Rob Cox
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cache,education,key-value,store
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Education
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: commitizen>=4.4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.170; extra == 'dev'
|
|
25
|
+
Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1.400; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.9.3; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# PyKV
|
|
34
|
+
|
|
35
|
+
An educational key-value store built from scratch in Python.
|
|
36
|
+
|
|
37
|
+
Part of the [PyLearn](https://github.com/robcox2910) series — learning computer science by building real tools.
|
|
38
|
+
|
|
39
|
+
## What Is a Key-Value Store?
|
|
40
|
+
|
|
41
|
+
Think of a **phonebook**: you look up a name (the *key*) and get back a phone number (the *value*). A key-value store works the same way — it's the simplest kind of database.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Get / Set / Delete** — basic phonebook operations
|
|
46
|
+
- **TTL (Time-To-Live)** — keys that expire, like milk with a use-by date
|
|
47
|
+
- **Multiple Data Types** — store strings, numbers, lists, and dicts
|
|
48
|
+
- **Persistence** — save to disk so data survives a restart
|
|
49
|
+
- **Namespaces** — group keys like folders in a filing cabinet
|
|
50
|
+
- **Batch Operations** — handle many keys at once, like a grocery list
|
|
51
|
+
- **Atomic Counters** — thread-safe increment/decrement, like a tally counter
|
|
52
|
+
- **Pattern Matching** — find keys matching a glob pattern
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from pykv.store import BasicStore
|
|
58
|
+
|
|
59
|
+
store = BasicStore()
|
|
60
|
+
store.set("greeting", "hello")
|
|
61
|
+
print(store.get("greeting")) # "hello"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Releasing
|
|
65
|
+
|
|
66
|
+
Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
|
|
67
|
+
not whatever is on `main`. To publish a change for them:
|
|
68
|
+
|
|
69
|
+
1. Merge the change to `main`.
|
|
70
|
+
2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
|
|
71
|
+
A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
|
|
72
|
+
anything else bumps the patch (`v0.1.0` → `v0.1.1`).
|
|
73
|
+
3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
|
|
74
|
+
`uv lock --upgrade-package <name>`, and open a pull request so CI checks
|
|
75
|
+
the pair together.
|
|
76
|
+
|
|
77
|
+
<!-- series:start -->
|
|
78
|
+
## Related Projects
|
|
79
|
+
|
|
80
|
+
This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
|
|
81
|
+
|
|
82
|
+
| Project | What it teaches | Repository | Docs |
|
|
83
|
+
| --- | --- | --- | --- |
|
|
84
|
+
| PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
|
|
85
|
+
| Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
|
|
86
|
+
| PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
|
|
87
|
+
| **PyKV** | **Key-value stores** | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
|
|
88
|
+
| PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
|
|
89
|
+
| PyGraphDB | Graph databases | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
|
|
90
|
+
| PyTSDB | Time-series databases | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
|
|
91
|
+
| PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
|
|
92
|
+
| PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
|
|
93
|
+
| PyCrypt | Cryptography | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
|
|
94
|
+
| PyNet | Computer networking | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
|
|
95
|
+
| PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
|
|
96
|
+
| PyGit | Version control | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
|
|
97
|
+
| PySearch | Search engines | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
|
|
98
|
+
| PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
|
|
99
|
+
| PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
|
|
100
|
+
| PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
|
|
101
|
+
| PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
|
|
102
|
+
<!-- series:end -->
|
|
103
|
+
|
|
104
|
+
<!-- docs:start -->
|
|
105
|
+
## Documentation
|
|
106
|
+
|
|
107
|
+
Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pykv/
|
|
108
|
+
<!-- docs:end -->
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pykv/__init__.py,sha256=TpQ1KNWdTqPxMx0BJ6CcCMN-GLfEdSN7O3W_wRrSdPo,2025
|
|
2
|
+
pykv/_version.py,sha256=HGBlVvZpQ8N2kh1n_LY5PM4s9ljcIgdbOCgXN-uFje4,520
|
|
3
|
+
pykv/atomic.py,sha256=QLLPGZqpA-TgVV5lDEKCLB7vVr05bxj97_Fwykz_gi4,2345
|
|
4
|
+
pykv/batch.py,sha256=LKMVD5CZ1FTNsssbSX6sGNGT5rHIKB4mflVTa3q2hS8,3176
|
|
5
|
+
pykv/counters.py,sha256=4nrcG04kGJu3kS24nyOQHXdsXUk1F1hnoKVCnqoP6j0,4087
|
|
6
|
+
pykv/errors.py,sha256=6VJJ2eHHPcB_8dDJDVbbbiZmvzfjvzrZGHbXjfPG7TQ,2187
|
|
7
|
+
pykv/namespace.py,sha256=27ljO5mL7RDdPFRhTgDmyQslPCtUJrkw_QjQfft15Ss,7807
|
|
8
|
+
pykv/patterns.py,sha256=RnEepUDjdsYG72r2zwuAQX8jL73yTu7vvOMl2C7koUk,2463
|
|
9
|
+
pykv/persistence.py,sha256=I5yzHMiWkbWnXVfkZmz7NUP7pSap8AgSFCXYnLsj_90,4984
|
|
10
|
+
pykv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
pykv/store.py,sha256=jW-1tLO-vMZ8-t5YlX9gDQezVPDMoRsmRGiDLcMlmAQ,5094
|
|
12
|
+
pykv/ttl.py,sha256=-S2nIfBJwSen1H5NQVmlmVa2ATELkbLKrj3k_Pcih9w,8286
|
|
13
|
+
pykv/types.py,sha256=DV_kXwZFpHsskAzyAP2BhqBUOgPBHcSLiaQy4vx3q0c,3311
|
|
14
|
+
fromscratch_pykv-0.4.0.dist-info/METADATA,sha256=VGEh_EZJUoal3HywhiRkvbYy0uUu0j8StKPD3CR3SDE,5820
|
|
15
|
+
fromscratch_pykv-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
fromscratch_pykv-0.4.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
|
|
17
|
+
fromscratch_pykv-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pykv/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""PyKV -- an educational key-value store built from scratch in Python.
|
|
2
|
+
|
|
3
|
+
A key-value store is like a giant phonebook for your data. You give each
|
|
4
|
+
piece of information a unique name (the "key") and store some data (the
|
|
5
|
+
"value") next to it. Later, you can look up the data just by knowing
|
|
6
|
+
its name -- lightning fast!
|
|
7
|
+
|
|
8
|
+
Real-world key-value stores like Redis and Memcached power some of the
|
|
9
|
+
biggest websites in the world. PyKV teaches you how they work from the
|
|
10
|
+
inside, one piece at a time.
|
|
11
|
+
|
|
12
|
+
Quick start:
|
|
13
|
+
>>> from pykv import BasicStore
|
|
14
|
+
>>> store = BasicStore()
|
|
15
|
+
>>> store.set("greeting", "Hello, world!")
|
|
16
|
+
>>> store.get("greeting")
|
|
17
|
+
'Hello, world!'
|
|
18
|
+
|
|
19
|
+
Features:
|
|
20
|
+
- BasicStore: Simple get/set/delete operations
|
|
21
|
+
- TTLStore: Keys that expire after a time limit
|
|
22
|
+
- Namespace: Group keys with prefixes
|
|
23
|
+
- AtomicCounter: Thread-safe counters
|
|
24
|
+
- Persistence: Save and load from disk
|
|
25
|
+
- Batch operations: Work with many keys at once
|
|
26
|
+
- Pattern matching: Find keys with wildcards
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from pykv.batch import batch_delete, batch_exists, batch_get, batch_set
|
|
30
|
+
from pykv.counters import AtomicCounter
|
|
31
|
+
from pykv.errors import (
|
|
32
|
+
KeyExpiredError,
|
|
33
|
+
KeyNotFoundError,
|
|
34
|
+
NamespaceError,
|
|
35
|
+
PersistenceError,
|
|
36
|
+
PyKVError,
|
|
37
|
+
SerializationError,
|
|
38
|
+
)
|
|
39
|
+
from pykv.namespace import Namespace
|
|
40
|
+
from pykv.patterns import count_matches, match_items, match_keys
|
|
41
|
+
from pykv.persistence import load_store, save_store
|
|
42
|
+
from pykv.store import BasicStore
|
|
43
|
+
from pykv.ttl import TTLStore
|
|
44
|
+
from pykv.types import StorableValue
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AtomicCounter",
|
|
48
|
+
# Core
|
|
49
|
+
"BasicStore",
|
|
50
|
+
"KeyExpiredError",
|
|
51
|
+
"KeyNotFoundError",
|
|
52
|
+
# Features
|
|
53
|
+
"Namespace",
|
|
54
|
+
"NamespaceError",
|
|
55
|
+
"PersistenceError",
|
|
56
|
+
# Errors
|
|
57
|
+
"PyKVError",
|
|
58
|
+
"SerializationError",
|
|
59
|
+
"StorableValue",
|
|
60
|
+
"TTLStore",
|
|
61
|
+
"batch_delete",
|
|
62
|
+
"batch_exists",
|
|
63
|
+
"batch_get",
|
|
64
|
+
# Batch
|
|
65
|
+
"batch_set",
|
|
66
|
+
"count_matches",
|
|
67
|
+
"load_store",
|
|
68
|
+
"match_items",
|
|
69
|
+
# Patterns
|
|
70
|
+
"match_keys",
|
|
71
|
+
# Persistence
|
|
72
|
+
"save_store",
|
|
73
|
+
]
|
pykv/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pykv/atomic.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Safe saving -- never leave a half-written file behind.
|
|
2
|
+
|
|
3
|
+
The usual way to save a file is: open it, wipe it, write the new
|
|
4
|
+
contents. If the computer crashes after the wipe but before the writing
|
|
5
|
+
finishes, your data is gone -- the old version was wiped and the new one
|
|
6
|
+
never arrived.
|
|
7
|
+
|
|
8
|
+
So we save like a careful student copying out their homework:
|
|
9
|
+
|
|
10
|
+
1. Write the new version on a **fresh sheet** (a temporary file sitting
|
|
11
|
+
right next to the real one).
|
|
12
|
+
2. Make sure the **ink is dry** -- ``fsync`` forces the bytes out of the
|
|
13
|
+
computer's memory and onto the disk.
|
|
14
|
+
3. **Swap** the fresh sheet in for the old one in a single instant step
|
|
15
|
+
(``Path.replace``). There is never a moment with no complete file: you
|
|
16
|
+
see either the old version or the new one, never half of each.
|
|
17
|
+
|
|
18
|
+
Real databases (SQLite, PostgreSQL, Redis) all use this same trick.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Permissions for a brand-new file: owner can read/write, others can read.
|
|
28
|
+
_NEW_FILE_MODE = 0o644
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
32
|
+
"""Replace the file at *path* with *text*, so a crash can never leave it half-written.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: The file to create or replace (its folder must already exist).
|
|
36
|
+
text: The new contents, saved as UTF-8.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else _NEW_FILE_MODE
|
|
40
|
+
fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
41
|
+
temp_path = Path(temp_name)
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as temp_file:
|
|
44
|
+
temp_file.write(text)
|
|
45
|
+
temp_file.flush()
|
|
46
|
+
os.fsync(temp_file.fileno())
|
|
47
|
+
temp_path.chmod(mode)
|
|
48
|
+
temp_path.replace(path)
|
|
49
|
+
except BaseException:
|
|
50
|
+
temp_path.unlink(missing_ok=True) # don't leave the scrap sheet lying around
|
|
51
|
+
raise
|
|
52
|
+
_sync_folder(path.parent)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sync_folder(folder: Path) -> None:
|
|
56
|
+
"""Make the swap itself durable by syncing the folder's list of files.
|
|
57
|
+
|
|
58
|
+
Some systems (like Windows) can't sync a folder; there the swap is
|
|
59
|
+
still safe, just not guaranteed to survive a power cut.
|
|
60
|
+
"""
|
|
61
|
+
with contextlib.suppress(OSError):
|
|
62
|
+
fd = os.open(folder, os.O_RDONLY)
|
|
63
|
+
try:
|
|
64
|
+
os.fsync(fd)
|
|
65
|
+
finally:
|
|
66
|
+
os.close(fd)
|
pykv/batch.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Batch operations -- get and set multiple keys at once.
|
|
2
|
+
|
|
3
|
+
Imagine going to the grocery store. You could make one trip for each item
|
|
4
|
+
on your list -- milk, bread, eggs -- but that would be exhausting! Instead,
|
|
5
|
+
you take your whole shopping list and grab everything in one trip. Batch
|
|
6
|
+
operations work the same way: instead of calling get() or set() over and
|
|
7
|
+
over, you do it all at once.
|
|
8
|
+
|
|
9
|
+
This is more convenient -- one call instead of many -- especially when you
|
|
10
|
+
have lots of keys to work with. Under the hood it is still a plain loop
|
|
11
|
+
calling ``set`` or ``get`` one key at a time, so it is not actually faster
|
|
12
|
+
than doing it yourself; it just keeps your code short and tidy.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> store = BasicStore()
|
|
16
|
+
>>> batch_set(store, {"name": "Alice", "age": 13, "grade": 8})
|
|
17
|
+
>>> batch_get(store, ["name", "age"])
|
|
18
|
+
{'name': 'Alice', 'age': 13}
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from pykv.store import BasicStore
|
|
23
|
+
from pykv.types import StorableValue
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def batch_set(store: BasicStore, items: dict[str, StorableValue]) -> None:
|
|
27
|
+
"""Set multiple key-value pairs in one call.
|
|
28
|
+
|
|
29
|
+
Like dropping off your whole grocery bag at once instead of
|
|
30
|
+
putting items away one by one.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
store: The store to write to.
|
|
34
|
+
items: A dictionary of key-value pairs to set.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
for key, value in items.items():
|
|
38
|
+
store.set(key, value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def batch_get(
|
|
42
|
+
store: BasicStore,
|
|
43
|
+
keys: list[str],
|
|
44
|
+
*,
|
|
45
|
+
skip_missing: bool = False,
|
|
46
|
+
) -> dict[str, StorableValue]:
|
|
47
|
+
"""Retrieve multiple values in one call.
|
|
48
|
+
|
|
49
|
+
Like reading multiple entries from your phonebook at once.
|
|
50
|
+
If ``skip_missing`` is True, keys that do not exist are silently
|
|
51
|
+
skipped. Otherwise, a ``KeyNotFoundError`` is raised for any
|
|
52
|
+
missing key.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
store: The store to read from.
|
|
56
|
+
keys: A list of keys to retrieve.
|
|
57
|
+
skip_missing: If True, skip keys that do not exist instead of raising.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A dictionary of the found key-value pairs.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
KeyNotFoundError: If a key is missing and skip_missing is False.
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
result: dict[str, StorableValue] = {}
|
|
67
|
+
for key in keys:
|
|
68
|
+
if skip_missing:
|
|
69
|
+
if store.exists(key):
|
|
70
|
+
result[key] = store.get(key)
|
|
71
|
+
else:
|
|
72
|
+
result[key] = store.get(key)
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def batch_delete(store: BasicStore, keys: list[str]) -> int:
|
|
77
|
+
"""Delete multiple keys in one call.
|
|
78
|
+
|
|
79
|
+
Like erasing several entries from your phonebook at once.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
store: The store to delete from.
|
|
83
|
+
keys: A list of keys to remove.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The number of keys that were actually deleted.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
count = 0
|
|
90
|
+
for key in keys:
|
|
91
|
+
if store.delete(key):
|
|
92
|
+
count += 1
|
|
93
|
+
return count
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def batch_exists(store: BasicStore, keys: list[str]) -> dict[str, bool]:
|
|
97
|
+
"""Check whether multiple keys exist in one call.
|
|
98
|
+
|
|
99
|
+
Like scanning your phonebook for a list of names and marking
|
|
100
|
+
which ones you found.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
store: The store to check.
|
|
104
|
+
keys: A list of keys to look for.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
A dictionary mapping each key to True/False.
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
return {key: store.exists(key) for key in keys}
|
pykv/counters.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Atomic counters -- thread-safe increment and decrement.
|
|
2
|
+
|
|
3
|
+
Imagine a tally counter (the clicky thing a bouncer uses to count people
|
|
4
|
+
entering a club). Every time someone walks in, you click +1. Every time
|
|
5
|
+
someone leaves, you click -1. The key word is "atomic" -- each click is
|
|
6
|
+
one single, unbreakable action. Even if two people try to click at the
|
|
7
|
+
exact same time, the counter never gets confused.
|
|
8
|
+
|
|
9
|
+
In programming, this matters when multiple threads (like multiple workers)
|
|
10
|
+
try to update the same number at the same time. Without atomic operations,
|
|
11
|
+
two threads reading "5" at the same time might both write "6" instead of
|
|
12
|
+
one writing "6" and the other writing "7".
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> store = BasicStore()
|
|
16
|
+
>>> counter = AtomicCounter(store)
|
|
17
|
+
>>> counter.increment("visitors")
|
|
18
|
+
1
|
|
19
|
+
>>> counter.increment("visitors")
|
|
20
|
+
2
|
|
21
|
+
>>> counter.decrement("visitors")
|
|
22
|
+
1
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
|
|
28
|
+
from pykv.errors import KeyNotFoundError
|
|
29
|
+
from pykv.store import BasicStore
|
|
30
|
+
from pykv.types import StorableValue
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AtomicCounter:
|
|
34
|
+
"""Thread-safe counter operations on integer keys in a BasicStore.
|
|
35
|
+
|
|
36
|
+
Like a digital tally counter that multiple people can click at the
|
|
37
|
+
same time without it ever getting confused. Each click (increment
|
|
38
|
+
or decrement) is guaranteed to be accurate.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, store: BasicStore) -> None:
|
|
42
|
+
"""Create an atomic counter backed by the given store.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
store: The BasicStore to use for storing counter values.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
self._store = store
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
def increment(self, key: str, amount: int = 1) -> int:
|
|
52
|
+
"""Increase a counter by the given amount, creating it if needed.
|
|
53
|
+
|
|
54
|
+
Like clicking the tally counter one or more times. If the counter
|
|
55
|
+
does not exist yet, it starts at 0 before incrementing.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
key: The counter name.
|
|
59
|
+
amount: How much to add (default 1).
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The new counter value after incrementing.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
with self._lock:
|
|
66
|
+
current = self._get_int(key)
|
|
67
|
+
new_value = current + amount
|
|
68
|
+
self._store.set(key, new_value)
|
|
69
|
+
return new_value
|
|
70
|
+
|
|
71
|
+
def decrement(self, key: str, amount: int = 1) -> int:
|
|
72
|
+
"""Decrease a counter by the given amount, creating it if needed.
|
|
73
|
+
|
|
74
|
+
Like un-clicking the tally counter. The counter can go negative
|
|
75
|
+
(below zero) -- there is no floor.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
key: The counter name.
|
|
79
|
+
amount: How much to subtract (default 1).
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The new counter value after decrementing.
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
with self._lock:
|
|
86
|
+
current = self._get_int(key)
|
|
87
|
+
new_value = current - amount
|
|
88
|
+
self._store.set(key, new_value)
|
|
89
|
+
return new_value
|
|
90
|
+
|
|
91
|
+
def get_value(self, key: str) -> int:
|
|
92
|
+
"""Read the current counter value.
|
|
93
|
+
|
|
94
|
+
Like glancing at the number on the tally counter without clicking
|
|
95
|
+
it. If nobody has clicked it yet, it reads 0.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
key: The counter name.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The current integer value, or 0 if the counter does not exist.
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
with self._lock:
|
|
105
|
+
return self._get_int(key)
|
|
106
|
+
|
|
107
|
+
def reset(self, key: str) -> None:
|
|
108
|
+
"""Reset a counter to zero.
|
|
109
|
+
|
|
110
|
+
Like clearing the tally counter back to 0.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
key: The counter name to reset.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._store.set(key, 0)
|
|
118
|
+
|
|
119
|
+
def _get_int(self, key: str) -> int:
|
|
120
|
+
"""Read the current integer value of a key, defaulting to 0.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
key: The key to read.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The integer value, or 0 if the key does not exist.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
value: StorableValue = self._store.get(key)
|
|
131
|
+
except KeyNotFoundError:
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
if not isinstance(value, int):
|
|
135
|
+
msg = f"Counter key {key!r} holds a {type(value).__name__}, not an int"
|
|
136
|
+
raise TypeError(msg)
|
|
137
|
+
return value
|
pykv/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Custom exceptions for PyKV.
|
|
2
|
+
|
|
3
|
+
Think of exceptions like warning signs on a road. They tell you when something
|
|
4
|
+
goes wrong so you can handle it properly instead of crashing. Each exception
|
|
5
|
+
type is like a different road sign -- "Key Not Found" is like "Road Closed",
|
|
6
|
+
while "Type Error" is like "Wrong Way".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PyKVError(Exception):
|
|
11
|
+
"""Base exception for all PyKV errors.
|
|
12
|
+
|
|
13
|
+
Every PyKV error inherits from this class, like how every animal
|
|
14
|
+
inherits from the "Animal" category. This lets you catch ALL PyKV
|
|
15
|
+
errors with a single ``except PyKVError`` block.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KeyNotFoundError(PyKVError):
|
|
20
|
+
"""Raise when a requested key does not exist in the store.
|
|
21
|
+
|
|
22
|
+
Imagine looking up a word in a dictionary and it is not there.
|
|
23
|
+
That is what this error represents.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, key: str) -> None:
|
|
27
|
+
"""Initialize with the missing key name."""
|
|
28
|
+
self.key = key
|
|
29
|
+
super().__init__(f"Key not found: {key!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KeyExpiredError(PyKVError):
|
|
33
|
+
"""Raise when a key has expired due to its TTL.
|
|
34
|
+
|
|
35
|
+
Like trying to drink milk after the expiration date -- the key
|
|
36
|
+
existed once, but its time-to-live has passed.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, key: str) -> None:
|
|
40
|
+
"""Initialize with the expired key name."""
|
|
41
|
+
self.key = key
|
|
42
|
+
super().__init__(f"Key has expired: {key!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SerializationError(PyKVError):
|
|
46
|
+
"""Raise when a value cannot be serialized or deserialized.
|
|
47
|
+
|
|
48
|
+
Some things cannot be written down in a notebook. If you try to
|
|
49
|
+
store something that PyKV cannot convert to a saveable format,
|
|
50
|
+
you get this error.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PersistenceError(PyKVError):
|
|
55
|
+
"""Raise when saving to or loading from disk fails.
|
|
56
|
+
|
|
57
|
+
Like trying to save your game but the memory card is full or
|
|
58
|
+
broken. Something went wrong with the file on disk.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NamespaceError(PyKVError):
|
|
63
|
+
"""Raise when a namespace name or a key inside a namespace breaks the rules.
|
|
64
|
+
|
|
65
|
+
Folders in a filing cabinet must never share a paper. A namespace
|
|
66
|
+
name can't be empty, and a key inside a namespace can't contain
|
|
67
|
+
the ``:`` separator -- otherwise one folder could reach into another.
|
|
68
|
+
"""
|