json-object-mapper 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """json_object_mapper public interface."""
2
+
3
+ from .core import JSONObjectMapper
4
+ from .exceptions import JSONAccessError
5
+
6
+ __all__ = ["JSONAccessError", "JSONObjectMapper"]
@@ -0,0 +1,220 @@
1
+ import copy
2
+ import json
3
+ from typing import (
4
+ Any,
5
+ Callable,
6
+ Dict,
7
+ Iterable,
8
+ Iterator,
9
+ List,
10
+ Mapping,
11
+ MutableMapping,
12
+ Optional,
13
+ Tuple,
14
+ Union,
15
+ )
16
+
17
+ from .exceptions import JSONAccessError
18
+ from .helpers import (
19
+ assign_at,
20
+ delete_on_parent,
21
+ ensure_next,
22
+ is_identifier,
23
+ is_last,
24
+ parse_path_tokens,
25
+ peek_is_index,
26
+ traverse_parent,
27
+ wrap_value,
28
+ )
29
+
30
+ JSONScalar = Union[str, int, float, bool, None]
31
+ JSONType = Union[JSONScalar, "JSONObjectMapper", List["JSONObjectMapper"], Dict[str, Any]]
32
+
33
+
34
+ class JSONObjectMapper:
35
+ __slots__ = ("__autocreate_missing", "__default_factory", "__json", "__readonly")
36
+
37
+ def __init__(
38
+ self,
39
+ data: Union[str, bytes, bytearray, Mapping[str, Any], MutableMapping[str, Any], List[Any]],
40
+ *,
41
+ readonly: bool = False,
42
+ default_factory: Optional[Callable[[], Any]] = None,
43
+ autocreate_missing: bool = False,
44
+ _no_copy: bool = False,
45
+ ) -> None:
46
+ self.__json = (
47
+ json.loads(data)
48
+ if isinstance(data, (str, bytes, bytearray))
49
+ else (data if _no_copy else copy.deepcopy(data))
50
+ )
51
+ if not isinstance(self.__json, (dict, list)):
52
+ raise TypeError("JSONObjectMapper must wrap a dict or list (or JSON string thereof).")
53
+ self.__readonly = bool(readonly)
54
+ self.__default_factory = default_factory
55
+ self.__autocreate_missing = bool(autocreate_missing)
56
+
57
+ def __repr__(self) -> str: # pragma: no cover
58
+ return f"JSONObjectMapper(readonly={self.__readonly}, data={self.__json!r})"
59
+
60
+ def to_dict(self) -> Any:
61
+ return copy.deepcopy(self.__json)
62
+
63
+ def to_json(self, *, indent: Optional[int] = None, sort_keys: bool = False) -> str:
64
+ return json.dumps(self.__json, indent=indent, sort_keys=sort_keys)
65
+
66
+ @classmethod
67
+ def from_json(
68
+ cls, json_string: Union[str, bytes, bytearray], *, readonly: bool = False
69
+ ) -> "JSONObjectMapper":
70
+ return cls(json_string, readonly=readonly)
71
+
72
+ def readonly(self) -> bool:
73
+ return self.__readonly
74
+
75
+ # ---------------------------- attribute access ----------------------------
76
+
77
+ def __getattr__(self, attribute_name: str) -> Any:
78
+ data = object.__getattribute__(self, "_JSONObjectMapper__json")
79
+ if isinstance(data, list):
80
+ raise JSONAccessError(f"Cannot access attribute '{attribute_name}' on list")
81
+ if attribute_name in type(self).__dict__:
82
+ return object.__getattribute__(self, attribute_name)
83
+ if not is_identifier(attribute_name):
84
+ raise JSONAccessError(f"'{attribute_name}' is not a valid attribute identifier")
85
+ try:
86
+ value = data[attribute_name]
87
+ except KeyError as error:
88
+ value = self._on_missing_attr(data, attribute_name, error)
89
+ return self._wrap(value)
90
+
91
+ def _on_missing_attr(self, data: dict, attribute_name: str, error: Exception) -> Any:
92
+ factory = object.__getattribute__(self, "_JSONObjectMapper__default_factory")
93
+ readonly_flag = object.__getattribute__(self, "_JSONObjectMapper__readonly")
94
+ autocreate_flag = object.__getattribute__(self, "_JSONObjectMapper__autocreate_missing")
95
+ if factory is None:
96
+ raise JSONAccessError(f"'{attribute_name}' not found") from error
97
+ produced = factory()
98
+ if autocreate_flag and not readonly_flag:
99
+ data[attribute_name] = produced
100
+ return produced
101
+
102
+ def _wrap(self, value: Any) -> Any:
103
+ readonly_flag = object.__getattribute__(self, "_JSONObjectMapper__readonly")
104
+ default_factory = object.__getattribute__(self, "_JSONObjectMapper__default_factory")
105
+ autocreate_flag = object.__getattribute__(self, "_JSONObjectMapper__autocreate_missing")
106
+ return wrap_value(
107
+ value, readonly_flag, default_factory, autocreate_flag, factory_object=self.__class__
108
+ )
109
+
110
+ def __setattr__(self, attribute_name: str, value: Any) -> None:
111
+ if attribute_name in {
112
+ "_JSONObjectMapper__json",
113
+ "_JSONObjectMapper__readonly",
114
+ "_JSONObjectMapper__default_factory",
115
+ "_JSONObjectMapper__autocreate_missing",
116
+ }:
117
+ return object.__setattr__(self, attribute_name, value)
118
+ if self.__readonly:
119
+ raise AttributeError("Mapper is read-only")
120
+ if isinstance(self.__json, list):
121
+ raise AttributeError("Cannot set attributes on a list")
122
+ if not is_identifier(attribute_name):
123
+ raise AttributeError(f"'{attribute_name}' is not a valid attribute name")
124
+ self.__json[attribute_name] = value
125
+
126
+ # ----------------------------- mapping access -----------------------------
127
+
128
+ def __getitem__(self, key: Union[int, str]) -> Any:
129
+ try:
130
+ value = self.__json[key] # type: ignore[index]
131
+ except Exception as error:
132
+ raise KeyError(key) from error
133
+ return self._wrap(value)
134
+
135
+ def __setitem__(self, key: Union[int, str], value: Any) -> None:
136
+ if self.__readonly:
137
+ raise AttributeError("Mapper is read-only")
138
+ self.__json[key] = value # type: ignore[index]
139
+
140
+ def __iter__(self) -> Iterator:
141
+ return iter(self.__json)
142
+
143
+ def __len__(self) -> int:
144
+ return len(self.__json)
145
+
146
+ def get(self, key: Union[str, int], default: Any = None) -> Any:
147
+ try:
148
+ value = self.__json[key] # type: ignore[index]
149
+ except Exception:
150
+ return default
151
+ return self._wrap(value)
152
+
153
+ def keys(self) -> Iterable:
154
+ if isinstance(self.__json, dict):
155
+ return self.__json.keys()
156
+ raise TypeError("keys() only valid for dict roots")
157
+
158
+ def items(self) -> Iterable[Tuple[Any, Any]]:
159
+ if isinstance(self.__json, dict):
160
+ return ((key, self._wrap(value)) for key, value in self.__json.items())
161
+ raise TypeError("items() only valid for dict roots")
162
+
163
+ def values(self) -> Iterable[Any]:
164
+ if isinstance(self.__json, dict):
165
+ return (self._wrap(value) for value in self.__json.values())
166
+ raise TypeError("values() only valid for dict roots")
167
+
168
+ # -------------------------------- path ops --------------------------------
169
+
170
+ def get_path(self, path: str, default: Any = None) -> Any:
171
+ current: Any = self
172
+ for token in parse_path_tokens(path):
173
+ try:
174
+ current = (
175
+ getattr(current, token.group("name"))
176
+ if token.group("name")
177
+ else current[int(token.group("index"))]
178
+ )
179
+ except Exception:
180
+ return default
181
+ return current
182
+
183
+ def set_path(self, path: str, value: Any, *, create_parents: bool = True) -> "JSONObjectMapper":
184
+ if self.__readonly:
185
+ raise AttributeError("Mapper is read-only")
186
+ tokens = parse_path_tokens(path)
187
+ if not tokens:
188
+ raise ValueError("Empty path")
189
+ current = self.__json
190
+ for index, token in enumerate(tokens):
191
+ if is_last(index, tokens):
192
+ assign_at(current, token, value, create_parents, path)
193
+ return self
194
+ current = ensure_next(
195
+ current, token, peek_is_index(tokens, index), create_parents, path
196
+ )
197
+ return self
198
+
199
+ def del_path(self, path: str, *, raise_on_missing: bool = False) -> "JSONObjectMapper":
200
+ if self.__readonly:
201
+ raise AttributeError("Mapper is read-only")
202
+ tokens = parse_path_tokens(path)
203
+ if not tokens:
204
+ raise ValueError("Empty path")
205
+ parent = traverse_parent(self.__json, tokens, path, raise_on_missing)
206
+ if parent is None:
207
+ return self
208
+ delete_on_parent(parent, tokens[-1], raise_on_missing)
209
+ return self
210
+
211
+ # --------------------------------- merge ----------------------------------
212
+
213
+ def merge(self, other: Mapping[str, Any]) -> "JSONObjectMapper":
214
+ if self.__readonly:
215
+ raise AttributeError("Mapper is read-only")
216
+ if not isinstance(self.__json, dict):
217
+ raise TypeError("merge() requires a dict root")
218
+ for key, value in other.items():
219
+ self.__json[key] = value
220
+ return self
@@ -0,0 +1,2 @@
1
+ class JSONAccessError(AttributeError):
2
+ """Raised when an attribute-style access fails."""
@@ -0,0 +1,143 @@
1
+ import re
2
+ from typing import Any, Callable, List, Optional
3
+
4
+
5
+ def is_identifier(key: str) -> bool:
6
+ return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key))
7
+
8
+
9
+ PATH_TOKEN = re.compile(
10
+ r"""
11
+ (?P<name>[A-Za-z_][A-Za-z0-9_]*) # identifier
12
+ | \[ (?P<index>\d+) \] # [index]
13
+ """,
14
+ re.VERBOSE,
15
+ )
16
+
17
+
18
+ def ensure_list_length(lst: List[Any], index: int) -> None:
19
+ if index >= len(lst):
20
+ lst.extend([None] * (index + 1 - len(lst)))
21
+
22
+
23
+ def parse_path_tokens(path: str):
24
+ return list(PATH_TOKEN.finditer(path.replace(".", " ")))
25
+
26
+
27
+ def is_last(current_index: int, tokens: List[Any]) -> bool:
28
+ return current_index == len(tokens) - 1
29
+
30
+
31
+ def peek_is_index(tokens: List[Any], current_index: int) -> bool:
32
+ return tokens[current_index + 1].group("index") is not None
33
+
34
+
35
+ def expect_dict(obj: Any, context: str):
36
+ if not isinstance(obj, dict):
37
+ raise TypeError(f"Expected dict {context}")
38
+
39
+
40
+ def expect_list(obj: Any, context: str):
41
+ if not isinstance(obj, list):
42
+ raise TypeError(f"Expected list {context}")
43
+
44
+
45
+ def get_token_value(container: Any, token):
46
+ name, index = token.group("name"), token.group("index")
47
+ if name is not None:
48
+ expect_dict(container, f"before '{name}'")
49
+ return container[name]
50
+ expect_list(container, f"before index [{index}]")
51
+ return container[int(index)]
52
+
53
+
54
+ def assign_at(container: Any, token, value: Any, create_parents: bool, path: str):
55
+ name, index = token.group("name"), token.group("index")
56
+ if name is not None:
57
+ expect_dict(container, f"in '{path}'")
58
+ container[name] = value
59
+ return
60
+ expect_list(container, f"in '{path}'")
61
+ index_int = int(index)
62
+ if index_int >= len(container) and not create_parents:
63
+ raise IndexError(f"Index {index_int} out of range in '{path}'")
64
+ ensure_list_length(container, index_int)
65
+ container[index_int] = value
66
+
67
+
68
+ def ensure_next(container: Any, token, next_is_index: bool, create_parents: bool, path: str):
69
+ name, index = token.group("name"), token.group("index")
70
+ if name is not None:
71
+ expect_dict(container, f"in '{path}'")
72
+ next_value = container.get(name)
73
+ if next_value is None:
74
+ if not create_parents:
75
+ raise KeyError(f"Missing key '{name}' in '{path}'")
76
+ container[name] = [] if next_is_index else {}
77
+ next_value = container[name]
78
+ return next_value
79
+ expect_list(container, f"in '{path}'")
80
+ index_int = int(index)
81
+ if index_int >= len(container):
82
+ if not create_parents:
83
+ raise IndexError(f"Index {index_int} out of range in '{path}'")
84
+ ensure_list_length(container, index_int)
85
+ container[index_int] = [] if next_is_index else {}
86
+ return container[index_int]
87
+
88
+
89
+ def traverse_parent(root: Any, tokens: List[Any], path: str, strict: bool):
90
+ current = root
91
+ for _, token in enumerate(tokens[:-1]):
92
+ try:
93
+ current = get_token_value(current, token)
94
+ except Exception:
95
+ if strict:
96
+ segment = token.group("name") or f"[{token.group('index')}]"
97
+ raise KeyError(f"Missing segment before end at '{segment}' in '{path}'") from None
98
+ return None
99
+ return current
100
+
101
+
102
+ def delete_on_parent(parent: Any, last_token, strict: bool):
103
+ name, index = last_token.group("name"), last_token.group("index")
104
+ if name is not None:
105
+ expect_dict(parent, "for deletion")
106
+ if name in parent:
107
+ del parent[name]
108
+ elif strict:
109
+ raise KeyError(f"Missing key '{name}'")
110
+ return
111
+ expect_list(parent, "for deletion")
112
+ index_int = int(index)
113
+ if 0 <= index_int < len(parent):
114
+ parent.pop(index_int)
115
+ elif strict:
116
+ raise IndexError(f"Index {index_int} out of range")
117
+
118
+
119
+ def wrap_value(
120
+ value: Any,
121
+ readonly: bool,
122
+ default_factory: Optional[Callable[[], Any]],
123
+ autocreate_missing: bool,
124
+ factory_object: type,
125
+ ) -> Any:
126
+ if isinstance(value, dict):
127
+ return factory_object(
128
+ value,
129
+ readonly=readonly,
130
+ default_factory=default_factory,
131
+ autocreate_missing=autocreate_missing,
132
+ _no_copy=True,
133
+ )
134
+ if isinstance(value, list):
135
+ return [
136
+ (
137
+ wrap_value(item, readonly, default_factory, autocreate_missing, factory_object)
138
+ if isinstance(item, (dict, list))
139
+ else item
140
+ )
141
+ for item in value
142
+ ]
143
+ return value
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: json-object-mapper
3
+ Version: 2.0.0
4
+ Summary: Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults
5
+ Author-email: Kobby Owen <dev@kobbyowen.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kobbyowen/json2obj
8
+ Project-URL: Repository, https://github.com/kobbyowen/json2obj
9
+ Project-URL: Issues, https://github.com/kobbyowen/json2obj/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # json-object-mapper
17
+
18
+ **`json-object-mapper`** is a lightweight Python library that lets you interact with JSON data using **attribute-style access** instead of dictionary keys.
19
+
20
+ The PyPI distribution is published as `json-object-mapper`.
21
+
22
+ It turns JSON objects into `JSONObjectMapper` instances that feel natural to use in Python code, while preserving full JSON compatibility.
23
+
24
+ ```python
25
+ from json_object_mapper import JSONObjectMapper
26
+
27
+ data = {
28
+ "user": {"name": "Kobby", "age": 29, "skills": ["python", "aws", "forex"]}
29
+ }
30
+ obj = JSONObjectMapper(data)
31
+ print(obj.user.name) # "Kobby"
32
+ print(obj.user.skills[0]) # "python"
33
+ print(obj.to_json(indent=2))
34
+ ```
35
+
36
+ ## ✨ Features
37
+
38
+ - Attribute-style access (`obj.key`) for dict keys
39
+ - Recursive wrapping for nested dicts and lists
40
+ - Read-only mode (immutability enforced)
41
+ - Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
42
+ - **New:** `set_path()` / `del_path()` for dot paths
43
+ - **New:** `default_factory` + `autocreate_missing` for safe defaults
44
+ - Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install json-object-mapper
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ from json_object_mapper import JSONObjectMapper, JSONAccessError
56
+
57
+ # 1) Wrap & read (basic)
58
+ obj = JSONObjectMapper({"user": {"name": "Kobby", "age": 29}})
59
+ assert obj.user.name == "Kobby"
60
+ assert obj.user.age == 29
61
+
62
+ # 2) Write with dot notation
63
+ obj.user.country = "GH"
64
+ assert obj.user.country == "GH"
65
+
66
+ # 3) Lists of dicts (read & write via attribute access)
67
+ obj.services = [{}] # start with a list containing one dict
68
+ obj.services[0].name = "auth" # item is wrapped → dot works
69
+ obj.services[0].enabled = True
70
+ assert obj.services[0].name == "auth"
71
+ assert obj.services[0].enabled is True
72
+
73
+ # 4) Non-identifier keys (use mapping-style access)
74
+ # Keys like "first-name" can't be attributes; use get()/[] instead
75
+ obj.meta = {"first-name": "Kobby"}
76
+ assert obj.meta.get("first-name") == "Kobby"
77
+ assert obj.meta["first-name"] == "Kobby"
78
+ # getattr(obj.meta, "first-name") would raise JSONAccessError
79
+
80
+ # 5) Safe defaults + auto-create (no extra helpers required)
81
+ # Missing attributes produce defaults; with autocreate they persist.
82
+ cfg = JSONObjectMapper({}, default_factory=dict, autocreate_missing=True)
83
+ cfg.profile.settings.theme = "dark" # on-demand creation of nested dicts
84
+ assert cfg.profile.settings.theme == "dark"
85
+
86
+ # 6) Merge convenience (shallow merge into root dict)
87
+ cfg.merge({"features": {"beta": True}})
88
+ assert cfg.features.beta is True
89
+
90
+ # 7) Read-only wrappers (safe reads; writes raise)
91
+ ro = JSONObjectMapper({"debug": True}, readonly=True)
92
+ assert ro.debug is True
93
+ try:
94
+ ro.debug = False
95
+ raise AssertionError("should not be able to write in readonly mode")
96
+ except AttributeError:
97
+ pass
98
+ ```
99
+
100
+ ## Tests
101
+
102
+ ```bash
103
+ python -m pip install -e .
104
+ python -m unittest discover -s tests -v
105
+ ```
106
+
107
+ ## Publishing
108
+
109
+ Publishing is automated with GitHub Actions using PyPI trusted publishing. Create a GitHub release after configuring the `pypi` environment in the repository settings.
110
+
111
+ MIT License.
@@ -0,0 +1,9 @@
1
+ json_object_mapper/__init__.py,sha256=4vcntXOGWh_GdDMLvaMIcEApPCTEoyrNxZhgUAXzcEU,170
2
+ json_object_mapper/core.py,sha256=OQJYNAws71cMroocNODJu4LZI6TTk6RM_hYLPnKQdNQ,8342
3
+ json_object_mapper/exceptions.py,sha256=6faK1dOrGhkMlZAlc5gd3gBrEpODrYgspondKDYvFT4,94
4
+ json_object_mapper/helpers.py,sha256=-VvK8iset75HJROpAU_midZuzQhwa5LqhU9-AtoqiMM,4534
5
+ json_object_mapper-2.0.0.dist-info/licenses/LICENSE,sha256=EF8lAlbQKq7IseJB5rYXmRO-LrSAnZdUcE_hMClzRt8,26
6
+ json_object_mapper-2.0.0.dist-info/METADATA,sha256=-CQ1onvVQ0z2YLuE7I2181Dfc9VM7MTHUdYU-MtdnPs,3617
7
+ json_object_mapper-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ json_object_mapper-2.0.0.dist-info/top_level.txt,sha256=JDsp0plGU8Ih89aeVsGyptwQR-vVxtz-TRs7K9KI354,19
9
+ json_object_mapper-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c)
@@ -0,0 +1 @@
1
+ json_object_mapper