-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathjson_path.py
More file actions
228 lines (199 loc) · 7.62 KB
/
Copy pathjson_path.py
File metadata and controls
228 lines (199 loc) · 7.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
from collections.abc import Iterator
from typing import Any
Token = str | int
def _escape_key_for_brackets(key: str) -> str:
"""Escape a key for bracket notation with double quotes."""
return key.replace("\\", "\\\\").replace('"', '\\"')
def _join_path(base: str, token: Token) -> str:
"""
Join a base path string with a token (dict key or list index) using a JSONPath-like syntax:
- dict keys with no '.' or '[' use dot notation
- otherwise keys are quoted: ["..."]
- list indices use [i]
The base may be '' or '$' or a full path.
"""
if isinstance(token, int):
return f"{base}[{token}]"
key = token
# Use dot-notation if it doesn't break the tokenizer used earlier.
use_dot = (key != "") and ("." not in key) and ("[" not in key)
if use_dot:
if not base or base == "$":
# "$" -> "$.key", "" -> "key"
return (base + "." if base == "$" else "") + key
return base + "." + key
else:
return f'{base}["{_escape_key_for_brackets(key)}"]'
def iter_json_paths(
obj: Any,
*,
include_root: bool = False,
leaves_only: bool = True,
include_containers: bool = False,
include_values: bool = False,
sort_keys: bool = False,
max_depth: int | None = None,
) -> Iterator[tuple[str, Any]]:
"""
Depth-first traversal that yields all JSONPath-like paths in `obj`.
By default, yields leaf paths only; see flags to tweak behavior.
Args:
obj: Any Python object; dicts and lists are traversed. Tuples are treated as leaves
(to mirror the setter that only understands lists).
include_root: If True, include '$' (or '' if combine paths without root) as a node
when include_containers=True and/or when obj is a leaf.
leaves_only: If True, only yield paths to non-dict/non-list values.
include_containers: If True, also yield paths to dict/list containers (including empty ones).
include_values: If True, yield (path, value) tuples; otherwise just the path strings.
sort_keys: If True, iterate dict keys in sorted(str(key)) order (deterministic).
max_depth: Optional positive int to cap recursion depth (root has depth=0). None = unlimited.
Yields:
Either the path string, or (path, value) if include_values=True.
"""
# Prepare root path
root_path = "$" if include_root else ""
seen_ids = set()
def yield_item(path: str, value: Any):
if include_values:
return path, value
return path
def rec(current: Any, path: str, depth: int):
# Depth cap
if max_depth is not None and depth > max_depth:
return
# Cycle protection for containers
if isinstance(current, (dict, list)):
oid = id(current)
if oid in seen_ids:
return
seen_ids.add(oid)
# Dict
if isinstance(current, dict):
if include_containers and not leaves_only:
yield yield_item(path or ("$" if include_root else ""), current)
if not current and include_containers and leaves_only:
# Empty dict counts as a leaf-like container if user wants containers included.
yield yield_item(path or ("$" if include_root else ""), current)
return
# Prepare iteration
items = current.items()
if sort_keys:
# sort by stringified key for deterministic ordering
items = sorted(items, key=lambda kv: str(kv[0])) # type: ignore[assignment]
for k, v in items:
# JSON keys are strings; if not, coerce and quote with brackets
if not isinstance(k, str):
k_str = str(k)
else:
k_str = k
child_path = _join_path(path or ("$" if include_root else ""), k_str)
# Recurse
if isinstance(v, (dict, list)):
# container
if include_containers and not leaves_only:
yield yield_item(child_path, v)
yield from rec(v, child_path, depth + 1)
else:
# leaf
if not leaves_only and include_containers:
# also include the parent container (already handled), leaf comes too
pass
yield yield_item(child_path, v)
# List
elif isinstance(current, list):
if include_containers and not leaves_only:
yield yield_item(path or ("$" if include_root else ""), current)
if not current and include_containers and leaves_only:
# Empty list as container-leaf
yield yield_item(path or ("$" if include_root else ""), current)
return
for idx, v in enumerate(current):
child_path = _join_path(path or ("$" if include_root else ""), idx)
if isinstance(v, (dict, list)):
if include_containers and not leaves_only:
yield yield_item(child_path, v)
yield from rec(v, child_path, depth + 1)
else:
yield yield_item(child_path, v)
# Scalar leaf or unsupported container type (tuple/set/etc. treated as leaf)
elif path or include_root:
yield yield_item(path or "$", current)
else:
# Edge case: scalar root without '$'
yield yield_item("", current)
# Optionally include the root itself
if include_root and include_containers and not leaves_only:
yield yield_item("$", obj)
yield from rec(obj, root_path, depth=0)
def list_json_paths(
obj: Any,
*,
include_root: bool = False,
leaves_only: bool = True,
include_containers: bool = False,
sort_keys: bool = False,
max_depth: int | None = None,
) -> list[str | tuple[str, Any]]:
"""
Convenience wrapper that returns only path strings.
"""
return list(
iter_json_paths(
obj,
include_root=include_root,
leaves_only=leaves_only,
include_containers=include_containers,
include_values=False,
sort_keys=sort_keys,
max_depth=max_depth,
)
)
def paths_with_values(
obj: Any,
*,
include_root: bool = False,
leaves_only: bool = True,
include_containers: bool = False,
sort_keys: bool = False,
max_depth: int | None = None,
exclude_none: bool = False,
) -> list[tuple[str, Any]]:
"""
Return a list of (path, value) pairs for `obj` using the same JSONPath-like syntax.
Set `exclude_none=True` to drop entries where value is None.
"""
pairs = iter_json_paths(
obj,
include_root=include_root,
leaves_only=leaves_only,
include_containers=include_containers,
include_values=True,
sort_keys=sort_keys,
max_depth=max_depth,
)
if exclude_none:
return [(p, v) for p, v in pairs if v is not None]
return list(pairs)
def path_value_map(
obj: Any,
*,
include_root: bool = False,
leaves_only: bool = True,
include_containers: bool = False,
sort_keys: bool = False,
max_depth: int | None = None,
exclude_none: bool = False,
) -> dict[str, Any]:
"""
Return a dict mapping path -> value.
"""
pairs = paths_with_values(
obj,
include_root=include_root,
leaves_only=leaves_only,
include_containers=include_containers,
sort_keys=sort_keys,
max_depth=max_depth,
exclude_none=exclude_none,
)
return dict(pairs)