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
48from 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
133175class 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