json-object-mapper 2.0.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,3 @@
1
+ MIT License
2
+
3
+ Copyright (c)
@@ -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,96 @@
1
+ # json-object-mapper
2
+
3
+ **`json-object-mapper`** is a lightweight Python library that lets you interact with JSON data using **attribute-style access** instead of dictionary keys.
4
+
5
+ The PyPI distribution is published as `json-object-mapper`.
6
+
7
+ It turns JSON objects into `JSONObjectMapper` instances that feel natural to use in Python code, while preserving full JSON compatibility.
8
+
9
+ ```python
10
+ from json_object_mapper import JSONObjectMapper
11
+
12
+ data = {
13
+ "user": {"name": "Kobby", "age": 29, "skills": ["python", "aws", "forex"]}
14
+ }
15
+ obj = JSONObjectMapper(data)
16
+ print(obj.user.name) # "Kobby"
17
+ print(obj.user.skills[0]) # "python"
18
+ print(obj.to_json(indent=2))
19
+ ```
20
+
21
+ ## ✨ Features
22
+
23
+ - Attribute-style access (`obj.key`) for dict keys
24
+ - Recursive wrapping for nested dicts and lists
25
+ - Read-only mode (immutability enforced)
26
+ - Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
27
+ - **New:** `set_path()` / `del_path()` for dot paths
28
+ - **New:** `default_factory` + `autocreate_missing` for safe defaults
29
+ - Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install json-object-mapper
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from json_object_mapper import JSONObjectMapper, JSONAccessError
41
+
42
+ # 1) Wrap & read (basic)
43
+ obj = JSONObjectMapper({"user": {"name": "Kobby", "age": 29}})
44
+ assert obj.user.name == "Kobby"
45
+ assert obj.user.age == 29
46
+
47
+ # 2) Write with dot notation
48
+ obj.user.country = "GH"
49
+ assert obj.user.country == "GH"
50
+
51
+ # 3) Lists of dicts (read & write via attribute access)
52
+ obj.services = [{}] # start with a list containing one dict
53
+ obj.services[0].name = "auth" # item is wrapped → dot works
54
+ obj.services[0].enabled = True
55
+ assert obj.services[0].name == "auth"
56
+ assert obj.services[0].enabled is True
57
+
58
+ # 4) Non-identifier keys (use mapping-style access)
59
+ # Keys like "first-name" can't be attributes; use get()/[] instead
60
+ obj.meta = {"first-name": "Kobby"}
61
+ assert obj.meta.get("first-name") == "Kobby"
62
+ assert obj.meta["first-name"] == "Kobby"
63
+ # getattr(obj.meta, "first-name") would raise JSONAccessError
64
+
65
+ # 5) Safe defaults + auto-create (no extra helpers required)
66
+ # Missing attributes produce defaults; with autocreate they persist.
67
+ cfg = JSONObjectMapper({}, default_factory=dict, autocreate_missing=True)
68
+ cfg.profile.settings.theme = "dark" # on-demand creation of nested dicts
69
+ assert cfg.profile.settings.theme == "dark"
70
+
71
+ # 6) Merge convenience (shallow merge into root dict)
72
+ cfg.merge({"features": {"beta": True}})
73
+ assert cfg.features.beta is True
74
+
75
+ # 7) Read-only wrappers (safe reads; writes raise)
76
+ ro = JSONObjectMapper({"debug": True}, readonly=True)
77
+ assert ro.debug is True
78
+ try:
79
+ ro.debug = False
80
+ raise AssertionError("should not be able to write in readonly mode")
81
+ except AttributeError:
82
+ pass
83
+ ```
84
+
85
+ ## Tests
86
+
87
+ ```bash
88
+ python -m pip install -e .
89
+ python -m unittest discover -s tests -v
90
+ ```
91
+
92
+ ## Publishing
93
+
94
+ Publishing is automated with GitHub Actions using PyPI trusted publishing. Create a GitHub release after configuring the `pypi` environment in the repository settings.
95
+
96
+ MIT License.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "json-object-mapper"
7
+ version = "2.0.0"
8
+ description = "Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ authors = [{name="Kobby Owen", email="dev@kobbyowen.com"}]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/kobbyowen/json2obj"
20
+ Repository = "https://github.com/kobbyowen/json2obj"
21
+ Issues = "https://github.com/kobbyowen/json2obj/issues"
22
+
23
+ [tool.setuptools]
24
+ package-dir = {"" = "src"}
25
+ include-package-data = true
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.pytest.ini_options]
31
+ addopts = "-q"
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+ target-version = "py38" # change to py39/py310/py311 as needed
36
+ extend-exclude = ["build", "dist", ".venv", ".git", ".mypy_cache", ".pytest_cache"]
37
+
38
+ [tool.ruff.lint]
39
+ # Rule sets: E (pycodestyle), F (pyflakes), I (isort), UP (pyupgrade), B (bugbear),
40
+ # SIM (simplify), PL (pylint-lite), RUF (ruff-specific)
41
+ select = ["E", "F", "I", "UP", "B", "SIM", "PL", "RUF"]
42
+ ignore = ["E501"] # ignore line-length; if you use Ruff as formatter, you can remove this
43
+ per-file-ignores = { "tests/*" = ["PLR2004"] } # allow magic numbers in tests
44
+
45
+ [tool.ruff.format] # only used if you run "ruff format"
46
+ quote-style = "double"
47
+ indent-style = "space"
48
+ skip-magic-trailing-comma = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ import setuptools
2
+
3
+ setuptools.setup()
@@ -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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ src/json_object_mapper/__init__.py
6
+ src/json_object_mapper/core.py
7
+ src/json_object_mapper/exceptions.py
8
+ src/json_object_mapper/helpers.py
9
+ src/json_object_mapper.egg-info/PKG-INFO
10
+ src/json_object_mapper.egg-info/SOURCES.txt
11
+ src/json_object_mapper.egg-info/dependency_links.txt
12
+ src/json_object_mapper.egg-info/top_level.txt
13
+ tests/test_json_object_mapper.py
@@ -0,0 +1 @@
1
+ json_object_mapper
@@ -0,0 +1,128 @@
1
+ import unittest
2
+
3
+ from json_object_mapper import JSONAccessError, JSONObjectMapper
4
+
5
+
6
+ class TestJSONObjectMapper(unittest.TestCase):
7
+ def test_attr_access(self):
8
+ m = JSONObjectMapper({"a": {"b": 1}})
9
+ self.assertEqual(m.a.b, 1)
10
+
11
+ def test_list_indexing(self):
12
+ m = JSONObjectMapper({"xs": [{"v": 1}, {"v": 2}]})
13
+ self.assertEqual(m.xs[1].v, 2)
14
+
15
+ def test_invalid_attr(self):
16
+ m = JSONObjectMapper({"a": 1})
17
+ with self.assertRaises(JSONAccessError):
18
+ _ = m.not_here
19
+
20
+ def test_non_identifier_key(self):
21
+ m = JSONObjectMapper({"not-valid!": 3})
22
+ self.assertEqual(m.get("not-valid!"), 3)
23
+ with self.assertRaises(JSONAccessError):
24
+ _ = getattr(m, "not-valid!")
25
+
26
+ def test_set_readonly(self):
27
+ m = JSONObjectMapper({"a": 1}, readonly=True)
28
+ with self.assertRaises(AttributeError):
29
+ m.a = 2
30
+ with self.assertRaises(AttributeError):
31
+ m["a"] = 2
32
+
33
+ def test_merge(self):
34
+ m = JSONObjectMapper({"a": 1})
35
+ m.merge({"b": 2})
36
+ self.assertEqual(m.b, 2)
37
+
38
+ def test_to_from_json(self):
39
+ m = JSONObjectMapper.from_json('{"x": 7, "y": {"z": 9}}')
40
+ self.assertEqual(m.y.z, 9)
41
+ s = m.to_json()
42
+ self.assertIn('"x": 7', s)
43
+
44
+ def test_get_path(self):
45
+ m = JSONObjectMapper({"u": {"n": {"a": [{"me": "k"}]}}})
46
+ self.assertEqual(m.get_path("u.n.a[0].me"), "k")
47
+ self.assertEqual(m.get_path("u.missing", default=None), None)
48
+
49
+ def test_default_factory_no_autocreate(self):
50
+ m = JSONObjectMapper({}, default_factory=dict)
51
+ self.assertEqual(m.profile.to_dict(), {})
52
+ self.assertNotIn("profile", m.to_dict())
53
+
54
+ def test_default_factory_with_autocreate(self):
55
+ m = JSONObjectMapper({}, default_factory=dict, autocreate_missing=True)
56
+ _ = m.profile
57
+ self.assertIn("profile", m.to_dict())
58
+ m.profile.settings = {"theme": "dark"}
59
+ self.assertEqual(m.profile.settings.theme, "dark")
60
+
61
+ def test_default_factory_readonly(self):
62
+ m = JSONObjectMapper({}, default_factory=dict, autocreate_missing=True, readonly=True)
63
+ _ = m.profile
64
+ self.assertNotIn("profile", m.to_dict())
65
+
66
+ def test_set_path_simple_create_parents(self):
67
+ m = JSONObjectMapper({})
68
+ m.set_path("a.b[0].c", 10)
69
+ self.assertEqual(m.get_path("a.b[0].c"), 10)
70
+ self.assertEqual(m.to_dict(), {"a": {"b": [{"c": 10}]}})
71
+
72
+ def test_set_path_replace_value(self):
73
+ m = JSONObjectMapper({"a": {"b": [{"c": 1}]}})
74
+ m.set_path("a.b[0].c", 2)
75
+ self.assertEqual(m.get_path("a.b[0].c"), 2)
76
+
77
+ def test_set_path_no_create_parents_errors(self):
78
+ m = JSONObjectMapper({})
79
+ with self.assertRaises(KeyError):
80
+ m.set_path("a.b.c", 1, create_parents=False)
81
+ m = JSONObjectMapper({"a": {}})
82
+ with self.assertRaises(TypeError):
83
+ m.set_path("a[0].c", 1, create_parents=False)
84
+ m = JSONObjectMapper({"a": {"b": []}})
85
+ with self.assertRaises(IndexError):
86
+ m.set_path("a.b[2].c", 1, create_parents=False)
87
+
88
+ def test_set_path_list_growth(self):
89
+ m = JSONObjectMapper({"xs": []})
90
+ m.set_path("xs[2]", 99)
91
+ self.assertEqual(m.to_dict(), {"xs": [None, None, 99]})
92
+
93
+ def test_del_path_dict_key(self):
94
+ m = JSONObjectMapper({"a": {"b": 1, "c": 2}})
95
+ m.del_path("a.b")
96
+ self.assertEqual(m.to_dict(), {"a": {"c": 2}})
97
+
98
+ def test_del_path_list_index(self):
99
+ m = JSONObjectMapper({"xs": [10, 20, 30]})
100
+ m.del_path("xs[1]")
101
+ self.assertEqual(m.to_dict(), {"xs": [10, 30]})
102
+
103
+ def test_del_path_missing_silent(self):
104
+ m = JSONObjectMapper({"a": {"b": 1}})
105
+ m.del_path("a.c")
106
+ self.assertEqual(m.to_dict(), {"a": {"b": 1}})
107
+
108
+ def test_del_path_missing_raise(self):
109
+ m = JSONObjectMapper({"a": {"b": 1}})
110
+ with self.assertRaises(KeyError):
111
+ m.del_path("a.c", raise_on_missing=True)
112
+ m2 = JSONObjectMapper({"xs": [1]})
113
+ with self.assertRaises(IndexError):
114
+ m2.del_path("xs[5]", raise_on_missing=True)
115
+
116
+ def test_set_path_readonly_raises(self):
117
+ m = JSONObjectMapper({}, readonly=True)
118
+ with self.assertRaises(AttributeError):
119
+ m.set_path("a.b", 1)
120
+
121
+ def test_del_path_readonly_raises(self):
122
+ m = JSONObjectMapper({"a": {"b": 1}}, readonly=True)
123
+ with self.assertRaises(AttributeError):
124
+ m.del_path("a.b")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ unittest.main()