grabmonkey 1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RexBytes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: grabmonkey
3
+ Version: 1.0.0
4
+ Summary: Dot-path access, mutation, and flattening for deeply nested dict/JSON data, with clear error messages.
5
+ Author-email: GoodBoy <pythonic@rexbytes.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 RexBytes
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/rexbytes/grabmonkey
29
+ Project-URL: Issues, https://github.com/rexbytes/grabmonkey/issues
30
+ Keywords: nested,dict,json,dotpath,path,access,flatten
31
+ Classifier: Development Status :: 5 - Production/Stable
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
39
+ Requires-Python: >=3.11
40
+ Description-Content-Type: text/markdown
41
+ License-File: LICENSE
42
+ Provides-Extra: dev
43
+ Requires-Dist: pytest>=7.0; extra == "dev"
44
+ Requires-Dist: pytest-cov; extra == "dev"
45
+ Requires-Dist: hypothesis>=6.0; extra == "dev"
46
+ Dynamic: license-file
47
+
48
+ # grabmonkey
49
+
50
+ Dot-path access, mutation, and flattening for deeply nested dict/JSON data —
51
+ with error messages that tell you exactly which level failed.
52
+
53
+ Stop writing this:
54
+
55
+ ```python
56
+ data.get("response", {}).get("results", [{}])[0].get("metadata", {}).get("created_at", "")
57
+ ```
58
+
59
+ Write this:
60
+
61
+ ```python
62
+ from grabmonkey import grab
63
+
64
+ grab(data, "response.results[0].metadata.created_at", default="")
65
+ ```
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install grabmonkey
71
+ ```
72
+
73
+ Requires Python 3.11+. No runtime dependencies.
74
+
75
+ ## Quick start
76
+
77
+ ```python
78
+ from grabmonkey import grab, grab_many, put, delete, flatten, unflatten
79
+
80
+ data = {"response": {"results": [{"metadata": {"id": 1, "created_at": "2026"}}]}}
81
+
82
+ # Read, any depth
83
+ grab(data, "response.results[0].metadata.created_at") # "2026"
84
+
85
+ # Safe default instead of an exception
86
+ grab(data, "response.results[0].missing", default="") # ""
87
+
88
+ # Type coercion on access
89
+ grab({"count": "42"}, "count", as_type=int) # 42
90
+
91
+ # Wildcards over lists
92
+ grab({"items": [{"n": 1}, {"n": 2}]}, "items[*].n") # [1, 2]
93
+
94
+ # Batch access
95
+ grab_many(data, {"id": "response.results[0].metadata.id"}) # {"id": 1}
96
+
97
+ # Set and delete (mutates in place, creates intermediate containers)
98
+ d = {}
99
+ put(d, "a.b[2].c", 9) # {"a": {"b": [None, None, {"c": 9}]}}
100
+ delete(d, "a.b[2].c") # {"a": {"b": [None, None, {}]}}
101
+ delete(d, "a.b[2]", prune=True) # {"a": {"b": [None, None]}} (prune drops empties)
102
+
103
+ # Flatten / unflatten
104
+ flatten({"a": {"b": [1, 2]}}) # {"a.b[0]": 1, "a.b[1]": 2}
105
+ unflatten({"a.b[0]": 1, "a.b[1]": 2}) # {"a": {"b": [1, 2]}}
106
+ ```
107
+
108
+ ## Clear errors
109
+
110
+ ```python
111
+ grab(data, "response.results[0].nope")
112
+ # grabmonkey.errors.PathError:
113
+ # Path 'response.results[0].nope' failed at 'nope':
114
+ # key not found in dict with keys ['metadata']
115
+ ```
116
+
117
+ `PathError` (a `LookupError`) means a structural miss — the case `default=`
118
+ absorbs. `PathSyntaxError` means a malformed path string. `CoercionError` means
119
+ the value was found but `as_type` could not convert it. The last two are *not*
120
+ absorbed by `default=`, on purpose.
121
+
122
+ ## Path syntax
123
+
124
+ | Syntax | Meaning |
125
+ |---|---|
126
+ | `a.b.c` | dict key or object attribute |
127
+ | `items[0]`, `items[-1]` | sequence index |
128
+ | `items[*]` | every element of a sequence / value of a mapping |
129
+ | `data['weird.key']` | quoted key for keys with dots/brackets/spaces |
130
+
131
+ Works on dicts, lists, dataclasses, named tuples, and any object with
132
+ attribute access. Strings and bytes are treated as leaf values, not navigable
133
+ containers (see LIMITATIONS.md).
134
+
135
+ ## CLI
136
+
137
+ Reads JSON from a file (`--input`) or stdin, prints JSON:
138
+
139
+ ```bash
140
+ echo '{"user":{"name":"Alice"}}' | grabmonkey grab user.name # "Alice"
141
+ echo '{"a":{"b":1}}' | grabmonkey flatten # {"a.b": 1}
142
+ grabmonkey grab metadata.id --input response.json
143
+ ```
144
+
145
+ ## Using with AI assistants
146
+
147
+ See [SKILL.md](./SKILL.md) for an LLM-consumable reference (decision table,
148
+ worked examples, anti-patterns). See [LIMITATIONS.md](./LIMITATIONS.md) for
149
+ deliberate design tradeoffs.
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,106 @@
1
+ # grabmonkey
2
+
3
+ Dot-path access, mutation, and flattening for deeply nested dict/JSON data —
4
+ with error messages that tell you exactly which level failed.
5
+
6
+ Stop writing this:
7
+
8
+ ```python
9
+ data.get("response", {}).get("results", [{}])[0].get("metadata", {}).get("created_at", "")
10
+ ```
11
+
12
+ Write this:
13
+
14
+ ```python
15
+ from grabmonkey import grab
16
+
17
+ grab(data, "response.results[0].metadata.created_at", default="")
18
+ ```
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install grabmonkey
24
+ ```
25
+
26
+ Requires Python 3.11+. No runtime dependencies.
27
+
28
+ ## Quick start
29
+
30
+ ```python
31
+ from grabmonkey import grab, grab_many, put, delete, flatten, unflatten
32
+
33
+ data = {"response": {"results": [{"metadata": {"id": 1, "created_at": "2026"}}]}}
34
+
35
+ # Read, any depth
36
+ grab(data, "response.results[0].metadata.created_at") # "2026"
37
+
38
+ # Safe default instead of an exception
39
+ grab(data, "response.results[0].missing", default="") # ""
40
+
41
+ # Type coercion on access
42
+ grab({"count": "42"}, "count", as_type=int) # 42
43
+
44
+ # Wildcards over lists
45
+ grab({"items": [{"n": 1}, {"n": 2}]}, "items[*].n") # [1, 2]
46
+
47
+ # Batch access
48
+ grab_many(data, {"id": "response.results[0].metadata.id"}) # {"id": 1}
49
+
50
+ # Set and delete (mutates in place, creates intermediate containers)
51
+ d = {}
52
+ put(d, "a.b[2].c", 9) # {"a": {"b": [None, None, {"c": 9}]}}
53
+ delete(d, "a.b[2].c") # {"a": {"b": [None, None, {}]}}
54
+ delete(d, "a.b[2]", prune=True) # {"a": {"b": [None, None]}} (prune drops empties)
55
+
56
+ # Flatten / unflatten
57
+ flatten({"a": {"b": [1, 2]}}) # {"a.b[0]": 1, "a.b[1]": 2}
58
+ unflatten({"a.b[0]": 1, "a.b[1]": 2}) # {"a": {"b": [1, 2]}}
59
+ ```
60
+
61
+ ## Clear errors
62
+
63
+ ```python
64
+ grab(data, "response.results[0].nope")
65
+ # grabmonkey.errors.PathError:
66
+ # Path 'response.results[0].nope' failed at 'nope':
67
+ # key not found in dict with keys ['metadata']
68
+ ```
69
+
70
+ `PathError` (a `LookupError`) means a structural miss — the case `default=`
71
+ absorbs. `PathSyntaxError` means a malformed path string. `CoercionError` means
72
+ the value was found but `as_type` could not convert it. The last two are *not*
73
+ absorbed by `default=`, on purpose.
74
+
75
+ ## Path syntax
76
+
77
+ | Syntax | Meaning |
78
+ |---|---|
79
+ | `a.b.c` | dict key or object attribute |
80
+ | `items[0]`, `items[-1]` | sequence index |
81
+ | `items[*]` | every element of a sequence / value of a mapping |
82
+ | `data['weird.key']` | quoted key for keys with dots/brackets/spaces |
83
+
84
+ Works on dicts, lists, dataclasses, named tuples, and any object with
85
+ attribute access. Strings and bytes are treated as leaf values, not navigable
86
+ containers (see LIMITATIONS.md).
87
+
88
+ ## CLI
89
+
90
+ Reads JSON from a file (`--input`) or stdin, prints JSON:
91
+
92
+ ```bash
93
+ echo '{"user":{"name":"Alice"}}' | grabmonkey grab user.name # "Alice"
94
+ echo '{"a":{"b":1}}' | grabmonkey flatten # {"a.b": 1}
95
+ grabmonkey grab metadata.id --input response.json
96
+ ```
97
+
98
+ ## Using with AI assistants
99
+
100
+ See [SKILL.md](./SKILL.md) for an LLM-consumable reference (decision table,
101
+ worked examples, anti-patterns). See [LIMITATIONS.md](./LIMITATIONS.md) for
102
+ deliberate design tradeoffs.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "grabmonkey"
7
+ version = "1.0.0"
8
+ description = "Dot-path access, mutation, and flattening for deeply nested dict/JSON data, with clear error messages."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "GoodBoy", email = "pythonic@rexbytes.com" }]
12
+ requires-python = ">=3.11"
13
+ keywords = ["nested", "dict", "json", "dotpath", "path", "access", "flatten"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=7.0", "pytest-cov", "hypothesis>=6.0"]
28
+
29
+ [project.scripts]
30
+ grabmonkey = "grabmonkey.cli:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/rexbytes/grabmonkey"
34
+ Issues = "https://github.com/rexbytes/grabmonkey/issues"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,29 @@
1
+ """grabmonkey: dot-path access, mutation, and flattening for nested data.
2
+
3
+ Public API (import from the package root):
4
+
5
+ from grabmonkey import grab, grab_many, put, delete, flatten, unflatten
6
+ from grabmonkey import GrabError, PathSyntaxError, PathError, CoercionError
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .access import grab, grab_many
12
+ from .errors import CoercionError, GrabError, PathError, PathSyntaxError
13
+ from .flatten import flatten, unflatten
14
+ from .mutate import delete, put
15
+
16
+ __all__ = [
17
+ "grab",
18
+ "grab_many",
19
+ "put",
20
+ "delete",
21
+ "flatten",
22
+ "unflatten",
23
+ "GrabError",
24
+ "PathSyntaxError",
25
+ "PathError",
26
+ "CoercionError",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
@@ -0,0 +1,218 @@
1
+ """Read access: :func:`grab` and :func:`grab_many`.
2
+
3
+ This module exists to walk a parsed path against in-memory data and either
4
+ return the resolved value or raise a :class:`PathError` whose message names the
5
+ exact segment that failed and why. It treats mappings as key lookups,
6
+ sequences as positional indexing, and anything else as attribute access, so the
7
+ same path works against dicts, lists, dataclasses, named tuples, and plain
8
+ objects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping, Sequence
14
+ from typing import Any, Callable, Mapping as MappingT
15
+
16
+ from .coerce import coerce_value
17
+ from .errors import PathError
18
+ from .path import (
19
+ Index,
20
+ Key,
21
+ Token,
22
+ Wildcard,
23
+ _is_simple_key,
24
+ _quote_key,
25
+ parse_path,
26
+ render_path,
27
+ )
28
+
29
+ _MISSING = object()
30
+
31
+ # How many dict keys to list before truncating, in a "key not found" message.
32
+ _MAX_KEYS_SHOWN = 12
33
+
34
+
35
+ def _describe_keys(mapping: Mapping[Any, Any]) -> str:
36
+ keys = list(mapping.keys())
37
+ shown = ", ".join(repr(k) for k in keys[:_MAX_KEYS_SHOWN])
38
+ if len(keys) > _MAX_KEYS_SHOWN:
39
+ shown += f", …(+{len(keys) - _MAX_KEYS_SHOWN} more)"
40
+ return "[" + shown + "]"
41
+
42
+
43
+ def _segment_label(tok: Token) -> str:
44
+ # Render the failing segment the same way render_path would, so the label is
45
+ # a literal substring of the rendered path (quoted keys included).
46
+ if isinstance(tok, Key):
47
+ return f"'{tok.name}'" if _is_simple_key(tok.name) else _quote_key(tok.name)
48
+ if isinstance(tok, Index):
49
+ return f"[{tok.index}]"
50
+ return "[*]"
51
+
52
+
53
+ def _fail(tokens: list[Token], k: int, reason: str) -> PathError:
54
+ rendered = render_path(tokens[: k + 1])
55
+ return PathError(
56
+ f"Path {rendered!r} failed at {_segment_label(tokens[k])}: {reason}"
57
+ )
58
+
59
+
60
+ def _is_seq(value: Any) -> bool:
61
+ return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray))
62
+
63
+
64
+ def _step(current: Any, tokens: list[Token], k: int) -> Any:
65
+ tok = tokens[k]
66
+ if isinstance(tok, Key):
67
+ if isinstance(current, Mapping):
68
+ if tok.name in current:
69
+ return current[tok.name]
70
+ raise _fail(
71
+ tokens, k,
72
+ f"key not found in dict with keys {_describe_keys(current)}",
73
+ )
74
+ if current is None:
75
+ raise _fail(tokens, k, "value is None, cannot look up a key")
76
+ if isinstance(current, (str, bytes, bytearray)):
77
+ # str/bytes/bytearray are treated as leaf values, not navigable
78
+ # containers (see LIMITATIONS.md). Stop here with a clear message
79
+ # rather than returning a bound method like str.upper.
80
+ raise _fail(
81
+ tokens, k,
82
+ f"reached a {type(current).__name__} leaf, cannot look up a key "
83
+ f"(str/bytes are leaf values, not containers)",
84
+ )
85
+ # Anything else (objects, dataclasses, named tuples) is tried as an
86
+ # attribute. Named tuples are Sequences, so this must come after the
87
+ # str/bytes guard but apply to sequences too.
88
+ try:
89
+ return getattr(current, tok.name)
90
+ except AttributeError:
91
+ raise _fail(
92
+ tokens, k,
93
+ f"attribute not found on {type(current).__name__} object",
94
+ ) from None
95
+
96
+ if isinstance(tok, Index):
97
+ if isinstance(current, Mapping):
98
+ if tok.index in current:
99
+ return current[tok.index]
100
+ raise _fail(
101
+ tokens, k,
102
+ f"integer key {tok.index} not found in dict with keys "
103
+ f"{_describe_keys(current)}",
104
+ )
105
+ if current is None:
106
+ raise _fail(tokens, k, "value is None, cannot index")
107
+ if _is_seq(current):
108
+ try:
109
+ return current[tok.index]
110
+ except IndexError:
111
+ raise _fail(
112
+ tokens, k,
113
+ f"index {tok.index} out of range for "
114
+ f"{type(current).__name__} of length {len(current)}",
115
+ ) from None
116
+ raise _fail(
117
+ tokens, k,
118
+ f"expected a sequence to index, got {type(current).__name__}",
119
+ )
120
+
121
+ raise AssertionError(f"unhandled token type: {tok!r}") # pragma: no cover
122
+
123
+
124
+ def _iter_wildcard(current: Any, tokens: list[Token], k: int):
125
+ if isinstance(current, Mapping):
126
+ return list(current.values())
127
+ if _is_seq(current):
128
+ return list(current)
129
+ if current is None:
130
+ raise _fail(tokens, k, "value is None, cannot expand wildcard '[*]'")
131
+ raise _fail(
132
+ tokens, k,
133
+ f"expected a sequence or mapping to expand '[*]', got "
134
+ f"{type(current).__name__}",
135
+ )
136
+
137
+
138
+ def _traverse(current: Any, tokens: list[Token], start: int = 0) -> Any:
139
+ k = start
140
+ while k < len(tokens):
141
+ tok = tokens[k]
142
+ if isinstance(tok, Wildcard):
143
+ elements = _iter_wildcard(current, tokens, k)
144
+ results = []
145
+ for element in elements:
146
+ try:
147
+ results.append(_traverse(element, tokens, k + 1))
148
+ except PathError:
149
+ # Elements that lack the remaining sub-path are skipped, not
150
+ # padded; see LIMITATIONS.md ("wildcard skips misses").
151
+ continue
152
+ return results
153
+ current = _step(current, tokens, k)
154
+ k += 1
155
+ return current
156
+
157
+
158
+ def grab(
159
+ data: Any,
160
+ path: str,
161
+ *,
162
+ default: Any = _MISSING,
163
+ as_type: Callable[[Any], Any] | None = None,
164
+ ) -> Any:
165
+ """Resolve ``path`` against ``data`` and return the value.
166
+
167
+ ``default``
168
+ Returned instead of raising when the path does not resolve
169
+ (:class:`PathError`). Absent by default, so a miss raises. A
170
+ :class:`PathSyntaxError` (malformed path) and a :class:`CoercionError`
171
+ (bad ``as_type``) are *not* absorbed by ``default``.
172
+ ``as_type``
173
+ One-argument callable applied to the resolved value. For a wildcard
174
+ path it is applied to each matched element. The ``default`` is returned
175
+ as-is and is never coerced.
176
+
177
+ Wildcards (``items[*].name``) return a list; elements that lack the
178
+ sub-path are skipped (see LIMITATIONS.md).
179
+ """
180
+ tokens = parse_path(path)
181
+ try:
182
+ value = _traverse(data, tokens)
183
+ except PathError:
184
+ if default is not _MISSING:
185
+ return default
186
+ raise
187
+
188
+ if as_type is not None:
189
+ rendered = render_path(tokens)
190
+ depth = sum(1 for t in tokens if isinstance(t, Wildcard))
191
+ value = _coerce_result(value, as_type, rendered, depth)
192
+ return value
193
+
194
+
195
+ def _coerce_result(value: Any, as_type: Callable[[Any], Any], rendered: str, depth: int) -> Any:
196
+ if depth == 0:
197
+ return coerce_value(value, as_type, rendered)
198
+ return [_coerce_result(item, as_type, rendered, depth - 1) for item in value]
199
+
200
+
201
+ def grab_many(
202
+ data: Any,
203
+ paths: MappingT[str, str],
204
+ *,
205
+ default: Any = _MISSING,
206
+ ) -> dict[str, Any]:
207
+ """Resolve several paths at once.
208
+
209
+ ``paths`` maps result keys to path strings; the return dict has the same
210
+ keys mapped to resolved values. ``default`` (one value for all paths) is
211
+ applied per path exactly as in :func:`grab`.
212
+ """
213
+ if not isinstance(paths, Mapping):
214
+ raise TypeError(
215
+ f"grab_many expects a mapping of name -> path, got "
216
+ f"{type(paths).__name__}"
217
+ )
218
+ return {name: grab(data, p, default=default) for name, p in paths.items()}