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.
- grabmonkey-1.0.0/LICENSE +21 -0
- grabmonkey-1.0.0/PKG-INFO +153 -0
- grabmonkey-1.0.0/README.md +106 -0
- grabmonkey-1.0.0/pyproject.toml +41 -0
- grabmonkey-1.0.0/setup.cfg +4 -0
- grabmonkey-1.0.0/src/grabmonkey/__init__.py +29 -0
- grabmonkey-1.0.0/src/grabmonkey/access.py +218 -0
- grabmonkey-1.0.0/src/grabmonkey/cli.py +170 -0
- grabmonkey-1.0.0/src/grabmonkey/coerce.py +32 -0
- grabmonkey-1.0.0/src/grabmonkey/errors.py +45 -0
- grabmonkey-1.0.0/src/grabmonkey/flatten.py +202 -0
- grabmonkey-1.0.0/src/grabmonkey/mutate.py +226 -0
- grabmonkey-1.0.0/src/grabmonkey/path.py +242 -0
- grabmonkey-1.0.0/src/grabmonkey.egg-info/PKG-INFO +153 -0
- grabmonkey-1.0.0/src/grabmonkey.egg-info/SOURCES.txt +24 -0
- grabmonkey-1.0.0/src/grabmonkey.egg-info/dependency_links.txt +1 -0
- grabmonkey-1.0.0/src/grabmonkey.egg-info/entry_points.txt +2 -0
- grabmonkey-1.0.0/src/grabmonkey.egg-info/requires.txt +5 -0
- grabmonkey-1.0.0/src/grabmonkey.egg-info/top_level.txt +1 -0
- grabmonkey-1.0.0/tests/test_access.py +173 -0
- grabmonkey-1.0.0/tests/test_cli.py +155 -0
- grabmonkey-1.0.0/tests/test_flatten.py +69 -0
- grabmonkey-1.0.0/tests/test_mutate.py +133 -0
- grabmonkey-1.0.0/tests/test_path.py +115 -0
- grabmonkey-1.0.0/tests/test_properties.py +107 -0
- grabmonkey-1.0.0/tests/test_review_fixes.py +290 -0
grabmonkey-1.0.0/LICENSE
ADDED
|
@@ -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,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()}
|