PersistentObjects 0.1.6__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.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: PersistentObjects
3
+ Version: 0.2.0
4
+ Summary: JSON-backed attribute-persistent object with namespace support
5
+ Author: Tornado300
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # PersistentObjects
15
+
16
+ A simple Python library that provides the `PersistentObject` class, which automatically saves all its attributes to a JSON file. Attributes are saved as soon as they are set.
17
+
18
+ > [!IMPORTANT]
19
+ > In-place mutations like `.append()`, `.insert()`, `.extend()`, etc. will **not** automatically save!
20
+ > Create a temp variable, mutate it, then reassign it back:
21
+ > ```python
22
+ > temp = pobject.my_list
23
+ > temp.append("new item")
24
+ > pobject.my_list = temp # triggers save
25
+ > ```
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install PersistentObjects
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ from PersistentObjects import PersistentObject
37
+
38
+ pobject = PersistentObject("save.json")
39
+
40
+ # attributes are saved to the json file immediately
41
+ pobject.first_value = 10
42
+ pobject.second_value = "foo"
43
+
44
+ # namespaces create sub-dicts in the json file
45
+ pobject.namespace("test_namespace")
46
+ pobject.test_namespace.third_value = [10, 5]
47
+
48
+ # or save the namespace to a variable
49
+ namespace = pobject.namespace("other_namespace")
50
+ namespace.value = 42
51
+
52
+ # suffix with '_' to make an attribute non-persistent (normal python attribute)
53
+ pobject.temp_ = True
54
+
55
+ # delete a persistent attribute
56
+ del pobject.first_value
57
+ ```
58
+
59
+ ## Namespaces
60
+
61
+ Use `namespace()` to create a named section within the JSON file. After calling `namespace()`, you can access it via dot notation. Namespaces can be nested arbitrarily deep.
62
+
63
+ ```python
64
+ # create and access via dot notation
65
+ pobject.namespace("ui")
66
+ pobject.ui.theme = "dark"
67
+ pobject.ui.font_size = 14
68
+
69
+ # nested namespaces
70
+ pobject.namespace("ui").namespace("colors")
71
+ pobject.ui.colors.primary = "#ff0000"
72
+ pobject.ui.colors.accent = "#0000ff"
73
+
74
+ # or chain it
75
+ colors = pobject.namespace("ui").namespace("colors")
76
+ colors.primary = "#ff0000"
77
+ ```
78
+
79
+ > [!NOTE]
80
+ > You must call `.namespace()` at least once before using dot-access. This is by design — without it, accessing a nonexistent attribute like `pobject.typo` would silently create an empty namespace instead of raising an `AttributeError`.
81
+
82
+ ## Supported types
83
+
84
+ Beyond the standard JSON types (`str`, `int`, `float`, `bool`, `list`, `dict`, `None`), the following Python types are automatically encoded and decoded:
85
+
86
+ | Type | JSON representation |
87
+ |---|---|
88
+ | `tuple` | `{"__type__": "tuple", "__value__": [...]}` |
89
+ | `set` | `{"__type__": "set", "__value__": [...]}` |
90
+ | `frozenset` | `{"__type__": "frozenset", "__value__": [...]}` |
91
+ | `bytes` | `{"__type__": "bytes", "__value__": "<base64>"}` |
92
+ | `datetime` | `{"__type__": "datetime", "__value__": "<isoformat>"}` |
93
+ | `date` | `{"__type__": "date", "__value__": "<isoformat>"}` |
94
+ | `time` | `{"__type__": "time", "__value__": "<isoformat>"}` |
95
+
96
+ Nested structures work too (e.g. a list of tuples, a set of tuples).
97
+
98
+ ```python
99
+ from datetime import datetime
100
+
101
+ pobject.my_set = {1, 2, 3}
102
+ pobject.my_tuple = ("a", "b", "c")
103
+ pobject.my_bytes = b"hello"
104
+ pobject.my_datetime = datetime(2025, 6, 15, 12, 30)
105
+ ```
106
+
107
+ ## Type enforcement
108
+
109
+ Use `settype()` to register a type constraint on an attribute. Assigning a value of the wrong type raises a `TypeError`.
110
+
111
+ ```python
112
+ from typing import Any
113
+
114
+ pobject.settype("count", int)
115
+ pobject.count = 5 # ok
116
+ pobject.count = "five" # TypeError
117
+
118
+ # union types (Python 3.10+)
119
+ pobject.settype("value", int | None)
120
+ pobject.value = None # ok
121
+
122
+ # tuple of types
123
+ pobject.settype("multi", (int, str))
124
+
125
+ # Any removes the constraint
126
+ pobject.settype("count", Any)
127
+
128
+ # also works on namespaces
129
+ namespace.settype("volume", int | float)
130
+ ```
131
+
132
+ ## Defaults
133
+
134
+ Use `setdefault()` to set a value only if the attribute doesn't already exist. It also registers the value for `reset()`. An optional `type` parameter combines setting a default with type enforcement.
135
+
136
+ ```python
137
+ pobject.setdefault("name", "default_name")
138
+ pobject.setdefault("count", 0, type=int) # sets default and enforces type
139
+
140
+ pobject.count = 99
141
+ pobject.reset("count") # back to 0
142
+ pobject.reset() # resets all attributes that have a registered default
143
+ ```
@@ -0,0 +1,383 @@
1
+ import base64, json, os, threading, types
2
+ from datetime import datetime, date, time as time_
3
+ from typing import Any
4
+
5
+ _TYPE_TAG = "__type__"
6
+ _VALUE_TAG = "__value__"
7
+ _TAGGED_TYPES = {"tuple", "set", "frozenset", "bytes", "datetime", "date", "time"}
8
+
9
+
10
+ def _encode(value):
11
+ """Recursively convert non-JSON-serializable types into tagged dicts."""
12
+ if isinstance(value, dict):
13
+ return {k: _encode(v) for k, v in value.items()}
14
+ if isinstance(value, list):
15
+ return [_encode(x) for x in value]
16
+ if isinstance(value, tuple):
17
+ return {_TYPE_TAG: "tuple", _VALUE_TAG: [_encode(x) for x in value]}
18
+ if isinstance(value, set):
19
+ return {_TYPE_TAG: "set", _VALUE_TAG: [_encode(x) for x in value]}
20
+ if isinstance(value, frozenset):
21
+ return {_TYPE_TAG: "frozenset", _VALUE_TAG: [_encode(x) for x in value]}
22
+ if isinstance(value, bytes):
23
+ return {_TYPE_TAG: "bytes", _VALUE_TAG: base64.b64encode(value).decode("ascii")}
24
+ if isinstance(value, datetime): # must be before date (datetime is subclass of date)
25
+ return {_TYPE_TAG: "datetime", _VALUE_TAG: value.isoformat()}
26
+ if isinstance(value, date):
27
+ return {_TYPE_TAG: "date", _VALUE_TAG: value.isoformat()}
28
+ if isinstance(value, time_):
29
+ return {_TYPE_TAG: "time", _VALUE_TAG: value.isoformat()}
30
+ return value
31
+
32
+
33
+ def _decode_hook(obj):
34
+ """json.load object_hook: convert tagged dicts back to Python types."""
35
+ if len(obj) == 2 and obj.get(_TYPE_TAG) in _TAGGED_TYPES and _VALUE_TAG in obj:
36
+ t, v = obj[_TYPE_TAG], obj[_VALUE_TAG]
37
+ if t == "tuple":
38
+ return tuple(v)
39
+ if t == "set":
40
+ return set(v)
41
+ if t == "frozenset":
42
+ return frozenset(v)
43
+ if t == "bytes":
44
+ return base64.b64decode(v)
45
+ if t == "datetime":
46
+ return datetime.fromisoformat(v)
47
+ if t == "date":
48
+ return date.fromisoformat(v)
49
+ if t == "time":
50
+ return time_.fromisoformat(v)
51
+ return obj
52
+
53
+
54
+ class _PersistentState:
55
+ _instances = {}
56
+
57
+ def __new__(cls, path):
58
+ path = os.path.abspath(path)
59
+
60
+ if path in cls._instances:
61
+ return cls._instances[path]
62
+
63
+ instance = super().__new__(cls)
64
+ cls._instances[path] = instance
65
+ return instance
66
+
67
+ def __init__(self, path: str) -> None:
68
+ # Prevent re-initialization when __new__ returns an existing instance
69
+ if hasattr(self, "_initialized"):
70
+ return
71
+
72
+ self._initialized = True
73
+ self._path = os.path.abspath(path)
74
+ self._lock = threading.Lock()
75
+ self._data = {}
76
+ self._defaults = {}
77
+ self._types = {}
78
+ self._load()
79
+
80
+ def _load(self) -> None:
81
+ if os.path.exists(self._path):
82
+ try:
83
+ with open(self._path, "r", encoding="utf-8") as f:
84
+ self._data = json.load(f, object_hook=_decode_hook)
85
+ except Exception:
86
+ self._data = {}
87
+
88
+ def _save(self) -> None:
89
+ tmp = self._path + ".tmp"
90
+ with open(tmp, "w", encoding="utf-8") as f:
91
+ json.dump(_encode(self._data), f, indent=2)
92
+ os.replace(tmp, self._path)
93
+
94
+ def __getattr__(self, name) -> "Any | _Namespace":
95
+ if name.startswith("_"):
96
+ raise AttributeError(f"Cannot access private attribute '{name}'")
97
+ if name.endswith("_"):
98
+ try:
99
+ return super().__getattribute__(name[:-1])
100
+ except AttributeError:
101
+ raise AttributeError(
102
+ f"'{self.__class__.__name__}' object has no attribute '{name}'.\n Notice: Attribute '{name}' is not persistent!"
103
+ )
104
+ try:
105
+ value = self._data[name]
106
+ # If it's a dict, return it as a namespace
107
+ if isinstance(value, dict):
108
+ return _Namespace(self, name)
109
+ return value
110
+ except KeyError:
111
+ raise AttributeError(
112
+ f"'{type(self).__name__}' object has no attribute '{name}'"
113
+ )
114
+
115
+ @staticmethod
116
+ def _check_type(name: str, value: Any, expected: Any) -> None:
117
+ if expected is None:
118
+ expected = type(None)
119
+ elif isinstance(expected, tuple):
120
+ expected = tuple(type(None) if t is None else t for t in expected)
121
+ if not isinstance(value, expected):
122
+ if isinstance(expected, types.UnionType):
123
+ type_name = str(expected)
124
+ elif isinstance(expected, tuple):
125
+ type_name = " | ".join(t.__name__ for t in expected)
126
+ else:
127
+ type_name = expected.__name__
128
+ raise TypeError(
129
+ f"Attribute '{name}' expected type '{type_name}', got '{type(value).__name__}'"
130
+ )
131
+
132
+ def __setattr__(self, name: str, value: Any) -> None:
133
+ if name.startswith("_"):
134
+ super().__setattr__(name, value)
135
+ return
136
+
137
+ if name.endswith("_"):
138
+ super().__setattr__(name[:-1], value)
139
+ return
140
+
141
+ if name in self._types:
142
+ self._check_type(name, value, self._types[name])
143
+
144
+ with self._lock:
145
+ self._data[name] = value
146
+ self._save()
147
+
148
+ def __delattr__(self, name: str) -> None:
149
+ if name.startswith("_"):
150
+ super().__delattr__(name)
151
+ return
152
+ if name.endswith("_"):
153
+ super().__delattr__(name[:-1])
154
+ return
155
+ with self._lock:
156
+ if name not in self._data:
157
+ raise AttributeError(
158
+ f"'{type(self).__name__}' object has no attribute '{name}'"
159
+ )
160
+ del self._data[name]
161
+ self._types.pop(name, None)
162
+ self._defaults.pop(name, None)
163
+ self._save()
164
+
165
+ def __repr__(self) -> str:
166
+ return f"{type(self).__name__}({self._path!r}, {self._data})"
167
+
168
+
169
+ class _Namespace:
170
+ _instances: dict = {}
171
+
172
+ def __new__(cls, root: _PersistentState, *path: str) -> "_Namespace":
173
+ instance_key = (id(root), path)
174
+
175
+ if instance_key in cls._instances:
176
+ return cls._instances[instance_key]
177
+
178
+ instance = super().__new__(cls)
179
+ cls._instances[instance_key] = instance
180
+ return instance
181
+
182
+ def __init__(self, root: _PersistentState, *path: str) -> None:
183
+ if hasattr(self, "_initialized"):
184
+ return
185
+
186
+ self._initialized = True
187
+ self._root = root
188
+ self._path = path
189
+ self._defaults: dict = {}
190
+ self._types: dict = {}
191
+
192
+ def _get_bucket(self, create: bool = False) -> dict:
193
+ """Navigate the path into root._data, returning the nested dict."""
194
+ data = self._root._data
195
+ for key in self._path:
196
+ if create:
197
+ data = data.setdefault(key, {})
198
+ else:
199
+ data = data[key]
200
+ return data
201
+
202
+ def __getattr__(self, name: str) -> "Any | _Namespace":
203
+ if name.startswith("_"):
204
+ raise AttributeError(name)
205
+ if name.endswith("_"):
206
+ try:
207
+ return super().__getattribute__(name[:-1])
208
+ except AttributeError:
209
+ raise AttributeError(
210
+ f"'{self.__class__.__name__}' object has no attribute '{name}'.\n Notice: Attribute '{name}' is not persistent!"
211
+ )
212
+ try:
213
+ value = self._get_bucket()[name]
214
+ if isinstance(value, dict):
215
+ return _Namespace(self._root, *self._path, name)
216
+ return value
217
+ except KeyError:
218
+ raise AttributeError(name)
219
+
220
+ def __setattr__(self, name: str, value: Any) -> None:
221
+ if name.startswith("_"):
222
+ super().__setattr__(name, value)
223
+ return
224
+
225
+ if name.endswith("_"):
226
+ super().__setattr__(name[:-1], value)
227
+ return
228
+
229
+ if name in self._types:
230
+ _PersistentState._check_type(name, value, self._types[name])
231
+
232
+ with self._root._lock:
233
+ bucket = self._get_bucket(create=True)
234
+ bucket[name] = value
235
+ self._root._save()
236
+
237
+ def __delattr__(self, name: str) -> None:
238
+ if name.startswith("_"):
239
+ super().__delattr__(name)
240
+ return
241
+ if name.endswith("_"):
242
+ super().__delattr__(name[:-1])
243
+ return
244
+ with self._root._lock:
245
+ try:
246
+ bucket = self._get_bucket()
247
+ except KeyError:
248
+ raise AttributeError(
249
+ f"'{type(self).__name__}' object has no attribute '{name}'"
250
+ )
251
+ if name not in bucket:
252
+ raise AttributeError(
253
+ f"'{type(self).__name__}' object has no attribute '{name}'"
254
+ )
255
+ del bucket[name]
256
+ self._types.pop(name, None)
257
+ self._defaults.pop(name, None)
258
+ self._root._save()
259
+
260
+ def __repr__(self) -> str:
261
+ try:
262
+ data = self._get_bucket()
263
+ except KeyError:
264
+ data = {}
265
+ path = ".".join(self._path)
266
+ return f"Namespace({path!r}, {data})"
267
+
268
+ def namespace(self, name: str) -> "_Namespace":
269
+ """Return a nested sub-namespace."""
270
+ with self._root._lock:
271
+ bucket = self._get_bucket(create=True)
272
+ if name not in bucket:
273
+ bucket[name] = {}
274
+ self._root._save()
275
+ return _Namespace(self._root, *self._path, name)
276
+
277
+ def settype(self, name: str, type_hint: Any) -> None:
278
+ """Register a type constraint for an attribute.
279
+
280
+ Accepted: a type, tuple of types, union type (int | str), None (means NoneType), or Any (removes constraint).
281
+ If the attribute already has a value, it is validated immediately.
282
+ """
283
+ if type_hint is Any:
284
+ self._types.pop(name, None)
285
+ return
286
+ self._types[name] = type_hint
287
+ try:
288
+ bucket = self._get_bucket()
289
+ if name in bucket:
290
+ _PersistentState._check_type(name, bucket[name], type_hint)
291
+ except KeyError:
292
+ pass
293
+
294
+ def setdefault(self, name: str, value: Any, type: Any = None) -> Any:
295
+ """Set a value only if the key doesn't already exist. Registers the value as the default for reset().
296
+
297
+ If type is provided, also registers a type constraint via settype().
298
+ """
299
+ if type is not None:
300
+ self.settype(name, type)
301
+ _PersistentState._check_type(name, value, type if type is not Any else value.__class__)
302
+ self._defaults[name] = value
303
+ with self._root._lock:
304
+ bucket = self._get_bucket(create=True)
305
+ if name not in bucket:
306
+ bucket[name] = value
307
+ self._root._save()
308
+ return self._get_bucket()[name]
309
+
310
+ def reset(self, name: str | None = None) -> None:
311
+ """Reset a key to its default value, or all keys if no name is given.
312
+
313
+ Raises KeyError if the key has no registered default.
314
+ """
315
+ with self._root._lock:
316
+ bucket = self._get_bucket(create=True)
317
+ if name is not None:
318
+ if name not in self._defaults:
319
+ raise KeyError(
320
+ f"No default registered for '{name}'. Use setdefault() first."
321
+ )
322
+ bucket[name] = self._defaults[name]
323
+ else:
324
+ for key, value in self._defaults.items():
325
+ bucket[key] = value
326
+ self._root._save()
327
+
328
+
329
+ class PersistentObject(_PersistentState):
330
+ def namespace(self, name: str) -> _Namespace:
331
+ with self._lock:
332
+ if name not in self._data:
333
+ self._data[name] = {}
334
+ self._save()
335
+ return _Namespace(self, name)
336
+
337
+ def settype(self, name: str, type_hint: Any) -> None:
338
+ """Register a type constraint for an attribute.
339
+
340
+ Accepted: a type, tuple of types, union type (int | str), None (means NoneType), or Any (removes constraint).
341
+ If the attribute already has a value, it is validated immediately.
342
+ """
343
+ if type_hint is Any:
344
+ self._types.pop(name, None)
345
+ return
346
+ self._types[name] = type_hint
347
+ if name in self._data:
348
+ self._check_type(name, self._data[name], type_hint)
349
+
350
+ def setdefault(self, name: str, value: Any, type: Any = None) -> Any:
351
+ """Set a value only if the key doesn't already exist. Registers the value as the default for reset().
352
+
353
+ If type is provided, also registers a type constraint via settype().
354
+ """
355
+ if type is not None:
356
+ self.settype(name, type)
357
+ self._check_type(name, value, type if type is not Any else value.__class__)
358
+ self._defaults[name] = value
359
+ with self._lock:
360
+ if name not in self._data:
361
+ self._data[name] = value
362
+ self._save()
363
+ return self._data[name]
364
+
365
+ def reset(self, name: str | None = None) -> None:
366
+ """Reset a key to its default value, or all keys if no name is given.
367
+
368
+ Raises KeyError if the key has no registered default.
369
+ """
370
+ with self._lock:
371
+ if name is not None:
372
+ if name not in self._defaults:
373
+ raise KeyError(
374
+ f"No default registered for '{name}'. Use setdefault() first."
375
+ )
376
+ self._data[name] = self._defaults[name]
377
+ else:
378
+ for key, value in self._defaults.items():
379
+ self._data[key] = value
380
+ self._save()
381
+
382
+
383
+ __all__ = ["PersistentObject"]
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: PersistentObjects
3
+ Version: 0.2.0
4
+ Summary: JSON-backed attribute-persistent object with namespace support
5
+ Author: Tornado300
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # PersistentObjects
15
+
16
+ A simple Python library that provides the `PersistentObject` class, which automatically saves all its attributes to a JSON file. Attributes are saved as soon as they are set.
17
+
18
+ > [!IMPORTANT]
19
+ > In-place mutations like `.append()`, `.insert()`, `.extend()`, etc. will **not** automatically save!
20
+ > Create a temp variable, mutate it, then reassign it back:
21
+ > ```python
22
+ > temp = pobject.my_list
23
+ > temp.append("new item")
24
+ > pobject.my_list = temp # triggers save
25
+ > ```
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install PersistentObjects
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ from PersistentObjects import PersistentObject
37
+
38
+ pobject = PersistentObject("save.json")
39
+
40
+ # attributes are saved to the json file immediately
41
+ pobject.first_value = 10
42
+ pobject.second_value = "foo"
43
+
44
+ # namespaces create sub-dicts in the json file
45
+ pobject.namespace("test_namespace")
46
+ pobject.test_namespace.third_value = [10, 5]
47
+
48
+ # or save the namespace to a variable
49
+ namespace = pobject.namespace("other_namespace")
50
+ namespace.value = 42
51
+
52
+ # suffix with '_' to make an attribute non-persistent (normal python attribute)
53
+ pobject.temp_ = True
54
+
55
+ # delete a persistent attribute
56
+ del pobject.first_value
57
+ ```
58
+
59
+ ## Namespaces
60
+
61
+ Use `namespace()` to create a named section within the JSON file. After calling `namespace()`, you can access it via dot notation. Namespaces can be nested arbitrarily deep.
62
+
63
+ ```python
64
+ # create and access via dot notation
65
+ pobject.namespace("ui")
66
+ pobject.ui.theme = "dark"
67
+ pobject.ui.font_size = 14
68
+
69
+ # nested namespaces
70
+ pobject.namespace("ui").namespace("colors")
71
+ pobject.ui.colors.primary = "#ff0000"
72
+ pobject.ui.colors.accent = "#0000ff"
73
+
74
+ # or chain it
75
+ colors = pobject.namespace("ui").namespace("colors")
76
+ colors.primary = "#ff0000"
77
+ ```
78
+
79
+ > [!NOTE]
80
+ > You must call `.namespace()` at least once before using dot-access. This is by design — without it, accessing a nonexistent attribute like `pobject.typo` would silently create an empty namespace instead of raising an `AttributeError`.
81
+
82
+ ## Supported types
83
+
84
+ Beyond the standard JSON types (`str`, `int`, `float`, `bool`, `list`, `dict`, `None`), the following Python types are automatically encoded and decoded:
85
+
86
+ | Type | JSON representation |
87
+ |---|---|
88
+ | `tuple` | `{"__type__": "tuple", "__value__": [...]}` |
89
+ | `set` | `{"__type__": "set", "__value__": [...]}` |
90
+ | `frozenset` | `{"__type__": "frozenset", "__value__": [...]}` |
91
+ | `bytes` | `{"__type__": "bytes", "__value__": "<base64>"}` |
92
+ | `datetime` | `{"__type__": "datetime", "__value__": "<isoformat>"}` |
93
+ | `date` | `{"__type__": "date", "__value__": "<isoformat>"}` |
94
+ | `time` | `{"__type__": "time", "__value__": "<isoformat>"}` |
95
+
96
+ Nested structures work too (e.g. a list of tuples, a set of tuples).
97
+
98
+ ```python
99
+ from datetime import datetime
100
+
101
+ pobject.my_set = {1, 2, 3}
102
+ pobject.my_tuple = ("a", "b", "c")
103
+ pobject.my_bytes = b"hello"
104
+ pobject.my_datetime = datetime(2025, 6, 15, 12, 30)
105
+ ```
106
+
107
+ ## Type enforcement
108
+
109
+ Use `settype()` to register a type constraint on an attribute. Assigning a value of the wrong type raises a `TypeError`.
110
+
111
+ ```python
112
+ from typing import Any
113
+
114
+ pobject.settype("count", int)
115
+ pobject.count = 5 # ok
116
+ pobject.count = "five" # TypeError
117
+
118
+ # union types (Python 3.10+)
119
+ pobject.settype("value", int | None)
120
+ pobject.value = None # ok
121
+
122
+ # tuple of types
123
+ pobject.settype("multi", (int, str))
124
+
125
+ # Any removes the constraint
126
+ pobject.settype("count", Any)
127
+
128
+ # also works on namespaces
129
+ namespace.settype("volume", int | float)
130
+ ```
131
+
132
+ ## Defaults
133
+
134
+ Use `setdefault()` to set a value only if the attribute doesn't already exist. It also registers the value for `reset()`. An optional `type` parameter combines setting a default with type enforcement.
135
+
136
+ ```python
137
+ pobject.setdefault("name", "default_name")
138
+ pobject.setdefault("count", 0, type=int) # sets default and enforces type
139
+
140
+ pobject.count = 99
141
+ pobject.reset("count") # back to 0
142
+ pobject.reset() # resets all attributes that have a registered default
143
+ ```
@@ -0,0 +1,130 @@
1
+ # PersistentObjects
2
+
3
+ A simple Python library that provides the `PersistentObject` class, which automatically saves all its attributes to a JSON file. Attributes are saved as soon as they are set.
4
+
5
+ > [!IMPORTANT]
6
+ > In-place mutations like `.append()`, `.insert()`, `.extend()`, etc. will **not** automatically save!
7
+ > Create a temp variable, mutate it, then reassign it back:
8
+ > ```python
9
+ > temp = pobject.my_list
10
+ > temp.append("new item")
11
+ > pobject.my_list = temp # triggers save
12
+ > ```
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install PersistentObjects
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ from PersistentObjects import PersistentObject
24
+
25
+ pobject = PersistentObject("save.json")
26
+
27
+ # attributes are saved to the json file immediately
28
+ pobject.first_value = 10
29
+ pobject.second_value = "foo"
30
+
31
+ # namespaces create sub-dicts in the json file
32
+ pobject.namespace("test_namespace")
33
+ pobject.test_namespace.third_value = [10, 5]
34
+
35
+ # or save the namespace to a variable
36
+ namespace = pobject.namespace("other_namespace")
37
+ namespace.value = 42
38
+
39
+ # suffix with '_' to make an attribute non-persistent (normal python attribute)
40
+ pobject.temp_ = True
41
+
42
+ # delete a persistent attribute
43
+ del pobject.first_value
44
+ ```
45
+
46
+ ## Namespaces
47
+
48
+ Use `namespace()` to create a named section within the JSON file. After calling `namespace()`, you can access it via dot notation. Namespaces can be nested arbitrarily deep.
49
+
50
+ ```python
51
+ # create and access via dot notation
52
+ pobject.namespace("ui")
53
+ pobject.ui.theme = "dark"
54
+ pobject.ui.font_size = 14
55
+
56
+ # nested namespaces
57
+ pobject.namespace("ui").namespace("colors")
58
+ pobject.ui.colors.primary = "#ff0000"
59
+ pobject.ui.colors.accent = "#0000ff"
60
+
61
+ # or chain it
62
+ colors = pobject.namespace("ui").namespace("colors")
63
+ colors.primary = "#ff0000"
64
+ ```
65
+
66
+ > [!NOTE]
67
+ > You must call `.namespace()` at least once before using dot-access. This is by design — without it, accessing a nonexistent attribute like `pobject.typo` would silently create an empty namespace instead of raising an `AttributeError`.
68
+
69
+ ## Supported types
70
+
71
+ Beyond the standard JSON types (`str`, `int`, `float`, `bool`, `list`, `dict`, `None`), the following Python types are automatically encoded and decoded:
72
+
73
+ | Type | JSON representation |
74
+ |---|---|
75
+ | `tuple` | `{"__type__": "tuple", "__value__": [...]}` |
76
+ | `set` | `{"__type__": "set", "__value__": [...]}` |
77
+ | `frozenset` | `{"__type__": "frozenset", "__value__": [...]}` |
78
+ | `bytes` | `{"__type__": "bytes", "__value__": "<base64>"}` |
79
+ | `datetime` | `{"__type__": "datetime", "__value__": "<isoformat>"}` |
80
+ | `date` | `{"__type__": "date", "__value__": "<isoformat>"}` |
81
+ | `time` | `{"__type__": "time", "__value__": "<isoformat>"}` |
82
+
83
+ Nested structures work too (e.g. a list of tuples, a set of tuples).
84
+
85
+ ```python
86
+ from datetime import datetime
87
+
88
+ pobject.my_set = {1, 2, 3}
89
+ pobject.my_tuple = ("a", "b", "c")
90
+ pobject.my_bytes = b"hello"
91
+ pobject.my_datetime = datetime(2025, 6, 15, 12, 30)
92
+ ```
93
+
94
+ ## Type enforcement
95
+
96
+ Use `settype()` to register a type constraint on an attribute. Assigning a value of the wrong type raises a `TypeError`.
97
+
98
+ ```python
99
+ from typing import Any
100
+
101
+ pobject.settype("count", int)
102
+ pobject.count = 5 # ok
103
+ pobject.count = "five" # TypeError
104
+
105
+ # union types (Python 3.10+)
106
+ pobject.settype("value", int | None)
107
+ pobject.value = None # ok
108
+
109
+ # tuple of types
110
+ pobject.settype("multi", (int, str))
111
+
112
+ # Any removes the constraint
113
+ pobject.settype("count", Any)
114
+
115
+ # also works on namespaces
116
+ namespace.settype("volume", int | float)
117
+ ```
118
+
119
+ ## Defaults
120
+
121
+ Use `setdefault()` to set a value only if the attribute doesn't already exist. It also registers the value for `reset()`. An optional `type` parameter combines setting a default with type enforcement.
122
+
123
+ ```python
124
+ pobject.setdefault("name", "default_name")
125
+ pobject.setdefault("count", 0, type=int) # sets default and enforces type
126
+
127
+ pobject.count = 99
128
+ pobject.reset("count") # back to 0
129
+ pobject.reset() # resets all attributes that have a registered default
130
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PersistentObjects"
7
- version = "0.1.6"
7
+ version = "0.2.0"
8
8
  description = "JSON-backed attribute-persistent object with namespace support"
9
9
  authors = [
10
10
  { name="Tornado300" }
@@ -1,41 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: PersistentObjects
3
- Version: 0.1.6
4
- Summary: JSON-backed attribute-persistent object with namespace support
5
- Author: Tornado300
6
- License-Expression: MIT
7
- Classifier: Programming Language :: Python :: 3
8
- Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.10
10
- Description-Content-Type: text/markdown
11
- License-File: LICENSE
12
- Dynamic: license-file
13
-
14
- This is a very simple lib that provides the PersistantObject class, which saves all its attributes to the given json file. Exceptions are attributes suffixed with '_'.
15
- Attributes are saved as soon as they are set.
16
-
17
- [!IMPORTANT]
18
- > morphing actions like appending, inserting, extending, etc. will not automaticly save!
19
- > so its recomended to create a temp var, then append to it and at the end set the PersistantObject attr to the temp var.
20
-
21
- with namespace(NAME) seperate sub namespaces can be created inside the object.
22
-
23
-
24
- ## Usage example:
25
- ```python
26
-
27
- from PersistantObject import PersistantObject
28
-
29
- pobject = PersistantObject("save.json")
30
-
31
- pobject.first_value = 10
32
- pobject.second_value = "foo"
33
-
34
- namespace = pobject.namespace("test_namespace") # creates a new namespace (new subdict in json)
35
- namespace.third_value = [10, 5]
36
-
37
-
38
- print(pobject.test_namespace.third_value) # alternate way to access namespaces
39
-
40
- pobject.temp_ = True # suffixed with '_' so it will not be saved and behaves like a normal attribute.
41
- ```
@@ -1,135 +0,0 @@
1
- import json, os, threading
2
-
3
-
4
- class _PersistentState:
5
- _instances = {}
6
-
7
- def __new__(cls, path):
8
- path = os.path.abspath(path)
9
-
10
- if path in cls._instances:
11
- return cls._instances[path]
12
-
13
- instance = super().__new__(cls)
14
- cls._instances[path] = instance
15
- return instance
16
-
17
- def __init__(self, path):
18
- # Prevent re-initialization when __new__ returns an existing instance
19
- if hasattr(self, "_initialized"):
20
- return
21
-
22
- self._initialized = True
23
- self._path = os.path.abspath(path)
24
- self._lock = threading.Lock()
25
- self._data = {}
26
- self._load()
27
-
28
- def _load(self):
29
- if os.path.exists(self._path):
30
- try:
31
- with open(self._path, "r", encoding="utf-8") as f:
32
- self._data = json.load(f)
33
- except Exception:
34
- self._data = {}
35
-
36
- def _save(self):
37
- tmp = self._path + ".tmp"
38
- with open(tmp, "w", encoding="utf-8") as f:
39
- json.dump(self._data, f, indent=2)
40
- os.replace(tmp, self._path)
41
-
42
- def __getattr__(self, name):
43
- if name.startswith("_"):
44
- raise AttributeError(f"Cannot access private attribute '{name}'")
45
- if name.endswith("_"):
46
- try:
47
- return super().__getattribute__(name[:-1])
48
- except AttributeError:
49
- raise AttributeError(
50
- f"'{self.__class__.__name__}' object has no attribute '{name}'.\n Notice: Attribute '{name}' is not persistent!"
51
- )
52
- try:
53
- value = self._data[name]
54
- # If it's a dict, return it as a namespace
55
- if isinstance(value, dict):
56
- return _Namespace(self, name)
57
- return value
58
- except KeyError:
59
- raise AttributeError(
60
- f"'{type(self).__name__}' object has no attribute '{name}'"
61
- )
62
-
63
- def __setattr__(self, name, value):
64
- if name.startswith("_"):
65
- super().__setattr__(name, value)
66
- return
67
-
68
- if name.endswith("_"):
69
- super().__setattr__(name[:-1], value)
70
- return
71
-
72
- with self._lock:
73
- self._data[name] = value
74
- self._save()
75
-
76
-
77
- class _Namespace:
78
- _instances = {}
79
-
80
- def __new__(cls, root, key):
81
- # Create unique identifier for this namespace
82
- instance_key = (id(root), key)
83
-
84
- if instance_key in cls._instances:
85
- return cls._instances[instance_key]
86
-
87
- instance = super().__new__(cls)
88
- cls._instances[instance_key] = instance
89
- return instance
90
-
91
- def __init__(self, root, key):
92
- # Prevent re-initialization
93
- if hasattr(self, "_initialized"):
94
- return
95
-
96
- self._initialized = True
97
- self._root = root
98
- self._key = key
99
-
100
- def __getattr__(self, name):
101
- if name.startswith("_"):
102
- raise AttributeError(name)
103
- if name.endswith("_"):
104
- try:
105
- return super().__getattribute__(name[:-1])
106
- except AttributeError:
107
- raise AttributeError(
108
- f"'{self.__class__.__name__}' object has no attribute '{name}'.\n Notice: Attribute '{name}' is not persistent!"
109
- )
110
- try:
111
- return self._root._data[self._key][name]
112
- except KeyError:
113
- raise AttributeError(name)
114
-
115
- def __setattr__(self, name, value):
116
- if name.startswith("_"):
117
- super().__setattr__(name, value)
118
- return
119
-
120
- if name.endswith("_"):
121
- super().__setattr__(name[:-1], value)
122
- return
123
-
124
- with self._root._lock:
125
- bucket = self._root._data.setdefault(self._key, {})
126
- bucket[name] = value
127
- self._root._save()
128
-
129
-
130
- class PersistentObject(_PersistentState):
131
- def namespace(self, name) -> _Namespace:
132
- return _Namespace(self, name)
133
-
134
-
135
- __all__ = ["PersistentObject"]
@@ -1,41 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: PersistentObjects
3
- Version: 0.1.6
4
- Summary: JSON-backed attribute-persistent object with namespace support
5
- Author: Tornado300
6
- License-Expression: MIT
7
- Classifier: Programming Language :: Python :: 3
8
- Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.10
10
- Description-Content-Type: text/markdown
11
- License-File: LICENSE
12
- Dynamic: license-file
13
-
14
- This is a very simple lib that provides the PersistantObject class, which saves all its attributes to the given json file. Exceptions are attributes suffixed with '_'.
15
- Attributes are saved as soon as they are set.
16
-
17
- [!IMPORTANT]
18
- > morphing actions like appending, inserting, extending, etc. will not automaticly save!
19
- > so its recomended to create a temp var, then append to it and at the end set the PersistantObject attr to the temp var.
20
-
21
- with namespace(NAME) seperate sub namespaces can be created inside the object.
22
-
23
-
24
- ## Usage example:
25
- ```python
26
-
27
- from PersistantObject import PersistantObject
28
-
29
- pobject = PersistantObject("save.json")
30
-
31
- pobject.first_value = 10
32
- pobject.second_value = "foo"
33
-
34
- namespace = pobject.namespace("test_namespace") # creates a new namespace (new subdict in json)
35
- namespace.third_value = [10, 5]
36
-
37
-
38
- print(pobject.test_namespace.third_value) # alternate way to access namespaces
39
-
40
- pobject.temp_ = True # suffixed with '_' so it will not be saved and behaves like a normal attribute.
41
- ```
@@ -1,28 +0,0 @@
1
- This is a very simple lib that provides the PersistantObject class, which saves all its attributes to the given json file. Exceptions are attributes suffixed with '_'.
2
- Attributes are saved as soon as they are set.
3
-
4
- [!IMPORTANT]
5
- > morphing actions like appending, inserting, extending, etc. will not automaticly save!
6
- > so its recomended to create a temp var, then append to it and at the end set the PersistantObject attr to the temp var.
7
-
8
- with namespace(NAME) seperate sub namespaces can be created inside the object.
9
-
10
-
11
- ## Usage example:
12
- ```python
13
-
14
- from PersistantObject import PersistantObject
15
-
16
- pobject = PersistantObject("save.json")
17
-
18
- pobject.first_value = 10
19
- pobject.second_value = "foo"
20
-
21
- namespace = pobject.namespace("test_namespace") # creates a new namespace (new subdict in json)
22
- namespace.third_value = [10, 5]
23
-
24
-
25
- print(pobject.test_namespace.third_value) # alternate way to access namespaces
26
-
27
- pobject.temp_ = True # suffixed with '_' so it will not be saved and behaves like a normal attribute.
28
- ```