rich-object 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.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: rich-object
3
+ Version: 0.1.0
4
+ Summary: A Python package containing rich-object utilities
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: jsonpath-ng~=1.7
11
+ Requires-Dist: jinja2~=3.1
12
+ Requires-Dist: deepdiff~=9.0
13
+
14
+ # rich-object
15
+
16
+ A powerful dictionary wrapper subclass that enables dot-notation attribute access, automatic nested path creation (autovivification), recursive locking, template rendering, JSONPath query resolution, and deep structure diffing.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install rich-object
22
+ ```
23
+
24
+ ## Features & Examples
25
+
26
+ ### 1. Basic Usage & Dot Access
27
+ Create an `Object` from mappings or keyword arguments and access properties with standard dot notation.
28
+ ```python
29
+ from rich_object import Object
30
+
31
+ # Initialize using keywords or raw dictionaries
32
+ obj = Object(name="John", address={"city": "New York"})
33
+
34
+ print(obj.name) # -> "John"
35
+ print(obj.address.city) # -> "New York"
36
+ ```
37
+
38
+ ### 2. Autovivification
39
+ Missing nested directories/attributes are automatically initialized when accessed, resolving nested paths on the fly.
40
+ ```python
41
+ obj = Object()
42
+ obj.user.profile.settings.theme = "dark"
43
+
44
+ print(obj.to_dict())
45
+ # -> {'user': {'profile': {'settings': {'theme': 'dark'}}}}
46
+ ```
47
+
48
+ ### 3. Structural Locks
49
+ Prevent mutations (creation, updates, deletes) recursively across all nested dictionaries and lists by passing `lock=True`.
50
+ ```python
51
+ frozen = Object({"items": [1, 2], "user": {"id": 42}}, lock=True)
52
+
53
+ # Any mutation throws a TypeError
54
+ try:
55
+ frozen.user.id = 99
56
+ except TypeError as e:
57
+ print(e) # -> 'Object' is locked and cannot be modified
58
+
59
+ try:
60
+ frozen.items.append(3)
61
+ except TypeError as e:
62
+ print(e) # -> 'ObjectList' is locked and cannot be modified
63
+ ```
64
+
65
+ ### 4. Advanced Property Setting (`set`)
66
+ Assign values safely to deeply nested paths using dot notation and bracket indices. Intermediary dictionary keys and list slots expand automatically.
67
+ ```python
68
+ obj = Object()
69
+ obj.set("store.books[1].title", "Moby Dick")
70
+
71
+ print(obj.store.books[1].title) # -> "Moby Dick"
72
+ print(obj.store.books[0]) # -> None (automatically padded slot)
73
+ ```
74
+
75
+ ### 5. JSONPath Querying (`get`)
76
+ Query the data structure dynamically by passing JSONPath queries starting with `$`.
77
+ ```python
78
+ data = Object({
79
+ "store": {
80
+ "book": [
81
+ {"category": "fiction", "price": 8.95},
82
+ {"category": "reference", "price": 12.00}
83
+ ]
84
+ }
85
+ })
86
+
87
+ # Retrieve a single value
88
+ print(data.get("$.store.book[0].category")) # -> "fiction"
89
+
90
+ # Retrieve matching node value list
91
+ print(data.get("$..price")) # -> [8.95, 12.0]
92
+ ```
93
+
94
+ ### 6. Deep Merging (`+`)
95
+ Perform clean, recursive merges of two structures using the `+` operator. Nested lists are automatically concatenated, and conflicting keys default to the right-hand value.
96
+ ```python
97
+ obj1 = Object({"a": {"x": 1}, "b": [1, 2]})
98
+ obj2 = Object({"a": {"y": 2}, "b": [3, 4]})
99
+
100
+ res = obj1 + obj2
101
+ print(res.to_dict())
102
+ # -> {'a': {'x': 1, 'y': 2}, 'b': [1, 2, 3, 4]}
103
+ ```
104
+
105
+ ### 7. Template Rendering (`render`)
106
+ Render Jinja2 templates in all string values recursively across the entire structure (including nested dictionaries and lists).
107
+ ```python
108
+ obj = Object({
109
+ "first_name": "Jane",
110
+ "greeting": "Hello {{ first_name }}!",
111
+ "matrix": [["Welcome {{ first_name }}"]]
112
+ })
113
+
114
+ rendered = obj.render()
115
+ print(rendered.greeting) # -> "Hello Jane!"
116
+ print(rendered.matrix) # -> [["Welcome Jane"]]
117
+ ```
118
+
119
+ You can also pass entire modules or objects to the `render` method to use them inside your templates:
120
+ ```python
121
+ import datetime
122
+
123
+ obj = Object({
124
+ "year": "{{ dt.datetime(2026, 7, 6).year }}",
125
+ "future_date": "{{ (dt.datetime(2026, 7, 6) + dt.timedelta(days=5)).strftime('%Y-%m-%d') }}"
126
+ })
127
+
128
+ res = obj.render(dt=datetime)
129
+ print(res.year) # -> 2026
130
+ print(res.future_date) # -> "2026-07-11"
131
+ ```
132
+
133
+ ### 8. Structural Diffing (`diff`)
134
+ Identify differences between two objects using the built-in DeepDiff interface.
135
+ ```python
136
+ obj1 = Object({"a": 1, "b": 2})
137
+ obj2 = Object({"a": 1, "b": 3})
138
+
139
+ difference = obj1.diff(obj2)
140
+ print(difference)
141
+ # -> {'values_changed': {"root['b']": {'new_value': 3, 'old_value': 2}}}
142
+ ```
@@ -0,0 +1,129 @@
1
+ # rich-object
2
+
3
+ A powerful dictionary wrapper subclass that enables dot-notation attribute access, automatic nested path creation (autovivification), recursive locking, template rendering, JSONPath query resolution, and deep structure diffing.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install rich-object
9
+ ```
10
+
11
+ ## Features & Examples
12
+
13
+ ### 1. Basic Usage & Dot Access
14
+ Create an `Object` from mappings or keyword arguments and access properties with standard dot notation.
15
+ ```python
16
+ from rich_object import Object
17
+
18
+ # Initialize using keywords or raw dictionaries
19
+ obj = Object(name="John", address={"city": "New York"})
20
+
21
+ print(obj.name) # -> "John"
22
+ print(obj.address.city) # -> "New York"
23
+ ```
24
+
25
+ ### 2. Autovivification
26
+ Missing nested directories/attributes are automatically initialized when accessed, resolving nested paths on the fly.
27
+ ```python
28
+ obj = Object()
29
+ obj.user.profile.settings.theme = "dark"
30
+
31
+ print(obj.to_dict())
32
+ # -> {'user': {'profile': {'settings': {'theme': 'dark'}}}}
33
+ ```
34
+
35
+ ### 3. Structural Locks
36
+ Prevent mutations (creation, updates, deletes) recursively across all nested dictionaries and lists by passing `lock=True`.
37
+ ```python
38
+ frozen = Object({"items": [1, 2], "user": {"id": 42}}, lock=True)
39
+
40
+ # Any mutation throws a TypeError
41
+ try:
42
+ frozen.user.id = 99
43
+ except TypeError as e:
44
+ print(e) # -> 'Object' is locked and cannot be modified
45
+
46
+ try:
47
+ frozen.items.append(3)
48
+ except TypeError as e:
49
+ print(e) # -> 'ObjectList' is locked and cannot be modified
50
+ ```
51
+
52
+ ### 4. Advanced Property Setting (`set`)
53
+ Assign values safely to deeply nested paths using dot notation and bracket indices. Intermediary dictionary keys and list slots expand automatically.
54
+ ```python
55
+ obj = Object()
56
+ obj.set("store.books[1].title", "Moby Dick")
57
+
58
+ print(obj.store.books[1].title) # -> "Moby Dick"
59
+ print(obj.store.books[0]) # -> None (automatically padded slot)
60
+ ```
61
+
62
+ ### 5. JSONPath Querying (`get`)
63
+ Query the data structure dynamically by passing JSONPath queries starting with `$`.
64
+ ```python
65
+ data = Object({
66
+ "store": {
67
+ "book": [
68
+ {"category": "fiction", "price": 8.95},
69
+ {"category": "reference", "price": 12.00}
70
+ ]
71
+ }
72
+ })
73
+
74
+ # Retrieve a single value
75
+ print(data.get("$.store.book[0].category")) # -> "fiction"
76
+
77
+ # Retrieve matching node value list
78
+ print(data.get("$..price")) # -> [8.95, 12.0]
79
+ ```
80
+
81
+ ### 6. Deep Merging (`+`)
82
+ Perform clean, recursive merges of two structures using the `+` operator. Nested lists are automatically concatenated, and conflicting keys default to the right-hand value.
83
+ ```python
84
+ obj1 = Object({"a": {"x": 1}, "b": [1, 2]})
85
+ obj2 = Object({"a": {"y": 2}, "b": [3, 4]})
86
+
87
+ res = obj1 + obj2
88
+ print(res.to_dict())
89
+ # -> {'a': {'x': 1, 'y': 2}, 'b': [1, 2, 3, 4]}
90
+ ```
91
+
92
+ ### 7. Template Rendering (`render`)
93
+ Render Jinja2 templates in all string values recursively across the entire structure (including nested dictionaries and lists).
94
+ ```python
95
+ obj = Object({
96
+ "first_name": "Jane",
97
+ "greeting": "Hello {{ first_name }}!",
98
+ "matrix": [["Welcome {{ first_name }}"]]
99
+ })
100
+
101
+ rendered = obj.render()
102
+ print(rendered.greeting) # -> "Hello Jane!"
103
+ print(rendered.matrix) # -> [["Welcome Jane"]]
104
+ ```
105
+
106
+ You can also pass entire modules or objects to the `render` method to use them inside your templates:
107
+ ```python
108
+ import datetime
109
+
110
+ obj = Object({
111
+ "year": "{{ dt.datetime(2026, 7, 6).year }}",
112
+ "future_date": "{{ (dt.datetime(2026, 7, 6) + dt.timedelta(days=5)).strftime('%Y-%m-%d') }}"
113
+ })
114
+
115
+ res = obj.render(dt=datetime)
116
+ print(res.year) # -> 2026
117
+ print(res.future_date) # -> "2026-07-11"
118
+ ```
119
+
120
+ ### 8. Structural Diffing (`diff`)
121
+ Identify differences between two objects using the built-in DeepDiff interface.
122
+ ```python
123
+ obj1 = Object({"a": 1, "b": 2})
124
+ obj2 = Object({"a": 1, "b": 3})
125
+
126
+ difference = obj1.diff(obj2)
127
+ print(difference)
128
+ # -> {'values_changed': {"root['b']": {'new_value': 3, 'old_value': 2}}}
129
+ ```
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rich-object"
7
+ version = "0.1.0"
8
+ description = "A Python package containing rich-object utilities"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ dependencies = [
17
+ "jsonpath-ng~=1.7",
18
+ "jinja2~=3.1",
19
+ "deepdiff~=9.0",
20
+ ]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ # rich_object package
2
+ from .object import Object
3
+
4
+ __all__ = ["Object"]
@@ -0,0 +1,558 @@
1
+ import copy
2
+ import json
3
+ import re
4
+
5
+ class ObjectList(list):
6
+ """A list subclass that respects lock state and auto-wraps dicts into Objects."""
7
+ def __init__(self, iterable=(), lock=False):
8
+ self._lock = False
9
+ super().__init__()
10
+ for item in iterable:
11
+ self.append(item)
12
+ self._lock = lock
13
+
14
+ def _check_lock(self):
15
+ if getattr(self, '_lock', False):
16
+ raise TypeError(f"'{self.__class__.__name__}' is locked and cannot be modified")
17
+
18
+ def _wrap(self, item):
19
+ if isinstance(item, dict) and not isinstance(item, Object):
20
+ return Object(item, lock=self._lock)
21
+ elif isinstance(item, list) and not isinstance(item, ObjectList):
22
+ return ObjectList(item, lock=self._lock)
23
+ return item
24
+
25
+ def append(self, item):
26
+ self._check_lock()
27
+ super().append(self._wrap(item))
28
+
29
+ def extend(self, iterable):
30
+ self._check_lock()
31
+ super().extend(self._wrap(item) for item in iterable)
32
+
33
+ def insert(self, index, item):
34
+ self._check_lock()
35
+ super().insert(index, self._wrap(item))
36
+
37
+ def remove(self, item):
38
+ self._check_lock()
39
+ super().remove(item)
40
+
41
+ def pop(self, index=-1):
42
+ self._check_lock()
43
+ return super().pop(index)
44
+
45
+ def clear(self):
46
+ self._check_lock()
47
+ super().clear()
48
+
49
+ def __setitem__(self, index, item):
50
+ self._check_lock()
51
+ super().__setitem__(index, self._wrap(item))
52
+
53
+ def __delitem__(self, index):
54
+ self._check_lock()
55
+ super().__delitem__(index)
56
+
57
+ def __iadd__(self, other):
58
+ self._check_lock()
59
+ super().extend(self._wrap(item) for item in other)
60
+ return self
61
+
62
+ def __copy__(self):
63
+ new_list = ObjectList(lock=False)
64
+ for item in self:
65
+ super(ObjectList, new_list).append(item)
66
+ new_list._lock = self._lock
67
+ return new_list
68
+
69
+ def __deepcopy__(self, memo):
70
+ new_list = ObjectList(lock=False)
71
+ memo[id(self)] = new_list
72
+ for item in self:
73
+ super(ObjectList, new_list).append(copy.deepcopy(item, memo))
74
+ new_list._lock = self._lock
75
+ return new_list
76
+
77
+
78
+ class Object(dict):
79
+ """A dictionary subclass that allows dot notation attribute access
80
+ and automatically creates missing paths (autovivification).
81
+
82
+ Supports lock (disables create/update/delete).
83
+ """
84
+
85
+ def __init__(self, *args, lock=False, **kwargs):
86
+ """Initializes a new Object instance.
87
+
88
+ Args:
89
+ *args: Initial dictionary or iterable of key-value pairs to populate the Object.
90
+ lock (bool): If True, disables any create, update, or delete operations on this Object
91
+ and all of its descendants/elements recursively.
92
+ **kwargs: Additional key-value pairs to populate the Object.
93
+
94
+ Example:
95
+ >>> obj = Object(name="John", address={"city": "New York"})
96
+ >>> obj.name
97
+ 'John'
98
+ >>> obj.address.city
99
+ 'New York'
100
+ >>> locked_obj = Object(x=1, lock=True)
101
+ >>> locked_obj.x = 2
102
+ TypeError: 'Object' is locked and cannot be modified
103
+ """
104
+ object.__setattr__(self, '_lock', lock)
105
+ object.__setattr__(self, '_initialized', False)
106
+
107
+ super().__init__()
108
+ self.update(dict(*args, **kwargs))
109
+
110
+ object.__setattr__(self, '_initialized', True)
111
+
112
+ def __getattr__(self, key: str):
113
+ if key.startswith('__') and key.endswith('__'):
114
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'")
115
+
116
+ if key not in self:
117
+ if object.__getattribute__(self, '_lock') and object.__getattribute__(self, '_initialized'):
118
+ raise TypeError(f"'{self.__class__.__name__}' is locked and cannot be modified")
119
+
120
+ lock_val = object.__getattribute__(self, '_lock')
121
+ self[key] = Object(lock=lock_val)
122
+ return self[key]
123
+
124
+ def __setattr__(self, key: str, value):
125
+ if key == '_lock':
126
+ if getattr(self, '_initialized', False):
127
+ raise TypeError("Cannot modify '_lock' after initialization")
128
+ object.__setattr__(self, key, value)
129
+ elif key == '_initialized':
130
+ object.__setattr__(self, key, value)
131
+ else:
132
+ self[key] = value
133
+
134
+ def __delattr__(self, key: str):
135
+ try:
136
+ del self[key]
137
+ except KeyError:
138
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{key}'")
139
+
140
+ def __getitem__(self, key):
141
+ if key not in self:
142
+ if object.__getattribute__(self, '_lock') and object.__getattribute__(self, '_initialized'):
143
+ raise TypeError(f"'{self.__class__.__name__}' is locked and cannot be modified")
144
+
145
+ lock_val = object.__getattribute__(self, '_lock')
146
+ self[key] = Object(lock=lock_val)
147
+ return super().__getitem__(key)
148
+
149
+ def __setitem__(self, key, value):
150
+ if object.__getattribute__(self, '_lock') and getattr(self, '_initialized', True):
151
+ raise TypeError(f"'{self.__class__.__name__}' is locked and cannot be modified")
152
+
153
+ lock_val = object.__getattribute__(self, '_lock')
154
+
155
+ if isinstance(value, dict) and not isinstance(value, Object):
156
+ value = Object(value, lock=lock_val)
157
+ elif isinstance(value, list) and not isinstance(value, ObjectList):
158
+ value = ObjectList(value, lock=lock_val)
159
+ super().__setitem__(key, value)
160
+
161
+ def __delitem__(self, key):
162
+ if object.__getattribute__(self, '_lock') and getattr(self, '_initialized', True):
163
+ raise TypeError(f"'{self.__class__.__name__}' is locked and cannot be modified")
164
+ super().__delitem__(key)
165
+
166
+ def update(self, *args, **kwargs):
167
+ for k, v in dict(*args, **kwargs).items():
168
+ self[k] = v
169
+
170
+ def set(self, path: str, value):
171
+ """Sets a value at the specified path, creating nested Objects or ObjectLists as needed.
172
+
173
+ Supports dot notation for dict keys and bracket notation with indices for list elements.
174
+
175
+ Args:
176
+ path (str): The dot-and-bracket path (e.g., 'a.b.list[0].c').
177
+ value: The value to set at the path.
178
+
179
+ Raises:
180
+ TypeError: If the Object is locked or if the path encounters a non-container type.
181
+
182
+ Example:
183
+ >>> obj = Object()
184
+ >>> obj.set("store.books[1].title", "Moby Dick")
185
+ >>> obj.store.books[1].title
186
+ 'Moby Dick'
187
+ >>> obj.store.books[0] is None
188
+ True
189
+ """
190
+ tokens = re.findall(r"[^.\[\]]+|\[\d+\]", path)
191
+ parsed_tokens = []
192
+ for t in tokens:
193
+ if t.startswith("[") and t.endswith("]"):
194
+ parsed_tokens.append(int(t[1:-1]))
195
+ else:
196
+ parsed_tokens.append(t)
197
+
198
+ if not parsed_tokens:
199
+ return
200
+
201
+ curr = self
202
+ for i in range(len(parsed_tokens) - 1):
203
+ token = parsed_tokens[i]
204
+ next_token = parsed_tokens[i+1]
205
+
206
+ node_type = ObjectList if isinstance(next_token, int) else Object
207
+
208
+ if isinstance(curr, dict):
209
+ if token not in curr:
210
+ curr[token] = node_type()
211
+ curr = curr[token]
212
+ elif isinstance(curr, list):
213
+ while len(curr) <= token:
214
+ curr.append(Object() if isinstance(next_token, str) else ObjectList())
215
+ if curr[token] is None:
216
+ curr[token] = node_type()
217
+ curr = curr[token]
218
+ else:
219
+ raise TypeError(f"Cannot set property '{next_token}' on non-object '{token}'")
220
+
221
+ last_token = parsed_tokens[-1]
222
+ if isinstance(curr, dict):
223
+ curr[last_token] = value
224
+ elif isinstance(curr, list):
225
+ while len(curr) <= last_token:
226
+ curr.append(None)
227
+ curr[last_token] = value
228
+
229
+ def get(self, key, default=None):
230
+ """Retrieves a value by key. Supports JSONPath expressions if the key starts with '$'.
231
+
232
+ Args:
233
+ key (str): The key or JSONPath expression (e.g., '$.store.book[*].price').
234
+ default: The fallback value to return if the key/path is not found.
235
+
236
+ Returns:
237
+ The matched value, a list of matched values (for JSONPath), or the default value.
238
+
239
+ Example:
240
+ >>> obj = Object({"store": {"book": [{"price": 10}, {"price": 15}]}})
241
+ >>> obj.get("$.store.book[*].price")
242
+ [10, 15]
243
+ >>> obj.get("nonexistent_key", "default_val")
244
+ 'default_val'
245
+ """
246
+ if isinstance(key, str) and key.startswith('$'):
247
+ try:
248
+ from jsonpath_ng import parse
249
+ jsonpath_expr = parse(key)
250
+ matches = [match.value for match in jsonpath_expr.find(self)]
251
+
252
+ if not matches:
253
+ return default
254
+ if len(matches) == 1:
255
+ return matches[0]
256
+ return matches
257
+ except Exception:
258
+ return default
259
+ return super().get(key, default)
260
+
261
+ def __add__(self, other):
262
+ """Merges this Object with another dictionary or Object and returns a new Object.
263
+
264
+ Performs a deep merge, extending list items and recursively merging nested mappings.
265
+
266
+ Args:
267
+ other (dict): The dictionary or Object to merge with.
268
+
269
+ Returns:
270
+ Object: A new merged Object.
271
+
272
+ Raises:
273
+ TypeError: If this Object is locked, or other is not a dictionary.
274
+
275
+ Example:
276
+ >>> obj1 = Object({"a": {"x": 1}, "b": [1, 2]})
277
+ >>> obj2 = {"a": {"y": 2}, "b": [3, 4]}
278
+ >>> merged = obj1 + obj2
279
+ >>> merged.to_dict()
280
+ {'a': {'x': 1, 'y': 2}, 'b': [1, 2, 3, 4]}
281
+ """
282
+ if object.__getattribute__(self, '_lock'):
283
+ raise TypeError(f"'{self.__class__.__name__}' is locked and cannot be merged")
284
+ if not isinstance(other, dict):
285
+ return NotImplemented
286
+
287
+ result = copy.deepcopy(self)
288
+
289
+ def merge(target, source):
290
+ for k, v in source.items():
291
+ if k in target:
292
+ if isinstance(target[k], dict) and isinstance(v, dict):
293
+ merge(target[k], v)
294
+ elif isinstance(target[k], list) and isinstance(v, list):
295
+ # Use target[k].extend so ObjectList wraps new dicts
296
+ target[k].extend(copy.deepcopy(v))
297
+ else:
298
+ target[k] = copy.deepcopy(v)
299
+ else:
300
+ target[k] = copy.deepcopy(v)
301
+
302
+ merge(result, other)
303
+ return result
304
+
305
+ def to_dict(self):
306
+ """Recursively converts this Object and all nested Objects/ObjectLists to standard Python dicts and lists.
307
+
308
+ Returns:
309
+ dict: A standard Python dict representation of this Object.
310
+
311
+ Example:
312
+ >>> obj = Object(a=1, b={"c": 2})
313
+ >>> type(obj.b)
314
+ <class 'rich_object.object.Object'>
315
+ >>> d = obj.to_dict()
316
+ >>> type(d['b'])
317
+ <class 'dict'>
318
+ """
319
+ def _convert(obj):
320
+ if isinstance(obj, Object):
321
+ return {k: _convert(v) for k, v in obj.items()}
322
+ elif isinstance(obj, list):
323
+ return [_convert(v) for v in obj]
324
+ return obj
325
+ return {k: _convert(v) for k, v in self.items()}
326
+
327
+ def render(self, **kwargs):
328
+ """Renders Jinja2 templates in all string values recursively across the entire structure.
329
+
330
+ Args:
331
+ **kwargs: Additional context variables passed to the Jinja template rendering environment.
332
+
333
+ Returns:
334
+ Object: A copy of this Object with all template variables rendered.
335
+
336
+ Raises:
337
+ TypeError: If this Object is locked.
338
+ ImportError: If the 'jinja2' package is not installed.
339
+
340
+ Example:
341
+ >>> obj = Object({"greeting": "Hello {{ name }}!"})
342
+ >>> rendered = obj.render(name="World")
343
+ >>> rendered.greeting
344
+ 'Hello World!'
345
+
346
+ >>> # Using filters like 'int' and 'str'
347
+ >>> obj = Object({
348
+ ... "number": "{{ '42' | int }}",
349
+ ... "serialized_list": "{{ [1, 2, 3] | str }}",
350
+ ... "serialized_dict": "{{ {'a': 1} | str }}"
351
+ ... })
352
+ >>> res = obj.render()
353
+ >>> res.number
354
+ 42
355
+ >>> res.serialized_list
356
+ '[1, 2, 3]'
357
+
358
+ >>> # Passing helper objects like a faker instance or datetime
359
+ >>> from datetime import datetime
360
+ >>> class MockFaker:
361
+ ... def name(self): return "Alice Smith"
362
+ ...
363
+ >>> obj = Object({
364
+ ... "username": "{{ fake.name() }}",
365
+ ... "created_year": "{{ now.strftime('%Y') }}"
366
+ ... })
367
+ >>> res = obj.render(fake=MockFaker(), now=datetime(2026, 7, 6))
368
+ >>> res.username
369
+ 'Alice Smith'
370
+ >>> res.created_year
371
+ '2026'
372
+
373
+ >>> # Passing entire modules
374
+ >>> import datetime
375
+ >>> obj = Object({
376
+ ... "year": "{{ dt.datetime(2026, 7, 6).year }}",
377
+ ... "future_date": "{{ (dt.datetime(2026, 7, 6) + dt.timedelta(days=5)).strftime('%Y-%m-%d') }}"
378
+ ... })
379
+ >>> res = obj.render(dt=datetime)
380
+ >>> res.year
381
+ 2026
382
+ >>> res.future_date
383
+ '2026-07-11'
384
+ """
385
+ if object.__getattribute__(self, '_lock'):
386
+ raise TypeError(f"'{self.__class__.__name__}' is locked and cannot be rendered")
387
+ try:
388
+ from jinja2.nativetypes import NativeEnvironment
389
+ from jinja2 import DebugUndefined
390
+ except ImportError:
391
+ raise ImportError("jinja2 is required to use the render() method. Install it with 'pip install jinja2'")
392
+
393
+ env = NativeEnvironment(undefined=DebugUndefined)
394
+
395
+ def parse_bool(val):
396
+ if isinstance(val, str):
397
+ return val.lower() in ('true', '1', 't', 'yes', 'y')
398
+ return bool(val)
399
+
400
+ env.filters['bool'] = parse_bool
401
+ env.filters['Object'] = Object
402
+
403
+ env.filters['str'] = lambda val: json.dumps(val) if isinstance(val, (dict, list)) else str(val)
404
+
405
+ env.globals['true'] = True
406
+ env.globals['false'] = False
407
+ env.globals['null'] = None
408
+ env.globals['Object'] = Object
409
+
410
+ context = self.to_dict()
411
+ context.update(kwargs)
412
+
413
+ result = copy.deepcopy(self)
414
+
415
+ def traverse_item(item):
416
+ if isinstance(item, str):
417
+ try:
418
+ template = env.from_string(item)
419
+ return template.render(**context)
420
+ except Exception:
421
+ return item
422
+ elif isinstance(item, dict):
423
+ for k, v in item.items():
424
+ item[k] = traverse_item(v)
425
+ return item
426
+ elif isinstance(item, list):
427
+ for i in range(len(item)):
428
+ item[i] = traverse_item(item[i])
429
+ return item
430
+ return item
431
+
432
+ for k, v in result.items():
433
+ result[k] = traverse_item(v)
434
+
435
+ return result
436
+
437
+ def diff(
438
+ self,
439
+ other,
440
+ ignore_order=False,
441
+ ignore_string_case=False,
442
+ exclude_paths=None,
443
+ exclude_regex_paths=None,
444
+ exclude_types=None,
445
+ include_paths=None,
446
+ significant_digits=None,
447
+ math_epsilon=None,
448
+ ignore_numeric_type_changes=False,
449
+ ignore_type_in_groups=None,
450
+ ignore_type_subclasses=False,
451
+ ignore_string_type_changes=False,
452
+ ignore_nan_inequality=False,
453
+ ignore_encoding_errors=False,
454
+ ignore_private_variables=True,
455
+ truncate_datetime=None,
456
+ cutoff_distance_for_pairs=0.3,
457
+ cutoff_intersection_for_pairs=0.7,
458
+ cache_size=0,
459
+ cache_purge_level=1,
460
+ log_frequency_in_sec=0,
461
+ max_passes=10000000,
462
+ max_diffs=None,
463
+ verbose_level=1,
464
+ view="text",
465
+ **kwargs
466
+ ):
467
+ """Computes the difference between this Object and another object using DeepDiff.
468
+
469
+ Args:
470
+ other: The other object or dictionary to compare against.
471
+ ignore_order (bool): See DeepDiff documentation.
472
+ ignore_string_case (bool): See DeepDiff documentation.
473
+ exclude_paths (list/set): See DeepDiff documentation.
474
+ exclude_regex_paths (list/set): See DeepDiff documentation.
475
+ exclude_types (list/set): See DeepDiff documentation.
476
+ include_paths (list/set): See DeepDiff documentation.
477
+ significant_digits (int): See DeepDiff documentation.
478
+ math_epsilon (float): See DeepDiff documentation.
479
+ ignore_numeric_type_changes (bool): See DeepDiff documentation.
480
+ ignore_type_in_groups: See DeepDiff documentation.
481
+ ignore_type_subclasses (bool): See DeepDiff documentation.
482
+ ignore_string_type_changes (bool): See DeepDiff documentation.
483
+ ignore_nan_inequality (bool): See DeepDiff documentation.
484
+ ignore_encoding_errors (bool): See DeepDiff documentation.
485
+ ignore_private_variables (bool): See DeepDiff documentation.
486
+ truncate_datetime: See DeepDiff documentation.
487
+ cutoff_distance_for_pairs (float): See DeepDiff documentation.
488
+ cutoff_intersection_for_pairs (float): See DeepDiff documentation.
489
+ cache_size (int): See DeepDiff documentation.
490
+ cache_purge_level (int): See DeepDiff documentation.
491
+ log_frequency_in_sec (int): See DeepDiff documentation.
492
+ max_passes (int): See DeepDiff documentation.
493
+ max_diffs (int): See DeepDiff documentation.
494
+ verbose_level (int): See DeepDiff documentation.
495
+ view (str): See DeepDiff documentation.
496
+ **kwargs: Additional keyword arguments passed directly to DeepDiff.
497
+
498
+ Returns:
499
+ DeepDiff: The resulting comparison object.
500
+
501
+ Raises:
502
+ ImportError: If the 'deepdiff' package is not installed.
503
+
504
+ Example:
505
+ >>> obj1 = Object({"a": 1})
506
+ >>> obj2 = {"a": 2}
507
+ >>> diff = obj1.diff(obj2)
508
+ >>> 'values_changed' in diff
509
+ True
510
+ """
511
+ try:
512
+ from deepdiff import DeepDiff
513
+ except ImportError:
514
+ raise ImportError("deepdiff is required to use the diff() method. Install it with pip install deepdiff")
515
+
516
+ kwargs.update({
517
+ "ignore_order": ignore_order,
518
+ "ignore_string_case": ignore_string_case,
519
+ "exclude_paths": exclude_paths,
520
+ "exclude_regex_paths": exclude_regex_paths,
521
+ "exclude_types": exclude_types,
522
+ "include_paths": include_paths,
523
+ "significant_digits": significant_digits,
524
+ "math_epsilon": math_epsilon,
525
+ "ignore_numeric_type_changes": ignore_numeric_type_changes,
526
+ "ignore_type_in_groups": ignore_type_in_groups,
527
+ "ignore_type_subclasses": ignore_type_subclasses,
528
+ "ignore_string_type_changes": ignore_string_type_changes,
529
+ "ignore_nan_inequality": ignore_nan_inequality,
530
+ "ignore_encoding_errors": ignore_encoding_errors,
531
+ "ignore_private_variables": ignore_private_variables,
532
+ "truncate_datetime": truncate_datetime,
533
+ "cutoff_distance_for_pairs": cutoff_distance_for_pairs,
534
+ "cutoff_intersection_for_pairs": cutoff_intersection_for_pairs,
535
+ "cache_size": cache_size,
536
+ "cache_purge_level": cache_purge_level,
537
+ "log_frequency_in_sec": log_frequency_in_sec,
538
+ "max_passes": max_passes,
539
+ "max_diffs": max_diffs,
540
+ "verbose_level": verbose_level,
541
+ "view": view,
542
+ })
543
+
544
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
545
+
546
+ return DeepDiff(
547
+ self.to_dict(),
548
+ other.to_dict() if hasattr(other, "to_dict") else other,
549
+ **kwargs
550
+ )
551
+
552
+
553
+
554
+ def __str__(self) -> str:
555
+ return str(self.to_dict())
556
+
557
+ def __repr__(self) -> str:
558
+ return repr(self.to_dict())
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: rich-object
3
+ Version: 0.1.0
4
+ Summary: A Python package containing rich-object utilities
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: jsonpath-ng~=1.7
11
+ Requires-Dist: jinja2~=3.1
12
+ Requires-Dist: deepdiff~=9.0
13
+
14
+ # rich-object
15
+
16
+ A powerful dictionary wrapper subclass that enables dot-notation attribute access, automatic nested path creation (autovivification), recursive locking, template rendering, JSONPath query resolution, and deep structure diffing.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install rich-object
22
+ ```
23
+
24
+ ## Features & Examples
25
+
26
+ ### 1. Basic Usage & Dot Access
27
+ Create an `Object` from mappings or keyword arguments and access properties with standard dot notation.
28
+ ```python
29
+ from rich_object import Object
30
+
31
+ # Initialize using keywords or raw dictionaries
32
+ obj = Object(name="John", address={"city": "New York"})
33
+
34
+ print(obj.name) # -> "John"
35
+ print(obj.address.city) # -> "New York"
36
+ ```
37
+
38
+ ### 2. Autovivification
39
+ Missing nested directories/attributes are automatically initialized when accessed, resolving nested paths on the fly.
40
+ ```python
41
+ obj = Object()
42
+ obj.user.profile.settings.theme = "dark"
43
+
44
+ print(obj.to_dict())
45
+ # -> {'user': {'profile': {'settings': {'theme': 'dark'}}}}
46
+ ```
47
+
48
+ ### 3. Structural Locks
49
+ Prevent mutations (creation, updates, deletes) recursively across all nested dictionaries and lists by passing `lock=True`.
50
+ ```python
51
+ frozen = Object({"items": [1, 2], "user": {"id": 42}}, lock=True)
52
+
53
+ # Any mutation throws a TypeError
54
+ try:
55
+ frozen.user.id = 99
56
+ except TypeError as e:
57
+ print(e) # -> 'Object' is locked and cannot be modified
58
+
59
+ try:
60
+ frozen.items.append(3)
61
+ except TypeError as e:
62
+ print(e) # -> 'ObjectList' is locked and cannot be modified
63
+ ```
64
+
65
+ ### 4. Advanced Property Setting (`set`)
66
+ Assign values safely to deeply nested paths using dot notation and bracket indices. Intermediary dictionary keys and list slots expand automatically.
67
+ ```python
68
+ obj = Object()
69
+ obj.set("store.books[1].title", "Moby Dick")
70
+
71
+ print(obj.store.books[1].title) # -> "Moby Dick"
72
+ print(obj.store.books[0]) # -> None (automatically padded slot)
73
+ ```
74
+
75
+ ### 5. JSONPath Querying (`get`)
76
+ Query the data structure dynamically by passing JSONPath queries starting with `$`.
77
+ ```python
78
+ data = Object({
79
+ "store": {
80
+ "book": [
81
+ {"category": "fiction", "price": 8.95},
82
+ {"category": "reference", "price": 12.00}
83
+ ]
84
+ }
85
+ })
86
+
87
+ # Retrieve a single value
88
+ print(data.get("$.store.book[0].category")) # -> "fiction"
89
+
90
+ # Retrieve matching node value list
91
+ print(data.get("$..price")) # -> [8.95, 12.0]
92
+ ```
93
+
94
+ ### 6. Deep Merging (`+`)
95
+ Perform clean, recursive merges of two structures using the `+` operator. Nested lists are automatically concatenated, and conflicting keys default to the right-hand value.
96
+ ```python
97
+ obj1 = Object({"a": {"x": 1}, "b": [1, 2]})
98
+ obj2 = Object({"a": {"y": 2}, "b": [3, 4]})
99
+
100
+ res = obj1 + obj2
101
+ print(res.to_dict())
102
+ # -> {'a': {'x': 1, 'y': 2}, 'b': [1, 2, 3, 4]}
103
+ ```
104
+
105
+ ### 7. Template Rendering (`render`)
106
+ Render Jinja2 templates in all string values recursively across the entire structure (including nested dictionaries and lists).
107
+ ```python
108
+ obj = Object({
109
+ "first_name": "Jane",
110
+ "greeting": "Hello {{ first_name }}!",
111
+ "matrix": [["Welcome {{ first_name }}"]]
112
+ })
113
+
114
+ rendered = obj.render()
115
+ print(rendered.greeting) # -> "Hello Jane!"
116
+ print(rendered.matrix) # -> [["Welcome Jane"]]
117
+ ```
118
+
119
+ You can also pass entire modules or objects to the `render` method to use them inside your templates:
120
+ ```python
121
+ import datetime
122
+
123
+ obj = Object({
124
+ "year": "{{ dt.datetime(2026, 7, 6).year }}",
125
+ "future_date": "{{ (dt.datetime(2026, 7, 6) + dt.timedelta(days=5)).strftime('%Y-%m-%d') }}"
126
+ })
127
+
128
+ res = obj.render(dt=datetime)
129
+ print(res.year) # -> 2026
130
+ print(res.future_date) # -> "2026-07-11"
131
+ ```
132
+
133
+ ### 8. Structural Diffing (`diff`)
134
+ Identify differences between two objects using the built-in DeepDiff interface.
135
+ ```python
136
+ obj1 = Object({"a": 1, "b": 2})
137
+ obj2 = Object({"a": 1, "b": 3})
138
+
139
+ difference = obj1.diff(obj2)
140
+ print(difference)
141
+ # -> {'values_changed': {"root['b']": {'new_value': 3, 'old_value': 2}}}
142
+ ```
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/rich_object/__init__.py
4
+ src/rich_object/object.py
5
+ src/rich_object.egg-info/PKG-INFO
6
+ src/rich_object.egg-info/SOURCES.txt
7
+ src/rich_object.egg-info/dependency_links.txt
8
+ src/rich_object.egg-info/requires.txt
9
+ src/rich_object.egg-info/top_level.txt
10
+ tests/test_object.py
@@ -0,0 +1,3 @@
1
+ jsonpath-ng~=1.7
2
+ jinja2~=3.1
3
+ deepdiff~=9.0
@@ -0,0 +1 @@
1
+ rich_object
@@ -0,0 +1,213 @@
1
+ import pytest
2
+ from rich_object.object import Object
3
+
4
+ def test_basic_initialization():
5
+ # Kwargs
6
+ obj1 = Object(a=1, b={"c": 2})
7
+ assert obj1.a == 1
8
+ assert obj1.b.c == 2
9
+ assert isinstance(obj1.b, Object)
10
+
11
+ # Plain dictionary
12
+ obj2 = Object({"x": 10, "y": {"z": 20}})
13
+ assert obj2.x == 10
14
+ assert obj2.y.z == 20
15
+ assert isinstance(obj2.y, Object)
16
+
17
+ # Mix
18
+ obj3 = Object({"m": 100}, n={"p": 200})
19
+ assert obj3.m == 100
20
+ assert obj3.n.p == 200
21
+
22
+ def test_autovivification():
23
+ a = Object()
24
+
25
+ # Missing path creation
26
+ a.b.c = 'val'
27
+ assert a.b.c == 'val'
28
+ assert isinstance(a.b, Object)
29
+
30
+ # Dict-like access
31
+ a['d']['e'] = 42
32
+ assert a.d.e == 42
33
+
34
+ # List conversion
35
+ a.f = [{"g": 1}]
36
+ assert isinstance(a.f[0], Object)
37
+ assert a.f[0].g == 1
38
+
39
+
40
+
41
+ def test_lock():
42
+ frozen_obj = Object({"x": 10}, lock=True)
43
+
44
+ with pytest.raises(TypeError):
45
+ frozen_obj.y = 20 # Cannot create
46
+
47
+ with pytest.raises(TypeError):
48
+ frozen_obj.x = 99 # Cannot update
49
+
50
+ with pytest.raises(TypeError):
51
+ del frozen_obj.x # Cannot delete
52
+
53
+
54
+
55
+ def test_dict_methods():
56
+ a = Object(key1="val1")
57
+
58
+ # .get() shouldn't autovivify
59
+ assert a.get("key2", "default") == "default"
60
+ assert "key2" not in a
61
+
62
+ assert "key1" in a.keys()
63
+ assert "val1" in a.values()
64
+
65
+ def test_get_jsonpath():
66
+ data = Object({
67
+ "store": {
68
+ "book": [
69
+ {"category": "fiction", "price": 8.95},
70
+ {"category": "fiction", "price": 12.99}
71
+ ],
72
+ "bicycle": {
73
+ "color": "red",
74
+ "price": 19.95
75
+ }
76
+ },
77
+ "val": 100
78
+ })
79
+
80
+ assert data.get("$.store.bicycle.color") == "red"
81
+ assert data.get("$..price") == [8.95, 12.99, 19.95]
82
+ assert data.get("$..author", "Not Found") == "Not Found"
83
+
84
+ # Single element list handling
85
+ data2 = Object({"store": {"book": [{"category": "reference"}]}})
86
+ assert data2.get("$.store.book") == [{"category": "reference"}]
87
+ assert data2.get("$..category") == "reference"
88
+
89
+ def test_add_operator():
90
+ obj1 = Object({"a": {"x": 1}, "b": [1, 2], "mismatch": 10})
91
+ obj2 = Object({"a": {"y": 2}, "b": [3, 4], "c": 3, "mismatch": [1, 2, 3]})
92
+
93
+ res = obj1 + obj2
94
+
95
+ # Check deep merge
96
+ assert res.a.x == 1
97
+ assert res.a.y == 2
98
+
99
+ # Check list concatenation
100
+ assert res.b == [1, 2, 3, 4]
101
+
102
+ # Check new keys
103
+ assert res.c == 3
104
+
105
+ # Check type mismatch overwrite (right side wins)
106
+ assert res.mismatch == [1, 2, 3]
107
+
108
+ # Ensure it's a deep copy, not mutating original
109
+ res.a.x = 99
110
+ assert obj1.a.x == 1
111
+
112
+ obj2.b.append(5)
113
+ assert res.b == [1, 2, 3, 4]
114
+
115
+ def test_render():
116
+ obj = Object({
117
+ "first_name": "John",
118
+ "last_name": "Doe",
119
+ "address": {
120
+ "city": "New York"
121
+ },
122
+ "greeting": "Hello {{ first_name }} {{ last_name }}",
123
+ "missing": "Hello {{ missing_var }}",
124
+ "nested_greeting": "Welcome to {{ address.city }}!",
125
+ "messages": ["Hi {{ first_name }}", "Bye {{ first_name }}"],
126
+ "mixed_vars": "Where is {{ missing_var }} for {{ first_name }}?"
127
+ })
128
+
129
+ res = obj.render()
130
+
131
+ assert res.greeting == "Hello John Doe"
132
+ assert res.missing == "Hello {{ missing_var }}"
133
+ assert res.nested_greeting == "Welcome to New York!"
134
+ assert res.messages == ["Hi John", "Bye John"]
135
+ assert res.mixed_vars == "Where is {{ missing_var }} for John?"
136
+
137
+ # Ensure deep copy
138
+ assert obj.greeting == "Hello {{ first_name }} {{ last_name }}"
139
+ res.address.city = "LA"
140
+ assert obj.address.city == "New York"
141
+
142
+ # Test custom variables via kwargs
143
+ res_custom = obj.render(first_name="Jane", missing_var="World", extra="!")
144
+ assert res_custom.greeting == "Hello Jane Doe" # Overridden value
145
+ assert res_custom.missing == "Hello World" # Provided value
146
+ assert res_custom.mixed_vars == "Where is World for Jane?"
147
+
148
+ # Test primitive rendering and filters
149
+ obj_prim = Object({
150
+ "num": "{{ 10 | int }}",
151
+ "bool_val": "{{ true | bool }}",
152
+ "list_val": "{{ [1, 2, 3] | list }}",
153
+ "mixed": "I have {{ 10 | int }} apples"
154
+ })
155
+ res_prim = obj_prim.render()
156
+ assert res_prim.num == 10
157
+ assert res_prim.bool_val is True
158
+ assert res_prim.list_val == [1, 2, 3]
159
+ assert res_prim.mixed == "I have 10 apples"
160
+
161
+
162
+ def test_set_path():
163
+ # Deep path creation
164
+ val = Object()
165
+ val.set("a.b.c.d", "val1")
166
+ assert val.a.b.c.d == "val1"
167
+
168
+ # Array creation inside a path
169
+ val2 = Object()
170
+ val2.set("a.b.list[0].c", "val2")
171
+ assert isinstance(val2.a.b.list, list)
172
+ assert val2.a.b.list[0].c == "val2"
173
+
174
+ # Array expansion
175
+ val3 = Object()
176
+ val3.set("a.b.list[2].d", "val3")
177
+ assert len(val3.a.b.list) == 3
178
+ assert val3.a.b.list[0] is not None
179
+ assert val3.a.b.list[1] is not None
180
+ assert val3.a.b.list[2].d == "val3"
181
+
182
+ def test_list_lock():
183
+ frozen_obj = Object({"my_list": [1, 2, 3]}, lock=True)
184
+ with pytest.raises(TypeError):
185
+ frozen_obj.my_list.append(4)
186
+ with pytest.raises(TypeError):
187
+ frozen_obj.my_list[0] = 99
188
+ with pytest.raises(TypeError):
189
+ del frozen_obj.my_list[1]
190
+ with pytest.raises(TypeError):
191
+ frozen_obj.my_list += [4]
192
+
193
+ def test_set_lock_respect():
194
+ frozen_obj = Object({"x": {"y": 1}}, lock=True)
195
+ with pytest.raises(TypeError):
196
+ frozen_obj.set("x.y", 999)
197
+ with pytest.raises(TypeError):
198
+ frozen_obj.set("z", 100)
199
+
200
+ def test_render_nested_lists():
201
+ obj = Object({
202
+ "matrix": [["{{ x }}", "plain"], ["{{ y }}"]],
203
+ "deep": [{"nested_list": ["{{ z }}"]}]
204
+ })
205
+ res = obj.render(x="A", y="B", z="C")
206
+ assert res.matrix[0][0] == "A"
207
+ assert res.matrix[0][1] == "plain"
208
+ assert res.matrix[1][0] == "B"
209
+ assert res.deep[0].nested_list[0] == "C"
210
+
211
+
212
+
213
+