Skip to content

Commit a46d66a

Browse files
committed
Redesign traversal path to make it more useful.
1 parent 5f14f36 commit a46d66a

2 files changed

Lines changed: 325 additions & 234 deletions

File tree

src/odin/traversal.py

Lines changed: 186 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,126 @@
11
"""Traversal of a datastructure."""
2-
from typing import Union, Sequence, Iterable, Optional, Tuple, Type
2+
import itertools
3+
from typing import Union, Optional, NamedTuple, Sequence, cast, Iterable, List, Any
34

5+
import odin
6+
from odin.exceptions import InvalidPathError, NoMatchError, MultipleMatchesError
7+
from odin.resources import ResourceBase
48
from odin.utils import getmeta
59

6-
from .exceptions import NoMatchError, MultipleMatchesError, InvalidPathError
7-
from .resources import ResourceBase
810

11+
class _NotSupplied:
12+
"""Placeholder"""
913

10-
class NotSupplied:
11-
pass
14+
__slots__ = ()
1215

1316

14-
NotSuppliedType = Type[NotSupplied]
15-
OptionalStr = Union[str, NotSuppliedType]
16-
PathAtom = Tuple[OptionalStr, OptionalStr, str]
17+
NotSupplied = _NotSupplied()
18+
NotSuppliedString = Union[str, _NotSupplied]
1719

1820

19-
def _split_atom(atom: str) -> PathAtom:
20-
"""Split a section of a path into lookups that can be used to navigate a path."""
21-
if "[" in atom:
22-
field, _, idx = atom.rstrip("]").partition("[")
23-
return idx, NotSupplied, field
24-
elif "{" in atom:
25-
field, _, kv = atom.rstrip("}").partition("{")
26-
key, _, value = kv.partition("=")
27-
return value, key, field
28-
else:
29-
return NotSupplied, NotSupplied, atom
21+
class PathAtom(NamedTuple):
22+
"""Atom within a traversal path."""
3023

24+
@classmethod
25+
def split(cls, atom: str) -> "PathAtom":
26+
# Index/Key into attribute
27+
if "[" in atom:
28+
attr, _, key = atom.rstrip("]").partition("[")
29+
return cls(key, NotSupplied, attr)
30+
31+
# Filter attributes
32+
if "{" in atom:
33+
attr, _, kv = atom.rstrip("}").partition("{")
34+
key, _, value = kv.partition("=")
35+
return cls(key, value, attr)
36+
37+
# Basic attribute
38+
return cls(NotSupplied, NotSupplied, atom)
39+
40+
@classmethod
41+
def create(
42+
cls,
43+
attr: str = "",
44+
key: NotSuppliedString = NotSupplied,
45+
value: NotSuppliedString = NotSupplied,
46+
) -> "PathAtom":
47+
"""Create a new PathAtom."""
48+
return cls(key, value, attr)
49+
50+
key: NotSuppliedString
51+
value: NotSuppliedString
52+
attr: str
53+
54+
def __repr__(self):
55+
return f"<PathAtom: {self}>"
56+
57+
def __str__(self):
58+
key, value, attr = self
59+
60+
if key is NotSupplied:
61+
return attr
62+
63+
if value is NotSupplied:
64+
return f"{attr}[{key}]"
65+
66+
return f"{attr}{{{key}={value}}}"
67+
68+
@property
69+
def is_indexed(self) -> bool:
70+
"""Indexes into a attribute."""
71+
key, value, _ = self
72+
return key is not NotSupplied and value is NotSupplied
73+
74+
@property
75+
def is_filter(self) -> bool:
76+
"""This is a filter atom."""
77+
_, value, _ = self
78+
return value is not NotSupplied
79+
80+
def extract_element(self, resource: ResourceBase) -> Any:
81+
"""Extract an element from a resource instance"""
82+
key, value, attr = self
83+
84+
try:
85+
field = getmeta(resource).field_map[attr]
86+
except KeyError:
87+
raise InvalidPathError(self, f"Unknown field {attr!r}")
88+
element = field.value_from_object(resource)
89+
90+
if key is NotSupplied:
91+
# No additional lookup required
92+
return element
93+
94+
elif value is NotSupplied:
95+
# Index or key into element
96+
key = cast(odin.CompositeField, field).key_to_python(key)
97+
try:
98+
return element[key]
99+
except LookupError:
100+
raise NoMatchError(self, f"Could not find index {key!r} in {field}.")
101+
102+
else:
103+
# Filter elements
104+
if isinstance(element, dict):
105+
element = element.values()
106+
107+
values = tuple(r for r in element if getattr(r, key) == value)
108+
if len(values) == 0:
109+
raise NoMatchError(
110+
self,
111+
f"Filter matched no values; {key!r} == {value!r} in {field}.",
112+
)
113+
114+
if len(values) > 1:
115+
raise MultipleMatchesError(
116+
self,
117+
f"Filter matched multiple values; {key!r} == {value!r}.",
118+
)
119+
120+
return values[0]
31121

32-
class TraversalPath:
122+
123+
class TraversalPath(tuple, Sequence[PathAtom]):
33124
"""A path through a resource structure."""
34125

35126
@classmethod
@@ -38,96 +129,47 @@ def parse(cls, path: Union["TraversalPath", str]) -> Optional["TraversalPath"]:
38129
if isinstance(path, TraversalPath):
39130
return path
40131
if isinstance(path, str):
41-
return cls(*[_split_atom(a) for a in path.split(".")])
42-
43-
__slots__ = ("_path",)
132+
return cls(PathAtom.split(a) for a in path.split(".")) if path else cls()
44133

45-
def __init__(self, *path: PathAtom):
46-
"""Initialise traversal path"""
47-
self._path = path
134+
__slots__ = ()
48135

49136
def __repr__(self):
50137
return f"<TraversalPath: {self}>"
51138

52139
def __str__(self) -> str:
53-
atoms = []
54-
for value, key, field in self._path:
55-
if value is NotSupplied:
56-
atoms.append(field)
57-
elif key is NotSupplied:
58-
atoms.append(f"{field}[{value}]")
59-
else:
60-
atoms.append(f"{field}{{{key}={value}}}")
61-
return ".".join(atoms)
62-
63-
def __hash__(self) -> int:
64-
"""Hash of the path."""
65-
return hash(self._path)
66-
67-
def __eq__(self, other) -> bool:
68-
"""Compare to another path."""
69-
if isinstance(other, TraversalPath):
70-
return hash(self) == hash(other)
71-
return NotImplemented
140+
return ".".join(str(a) for a in self)
72141

73-
def __add__(self, other) -> "TraversalPath":
142+
def __add__(self, other: Union["TraversalPath", str, PathAtom]) -> "TraversalPath":
74143
"""Join paths together."""
75144
if isinstance(other, TraversalPath):
76-
return TraversalPath(*(self._path + other._path))
145+
return TraversalPath(itertools.chain(self, other))
77146

78-
# Assume appending a field
79147
if isinstance(other, str):
80-
return TraversalPath(
81-
*(self._path + tuple([(NotSupplied, NotSupplied, other)]))
82-
)
148+
other = PathAtom.split(other)
149+
150+
if isinstance(other, PathAtom):
151+
return TraversalPath(itertools.chain(self, (other,)))
83152

84153
raise TypeError(f"Cannot add '{other}' to a path.")
85154

86-
def __iter__(self) -> Iterable[PathAtom]:
87-
"""Iterate a path returning each element on the path."""
88-
return iter(self._path)
155+
@property
156+
def parent(self) -> Optional["TraversalPath"]:
157+
"""Get parent item"""
158+
if len(self) > 1:
159+
return TraversalPath(self[:-1])
160+
161+
def iter_resource(self, root_resource: ResourceBase):
162+
"""Iterate over a resource document. Yielding each parent."""
163+
current = root_resource
164+
yield current
165+
166+
for atom in self:
167+
current = atom.extract_element(current)
168+
yield current
89169

90170
def get_value(self, root_resource: ResourceBase):
91-
"""Get a value from a resource structure."""
92-
result = root_resource
93-
for value, key, attr in self:
94-
meta = getmeta(result)
95-
try:
96-
field = meta.field_map[attr]
97-
except KeyError:
98-
raise InvalidPathError(self, f"Unknown field {attr!r}")
99-
100-
result = field.value_from_object(result)
101-
if value is NotSupplied:
102-
# Nothing is required
103-
continue
104-
elif key is NotSupplied:
105-
# Index or key into element
106-
value = field.key_to_python(value)
107-
try:
108-
result = result[value]
109-
except (KeyError, IndexError):
110-
raise NoMatchError(
111-
self, f"Could not find index {value!r} in {field}."
112-
)
113-
else:
114-
# Filter elements
115-
if isinstance(result, dict):
116-
result = result.values()
117-
results = tuple(r for r in result if getattr(r, key) == value)
118-
if len(results) == 0:
119-
raise NoMatchError(
120-
self,
121-
f"Filter matched no values; {key!r} == {value!r} in {field}.",
122-
)
123-
elif len(results) > 1:
124-
raise MultipleMatchesError(
125-
self,
126-
f"Filter matched multiple values; {key!r} == {value!r}.",
127-
)
128-
else:
129-
result = results[0]
130-
return result
171+
"""Get a value from a resource document."""
172+
return tuple(self.iter_resource(root_resource))[-1]
131173

132174

133175
class ResourceTraversalIterator:
@@ -154,7 +196,7 @@ def __init__(self, resource: Union[ResourceBase, Sequence[ResourceBase]]):
154196
# Stack of composite fields, found on each resource, each composite field is interrogated for resources.
155197
self._field_iters = []
156198
# The "path" to the current resource.
157-
self._path = [(NotSupplied, NotSupplied, NotSupplied)]
199+
self._path: List[PathAtom] = [PathAtom.create()]
158200
self._resource_stack = [None]
159201

160202
def __iter__(self) -> Iterable[ResourceBase]:
@@ -163,57 +205,57 @@ def __iter__(self) -> Iterable[ResourceBase]:
163205

164206
def __next__(self) -> ResourceBase:
165207
"""Get next resource instance."""
166-
if self._resource_iters:
167-
if self._field_iters:
168-
# Check if the last entry in the field stack has any unprocessed fields.
169-
if self._field_iters[-1]:
170-
# Select the very last field in the field stack.
171-
field = self._field_iters[-1][0]
172-
# Request a list of resources along with keys from the composite field.
173-
self._resource_iters.append(
174-
field.item_iter_from_object(self.current_resource)
175-
)
176-
# Update the path
177-
self._path.append((NotSupplied, NotSupplied, field.name))
178-
self._resource_stack.append(None)
179-
# Remove the field from the list (and remove this field entry if it has been emptied)
180-
self._field_iters[-1].pop(0)
181-
else:
182-
self._field_iters.pop()
183-
184-
if self.current_resource and hasattr(self, "on_exit"):
185-
self.on_exit()
208+
if not self._resource_iters:
209+
raise StopIteration()
186210

187-
try:
188-
key, next_resource = next(self._resource_iters[-1])
189-
except StopIteration:
190-
# End of the current list of resources pop this list off and get the next list.
191-
self._path.pop()
192-
self._resource_iters.pop()
193-
self._resource_stack.pop()
194-
195-
return next(self)
211+
if self._field_iters:
212+
# Check if the last entry in the field stack has any unprocessed fields.
213+
if self._field_iters[-1]:
214+
# Select the very last field in the field stack.
215+
field = self._field_iters[-1][0]
216+
# Request a list of resources along with keys from the composite field.
217+
self._resource_iters.append(
218+
field.item_iter_from_object(self.current_resource)
219+
)
220+
# Update the path
221+
self._path.append(PathAtom.create(field.attname))
222+
self._resource_stack.append(None)
223+
# Remove the field from the list (and remove this field entry if it has been emptied)
224+
self._field_iters[-1].pop(0)
196225
else:
197-
next_meta = getmeta(next_resource)
198-
# If we have a key (ie DictOf, ListOf composite fields) update the path key field.
199-
if key is not None:
200-
_, name, field = self._path[-1]
201-
if next_meta.key_field:
202-
key = next_meta.key_field.value_from_object(next_resource)
203-
name = next_meta.key_field.name
204-
self._path[-1] = (key, name, field)
205-
206-
# Get list of any composite fields for this resource (this is a cached field).
207-
self._field_iters.append(list(next_meta.composite_fields))
208-
209-
# self.current_resource = next_resource
210-
self._resource_stack[-1] = next_resource
211-
212-
if hasattr(self, "on_enter"):
213-
self.on_enter()
214-
return next_resource
226+
self._field_iters.pop()
227+
228+
if self.current_resource and hasattr(self, "on_exit"):
229+
self.on_exit()
230+
231+
try:
232+
key, next_resource = next(self._resource_iters[-1])
233+
except StopIteration:
234+
# End of the current list of resources pop this list off and get the next list.
235+
self._path.pop()
236+
self._resource_iters.pop()
237+
self._resource_stack.pop()
238+
return next(self)
239+
215240
else:
216-
raise StopIteration()
241+
next_meta = getmeta(next_resource)
242+
# If we have a key (ie DictOf, ListOf composite fields) update the path key field.
243+
if key is not None:
244+
_, value, field = self._path[-1]
245+
if next_meta.key_field:
246+
value = next_meta.key_field.value_from_object(next_resource)
247+
key = next_meta.key_field.attname
248+
self._path[-1] = PathAtom(key, value, field)
249+
250+
# Get list of any composite fields for this resource (this is a cached field).
251+
self._field_iters.append(list(next_meta.composite_fields))
252+
253+
# self.current_resource = next_resource
254+
self._resource_stack[-1] = next_resource
255+
256+
if hasattr(self, "on_enter"):
257+
self.on_enter()
258+
return next_resource
217259

218260
@property
219261
def path(self) -> TraversalPath:
@@ -222,7 +264,7 @@ def path(self) -> TraversalPath:
222264
This path can be used to later traverse the tree structure to find get to the specified resource.
223265
"""
224266
# The path is offset by one as the path includes the root to simplify next method.
225-
return TraversalPath(*self._path[1:])
267+
return TraversalPath(self._path[1:])
226268

227269
@property
228270
def depth(self) -> int:

0 commit comments

Comments
 (0)