jsondocstore 0.1.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.
- jsondocstore-0.1.0/LICENSE +21 -0
- jsondocstore-0.1.0/PKG-INFO +129 -0
- jsondocstore-0.1.0/README.md +118 -0
- jsondocstore-0.1.0/pyproject.toml +23 -0
- jsondocstore-0.1.0/setup.cfg +4 -0
- jsondocstore-0.1.0/src/jsondocstore/__init__.py +3 -0
- jsondocstore-0.1.0/src/jsondocstore/cli.py +239 -0
- jsondocstore-0.1.0/src/jsondocstore/core.py +173 -0
- jsondocstore-0.1.0/src/jsondocstore.egg-info/PKG-INFO +129 -0
- jsondocstore-0.1.0/src/jsondocstore.egg-info/SOURCES.txt +11 -0
- jsondocstore-0.1.0/src/jsondocstore.egg-info/dependency_links.txt +1 -0
- jsondocstore-0.1.0/src/jsondocstore.egg-info/entry_points.txt +2 -0
- jsondocstore-0.1.0/src/jsondocstore.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mirko Ortensi
|
|
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.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jsondocstore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple JSON document storage on disk
|
|
5
|
+
Author-email: Mirko Ortensi <mirko.ortensi@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# JsonDocStore
|
|
13
|
+
|
|
14
|
+
Simple JSON document storage on disk.
|
|
15
|
+
|
|
16
|
+
Each document is stored as one file named `<key>.json`. The document key is the filename stem, not a field inside the JSON body.
|
|
17
|
+
|
|
18
|
+
If you want to organize documents into different folders, use one `JsonDocStore` instance per folder. Each instance manages only the JSON files in its own directory.
|
|
19
|
+
|
|
20
|
+
## What It Does
|
|
21
|
+
|
|
22
|
+
- Stores one JSON document per file.
|
|
23
|
+
- Reads documents directly by filename.
|
|
24
|
+
- Uses optional in-memory indexes defined in `index.json`.
|
|
25
|
+
- Provides an interactive CLI in `cli.py`.
|
|
26
|
+
|
|
27
|
+
## Data Model
|
|
28
|
+
|
|
29
|
+
- `get(key)` reads `<key>.json`.
|
|
30
|
+
- `insert(key, doc)` writes `<key>.json`.
|
|
31
|
+
- Valid keys may contain letters, digits, `.`, `_`, and `-`. No whitespace.
|
|
32
|
+
|
|
33
|
+
## Schema
|
|
34
|
+
|
|
35
|
+
`index.json` is optional.
|
|
36
|
+
|
|
37
|
+
- `create=True` creates the directory if needed. It does not create `index.json`.
|
|
38
|
+
- `index_fields` defines which fields are indexed in memory.
|
|
39
|
+
- `get()`, `insert()`, `delete()`, and `list_all()` work without `index.json`.
|
|
40
|
+
- `query_by()` only works when an index exists. Querying without an index, or on a non-indexed field, raises an error.
|
|
41
|
+
- Indexes work only on top-level document fields, not nested paths.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"index_fields": ["email", "status"]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For example, this works:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"email": "alice@example.com",
|
|
56
|
+
"profile": {
|
|
57
|
+
"city": "Rome"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- you can index `email`
|
|
63
|
+
- you cannot index `profile.city`
|
|
64
|
+
|
|
65
|
+
## CLI
|
|
66
|
+
|
|
67
|
+
Start the interactive shell:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
jsondocstore /path/to/store
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Commands:
|
|
74
|
+
|
|
75
|
+
- `list` prints document filenames only
|
|
76
|
+
- `listindexes` prints indexed fields, or `[]` if there is no `index.json`
|
|
77
|
+
- `queryby FIELD VALUE`
|
|
78
|
+
- `createindex FIELD`
|
|
79
|
+
- `deleteindex FIELD`
|
|
80
|
+
- `insert KEY JSON_DOCUMENT`
|
|
81
|
+
- `update KEY JSON_DOCUMENT`
|
|
82
|
+
- `delete KEY`
|
|
83
|
+
- `exit`
|
|
84
|
+
|
|
85
|
+
Example session:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
$ jsondocstore ./data
|
|
89
|
+
jsondocstore> insert user-1 '{"username": "alice", "password": "secret1", "role": "admin"}'
|
|
90
|
+
jsondocstore> insert user-2 '{"username": "bob", "password": "secret2", "role": "user"}'
|
|
91
|
+
jsondocstore> insert user-3 '{"username": "carol", "password": "secret3", "role": "user"}'
|
|
92
|
+
jsondocstore> update user-2 '{"username": "bob", "password": "secret2", "role": "admin"}'
|
|
93
|
+
jsondocstore> list
|
|
94
|
+
[
|
|
95
|
+
"user-1.json",
|
|
96
|
+
"user-2.json",
|
|
97
|
+
"user-3.json"
|
|
98
|
+
]
|
|
99
|
+
jsondocstore> createindex role
|
|
100
|
+
jsondocstore> queryby role user
|
|
101
|
+
[
|
|
102
|
+
{
|
|
103
|
+
"password": "secret2",
|
|
104
|
+
"role": "user",
|
|
105
|
+
"username": "bob"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"password": "secret3",
|
|
109
|
+
"role": "user",
|
|
110
|
+
"username": "carol"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Library
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from jsondocstore import JsonDocStore
|
|
119
|
+
|
|
120
|
+
store = JsonDocStore("./data", create=True)
|
|
121
|
+
store.insert("user-1", {"username": "alice", "password": "secret1", "role": "admin"})
|
|
122
|
+
store.insert("user-2", {"username": "bob", "password": "secret2", "role": "user"})
|
|
123
|
+
store.insert("user-3", {"username": "carol", "password": "secret3", "role": "user"})
|
|
124
|
+
store.update("user-2", {"username": "bob", "password": "secret2", "role": "admin"})
|
|
125
|
+
names = store.list_all()
|
|
126
|
+
store.create_index("role")
|
|
127
|
+
admins = store.query_by("role", "admin")
|
|
128
|
+
doc = store.get("user-1")
|
|
129
|
+
```
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# JsonDocStore
|
|
2
|
+
|
|
3
|
+
Simple JSON document storage on disk.
|
|
4
|
+
|
|
5
|
+
Each document is stored as one file named `<key>.json`. The document key is the filename stem, not a field inside the JSON body.
|
|
6
|
+
|
|
7
|
+
If you want to organize documents into different folders, use one `JsonDocStore` instance per folder. Each instance manages only the JSON files in its own directory.
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
- Stores one JSON document per file.
|
|
12
|
+
- Reads documents directly by filename.
|
|
13
|
+
- Uses optional in-memory indexes defined in `index.json`.
|
|
14
|
+
- Provides an interactive CLI in `cli.py`.
|
|
15
|
+
|
|
16
|
+
## Data Model
|
|
17
|
+
|
|
18
|
+
- `get(key)` reads `<key>.json`.
|
|
19
|
+
- `insert(key, doc)` writes `<key>.json`.
|
|
20
|
+
- Valid keys may contain letters, digits, `.`, `_`, and `-`. No whitespace.
|
|
21
|
+
|
|
22
|
+
## Schema
|
|
23
|
+
|
|
24
|
+
`index.json` is optional.
|
|
25
|
+
|
|
26
|
+
- `create=True` creates the directory if needed. It does not create `index.json`.
|
|
27
|
+
- `index_fields` defines which fields are indexed in memory.
|
|
28
|
+
- `get()`, `insert()`, `delete()`, and `list_all()` work without `index.json`.
|
|
29
|
+
- `query_by()` only works when an index exists. Querying without an index, or on a non-indexed field, raises an error.
|
|
30
|
+
- Indexes work only on top-level document fields, not nested paths.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"index_fields": ["email", "status"]
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For example, this works:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"email": "alice@example.com",
|
|
45
|
+
"profile": {
|
|
46
|
+
"city": "Rome"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- you can index `email`
|
|
52
|
+
- you cannot index `profile.city`
|
|
53
|
+
|
|
54
|
+
## CLI
|
|
55
|
+
|
|
56
|
+
Start the interactive shell:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
jsondocstore /path/to/store
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Commands:
|
|
63
|
+
|
|
64
|
+
- `list` prints document filenames only
|
|
65
|
+
- `listindexes` prints indexed fields, or `[]` if there is no `index.json`
|
|
66
|
+
- `queryby FIELD VALUE`
|
|
67
|
+
- `createindex FIELD`
|
|
68
|
+
- `deleteindex FIELD`
|
|
69
|
+
- `insert KEY JSON_DOCUMENT`
|
|
70
|
+
- `update KEY JSON_DOCUMENT`
|
|
71
|
+
- `delete KEY`
|
|
72
|
+
- `exit`
|
|
73
|
+
|
|
74
|
+
Example session:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
$ jsondocstore ./data
|
|
78
|
+
jsondocstore> insert user-1 '{"username": "alice", "password": "secret1", "role": "admin"}'
|
|
79
|
+
jsondocstore> insert user-2 '{"username": "bob", "password": "secret2", "role": "user"}'
|
|
80
|
+
jsondocstore> insert user-3 '{"username": "carol", "password": "secret3", "role": "user"}'
|
|
81
|
+
jsondocstore> update user-2 '{"username": "bob", "password": "secret2", "role": "admin"}'
|
|
82
|
+
jsondocstore> list
|
|
83
|
+
[
|
|
84
|
+
"user-1.json",
|
|
85
|
+
"user-2.json",
|
|
86
|
+
"user-3.json"
|
|
87
|
+
]
|
|
88
|
+
jsondocstore> createindex role
|
|
89
|
+
jsondocstore> queryby role user
|
|
90
|
+
[
|
|
91
|
+
{
|
|
92
|
+
"password": "secret2",
|
|
93
|
+
"role": "user",
|
|
94
|
+
"username": "bob"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"password": "secret3",
|
|
98
|
+
"role": "user",
|
|
99
|
+
"username": "carol"
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Library
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from jsondocstore import JsonDocStore
|
|
108
|
+
|
|
109
|
+
store = JsonDocStore("./data", create=True)
|
|
110
|
+
store.insert("user-1", {"username": "alice", "password": "secret1", "role": "admin"})
|
|
111
|
+
store.insert("user-2", {"username": "bob", "password": "secret2", "role": "user"})
|
|
112
|
+
store.insert("user-3", {"username": "carol", "password": "secret3", "role": "user"})
|
|
113
|
+
store.update("user-2", {"username": "bob", "password": "secret2", "role": "admin"})
|
|
114
|
+
names = store.list_all()
|
|
115
|
+
store.create_index("role")
|
|
116
|
+
admins = store.query_by("role", "admin")
|
|
117
|
+
doc = store.get("user-1")
|
|
118
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jsondocstore"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Simple JSON document storage on disk"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Mirko Ortensi", email = "mirko.ortensi@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
jsondocstore = "jsondocstore.cli:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
package-dir = {"" = "src"}
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import cmd
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import readline # noqa: F401
|
|
11
|
+
except ImportError:
|
|
12
|
+
readline = None
|
|
13
|
+
|
|
14
|
+
from .core import JsonDocStore, _json_dump
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_query_value(text: str):
|
|
18
|
+
try:
|
|
19
|
+
return json.loads(text)
|
|
20
|
+
except json.JSONDecodeError:
|
|
21
|
+
return text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _configure_readline() -> None:
|
|
25
|
+
if readline is None:
|
|
26
|
+
return
|
|
27
|
+
doc = getattr(readline, "__doc__", "") or ""
|
|
28
|
+
if "libedit" in doc:
|
|
29
|
+
readline.parse_and_bind("bind ^I rl_complete")
|
|
30
|
+
else:
|
|
31
|
+
readline.parse_and_bind("tab: complete")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JsonDocStoreShell(cmd.Cmd):
|
|
35
|
+
intro = "JsonDocStore interactive shell. Type 'help' for commands."
|
|
36
|
+
prompt = "jsondocstore> "
|
|
37
|
+
|
|
38
|
+
def __init__(self, store: JsonDocStore):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.store = store
|
|
41
|
+
|
|
42
|
+
def get_names(self):
|
|
43
|
+
return [name for name in super().get_names() if name != "do_EOF"]
|
|
44
|
+
|
|
45
|
+
def emptyline(self):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def _print_json(self, value):
|
|
49
|
+
print(_json_dump(value))
|
|
50
|
+
|
|
51
|
+
def _complete_from_options(self, text, options):
|
|
52
|
+
return sorted(option for option in options if option.startswith(text))
|
|
53
|
+
|
|
54
|
+
def _document_keys(self):
|
|
55
|
+
root = getattr(self.store, "root", None)
|
|
56
|
+
if root is None:
|
|
57
|
+
return []
|
|
58
|
+
return sorted(
|
|
59
|
+
path.stem
|
|
60
|
+
for path in Path(root).glob("*.json")
|
|
61
|
+
if path.name != "index.json"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _document_fields(self):
|
|
65
|
+
fields = set()
|
|
66
|
+
root = getattr(self.store, "root", None)
|
|
67
|
+
if root is None:
|
|
68
|
+
return []
|
|
69
|
+
for path in Path(root).glob("*.json"):
|
|
70
|
+
if path.name == "index.json":
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
74
|
+
fields.update(doc.keys())
|
|
75
|
+
except Exception:
|
|
76
|
+
continue
|
|
77
|
+
return sorted(field for field in fields if isinstance(field, str))
|
|
78
|
+
|
|
79
|
+
def _query_fields(self):
|
|
80
|
+
try:
|
|
81
|
+
indexes = self.store.list_indexes()
|
|
82
|
+
except Exception:
|
|
83
|
+
return []
|
|
84
|
+
return indexes or self._document_fields()
|
|
85
|
+
|
|
86
|
+
def complete_queryby(self, text, line, begidx, endidx):
|
|
87
|
+
args = shlex.split(line[:begidx])
|
|
88
|
+
if len(args) <= 1:
|
|
89
|
+
return self._complete_from_options(text, self._query_fields())
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
def complete_createindex(self, text, line, begidx, endidx):
|
|
93
|
+
indexed = set(self.store.list_indexes())
|
|
94
|
+
fields = [field for field in self._document_fields() if field not in indexed]
|
|
95
|
+
return self._complete_from_options(text, fields)
|
|
96
|
+
|
|
97
|
+
def complete_deleteindex(self, text, line, begidx, endidx):
|
|
98
|
+
return self._complete_from_options(text, self.store.list_indexes())
|
|
99
|
+
|
|
100
|
+
def complete_delete(self, text, line, begidx, endidx):
|
|
101
|
+
return self._complete_from_options(text, self._document_keys())
|
|
102
|
+
|
|
103
|
+
def do_list(self, arg):
|
|
104
|
+
"""List all documents."""
|
|
105
|
+
self._print_json(self.store.list_all())
|
|
106
|
+
|
|
107
|
+
def do_listindexes(self, arg):
|
|
108
|
+
"""List indexed fields."""
|
|
109
|
+
self._print_json(self.store.list_indexes())
|
|
110
|
+
|
|
111
|
+
def do_queryby(self, arg):
|
|
112
|
+
"""queryby FIELD VALUE"""
|
|
113
|
+
try:
|
|
114
|
+
field, value = shlex.split(arg)
|
|
115
|
+
except ValueError:
|
|
116
|
+
print("Usage: queryby FIELD VALUE")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
self._print_json(self.store.query_by(field, _parse_query_value(value)))
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"Error: {e}")
|
|
123
|
+
|
|
124
|
+
def do_createindex(self, arg):
|
|
125
|
+
"""createindex FIELD"""
|
|
126
|
+
field = arg.strip()
|
|
127
|
+
if not field:
|
|
128
|
+
print("Usage: createindex FIELD")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
self.store.create_index(field)
|
|
133
|
+
print(f"Created index {field}")
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"Error: {e}")
|
|
136
|
+
|
|
137
|
+
def do_deleteindex(self, arg):
|
|
138
|
+
"""deleteindex FIELD"""
|
|
139
|
+
field = arg.strip()
|
|
140
|
+
if not field:
|
|
141
|
+
print("Usage: deleteindex FIELD")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
deleted = self.store.delete_index(field)
|
|
146
|
+
if deleted:
|
|
147
|
+
print(f"Deleted index {field}")
|
|
148
|
+
else:
|
|
149
|
+
print(f"Index not found: {field}")
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f"Error: {e}")
|
|
152
|
+
|
|
153
|
+
def do_insert(self, arg):
|
|
154
|
+
"""insert KEY JSON_DOCUMENT"""
|
|
155
|
+
if not arg.strip():
|
|
156
|
+
print("Usage: insert KEY JSON_DOCUMENT")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
parts = shlex.split(arg)
|
|
161
|
+
if len(parts) < 2:
|
|
162
|
+
raise ValueError
|
|
163
|
+
key = parts[0]
|
|
164
|
+
json_text = " ".join(parts[1:])
|
|
165
|
+
except ValueError:
|
|
166
|
+
print("Usage: insert KEY JSON_DOCUMENT")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
self._print_json(self.store.insert(key, json.loads(json_text)))
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(f"Error: {e}")
|
|
173
|
+
|
|
174
|
+
def do_update(self, arg):
|
|
175
|
+
"""update KEY JSON_DOCUMENT"""
|
|
176
|
+
if not arg.strip():
|
|
177
|
+
print("Usage: update KEY JSON_DOCUMENT")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
parts = shlex.split(arg)
|
|
182
|
+
if len(parts) < 2:
|
|
183
|
+
raise ValueError
|
|
184
|
+
key = parts[0]
|
|
185
|
+
json_text = " ".join(parts[1:])
|
|
186
|
+
except ValueError:
|
|
187
|
+
print("Usage: update KEY JSON_DOCUMENT")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
self._print_json(self.store.update(key, json.loads(json_text)))
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"Error: {e}")
|
|
194
|
+
|
|
195
|
+
def do_delete(self, arg):
|
|
196
|
+
"""delete PK"""
|
|
197
|
+
pk = arg.strip()
|
|
198
|
+
if not pk:
|
|
199
|
+
print("Usage: delete PK")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
self.store.delete(pk)
|
|
204
|
+
print(f"Deleted {pk}")
|
|
205
|
+
except Exception as e:
|
|
206
|
+
print(f"Error: {e}")
|
|
207
|
+
|
|
208
|
+
def do_exit(self, arg):
|
|
209
|
+
"""Exit the shell."""
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
def do_EOF(self, arg):
|
|
213
|
+
"""Exit on Ctrl-D."""
|
|
214
|
+
print()
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main():
|
|
219
|
+
if len(sys.argv) != 2:
|
|
220
|
+
print("Usage: python -m jsondocstore /path/to/db", file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
_configure_readline()
|
|
224
|
+
root = Path(sys.argv[1])
|
|
225
|
+
try:
|
|
226
|
+
store = JsonDocStore(root, create=True)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
229
|
+
return 1
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
JsonDocStoreShell(store).cmdloop()
|
|
233
|
+
except KeyboardInterrupt:
|
|
234
|
+
print()
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _json_dump(value: Any) -> str:
|
|
12
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_VALID_KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JsonDocStore:
|
|
19
|
+
def __init__(self, root: str | Path, create: bool = False):
|
|
20
|
+
self.root = Path(root)
|
|
21
|
+
if create:
|
|
22
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
elif not self.root.exists():
|
|
24
|
+
raise ValueError(f"Directory does not exist: {self.root}")
|
|
25
|
+
if not self.root.is_dir():
|
|
26
|
+
raise ValueError(f"Path is not a directory: {self.root}")
|
|
27
|
+
|
|
28
|
+
self.schema_path = self.root / "index.json"
|
|
29
|
+
self.schema = self._load_schema() if self.schema_path.exists() else None
|
|
30
|
+
self.index_fields = list(self.schema.get("index_fields", [])) if self.schema else []
|
|
31
|
+
self.indexes: dict[str, dict[Any, set[str]]] = {field: defaultdict(set) for field in self.index_fields}
|
|
32
|
+
if self.schema is not None:
|
|
33
|
+
self._rebuild_index()
|
|
34
|
+
|
|
35
|
+
def _load_schema(self) -> dict[str, Any]:
|
|
36
|
+
schema = json.loads(self.schema_path.read_text(encoding="utf-8"))
|
|
37
|
+
if "index_fields" not in schema:
|
|
38
|
+
raise ValueError("index.json must contain 'index_fields'")
|
|
39
|
+
if not isinstance(schema["index_fields"], list):
|
|
40
|
+
raise ValueError("'index_fields' must be a list")
|
|
41
|
+
if not all(isinstance(field, str) for field in schema["index_fields"]):
|
|
42
|
+
raise ValueError("'index_fields' entries must be strings")
|
|
43
|
+
return schema
|
|
44
|
+
|
|
45
|
+
def _doc_path(self, pk: str) -> Path:
|
|
46
|
+
return self.root / f"{pk}.json"
|
|
47
|
+
|
|
48
|
+
def _validate_key(self, pk: str) -> None:
|
|
49
|
+
if not pk:
|
|
50
|
+
raise ValueError("Document key must not be empty")
|
|
51
|
+
if pk in {".", ".."}:
|
|
52
|
+
raise ValueError(f"Invalid document key: {pk}")
|
|
53
|
+
if pk == "index":
|
|
54
|
+
raise ValueError("Document key 'index' is reserved")
|
|
55
|
+
if not _VALID_KEY_RE.fullmatch(pk):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"Invalid document key. Use only letters, digits, dot, underscore, or hyphen"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _rebuild_index(self) -> None:
|
|
61
|
+
for field in self.index_fields:
|
|
62
|
+
self.indexes[field].clear()
|
|
63
|
+
for path in sorted(self.root.glob("*.json")):
|
|
64
|
+
if path.name == "index.json":
|
|
65
|
+
continue
|
|
66
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
self._add_indexes(path.stem, doc)
|
|
68
|
+
|
|
69
|
+
def _list_doc_names(self) -> list[str]:
|
|
70
|
+
return [
|
|
71
|
+
path.name
|
|
72
|
+
for path in sorted(self.root.glob("*.json"))
|
|
73
|
+
if path.name != "index.json"
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def _add_indexes(self, pk: str, doc: dict[str, Any]) -> None:
|
|
77
|
+
for field in self.index_fields:
|
|
78
|
+
if field in doc:
|
|
79
|
+
self.indexes[field][doc[field]].add(pk)
|
|
80
|
+
|
|
81
|
+
def _remove_indexes(self, pk: str, doc: dict[str, Any]) -> None:
|
|
82
|
+
for field in self.index_fields:
|
|
83
|
+
if field in doc:
|
|
84
|
+
value = doc[field]
|
|
85
|
+
bucket = self.indexes[field].get(value)
|
|
86
|
+
if bucket is not None:
|
|
87
|
+
bucket.discard(pk)
|
|
88
|
+
if not bucket:
|
|
89
|
+
del self.indexes[field][value]
|
|
90
|
+
|
|
91
|
+
def _write_json_atomic(self, path: Path, obj: dict[str, Any]) -> None:
|
|
92
|
+
with tempfile.NamedTemporaryFile(
|
|
93
|
+
"w", encoding="utf-8", dir=str(self.root), delete=False
|
|
94
|
+
) as tmp:
|
|
95
|
+
tmp.write(_json_dump(obj))
|
|
96
|
+
tmp_path = Path(tmp.name)
|
|
97
|
+
tmp_path.replace(path)
|
|
98
|
+
|
|
99
|
+
def list_all(self) -> list[str]:
|
|
100
|
+
return self._list_doc_names()
|
|
101
|
+
|
|
102
|
+
def get(self, pk: str) -> dict[str, Any]:
|
|
103
|
+
self._validate_key(pk)
|
|
104
|
+
path = self._doc_path(pk)
|
|
105
|
+
if not path.exists():
|
|
106
|
+
raise KeyError(f"Document not found: {pk}")
|
|
107
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
|
|
109
|
+
def query_by(self, field: str, value: Any) -> list[dict[str, Any]]:
|
|
110
|
+
if self.schema is None:
|
|
111
|
+
raise ValueError("Cannot query without an index. Create an index first")
|
|
112
|
+
if field not in self.indexes:
|
|
113
|
+
raise ValueError(f"Field is not indexed: {field}")
|
|
114
|
+
return [self.get(pk) for pk in sorted(self.indexes[field].get(value, ()))]
|
|
115
|
+
|
|
116
|
+
def create_index(self, field: str) -> None:
|
|
117
|
+
if self.schema is None:
|
|
118
|
+
self.schema = {"index_fields": []}
|
|
119
|
+
self.index_fields = []
|
|
120
|
+
self.indexes = {}
|
|
121
|
+
if field in self.indexes:
|
|
122
|
+
raise ValueError(f"Index already exists: {field}. Delete it before recreating")
|
|
123
|
+
self.index_fields.append(field)
|
|
124
|
+
self.indexes[field] = defaultdict(set)
|
|
125
|
+
self.schema["index_fields"] = list(self.index_fields)
|
|
126
|
+
self._write_json_atomic(self.schema_path, self.schema)
|
|
127
|
+
self._rebuild_index()
|
|
128
|
+
|
|
129
|
+
def list_indexes(self) -> list[str]:
|
|
130
|
+
return sorted(self.index_fields)
|
|
131
|
+
|
|
132
|
+
def delete_index(self, field: str) -> bool:
|
|
133
|
+
if self.schema is None:
|
|
134
|
+
raise ValueError("Cannot delete an index without an index.json")
|
|
135
|
+
if field not in self.indexes:
|
|
136
|
+
return False
|
|
137
|
+
self.index_fields = [name for name in self.index_fields if name != field]
|
|
138
|
+
del self.indexes[field]
|
|
139
|
+
self.schema["index_fields"] = list(self.index_fields)
|
|
140
|
+
self._write_json_atomic(self.schema_path, self.schema)
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
def insert(self, pk: str, doc: dict[str, Any]) -> dict[str, Any]:
|
|
144
|
+
self._validate_key(pk)
|
|
145
|
+
path = self._doc_path(pk)
|
|
146
|
+
if path.exists():
|
|
147
|
+
raise ValueError(f"Primary key already exists: {pk}")
|
|
148
|
+
if self.schema is not None:
|
|
149
|
+
self._add_indexes(pk, doc)
|
|
150
|
+
self._write_json_atomic(path, doc)
|
|
151
|
+
return doc
|
|
152
|
+
|
|
153
|
+
def update(self, pk: str, doc: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
self._validate_key(pk)
|
|
155
|
+
path = self._doc_path(pk)
|
|
156
|
+
if not path.exists():
|
|
157
|
+
raise KeyError(f"Document not found: {pk}")
|
|
158
|
+
if self.schema is not None:
|
|
159
|
+
old_doc = self.get(pk)
|
|
160
|
+
self._remove_indexes(pk, old_doc)
|
|
161
|
+
self._add_indexes(pk, doc)
|
|
162
|
+
self._write_json_atomic(path, doc)
|
|
163
|
+
return doc
|
|
164
|
+
|
|
165
|
+
def delete(self, pk: str) -> None:
|
|
166
|
+
self._validate_key(pk)
|
|
167
|
+
path = self._doc_path(pk)
|
|
168
|
+
if not path.exists():
|
|
169
|
+
raise KeyError(f"Document not found: {pk}")
|
|
170
|
+
if self.schema is not None:
|
|
171
|
+
doc = self.get(pk)
|
|
172
|
+
self._remove_indexes(pk, doc)
|
|
173
|
+
path.unlink()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jsondocstore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple JSON document storage on disk
|
|
5
|
+
Author-email: Mirko Ortensi <mirko.ortensi@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# JsonDocStore
|
|
13
|
+
|
|
14
|
+
Simple JSON document storage on disk.
|
|
15
|
+
|
|
16
|
+
Each document is stored as one file named `<key>.json`. The document key is the filename stem, not a field inside the JSON body.
|
|
17
|
+
|
|
18
|
+
If you want to organize documents into different folders, use one `JsonDocStore` instance per folder. Each instance manages only the JSON files in its own directory.
|
|
19
|
+
|
|
20
|
+
## What It Does
|
|
21
|
+
|
|
22
|
+
- Stores one JSON document per file.
|
|
23
|
+
- Reads documents directly by filename.
|
|
24
|
+
- Uses optional in-memory indexes defined in `index.json`.
|
|
25
|
+
- Provides an interactive CLI in `cli.py`.
|
|
26
|
+
|
|
27
|
+
## Data Model
|
|
28
|
+
|
|
29
|
+
- `get(key)` reads `<key>.json`.
|
|
30
|
+
- `insert(key, doc)` writes `<key>.json`.
|
|
31
|
+
- Valid keys may contain letters, digits, `.`, `_`, and `-`. No whitespace.
|
|
32
|
+
|
|
33
|
+
## Schema
|
|
34
|
+
|
|
35
|
+
`index.json` is optional.
|
|
36
|
+
|
|
37
|
+
- `create=True` creates the directory if needed. It does not create `index.json`.
|
|
38
|
+
- `index_fields` defines which fields are indexed in memory.
|
|
39
|
+
- `get()`, `insert()`, `delete()`, and `list_all()` work without `index.json`.
|
|
40
|
+
- `query_by()` only works when an index exists. Querying without an index, or on a non-indexed field, raises an error.
|
|
41
|
+
- Indexes work only on top-level document fields, not nested paths.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"index_fields": ["email", "status"]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For example, this works:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"email": "alice@example.com",
|
|
56
|
+
"profile": {
|
|
57
|
+
"city": "Rome"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- you can index `email`
|
|
63
|
+
- you cannot index `profile.city`
|
|
64
|
+
|
|
65
|
+
## CLI
|
|
66
|
+
|
|
67
|
+
Start the interactive shell:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
jsondocstore /path/to/store
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Commands:
|
|
74
|
+
|
|
75
|
+
- `list` prints document filenames only
|
|
76
|
+
- `listindexes` prints indexed fields, or `[]` if there is no `index.json`
|
|
77
|
+
- `queryby FIELD VALUE`
|
|
78
|
+
- `createindex FIELD`
|
|
79
|
+
- `deleteindex FIELD`
|
|
80
|
+
- `insert KEY JSON_DOCUMENT`
|
|
81
|
+
- `update KEY JSON_DOCUMENT`
|
|
82
|
+
- `delete KEY`
|
|
83
|
+
- `exit`
|
|
84
|
+
|
|
85
|
+
Example session:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
$ jsondocstore ./data
|
|
89
|
+
jsondocstore> insert user-1 '{"username": "alice", "password": "secret1", "role": "admin"}'
|
|
90
|
+
jsondocstore> insert user-2 '{"username": "bob", "password": "secret2", "role": "user"}'
|
|
91
|
+
jsondocstore> insert user-3 '{"username": "carol", "password": "secret3", "role": "user"}'
|
|
92
|
+
jsondocstore> update user-2 '{"username": "bob", "password": "secret2", "role": "admin"}'
|
|
93
|
+
jsondocstore> list
|
|
94
|
+
[
|
|
95
|
+
"user-1.json",
|
|
96
|
+
"user-2.json",
|
|
97
|
+
"user-3.json"
|
|
98
|
+
]
|
|
99
|
+
jsondocstore> createindex role
|
|
100
|
+
jsondocstore> queryby role user
|
|
101
|
+
[
|
|
102
|
+
{
|
|
103
|
+
"password": "secret2",
|
|
104
|
+
"role": "user",
|
|
105
|
+
"username": "bob"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"password": "secret3",
|
|
109
|
+
"role": "user",
|
|
110
|
+
"username": "carol"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Library
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from jsondocstore import JsonDocStore
|
|
119
|
+
|
|
120
|
+
store = JsonDocStore("./data", create=True)
|
|
121
|
+
store.insert("user-1", {"username": "alice", "password": "secret1", "role": "admin"})
|
|
122
|
+
store.insert("user-2", {"username": "bob", "password": "secret2", "role": "user"})
|
|
123
|
+
store.insert("user-3", {"username": "carol", "password": "secret3", "role": "user"})
|
|
124
|
+
store.update("user-2", {"username": "bob", "password": "secret2", "role": "admin"})
|
|
125
|
+
names = store.list_all()
|
|
126
|
+
store.create_index("role")
|
|
127
|
+
admins = store.query_by("role", "admin")
|
|
128
|
+
doc = store.get("user-1")
|
|
129
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/jsondocstore/__init__.py
|
|
5
|
+
src/jsondocstore/cli.py
|
|
6
|
+
src/jsondocstore/core.py
|
|
7
|
+
src/jsondocstore.egg-info/PKG-INFO
|
|
8
|
+
src/jsondocstore.egg-info/SOURCES.txt
|
|
9
|
+
src/jsondocstore.egg-info/dependency_links.txt
|
|
10
|
+
src/jsondocstore.egg-info/entry_points.txt
|
|
11
|
+
src/jsondocstore.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jsondocstore
|