exploration.graphs

  • Authors: Peter Mawhorter
  • Consulted:
  • Date: 2022-3-5
  • Purpose: Low-level graph helpers & types.

This file defines tools on top of the networkx package which are lower-level than the key types used for most tasks (see core.py for those).

  1"""
  2- Authors: Peter Mawhorter
  3- Consulted:
  4- Date: 2022-3-5
  5- Purpose: Low-level graph helpers & types.
  6
  7This file defines tools on top of the `networkx` package which are
  8lower-level than the key types used for most tasks (see `core.py` for
  9those).
 10"""
 11
 12from typing import (
 13    Optional, Hashable, Dict, Union, Iterable, Tuple, Any, NoReturn,
 14    Set, Sequence, cast, List, TypeVar, Generic
 15)
 16
 17import networkx as nx  # type: ignore[import]
 18
 19
 20Node = TypeVar('Node', bound=Hashable)
 21"Type variable for graph nodes."
 22
 23Edge = TypeVar('Edge', bound=Hashable)
 24"Type variable for graph edges."
 25
 26
 27class UniqueExitsGraph(nx.MultiDiGraph, Generic[Node, Edge]):
 28    """
 29    A `networkx.MultiDiGraph` which has unique-per-source-node names for
 30    each edge. On top of base functionality, this uses some extra memory
 31    to store per-edge outgoing (but not incoming) by-edge dictionaries,
 32    so that you can iterate over edges by their names rather than
 33    iterating over neighbor nodes. This helps in some circumstances where
 34    you know the edge name but not the name of the room it connects to.
 35
 36    This does NOT change the meaning of any of the built-in
 37    `networkx.MultiDiGraph` methods, but instead adds new methods for
 38    access to nodes or attributes by node -> edge name.
 39    """
 40    def __init__(self) -> None:
 41        super().__init__()
 42        # A dictionary that maps nodes to edge names, storing neighbor
 43        # nodes for each edge. Those neighbor nodes can be used to look
 44        # up edge attributes using the normal MultiDiGraph machinery.
 45        self._byEdge: Dict[Node, Dict[Edge, Node]] = {}
 46
 47    # Note: not hashable
 48
 49    def __eq__(self, other: Any) -> bool:
 50        """
 51        Compares two graphs for equality. Note that various kinds of
 52        graphs can be equal to a `UniqueExitsGraph` as long as the node
 53        names, edge names, and data attributes are all the same.
 54        """
 55        if not isinstance(other, nx.Graph):
 56            return False
 57        else:
 58            # Compare nodes
 59            myNodes = list(self)
 60            otherNodes = list(self)
 61            if len(myNodes) != len(otherNodes):
 62                return False
 63            myNodes.sort()
 64            otherNodes.sort()
 65            if myNodes != otherNodes:
 66                return False
 67
 68            # Compare edges
 69            myEdges = list(self.edges)
 70            otherEdges = list(other.edges)
 71            if len(myEdges) != len(otherEdges):
 72                return False
 73            if len(myEdges) > 0 and len(myEdges[0]) != len(otherEdges[0]):
 74                return False
 75            myEdges.sort()
 76            otherEdges.sort()
 77            if myEdges != otherEdges:
 78                return False
 79
 80            # Compare node data
 81            if any(
 82                self.nodes[node] != other.nodes[node]
 83                for node in myNodes
 84            ):
 85                return False
 86
 87            # Compare edge data
 88            if any(
 89                self.edges[edge] != other.edges[edge]
 90                for edge in myEdges
 91            ):
 92                return False
 93
 94            # Everything checks out...
 95            return True
 96
 97    def new_edge_key(self, u: Node, v: Node) -> NoReturn:
 98        """
 99        This method would normally be used to generate new edge keys. We
100        disable it, because we want to ensure that all edges are properly
101        labeled.
102        """
103        raise NotImplementedError(
104            "Attempted to add an edge without specifying a key!"
105        )
106
107    # TODO: Sort out networkx type annotations?
108    def add_node(self, node: Node, **attr: Any):  # type:ignore [override]
109        """
110        See `networkx.MultiDiGraph.add_node`.
111        """
112        super().add_node(node, **attr)
113        self._byEdge[node] = {}  # type Dict[Edge, Node]
114
115    def add_nodes_from(  # type:ignore [override]
116        self,
117        nodes: Union[
118            Iterable[Node],
119            Iterable[Tuple[Node, Dict[Any, Any]]]
120        ],
121        **attr: Any
122    ):
123        """
124        See `networkx.MultiDiGraph.add_nodes_from`.
125        """
126        super().add_nodes_from(nodes, **attr)
127        # Reassignment during tuple unpacking is not checkable...
128        n: Any
129        for n in nodes:
130            # Test for hashability & unpack tuple if not
131            try:
132                self._byEdge.get(n)
133            except TypeError:
134                n, _ = n  # mypy can't handle this properly
135            self._byEdge[n] = {}
136
137    def remove_node(self, node: Node):
138        """
139        See `networkx.MultiDiGraph.remove_node`.
140        """
141        # Handle deletion from inherited structures
142        super().remove_node(node)
143
144        # Ignore if not present
145        if node not in self._byEdge:
146            return
147
148        # Remove record of outgoing edges
149        del self._byEdge[node]
150
151        # Remove incoming edge records
152        for source, edgeMap in self._byEdge.items():
153            delete = []
154            # Find all edges which go to the deleted node
155            # (this is not terribly efficient)
156            for edgeName, dest in edgeMap.items():
157                if dest == node:
158                    delete.append(edgeName)
159            # Delete them in a separate loop, so that we don't
160            # modify-while-iterating (not efficient and maybe
161            # unnecessary?)
162            for d in delete:
163                del edgeMap[d]
164
165    def remove_nodes_from(self, nodes: Iterable[Node]):
166        """
167        See `networkx.MultiDiGraph.remove_nodes_from`.
168        """
169        # First use inherited method to remove from inherited structures
170        super().remove_nodes_from(nodes)
171        # remove our custom info
172        for n in nodes:
173            if n in self._byEdge:
174                del self._byEdge[n]
175
176            for source, edgeMap in self._byEdge.items():
177                delete = []
178                # Find all edges that go to any deleted node
179                for edgeName, dest in edgeMap.items():
180                    if dest in nodes:
181                        delete.append(edgeName)
182
183                # Remove edges in separate loop to avoid
184                # modifying-while-iterating (not efficient and maybe
185                # unnecessary?)
186                for d in delete:
187                    del edgeMap[d]
188
189    def add_edge( # type:ignore [override]
190        self,
191        u_of_edge: Node,
192        v_of_edge: Node,
193        key: Edge,
194        **attr: Any
195    ) -> Edge:
196        """
197        See `networkx.MultiDiGraph.add_edge`.
198
199        For a `UniqueExitsGraph`, an edge key must be supplied
200        explicitly. A `KeyError` will be raised if an edge using the
201        given key (i.e., name) already exists starting at the source node
202        (regardless of its destination!).
203
204        Returns the key it was given, to match the base `add_edge` API.
205        """
206        if u_of_edge in self._byEdge and key in self._byEdge[u_of_edge]:
207            raise KeyError(
208                f"Cannot add a second edge {key!r} starting at node"
209                f" {u_of_edge!r}."
210            )
211        super().add_edge(u_of_edge, v_of_edge, key, **attr)
212        # Note: the base add_edge function does NOT call our add_node
213        # function :(
214        if u_of_edge not in self._byEdge:
215            self._byEdge[u_of_edge] = {}
216        if v_of_edge not in self._byEdge:
217            self._byEdge[v_of_edge] = {}
218        # Add the edge to our by-edge-name structure
219        self._byEdge[u_of_edge][key] = v_of_edge
220
221        return key
222
223    def add_edges_from(
224        self,
225        ebunch_to_add: Any,
226        # Type should be this, but checker won't pass it:
227        # Union[
228        #     Iterable[Tuple[Node, Node, Edge]],
229        #     Iterable[Tuple[Node, Node, Edge, Dict[Any, Any]]]
230        # ],
231        **attr: Any
232    ):
233        """
234        See `networkx.MultiDiGraph.add_edges_from`. Tuples in the ebunch
235        must be 3- or 4-tuples that include a specific key (not just
236        data). Nodes will be created as necessary.
237
238        Raises a `KeyError` if adding an edge is impossible because it
239        re-uses the same edge name at a particular source node, but if an
240        attempt is made to add an existing edge with the same
241        destination, this will just update the relevant edge attributes.
242
243        Raises a `KeyError` instead of silently updating edge properties
244        if the existing edge was also added by an earlier entry in the
245        `ebunch_to_add` (i.e., if you are trying to add two edges at
246        once that go between the same pair of nodes and use the same
247        edge key).
248
249        >>> from exploration import graphs as eg
250        >>> g = eg.UniqueExitsGraph()
251        >>> g.add_edges_from([
252        ...     ('A', 'B', 'up'),
253        ...     ('A', 'B', 'up2'),
254        ...     ('B', 'A', 'down'),
255        ...     ('B', 'B', 'self'),
256        ...     ('B', 'C', 'next'),
257        ...     ('C', 'B', 'prev')
258        ... ])
259        >>> g.nodes
260        NodeView(('A', 'B', 'C'))
261        >>> for edge in g.edges:
262        ...    print(edge)
263        ('A', 'B', 'up')
264        ('A', 'B', 'up2')
265        ('B', 'A', 'down')
266        ('B', 'B', 'self')
267        ('B', 'C', 'next')
268        ('C', 'B', 'prev')
269        """
270        etuple: Any
271        for i, etuple in enumerate(ebunch_to_add):
272            if len(etuple) < 3:
273                raise ValueError(
274                    f"Edges to add must contain explicit keys for a"
275                    f" UniqueExitsGraph (edge #{i} had only 2 parts)."
276                )
277            try:
278                hash(etuple[2])
279            except TypeError:
280                raise ValueError(
281                    f"Edges to add must contain explicit keys for a"
282                    f" UniqueExitsGraph (edge #{i} had an unhashable 3rd"
283                    f" component)."
284                )
285
286            # Check edge name uniqueness
287            u, v, k = etuple[:3]
288            if u in self._byEdge and self._byEdge[u].get(k) != v:
289                raise KeyError(
290                    f"Cannot add or update an edge named '{k}' from node"
291                    f" '{u}' to node '{v}' because an edge by that name"
292                    f" already exists and goes to a different"
293                    f" destination."
294                )
295
296        # Add edges to inherited structures
297        super().add_edges_from(ebunch_to_add, **attr)
298
299        # Note base implementation calls add_edge, so we don't need to
300        # add edges to our extra structure
301
302    def remove_edge(  # type:ignore [override]
303        self,
304        u_of_edge: Node,
305        v_of_edge: Node,
306        key: Edge
307    ):
308        """
309        See `networkx.MultiDiGraph.remove_edge`. A key is required in
310        this version to specify which edge we're removing.
311
312        Raises a NetworkXError if the target edge does not exist.
313        """
314        super().remove_edge(u_of_edge, v_of_edge, key)
315        del self._byEdge[u_of_edge][key]
316
317    def remove_edges_from(
318        self,
319        ebunch: Union[  # type:ignore [override]
320            Iterable[Tuple[Node, Node, Edge]],
321            Iterable[Tuple[Node, Node, Edge, Dict[Any, Any]]]
322        ]
323    ):
324        """
325        See `networkx.MultiDiGraph.remove_edges_from`. Edge tuples in
326        the ebunch must be 3- or 4-tuples that include a key.
327
328        If an edge being removed is not present, it will be ignored.
329        """
330        if any(len(etuple) not in (3, 4) for etuple in ebunch):
331            raise ValueError(
332                "Edges to remove must be u, v, k 3-tuples or u, v, k, d"
333                " 4-tuples."
334            )
335        # TODO: Fix networkx MultiDiGraph type stubs
336        super().remove_edges_from(ebunch)  # type:ignore [arg-type]
337        # This calls self.remove_edge under the hood so we don't need
338        # extra cleanup steps for _byEdge.
339
340    def clear(self) -> None:
341        """
342        See `networkx.MultiDiGraph.clear`.
343        """
344        super().clear()
345        self._byEdge.clear()
346
347    def clear_edges(self) -> None:
348        """
349        See `networkx.MultiDiGraph.clear_edges`.
350        """
351        super().clear_edges()
352        for _, edgeMap in self._byEdge.items():
353            edgeMap.clear()
354
355    def reverse(self) -> NoReturn:  # type:ignore [override]
356        """
357        See `networkx.MultiDiGraph.reverse`.
358        """
359        raise NotImplementedError(
360            "Reversing a UniqueExitsGraph is not supported because"
361            " reversed edge names might not be unique."
362        )
363
364    def removeEdgeByKey(self, uOfEdge: Node, key: Edge):
365        """
366        Removes an edge sourced at a particular node that has a
367        particular key, without knowing what the destination is.
368
369        Raises a `KeyError` if the named edge does not exist.
370
371        ## Example
372
373        >>> g = UniqueExitsGraph()
374        >>> g.add_edges_from([
375        ...     ('A', 'B', 'up'),
376        ...     ('A', 'B', 'up2'),
377        ...     ('B', 'A', 'down'),
378        ...     ('B', 'B', 'self'),
379        ...     ('B', 'C', 'next'),
380        ...     ('C', 'B', 'prev')
381        ... ])
382        >>> g.getDestination('A', 'up')
383        'B'
384        >>> g.getDestination('A', 'up2')
385        'B'
386        >>> g.getDestination('B', 'self')
387        'B'
388        >>> g.removeEdgeByKey('A', 'up2')
389        >>> g.removeEdgeByKey('B', 'self')
390        >>> g.getDestination('A', 'up2') is None
391        True
392        >>> g.getDestination('B', 'self') is None
393        True
394        """
395        vOfEdge = self._byEdge[uOfEdge][key]
396        super().remove_edge(uOfEdge, vOfEdge, key)
397        del self._byEdge[uOfEdge][key]
398
399    def removeEdgesByKey(self, edgeIds: Iterable[Tuple[Node, Edge]]):
400        """
401        Removes multiple edges by source node and key, without needing
402        to know destination nodes. The `edgeIds` argument must be a list
403        of tuples containing source node, edge key pairs.
404
405        Silently ignores already-nonexistent edges.
406
407        ## Example
408
409        >>> g = UniqueExitsGraph()
410        >>> g.add_edges_from([
411        ...     ('A', 'B', 'up'),
412        ...     ('A', 'B', 'up2'),
413        ...     ('B', 'A', 'down'),
414        ...     ('B', 'B', 'self'),
415        ...     ('B', 'C', 'next'),
416        ...     ('C', 'B', 'prev')
417        ... ])
418        >>> g.getDestination('A', 'up')
419        'B'
420        >>> g.getDestination('A', 'up2')
421        'B'
422        >>> g.getDestination('B', 'self')
423        'B'
424        >>> g.removeEdgesByKey([('A', 'up2'), ('B', 'self')])
425        >>> g.getDestination('A', 'up2') is None
426        True
427        >>> g.getDestination('B', 'self') is None
428        True
429        """
430        for source, key in edgeIds:
431            if key in self._byEdge.get(source, {}):
432                self.removeEdgeByKey(source, key)
433            # Otherwise ignore this edge...
434
435    def destinationsFrom(self, source: Node) -> Dict[Edge, Node]:
436        """
437        Given a source node, returns a dictionary mapping the keys of all
438        outgoing edges from that node to their destination nodes. Raises
439        a `KeyError` if the node is not present in the graph.
440
441        Editing the dictionary returned could cause serious problems, so
442        please don't; it will be updated live as the graph is changed.
443
444        ## Example
445
446        >>> g = UniqueExitsGraph()
447        >>> g.add_edges_from([
448        ...     ('A', 'B', 'up'),
449        ...     ('A', 'B', 'up2'),
450        ...     ('B', 'A', 'down'),
451        ...     ('B', 'B', 'self'),
452        ...     ('B', 'C', 'next'),
453        ...     ('C', 'B', 'prev')
454        ... ])
455        >>> g.destinationsFrom('A')
456        {'up': 'B', 'up2': 'B'}
457        >>> g.destinationsFrom('B')
458        {'down': 'A', 'self': 'B', 'next': 'C'}
459        >>> g.destinationsFrom('C')
460        {'prev': 'B'}
461        >>> g.destinationsFrom('D')
462        Traceback (most recent call last):
463        ...
464        KeyError...
465        """
466        return self._byEdge[source]
467
468    def destination(self, source: Node, edge: Edge) -> Node:
469        """
470        Given a source node and an edge key, looks up and returns the
471        destination node for that edge. Raises a `KeyError` if there is no
472        edge from the specified node with the specified name.
473
474        ## Example
475
476        >>> g = UniqueExitsGraph()
477        >>> g.add_edges_from([
478        ...     ('A', 'B', 'up'),
479        ...     ('A', 'B', 'up2'),
480        ...     ('B', 'A', 'down'),
481        ...     ('B', 'B', 'self'),
482        ...     ('B', 'C', 'next'),
483        ...     ('C', 'B', 'prev')
484        ... ])
485        >>> g.destination('A', 'up')
486        'B'
487        >>> g.destination('A', 'up2')
488        'B'
489        >>> g.destination('B', 'down')
490        'A'
491        >>> g.destination('A', 'nonexistent')
492        Traceback (most recent call last):
493        ...
494        KeyError...
495        >>> g.destination('D', 'any')
496        Traceback (most recent call last):
497        ...
498        KeyError...
499        """
500        return self._byEdge[source][edge]
501
502    def getDestination(
503        self,
504        source: Node,
505        edge: Edge,
506        default: Any = None
507    ) -> Optional[Node]:
508        """
509        Works like `destination`, but instead of raising a `KeyError` if
510        the node or edge is missing, it returns a default value (with a
511        default default of `None`).
512
513        ## Example
514
515        >>> g = UniqueExitsGraph()
516        >>> g.add_edges_from([
517        ...     ('A', 'B', 'up'),
518        ...     ('A', 'B', 'up2'),
519        ...     ('B', 'A', 'down'),
520        ...     ('B', 'B', 'self'),
521        ...     ('B', 'C', 'next'),
522        ...     ('C', 'B', 'prev')
523        ... ])
524        >>> g.getDestination('A', 'up')
525        'B'
526        >>> g.getDestination('A', 'up2')
527        'B'
528        >>> g.getDestination('B', 'down')
529        'A'
530        >>> g.getDestination('A', 'nonexistent') is None
531        True
532        >>> g.getDestination('A', 'nonexistent', 'default')
533        'default'
534        >>> g.getDestination('D', 'any') is None
535        True
536        """
537        return self._byEdge.get(source, {}).get(edge, default)
538
539    def allEdgesTo(
540        self,
541        destination: Node
542    ) -> List[Tuple[Node, Edge]]:
543        """
544        Searches the entire graph for edges whose destinations are the
545        specified destination, and returns a list of (node, edge) pairs
546        indicating the source node and edge name for each of those edges.
547        Self-edges are included in this list.
548
549        ## Example
550
551        >>> g = UniqueExitsGraph()
552        >>> g.add_edges_from([
553        ...     ('A', 'B', 'up'),
554        ...     ('A', 'B', 'up2'),
555        ...     ('B', 'A', 'down'),
556        ...     ('B', 'B', 'self'),
557        ...     ('B', 'C', 'next'),
558        ...     ('C', 'B', 'prev')
559        ... ])
560        >>> g.allEdgesTo('A')
561        [('B', 'down')]
562        >>> g.allEdgesTo('B')
563        [('A', 'up'), ('A', 'up2'), ('B', 'self'), ('C', 'prev')]
564        >>> g.allEdgesTo('C')
565        [('B', 'next')]
566        >>> g.allEdgesTo('D')
567        []
568        """
569        results = []
570        for node in self:
571            fromThere = self[node]
572            toHere = fromThere.get(destination, {})
573            for edgeKey in toHere:
574                results.append((node, edgeKey))
575
576        return results
577
578    def textMapObj(
579        self,
580        edgeSep: str = '::',
581        external: Optional[Set[Node]] = None,
582        explorationOrder: Optional[Tuple[Node, Sequence[Edge]]] = None,
583        edgeOrders: Union[
584            Dict[Node, Sequence[Edge]],
585            Dict[Node, Dict[Edge, Any]],
586            None
587        ] = None
588    ):
589        """
590        Returns a special object which is JSON-serializable and which
591        when serialized creates a semi-human-usable text-format map of
592        the graph.
593
594        The object consists of nested dictionaries, one per node, where
595        keys are node name + edge name strings (combined using the
596        `edgeSep` argument, default is '::'). The value for each key is
597        one of:
598
599        1. Another dictionary representing the node that edge leads
600            to, which can in turn have dictionary values...
601        2. A string naming a destination node that's already represented
602            elsewhere (or naming the current node for self-edges).
603
604        Any node present in the specified `external` set will be linked
605        to instead of listed out, even if it exists in the graph. The
606        `external` set **will be modified** by this function to include
607        all visited nodes in the graph.
608
609        If an `explorationOrder` is provided, it must be a tuple
610        specifying a start node followed by a sequence of edges that
611        indicates the path taken, and the edges will be visited
612        according to that order (this only matters in Python 3.7+ where
613        dictionaries have consistent order). A `ValueError` will be
614        raised if an invalid exploration order is provided. The path
615        list will be ignored if `edgeOrders` is provided explicitly.
616
617        TODO: What about unexplorable graphs (allow node names in place
618        of edge names in exploration order?)?!?
619
620        If `edgeOrders` is provided directly, it will override the
621        path part of the `explorationOrder` to determine the ordering of
622        edges at each node. If not and `explorationOrder` is provided, it
623        will be deduced from the `explorationOrder`. If neither is
624        present, ordering will follow whatever natural order is in the
625        graph, which in most cases should be order-of-creation.
626
627        Notes:
628        - For the format to avoid ambiguity, the `edgeSep` value must be
629            a string which does not appear in any node or edge names.
630        - Nodes and edge values will be converted to strings to build the
631            map.
632        - Node and edge properties are not represented in the resulting
633            object.
634        - For a variety of reasons, the result cannot be converted back
635            to a graph object. This is not intended for use as a JSON
636            serialization route (see the `networkx.readwrite.json_graph`
637            module for some built-in options).
638        - To get a string representation, one could do:
639            `json.dumps(graph.textMapObj())`
640
641        ## Examples
642
643        >>> from exploration import graphs as eg
644        >>> import json
645        >>> g = eg.UniqueExitsGraph()
646        >>> g.add_edges_from([
647        ...     ('A', 'B', 'up'),
648        ...     ('A', 'B', 'up2'),
649        ...     ('B', 'A', 'down'),
650        ...     ('B', 'B', 'self'),
651        ...     ('B', 'C', 'next'),
652        ...     ('C', 'B', 'prev')
653        ... ])
654        >>> print(json.dumps(g.textMapObj(), indent=2))
655        {
656          "A::up": {
657            "B::down": "A",
658            "B::self": "B",
659            "B::next": {
660              "C::prev": "B"
661            }
662          },
663          "A::up2": "B"
664        }
665        """
666        # We use `external` as our visited set
667        if external is None:
668            external = set()
669
670        if explorationOrder is not None:
671            here, path = explorationOrder
672        else:
673            # Find first non-external node as our starting node
674            for here in self.nodes:
675                if here not in external:
676                    break
677
678            # Path is empty
679            path = []
680
681        # Determine edge ordering for each node from exploration order
682        # or by natural ordering if no explorationOrder is available
683        if edgeOrders is None:
684            edgeOrders = cast(
685                Dict[Node, Dict[Edge, Any]],
686                {}
687            )
688            current = here
689            for i in range(len(path)):
690                edge = path[i]
691                # Add this edge next in the ordering for this node
692                orderHere: Dict[Edge, Any] = edgeOrders.setdefault(current, {})
693                # Note: we use a dictionary here because dictionaries do
694                # preserve insertion ordering (3.7+) and we need to both
695                # keep things in order AND do a bunch of lookups to
696                # avoid duplicates.
697                if edge not in orderHere:
698                    orderHere[edge] = True
699
700                # Move to next node
701                if edge not in self._byEdge[current]:
702                    raise ValueError(
703                        f"Invalid edge in exploration order path: at"
704                        f" step {i} we reached node {current} and were"
705                        f" supposed to take edge {edge} but that edge"
706                        f" does not exist."
707                    )
708                current = self._byEdge[current][edge]
709
710            # Add any unexplored nodes and/or edges in natural order
711            for node in self.nodes:
712                orderHere = edgeOrders.setdefault(node, {})
713                for edge in self._byEdge[node]:
714                    if edge not in orderHere:
715                        orderHere[edge] = True
716
717        result = {}
718        external.add(here)
719        # Now loop through keys of this node
720        for key in edgeOrders[here]:
721            combined = str(here) + edgeSep + str(key)
722            dest = self._byEdge[here][key]
723            if dest in external:
724                # links, including self-links
725                result[combined] = str(dest)
726            else:
727                # Recurse
728                result[combined] = self.textMapObj(
729                    edgeSep,
730                    external,
731                    (dest, []),  # empty path since we have edgeOrders
732                    edgeOrders
733                )
734
735        return result
Node = ~Node

Type variable for graph nodes.

Edge = ~Edge

Type variable for graph edges.

class UniqueExitsGraph(networkx.classes.multidigraph.MultiDiGraph, typing.Generic[~Node, ~Edge]):
 28class UniqueExitsGraph(nx.MultiDiGraph, Generic[Node, Edge]):
 29    """
 30    A `networkx.MultiDiGraph` which has unique-per-source-node names for
 31    each edge. On top of base functionality, this uses some extra memory
 32    to store per-edge outgoing (but not incoming) by-edge dictionaries,
 33    so that you can iterate over edges by their names rather than
 34    iterating over neighbor nodes. This helps in some circumstances where
 35    you know the edge name but not the name of the room it connects to.
 36
 37    This does NOT change the meaning of any of the built-in
 38    `networkx.MultiDiGraph` methods, but instead adds new methods for
 39    access to nodes or attributes by node -> edge name.
 40    """
 41    def __init__(self) -> None:
 42        super().__init__()
 43        # A dictionary that maps nodes to edge names, storing neighbor
 44        # nodes for each edge. Those neighbor nodes can be used to look
 45        # up edge attributes using the normal MultiDiGraph machinery.
 46        self._byEdge: Dict[Node, Dict[Edge, Node]] = {}
 47
 48    # Note: not hashable
 49
 50    def __eq__(self, other: Any) -> bool:
 51        """
 52        Compares two graphs for equality. Note that various kinds of
 53        graphs can be equal to a `UniqueExitsGraph` as long as the node
 54        names, edge names, and data attributes are all the same.
 55        """
 56        if not isinstance(other, nx.Graph):
 57            return False
 58        else:
 59            # Compare nodes
 60            myNodes = list(self)
 61            otherNodes = list(self)
 62            if len(myNodes) != len(otherNodes):
 63                return False
 64            myNodes.sort()
 65            otherNodes.sort()
 66            if myNodes != otherNodes:
 67                return False
 68
 69            # Compare edges
 70            myEdges = list(self.edges)
 71            otherEdges = list(other.edges)
 72            if len(myEdges) != len(otherEdges):
 73                return False
 74            if len(myEdges) > 0 and len(myEdges[0]) != len(otherEdges[0]):
 75                return False
 76            myEdges.sort()
 77            otherEdges.sort()
 78            if myEdges != otherEdges:
 79                return False
 80
 81            # Compare node data
 82            if any(
 83                self.nodes[node] != other.nodes[node]
 84                for node in myNodes
 85            ):
 86                return False
 87
 88            # Compare edge data
 89            if any(
 90                self.edges[edge] != other.edges[edge]
 91                for edge in myEdges
 92            ):
 93                return False
 94
 95            # Everything checks out...
 96            return True
 97
 98    def new_edge_key(self, u: Node, v: Node) -> NoReturn:
 99        """
100        This method would normally be used to generate new edge keys. We
101        disable it, because we want to ensure that all edges are properly
102        labeled.
103        """
104        raise NotImplementedError(
105            "Attempted to add an edge without specifying a key!"
106        )
107
108    # TODO: Sort out networkx type annotations?
109    def add_node(self, node: Node, **attr: Any):  # type:ignore [override]
110        """
111        See `networkx.MultiDiGraph.add_node`.
112        """
113        super().add_node(node, **attr)
114        self._byEdge[node] = {}  # type Dict[Edge, Node]
115
116    def add_nodes_from(  # type:ignore [override]
117        self,
118        nodes: Union[
119            Iterable[Node],
120            Iterable[Tuple[Node, Dict[Any, Any]]]
121        ],
122        **attr: Any
123    ):
124        """
125        See `networkx.MultiDiGraph.add_nodes_from`.
126        """
127        super().add_nodes_from(nodes, **attr)
128        # Reassignment during tuple unpacking is not checkable...
129        n: Any
130        for n in nodes:
131            # Test for hashability & unpack tuple if not
132            try:
133                self._byEdge.get(n)
134            except TypeError:
135                n, _ = n  # mypy can't handle this properly
136            self._byEdge[n] = {}
137
138    def remove_node(self, node: Node):
139        """
140        See `networkx.MultiDiGraph.remove_node`.
141        """
142        # Handle deletion from inherited structures
143        super().remove_node(node)
144
145        # Ignore if not present
146        if node not in self._byEdge:
147            return
148
149        # Remove record of outgoing edges
150        del self._byEdge[node]
151
152        # Remove incoming edge records
153        for source, edgeMap in self._byEdge.items():
154            delete = []
155            # Find all edges which go to the deleted node
156            # (this is not terribly efficient)
157            for edgeName, dest in edgeMap.items():
158                if dest == node:
159                    delete.append(edgeName)
160            # Delete them in a separate loop, so that we don't
161            # modify-while-iterating (not efficient and maybe
162            # unnecessary?)
163            for d in delete:
164                del edgeMap[d]
165
166    def remove_nodes_from(self, nodes: Iterable[Node]):
167        """
168        See `networkx.MultiDiGraph.remove_nodes_from`.
169        """
170        # First use inherited method to remove from inherited structures
171        super().remove_nodes_from(nodes)
172        # remove our custom info
173        for n in nodes:
174            if n in self._byEdge:
175                del self._byEdge[n]
176
177            for source, edgeMap in self._byEdge.items():
178                delete = []
179                # Find all edges that go to any deleted node
180                for edgeName, dest in edgeMap.items():
181                    if dest in nodes:
182                        delete.append(edgeName)
183
184                # Remove edges in separate loop to avoid
185                # modifying-while-iterating (not efficient and maybe
186                # unnecessary?)
187                for d in delete:
188                    del edgeMap[d]
189
190    def add_edge( # type:ignore [override]
191        self,
192        u_of_edge: Node,
193        v_of_edge: Node,
194        key: Edge,
195        **attr: Any
196    ) -> Edge:
197        """
198        See `networkx.MultiDiGraph.add_edge`.
199
200        For a `UniqueExitsGraph`, an edge key must be supplied
201        explicitly. A `KeyError` will be raised if an edge using the
202        given key (i.e., name) already exists starting at the source node
203        (regardless of its destination!).
204
205        Returns the key it was given, to match the base `add_edge` API.
206        """
207        if u_of_edge in self._byEdge and key in self._byEdge[u_of_edge]:
208            raise KeyError(
209                f"Cannot add a second edge {key!r} starting at node"
210                f" {u_of_edge!r}."
211            )
212        super().add_edge(u_of_edge, v_of_edge, key, **attr)
213        # Note: the base add_edge function does NOT call our add_node
214        # function :(
215        if u_of_edge not in self._byEdge:
216            self._byEdge[u_of_edge] = {}
217        if v_of_edge not in self._byEdge:
218            self._byEdge[v_of_edge] = {}
219        # Add the edge to our by-edge-name structure
220        self._byEdge[u_of_edge][key] = v_of_edge
221
222        return key
223
224    def add_edges_from(
225        self,
226        ebunch_to_add: Any,
227        # Type should be this, but checker won't pass it:
228        # Union[
229        #     Iterable[Tuple[Node, Node, Edge]],
230        #     Iterable[Tuple[Node, Node, Edge, Dict[Any, Any]]]
231        # ],
232        **attr: Any
233    ):
234        """
235        See `networkx.MultiDiGraph.add_edges_from`. Tuples in the ebunch
236        must be 3- or 4-tuples that include a specific key (not just
237        data). Nodes will be created as necessary.
238
239        Raises a `KeyError` if adding an edge is impossible because it
240        re-uses the same edge name at a particular source node, but if an
241        attempt is made to add an existing edge with the same
242        destination, this will just update the relevant edge attributes.
243
244        Raises a `KeyError` instead of silently updating edge properties
245        if the existing edge was also added by an earlier entry in the
246        `ebunch_to_add` (i.e., if you are trying to add two edges at
247        once that go between the same pair of nodes and use the same
248        edge key).
249
250        >>> from exploration import graphs as eg
251        >>> g = eg.UniqueExitsGraph()
252        >>> g.add_edges_from([
253        ...     ('A', 'B', 'up'),
254        ...     ('A', 'B', 'up2'),
255        ...     ('B', 'A', 'down'),
256        ...     ('B', 'B', 'self'),
257        ...     ('B', 'C', 'next'),
258        ...     ('C', 'B', 'prev')
259        ... ])
260        >>> g.nodes
261        NodeView(('A', 'B', 'C'))
262        >>> for edge in g.edges:
263        ...    print(edge)
264        ('A', 'B', 'up')
265        ('A', 'B', 'up2')
266        ('B', 'A', 'down')
267        ('B', 'B', 'self')
268        ('B', 'C', 'next')
269        ('C', 'B', 'prev')
270        """
271        etuple: Any
272        for i, etuple in enumerate(ebunch_to_add):
273            if len(etuple) < 3:
274                raise ValueError(
275                    f"Edges to add must contain explicit keys for a"
276                    f" UniqueExitsGraph (edge #{i} had only 2 parts)."
277                )
278            try:
279                hash(etuple[2])
280            except TypeError:
281                raise ValueError(
282                    f"Edges to add must contain explicit keys for a"
283                    f" UniqueExitsGraph (edge #{i} had an unhashable 3rd"
284                    f" component)."
285                )
286
287            # Check edge name uniqueness
288            u, v, k = etuple[:3]
289            if u in self._byEdge and self._byEdge[u].get(k) != v:
290                raise KeyError(
291                    f"Cannot add or update an edge named '{k}' from node"
292                    f" '{u}' to node '{v}' because an edge by that name"
293                    f" already exists and goes to a different"
294                    f" destination."
295                )
296
297        # Add edges to inherited structures
298        super().add_edges_from(ebunch_to_add, **attr)
299
300        # Note base implementation calls add_edge, so we don't need to
301        # add edges to our extra structure
302
303    def remove_edge(  # type:ignore [override]
304        self,
305        u_of_edge: Node,
306        v_of_edge: Node,
307        key: Edge
308    ):
309        """
310        See `networkx.MultiDiGraph.remove_edge`. A key is required in
311        this version to specify which edge we're removing.
312
313        Raises a NetworkXError if the target edge does not exist.
314        """
315        super().remove_edge(u_of_edge, v_of_edge, key)
316        del self._byEdge[u_of_edge][key]
317
318    def remove_edges_from(
319        self,
320        ebunch: Union[  # type:ignore [override]
321            Iterable[Tuple[Node, Node, Edge]],
322            Iterable[Tuple[Node, Node, Edge, Dict[Any, Any]]]
323        ]
324    ):
325        """
326        See `networkx.MultiDiGraph.remove_edges_from`. Edge tuples in
327        the ebunch must be 3- or 4-tuples that include a key.
328
329        If an edge being removed is not present, it will be ignored.
330        """
331        if any(len(etuple) not in (3, 4) for etuple in ebunch):
332            raise ValueError(
333                "Edges to remove must be u, v, k 3-tuples or u, v, k, d"
334                " 4-tuples."
335            )
336        # TODO: Fix networkx MultiDiGraph type stubs
337        super().remove_edges_from(ebunch)  # type:ignore [arg-type]
338        # This calls self.remove_edge under the hood so we don't need
339        # extra cleanup steps for _byEdge.
340
341    def clear(self) -> None:
342        """
343        See `networkx.MultiDiGraph.clear`.
344        """
345        super().clear()
346        self._byEdge.clear()
347
348    def clear_edges(self) -> None:
349        """
350        See `networkx.MultiDiGraph.clear_edges`.
351        """
352        super().clear_edges()
353        for _, edgeMap in self._byEdge.items():
354            edgeMap.clear()
355
356    def reverse(self) -> NoReturn:  # type:ignore [override]
357        """
358        See `networkx.MultiDiGraph.reverse`.
359        """
360        raise NotImplementedError(
361            "Reversing a UniqueExitsGraph is not supported because"
362            " reversed edge names might not be unique."
363        )
364
365    def removeEdgeByKey(self, uOfEdge: Node, key: Edge):
366        """
367        Removes an edge sourced at a particular node that has a
368        particular key, without knowing what the destination is.
369
370        Raises a `KeyError` if the named edge does not exist.
371
372        ## Example
373
374        >>> g = UniqueExitsGraph()
375        >>> g.add_edges_from([
376        ...     ('A', 'B', 'up'),
377        ...     ('A', 'B', 'up2'),
378        ...     ('B', 'A', 'down'),
379        ...     ('B', 'B', 'self'),
380        ...     ('B', 'C', 'next'),
381        ...     ('C', 'B', 'prev')
382        ... ])
383        >>> g.getDestination('A', 'up')
384        'B'
385        >>> g.getDestination('A', 'up2')
386        'B'
387        >>> g.getDestination('B', 'self')
388        'B'
389        >>> g.removeEdgeByKey('A', 'up2')
390        >>> g.removeEdgeByKey('B', 'self')
391        >>> g.getDestination('A', 'up2') is None
392        True
393        >>> g.getDestination('B', 'self') is None
394        True
395        """
396        vOfEdge = self._byEdge[uOfEdge][key]
397        super().remove_edge(uOfEdge, vOfEdge, key)
398        del self._byEdge[uOfEdge][key]
399
400    def removeEdgesByKey(self, edgeIds: Iterable[Tuple[Node, Edge]]):
401        """
402        Removes multiple edges by source node and key, without needing
403        to know destination nodes. The `edgeIds` argument must be a list
404        of tuples containing source node, edge key pairs.
405
406        Silently ignores already-nonexistent edges.
407
408        ## Example
409
410        >>> g = UniqueExitsGraph()
411        >>> g.add_edges_from([
412        ...     ('A', 'B', 'up'),
413        ...     ('A', 'B', 'up2'),
414        ...     ('B', 'A', 'down'),
415        ...     ('B', 'B', 'self'),
416        ...     ('B', 'C', 'next'),
417        ...     ('C', 'B', 'prev')
418        ... ])
419        >>> g.getDestination('A', 'up')
420        'B'
421        >>> g.getDestination('A', 'up2')
422        'B'
423        >>> g.getDestination('B', 'self')
424        'B'
425        >>> g.removeEdgesByKey([('A', 'up2'), ('B', 'self')])
426        >>> g.getDestination('A', 'up2') is None
427        True
428        >>> g.getDestination('B', 'self') is None
429        True
430        """
431        for source, key in edgeIds:
432            if key in self._byEdge.get(source, {}):
433                self.removeEdgeByKey(source, key)
434            # Otherwise ignore this edge...
435
436    def destinationsFrom(self, source: Node) -> Dict[Edge, Node]:
437        """
438        Given a source node, returns a dictionary mapping the keys of all
439        outgoing edges from that node to their destination nodes. Raises
440        a `KeyError` if the node is not present in the graph.
441
442        Editing the dictionary returned could cause serious problems, so
443        please don't; it will be updated live as the graph is changed.
444
445        ## Example
446
447        >>> g = UniqueExitsGraph()
448        >>> g.add_edges_from([
449        ...     ('A', 'B', 'up'),
450        ...     ('A', 'B', 'up2'),
451        ...     ('B', 'A', 'down'),
452        ...     ('B', 'B', 'self'),
453        ...     ('B', 'C', 'next'),
454        ...     ('C', 'B', 'prev')
455        ... ])
456        >>> g.destinationsFrom('A')
457        {'up': 'B', 'up2': 'B'}
458        >>> g.destinationsFrom('B')
459        {'down': 'A', 'self': 'B', 'next': 'C'}
460        >>> g.destinationsFrom('C')
461        {'prev': 'B'}
462        >>> g.destinationsFrom('D')
463        Traceback (most recent call last):
464        ...
465        KeyError...
466        """
467        return self._byEdge[source]
468
469    def destination(self, source: Node, edge: Edge) -> Node:
470        """
471        Given a source node and an edge key, looks up and returns the
472        destination node for that edge. Raises a `KeyError` if there is no
473        edge from the specified node with the specified name.
474
475        ## Example
476
477        >>> g = UniqueExitsGraph()
478        >>> g.add_edges_from([
479        ...     ('A', 'B', 'up'),
480        ...     ('A', 'B', 'up2'),
481        ...     ('B', 'A', 'down'),
482        ...     ('B', 'B', 'self'),
483        ...     ('B', 'C', 'next'),
484        ...     ('C', 'B', 'prev')
485        ... ])
486        >>> g.destination('A', 'up')
487        'B'
488        >>> g.destination('A', 'up2')
489        'B'
490        >>> g.destination('B', 'down')
491        'A'
492        >>> g.destination('A', 'nonexistent')
493        Traceback (most recent call last):
494        ...
495        KeyError...
496        >>> g.destination('D', 'any')
497        Traceback (most recent call last):
498        ...
499        KeyError...
500        """
501        return self._byEdge[source][edge]
502
503    def getDestination(
504        self,
505        source: Node,
506        edge: Edge,
507        default: Any = None
508    ) -> Optional[Node]:
509        """
510        Works like `destination`, but instead of raising a `KeyError` if
511        the node or edge is missing, it returns a default value (with a
512        default default of `None`).
513
514        ## Example
515
516        >>> g = UniqueExitsGraph()
517        >>> g.add_edges_from([
518        ...     ('A', 'B', 'up'),
519        ...     ('A', 'B', 'up2'),
520        ...     ('B', 'A', 'down'),
521        ...     ('B', 'B', 'self'),
522        ...     ('B', 'C', 'next'),
523        ...     ('C', 'B', 'prev')
524        ... ])
525        >>> g.getDestination('A', 'up')
526        'B'
527        >>> g.getDestination('A', 'up2')
528        'B'
529        >>> g.getDestination('B', 'down')
530        'A'
531        >>> g.getDestination('A', 'nonexistent') is None
532        True
533        >>> g.getDestination('A', 'nonexistent', 'default')
534        'default'
535        >>> g.getDestination('D', 'any') is None
536        True
537        """
538        return self._byEdge.get(source, {}).get(edge, default)
539
540    def allEdgesTo(
541        self,
542        destination: Node
543    ) -> List[Tuple[Node, Edge]]:
544        """
545        Searches the entire graph for edges whose destinations are the
546        specified destination, and returns a list of (node, edge) pairs
547        indicating the source node and edge name for each of those edges.
548        Self-edges are included in this list.
549
550        ## Example
551
552        >>> g = UniqueExitsGraph()
553        >>> g.add_edges_from([
554        ...     ('A', 'B', 'up'),
555        ...     ('A', 'B', 'up2'),
556        ...     ('B', 'A', 'down'),
557        ...     ('B', 'B', 'self'),
558        ...     ('B', 'C', 'next'),
559        ...     ('C', 'B', 'prev')
560        ... ])
561        >>> g.allEdgesTo('A')
562        [('B', 'down')]
563        >>> g.allEdgesTo('B')
564        [('A', 'up'), ('A', 'up2'), ('B', 'self'), ('C', 'prev')]
565        >>> g.allEdgesTo('C')
566        [('B', 'next')]
567        >>> g.allEdgesTo('D')
568        []
569        """
570        results = []
571        for node in self:
572            fromThere = self[node]
573            toHere = fromThere.get(destination, {})
574            for edgeKey in toHere:
575                results.append((node, edgeKey))
576
577        return results
578
579    def textMapObj(
580        self,
581        edgeSep: str = '::',
582        external: Optional[Set[Node]] = None,
583        explorationOrder: Optional[Tuple[Node, Sequence[Edge]]] = None,
584        edgeOrders: Union[
585            Dict[Node, Sequence[Edge]],
586            Dict[Node, Dict[Edge, Any]],
587            None
588        ] = None
589    ):
590        """
591        Returns a special object which is JSON-serializable and which
592        when serialized creates a semi-human-usable text-format map of
593        the graph.
594
595        The object consists of nested dictionaries, one per node, where
596        keys are node name + edge name strings (combined using the
597        `edgeSep` argument, default is '::'). The value for each key is
598        one of:
599
600        1. Another dictionary representing the node that edge leads
601            to, which can in turn have dictionary values...
602        2. A string naming a destination node that's already represented
603            elsewhere (or naming the current node for self-edges).
604
605        Any node present in the specified `external` set will be linked
606        to instead of listed out, even if it exists in the graph. The
607        `external` set **will be modified** by this function to include
608        all visited nodes in the graph.
609
610        If an `explorationOrder` is provided, it must be a tuple
611        specifying a start node followed by a sequence of edges that
612        indicates the path taken, and the edges will be visited
613        according to that order (this only matters in Python 3.7+ where
614        dictionaries have consistent order). A `ValueError` will be
615        raised if an invalid exploration order is provided. The path
616        list will be ignored if `edgeOrders` is provided explicitly.
617
618        TODO: What about unexplorable graphs (allow node names in place
619        of edge names in exploration order?)?!?
620
621        If `edgeOrders` is provided directly, it will override the
622        path part of the `explorationOrder` to determine the ordering of
623        edges at each node. If not and `explorationOrder` is provided, it
624        will be deduced from the `explorationOrder`. If neither is
625        present, ordering will follow whatever natural order is in the
626        graph, which in most cases should be order-of-creation.
627
628        Notes:
629        - For the format to avoid ambiguity, the `edgeSep` value must be
630            a string which does not appear in any node or edge names.
631        - Nodes and edge values will be converted to strings to build the
632            map.
633        - Node and edge properties are not represented in the resulting
634            object.
635        - For a variety of reasons, the result cannot be converted back
636            to a graph object. This is not intended for use as a JSON
637            serialization route (see the `networkx.readwrite.json_graph`
638            module for some built-in options).
639        - To get a string representation, one could do:
640            `json.dumps(graph.textMapObj())`
641
642        ## Examples
643
644        >>> from exploration import graphs as eg
645        >>> import json
646        >>> g = eg.UniqueExitsGraph()
647        >>> g.add_edges_from([
648        ...     ('A', 'B', 'up'),
649        ...     ('A', 'B', 'up2'),
650        ...     ('B', 'A', 'down'),
651        ...     ('B', 'B', 'self'),
652        ...     ('B', 'C', 'next'),
653        ...     ('C', 'B', 'prev')
654        ... ])
655        >>> print(json.dumps(g.textMapObj(), indent=2))
656        {
657          "A::up": {
658            "B::down": "A",
659            "B::self": "B",
660            "B::next": {
661              "C::prev": "B"
662            }
663          },
664          "A::up2": "B"
665        }
666        """
667        # We use `external` as our visited set
668        if external is None:
669            external = set()
670
671        if explorationOrder is not None:
672            here, path = explorationOrder
673        else:
674            # Find first non-external node as our starting node
675            for here in self.nodes:
676                if here not in external:
677                    break
678
679            # Path is empty
680            path = []
681
682        # Determine edge ordering for each node from exploration order
683        # or by natural ordering if no explorationOrder is available
684        if edgeOrders is None:
685            edgeOrders = cast(
686                Dict[Node, Dict[Edge, Any]],
687                {}
688            )
689            current = here
690            for i in range(len(path)):
691                edge = path[i]
692                # Add this edge next in the ordering for this node
693                orderHere: Dict[Edge, Any] = edgeOrders.setdefault(current, {})
694                # Note: we use a dictionary here because dictionaries do
695                # preserve insertion ordering (3.7+) and we need to both
696                # keep things in order AND do a bunch of lookups to
697                # avoid duplicates.
698                if edge not in orderHere:
699                    orderHere[edge] = True
700
701                # Move to next node
702                if edge not in self._byEdge[current]:
703                    raise ValueError(
704                        f"Invalid edge in exploration order path: at"
705                        f" step {i} we reached node {current} and were"
706                        f" supposed to take edge {edge} but that edge"
707                        f" does not exist."
708                    )
709                current = self._byEdge[current][edge]
710
711            # Add any unexplored nodes and/or edges in natural order
712            for node in self.nodes:
713                orderHere = edgeOrders.setdefault(node, {})
714                for edge in self._byEdge[node]:
715                    if edge not in orderHere:
716                        orderHere[edge] = True
717
718        result = {}
719        external.add(here)
720        # Now loop through keys of this node
721        for key in edgeOrders[here]:
722            combined = str(here) + edgeSep + str(key)
723            dest = self._byEdge[here][key]
724            if dest in external:
725                # links, including self-links
726                result[combined] = str(dest)
727            else:
728                # Recurse
729                result[combined] = self.textMapObj(
730                    edgeSep,
731                    external,
732                    (dest, []),  # empty path since we have edgeOrders
733                    edgeOrders
734                )
735
736        return result

A networkx.MultiDiGraph which has unique-per-source-node names for each edge. On top of base functionality, this uses some extra memory to store per-edge outgoing (but not incoming) by-edge dictionaries, so that you can iterate over edges by their names rather than iterating over neighbor nodes. This helps in some circumstances where you know the edge name but not the name of the room it connects to.

This does NOT change the meaning of any of the built-in networkx.MultiDiGraph methods, but instead adds new methods for access to nodes or attributes by node -> edge name.

def new_edge_key(self, u: ~Node, v: ~Node) -> NoReturn:
 98    def new_edge_key(self, u: Node, v: Node) -> NoReturn:
 99        """
100        This method would normally be used to generate new edge keys. We
101        disable it, because we want to ensure that all edges are properly
102        labeled.
103        """
104        raise NotImplementedError(
105            "Attempted to add an edge without specifying a key!"
106        )

This method would normally be used to generate new edge keys. We disable it, because we want to ensure that all edges are properly labeled.

def add_node(self, node: ~Node, **attr: Any):
109    def add_node(self, node: Node, **attr: Any):  # type:ignore [override]
110        """
111        See `networkx.MultiDiGraph.add_node`.
112        """
113        super().add_node(node, **attr)
114        self._byEdge[node] = {}  # type Dict[Edge, Node]

See networkx.MultiDiGraph.add_node.

def add_nodes_from( self, nodes: Union[Iterable[~Node], Iterable[Tuple[~Node, Dict[Any, Any]]]], **attr: Any):
116    def add_nodes_from(  # type:ignore [override]
117        self,
118        nodes: Union[
119            Iterable[Node],
120            Iterable[Tuple[Node, Dict[Any, Any]]]
121        ],
122        **attr: Any
123    ):
124        """
125        See `networkx.MultiDiGraph.add_nodes_from`.
126        """
127        super().add_nodes_from(nodes, **attr)
128        # Reassignment during tuple unpacking is not checkable...
129        n: Any
130        for n in nodes:
131            # Test for hashability & unpack tuple if not
132            try:
133                self._byEdge.get(n)
134            except TypeError:
135                n, _ = n  # mypy can't handle this properly
136            self._byEdge[n] = {}

See networkx.MultiDiGraph.add_nodes_from.

def remove_node(self, node: ~Node):
138    def remove_node(self, node: Node):
139        """
140        See `networkx.MultiDiGraph.remove_node`.
141        """
142        # Handle deletion from inherited structures
143        super().remove_node(node)
144
145        # Ignore if not present
146        if node not in self._byEdge:
147            return
148
149        # Remove record of outgoing edges
150        del self._byEdge[node]
151
152        # Remove incoming edge records
153        for source, edgeMap in self._byEdge.items():
154            delete = []
155            # Find all edges which go to the deleted node
156            # (this is not terribly efficient)
157            for edgeName, dest in edgeMap.items():
158                if dest == node:
159                    delete.append(edgeName)
160            # Delete them in a separate loop, so that we don't
161            # modify-while-iterating (not efficient and maybe
162            # unnecessary?)
163            for d in delete:
164                del edgeMap[d]

See networkx.MultiDiGraph.remove_node.

def remove_nodes_from(self, nodes: Iterable[~Node]):
166    def remove_nodes_from(self, nodes: Iterable[Node]):
167        """
168        See `networkx.MultiDiGraph.remove_nodes_from`.
169        """
170        # First use inherited method to remove from inherited structures
171        super().remove_nodes_from(nodes)
172        # remove our custom info
173        for n in nodes:
174            if n in self._byEdge:
175                del self._byEdge[n]
176
177            for source, edgeMap in self._byEdge.items():
178                delete = []
179                # Find all edges that go to any deleted node
180                for edgeName, dest in edgeMap.items():
181                    if dest in nodes:
182                        delete.append(edgeName)
183
184                # Remove edges in separate loop to avoid
185                # modifying-while-iterating (not efficient and maybe
186                # unnecessary?)
187                for d in delete:
188                    del edgeMap[d]

See networkx.MultiDiGraph.remove_nodes_from.

def add_edges_from(self, ebunch_to_add: Any, **attr: Any):
224    def add_edges_from(
225        self,
226        ebunch_to_add: Any,
227        # Type should be this, but checker won't pass it:
228        # Union[
229        #     Iterable[Tuple[Node, Node, Edge]],
230        #     Iterable[Tuple[Node, Node, Edge, Dict[Any, Any]]]
231        # ],
232        **attr: Any
233    ):
234        """
235        See `networkx.MultiDiGraph.add_edges_from`. Tuples in the ebunch
236        must be 3- or 4-tuples that include a specific key (not just
237        data). Nodes will be created as necessary.
238
239        Raises a `KeyError` if adding an edge is impossible because it
240        re-uses the same edge name at a particular source node, but if an
241        attempt is made to add an existing edge with the same
242        destination, this will just update the relevant edge attributes.
243
244        Raises a `KeyError` instead of silently updating edge properties
245        if the existing edge was also added by an earlier entry in the
246        `ebunch_to_add` (i.e., if you are trying to add two edges at
247        once that go between the same pair of nodes and use the same
248        edge key).
249
250        >>> from exploration import graphs as eg
251        >>> g = eg.UniqueExitsGraph()
252        >>> g.add_edges_from([
253        ...     ('A', 'B', 'up'),
254        ...     ('A', 'B', 'up2'),
255        ...     ('B', 'A', 'down'),
256        ...     ('B', 'B', 'self'),
257        ...     ('B', 'C', 'next'),
258        ...     ('C', 'B', 'prev')
259        ... ])
260        >>> g.nodes
261        NodeView(('A', 'B', 'C'))
262        >>> for edge in g.edges:
263        ...    print(edge)
264        ('A', 'B', 'up')
265        ('A', 'B', 'up2')
266        ('B', 'A', 'down')
267        ('B', 'B', 'self')
268        ('B', 'C', 'next')
269        ('C', 'B', 'prev')
270        """
271        etuple: Any
272        for i, etuple in enumerate(ebunch_to_add):
273            if len(etuple) < 3:
274                raise ValueError(
275                    f"Edges to add must contain explicit keys for a"
276                    f" UniqueExitsGraph (edge #{i} had only 2 parts)."
277                )
278            try:
279                hash(etuple[2])
280            except TypeError:
281                raise ValueError(
282                    f"Edges to add must contain explicit keys for a"
283                    f" UniqueExitsGraph (edge #{i} had an unhashable 3rd"
284                    f" component)."
285                )
286
287            # Check edge name uniqueness
288            u, v, k = etuple[:3]
289            if u in self._byEdge and self._byEdge[u].get(k) != v:
290                raise KeyError(
291                    f"Cannot add or update an edge named '{k}' from node"
292                    f" '{u}' to node '{v}' because an edge by that name"
293                    f" already exists and goes to a different"
294                    f" destination."
295                )
296
297        # Add edges to inherited structures
298        super().add_edges_from(ebunch_to_add, **attr)
299
300        # Note base implementation calls add_edge, so we don't need to
301        # add edges to our extra structure

See networkx.MultiDiGraph.add_edges_from. Tuples in the ebunch must be 3- or 4-tuples that include a specific key (not just data). Nodes will be created as necessary.

Raises a KeyError if adding an edge is impossible because it re-uses the same edge name at a particular source node, but if an attempt is made to add an existing edge with the same destination, this will just update the relevant edge attributes.

Raises a KeyError instead of silently updating edge properties if the existing edge was also added by an earlier entry in the ebunch_to_add (i.e., if you are trying to add two edges at once that go between the same pair of nodes and use the same edge key).

>>> from exploration import graphs as eg
>>> g = eg.UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> g.nodes
NodeView(('A', 'B', 'C'))
>>> for edge in g.edges:
...    print(edge)
('A', 'B', 'up')
('A', 'B', 'up2')
('B', 'A', 'down')
('B', 'B', 'self')
('B', 'C', 'next')
('C', 'B', 'prev')
def remove_edges_from( self, ebunch: Union[Iterable[Tuple[~Node, ~Node, ~Edge]], Iterable[Tuple[~Node, ~Node, ~Edge, Dict[Any, Any]]]]):
318    def remove_edges_from(
319        self,
320        ebunch: Union[  # type:ignore [override]
321            Iterable[Tuple[Node, Node, Edge]],
322            Iterable[Tuple[Node, Node, Edge, Dict[Any, Any]]]
323        ]
324    ):
325        """
326        See `networkx.MultiDiGraph.remove_edges_from`. Edge tuples in
327        the ebunch must be 3- or 4-tuples that include a key.
328
329        If an edge being removed is not present, it will be ignored.
330        """
331        if any(len(etuple) not in (3, 4) for etuple in ebunch):
332            raise ValueError(
333                "Edges to remove must be u, v, k 3-tuples or u, v, k, d"
334                " 4-tuples."
335            )
336        # TODO: Fix networkx MultiDiGraph type stubs
337        super().remove_edges_from(ebunch)  # type:ignore [arg-type]
338        # This calls self.remove_edge under the hood so we don't need
339        # extra cleanup steps for _byEdge.

See networkx.MultiDiGraph.remove_edges_from. Edge tuples in the ebunch must be 3- or 4-tuples that include a key.

If an edge being removed is not present, it will be ignored.

def clear(self) -> None:
341    def clear(self) -> None:
342        """
343        See `networkx.MultiDiGraph.clear`.
344        """
345        super().clear()
346        self._byEdge.clear()

See networkx.MultiDiGraph.clear.

def clear_edges(self) -> None:
348    def clear_edges(self) -> None:
349        """
350        See `networkx.MultiDiGraph.clear_edges`.
351        """
352        super().clear_edges()
353        for _, edgeMap in self._byEdge.items():
354            edgeMap.clear()

See networkx.MultiDiGraph.clear_edges.

def removeEdgeByKey(self, uOfEdge: ~Node, key: ~Edge):
365    def removeEdgeByKey(self, uOfEdge: Node, key: Edge):
366        """
367        Removes an edge sourced at a particular node that has a
368        particular key, without knowing what the destination is.
369
370        Raises a `KeyError` if the named edge does not exist.
371
372        ## Example
373
374        >>> g = UniqueExitsGraph()
375        >>> g.add_edges_from([
376        ...     ('A', 'B', 'up'),
377        ...     ('A', 'B', 'up2'),
378        ...     ('B', 'A', 'down'),
379        ...     ('B', 'B', 'self'),
380        ...     ('B', 'C', 'next'),
381        ...     ('C', 'B', 'prev')
382        ... ])
383        >>> g.getDestination('A', 'up')
384        'B'
385        >>> g.getDestination('A', 'up2')
386        'B'
387        >>> g.getDestination('B', 'self')
388        'B'
389        >>> g.removeEdgeByKey('A', 'up2')
390        >>> g.removeEdgeByKey('B', 'self')
391        >>> g.getDestination('A', 'up2') is None
392        True
393        >>> g.getDestination('B', 'self') is None
394        True
395        """
396        vOfEdge = self._byEdge[uOfEdge][key]
397        super().remove_edge(uOfEdge, vOfEdge, key)
398        del self._byEdge[uOfEdge][key]

Removes an edge sourced at a particular node that has a particular key, without knowing what the destination is.

Raises a KeyError if the named edge does not exist.

Example

>>> g = UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> g.getDestination('A', 'up')
'B'
>>> g.getDestination('A', 'up2')
'B'
>>> g.getDestination('B', 'self')
'B'
>>> g.removeEdgeByKey('A', 'up2')
>>> g.removeEdgeByKey('B', 'self')
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('B', 'self') is None
True
def removeEdgesByKey(self, edgeIds: Iterable[Tuple[~Node, ~Edge]]):
400    def removeEdgesByKey(self, edgeIds: Iterable[Tuple[Node, Edge]]):
401        """
402        Removes multiple edges by source node and key, without needing
403        to know destination nodes. The `edgeIds` argument must be a list
404        of tuples containing source node, edge key pairs.
405
406        Silently ignores already-nonexistent edges.
407
408        ## Example
409
410        >>> g = UniqueExitsGraph()
411        >>> g.add_edges_from([
412        ...     ('A', 'B', 'up'),
413        ...     ('A', 'B', 'up2'),
414        ...     ('B', 'A', 'down'),
415        ...     ('B', 'B', 'self'),
416        ...     ('B', 'C', 'next'),
417        ...     ('C', 'B', 'prev')
418        ... ])
419        >>> g.getDestination('A', 'up')
420        'B'
421        >>> g.getDestination('A', 'up2')
422        'B'
423        >>> g.getDestination('B', 'self')
424        'B'
425        >>> g.removeEdgesByKey([('A', 'up2'), ('B', 'self')])
426        >>> g.getDestination('A', 'up2') is None
427        True
428        >>> g.getDestination('B', 'self') is None
429        True
430        """
431        for source, key in edgeIds:
432            if key in self._byEdge.get(source, {}):
433                self.removeEdgeByKey(source, key)
434            # Otherwise ignore this edge...

Removes multiple edges by source node and key, without needing to know destination nodes. The edgeIds argument must be a list of tuples containing source node, edge key pairs.

Silently ignores already-nonexistent edges.

Example

>>> g = UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> g.getDestination('A', 'up')
'B'
>>> g.getDestination('A', 'up2')
'B'
>>> g.getDestination('B', 'self')
'B'
>>> g.removeEdgesByKey([('A', 'up2'), ('B', 'self')])
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('B', 'self') is None
True
def destinationsFrom(self, source: ~Node) -> Dict[~Edge, ~Node]:
436    def destinationsFrom(self, source: Node) -> Dict[Edge, Node]:
437        """
438        Given a source node, returns a dictionary mapping the keys of all
439        outgoing edges from that node to their destination nodes. Raises
440        a `KeyError` if the node is not present in the graph.
441
442        Editing the dictionary returned could cause serious problems, so
443        please don't; it will be updated live as the graph is changed.
444
445        ## Example
446
447        >>> g = UniqueExitsGraph()
448        >>> g.add_edges_from([
449        ...     ('A', 'B', 'up'),
450        ...     ('A', 'B', 'up2'),
451        ...     ('B', 'A', 'down'),
452        ...     ('B', 'B', 'self'),
453        ...     ('B', 'C', 'next'),
454        ...     ('C', 'B', 'prev')
455        ... ])
456        >>> g.destinationsFrom('A')
457        {'up': 'B', 'up2': 'B'}
458        >>> g.destinationsFrom('B')
459        {'down': 'A', 'self': 'B', 'next': 'C'}
460        >>> g.destinationsFrom('C')
461        {'prev': 'B'}
462        >>> g.destinationsFrom('D')
463        Traceback (most recent call last):
464        ...
465        KeyError...
466        """
467        return self._byEdge[source]

Given a source node, returns a dictionary mapping the keys of all outgoing edges from that node to their destination nodes. Raises a KeyError if the node is not present in the graph.

Editing the dictionary returned could cause serious problems, so please don't; it will be updated live as the graph is changed.

Example

>>> g = UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> g.destinationsFrom('A')
{'up': 'B', 'up2': 'B'}
>>> g.destinationsFrom('B')
{'down': 'A', 'self': 'B', 'next': 'C'}
>>> g.destinationsFrom('C')
{'prev': 'B'}
>>> g.destinationsFrom('D')
Traceback (most recent call last):
...
KeyError...
def destination(self, source: ~Node, edge: ~Edge) -> ~Node:
469    def destination(self, source: Node, edge: Edge) -> Node:
470        """
471        Given a source node and an edge key, looks up and returns the
472        destination node for that edge. Raises a `KeyError` if there is no
473        edge from the specified node with the specified name.
474
475        ## Example
476
477        >>> g = UniqueExitsGraph()
478        >>> g.add_edges_from([
479        ...     ('A', 'B', 'up'),
480        ...     ('A', 'B', 'up2'),
481        ...     ('B', 'A', 'down'),
482        ...     ('B', 'B', 'self'),
483        ...     ('B', 'C', 'next'),
484        ...     ('C', 'B', 'prev')
485        ... ])
486        >>> g.destination('A', 'up')
487        'B'
488        >>> g.destination('A', 'up2')
489        'B'
490        >>> g.destination('B', 'down')
491        'A'
492        >>> g.destination('A', 'nonexistent')
493        Traceback (most recent call last):
494        ...
495        KeyError...
496        >>> g.destination('D', 'any')
497        Traceback (most recent call last):
498        ...
499        KeyError...
500        """
501        return self._byEdge[source][edge]

Given a source node and an edge key, looks up and returns the destination node for that edge. Raises a KeyError if there is no edge from the specified node with the specified name.

Example

>>> g = UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> g.destination('A', 'up')
'B'
>>> g.destination('A', 'up2')
'B'
>>> g.destination('B', 'down')
'A'
>>> g.destination('A', 'nonexistent')
Traceback (most recent call last):
...
KeyError...
>>> g.destination('D', 'any')
Traceback (most recent call last):
...
KeyError...
def getDestination(self, source: ~Node, edge: ~Edge, default: Any = None) -> Optional[~Node]:
503    def getDestination(
504        self,
505        source: Node,
506        edge: Edge,
507        default: Any = None
508    ) -> Optional[Node]:
509        """
510        Works like `destination`, but instead of raising a `KeyError` if
511        the node or edge is missing, it returns a default value (with a
512        default default of `None`).
513
514        ## Example
515
516        >>> g = UniqueExitsGraph()
517        >>> g.add_edges_from([
518        ...     ('A', 'B', 'up'),
519        ...     ('A', 'B', 'up2'),
520        ...     ('B', 'A', 'down'),
521        ...     ('B', 'B', 'self'),
522        ...     ('B', 'C', 'next'),
523        ...     ('C', 'B', 'prev')
524        ... ])
525        >>> g.getDestination('A', 'up')
526        'B'
527        >>> g.getDestination('A', 'up2')
528        'B'
529        >>> g.getDestination('B', 'down')
530        'A'
531        >>> g.getDestination('A', 'nonexistent') is None
532        True
533        >>> g.getDestination('A', 'nonexistent', 'default')
534        'default'
535        >>> g.getDestination('D', 'any') is None
536        True
537        """
538        return self._byEdge.get(source, {}).get(edge, default)

Works like destination, but instead of raising a KeyError if the node or edge is missing, it returns a default value (with a default default of None).

Example

>>> g = UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> g.getDestination('A', 'up')
'B'
>>> g.getDestination('A', 'up2')
'B'
>>> g.getDestination('B', 'down')
'A'
>>> g.getDestination('A', 'nonexistent') is None
True
>>> g.getDestination('A', 'nonexistent', 'default')
'default'
>>> g.getDestination('D', 'any') is None
True
def allEdgesTo(self, destination: ~Node) -> List[Tuple[~Node, ~Edge]]:
540    def allEdgesTo(
541        self,
542        destination: Node
543    ) -> List[Tuple[Node, Edge]]:
544        """
545        Searches the entire graph for edges whose destinations are the
546        specified destination, and returns a list of (node, edge) pairs
547        indicating the source node and edge name for each of those edges.
548        Self-edges are included in this list.
549
550        ## Example
551
552        >>> g = UniqueExitsGraph()
553        >>> g.add_edges_from([
554        ...     ('A', 'B', 'up'),
555        ...     ('A', 'B', 'up2'),
556        ...     ('B', 'A', 'down'),
557        ...     ('B', 'B', 'self'),
558        ...     ('B', 'C', 'next'),
559        ...     ('C', 'B', 'prev')
560        ... ])
561        >>> g.allEdgesTo('A')
562        [('B', 'down')]
563        >>> g.allEdgesTo('B')
564        [('A', 'up'), ('A', 'up2'), ('B', 'self'), ('C', 'prev')]
565        >>> g.allEdgesTo('C')
566        [('B', 'next')]
567        >>> g.allEdgesTo('D')
568        []
569        """
570        results = []
571        for node in self:
572            fromThere = self[node]
573            toHere = fromThere.get(destination, {})
574            for edgeKey in toHere:
575                results.append((node, edgeKey))
576
577        return results

Searches the entire graph for edges whose destinations are the specified destination, and returns a list of (node, edge) pairs indicating the source node and edge name for each of those edges. Self-edges are included in this list.

Example

>>> g = UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> g.allEdgesTo('A')
[('B', 'down')]
>>> g.allEdgesTo('B')
[('A', 'up'), ('A', 'up2'), ('B', 'self'), ('C', 'prev')]
>>> g.allEdgesTo('C')
[('B', 'next')]
>>> g.allEdgesTo('D')
[]
def textMapObj( self, edgeSep: str = '::', external: Optional[Set[~Node]] = None, explorationOrder: Optional[Tuple[~Node, Sequence[~Edge]]] = None, edgeOrders: Union[Dict[~Node, Sequence[~Edge]], Dict[~Node, Dict[~Edge, Any]], NoneType] = None):
579    def textMapObj(
580        self,
581        edgeSep: str = '::',
582        external: Optional[Set[Node]] = None,
583        explorationOrder: Optional[Tuple[Node, Sequence[Edge]]] = None,
584        edgeOrders: Union[
585            Dict[Node, Sequence[Edge]],
586            Dict[Node, Dict[Edge, Any]],
587            None
588        ] = None
589    ):
590        """
591        Returns a special object which is JSON-serializable and which
592        when serialized creates a semi-human-usable text-format map of
593        the graph.
594
595        The object consists of nested dictionaries, one per node, where
596        keys are node name + edge name strings (combined using the
597        `edgeSep` argument, default is '::'). The value for each key is
598        one of:
599
600        1. Another dictionary representing the node that edge leads
601            to, which can in turn have dictionary values...
602        2. A string naming a destination node that's already represented
603            elsewhere (or naming the current node for self-edges).
604
605        Any node present in the specified `external` set will be linked
606        to instead of listed out, even if it exists in the graph. The
607        `external` set **will be modified** by this function to include
608        all visited nodes in the graph.
609
610        If an `explorationOrder` is provided, it must be a tuple
611        specifying a start node followed by a sequence of edges that
612        indicates the path taken, and the edges will be visited
613        according to that order (this only matters in Python 3.7+ where
614        dictionaries have consistent order). A `ValueError` will be
615        raised if an invalid exploration order is provided. The path
616        list will be ignored if `edgeOrders` is provided explicitly.
617
618        TODO: What about unexplorable graphs (allow node names in place
619        of edge names in exploration order?)?!?
620
621        If `edgeOrders` is provided directly, it will override the
622        path part of the `explorationOrder` to determine the ordering of
623        edges at each node. If not and `explorationOrder` is provided, it
624        will be deduced from the `explorationOrder`. If neither is
625        present, ordering will follow whatever natural order is in the
626        graph, which in most cases should be order-of-creation.
627
628        Notes:
629        - For the format to avoid ambiguity, the `edgeSep` value must be
630            a string which does not appear in any node or edge names.
631        - Nodes and edge values will be converted to strings to build the
632            map.
633        - Node and edge properties are not represented in the resulting
634            object.
635        - For a variety of reasons, the result cannot be converted back
636            to a graph object. This is not intended for use as a JSON
637            serialization route (see the `networkx.readwrite.json_graph`
638            module for some built-in options).
639        - To get a string representation, one could do:
640            `json.dumps(graph.textMapObj())`
641
642        ## Examples
643
644        >>> from exploration import graphs as eg
645        >>> import json
646        >>> g = eg.UniqueExitsGraph()
647        >>> g.add_edges_from([
648        ...     ('A', 'B', 'up'),
649        ...     ('A', 'B', 'up2'),
650        ...     ('B', 'A', 'down'),
651        ...     ('B', 'B', 'self'),
652        ...     ('B', 'C', 'next'),
653        ...     ('C', 'B', 'prev')
654        ... ])
655        >>> print(json.dumps(g.textMapObj(), indent=2))
656        {
657          "A::up": {
658            "B::down": "A",
659            "B::self": "B",
660            "B::next": {
661              "C::prev": "B"
662            }
663          },
664          "A::up2": "B"
665        }
666        """
667        # We use `external` as our visited set
668        if external is None:
669            external = set()
670
671        if explorationOrder is not None:
672            here, path = explorationOrder
673        else:
674            # Find first non-external node as our starting node
675            for here in self.nodes:
676                if here not in external:
677                    break
678
679            # Path is empty
680            path = []
681
682        # Determine edge ordering for each node from exploration order
683        # or by natural ordering if no explorationOrder is available
684        if edgeOrders is None:
685            edgeOrders = cast(
686                Dict[Node, Dict[Edge, Any]],
687                {}
688            )
689            current = here
690            for i in range(len(path)):
691                edge = path[i]
692                # Add this edge next in the ordering for this node
693                orderHere: Dict[Edge, Any] = edgeOrders.setdefault(current, {})
694                # Note: we use a dictionary here because dictionaries do
695                # preserve insertion ordering (3.7+) and we need to both
696                # keep things in order AND do a bunch of lookups to
697                # avoid duplicates.
698                if edge not in orderHere:
699                    orderHere[edge] = True
700
701                # Move to next node
702                if edge not in self._byEdge[current]:
703                    raise ValueError(
704                        f"Invalid edge in exploration order path: at"
705                        f" step {i} we reached node {current} and were"
706                        f" supposed to take edge {edge} but that edge"
707                        f" does not exist."
708                    )
709                current = self._byEdge[current][edge]
710
711            # Add any unexplored nodes and/or edges in natural order
712            for node in self.nodes:
713                orderHere = edgeOrders.setdefault(node, {})
714                for edge in self._byEdge[node]:
715                    if edge not in orderHere:
716                        orderHere[edge] = True
717
718        result = {}
719        external.add(here)
720        # Now loop through keys of this node
721        for key in edgeOrders[here]:
722            combined = str(here) + edgeSep + str(key)
723            dest = self._byEdge[here][key]
724            if dest in external:
725                # links, including self-links
726                result[combined] = str(dest)
727            else:
728                # Recurse
729                result[combined] = self.textMapObj(
730                    edgeSep,
731                    external,
732                    (dest, []),  # empty path since we have edgeOrders
733                    edgeOrders
734                )
735
736        return result

Returns a special object which is JSON-serializable and which when serialized creates a semi-human-usable text-format map of the graph.

The object consists of nested dictionaries, one per node, where keys are node name + edge name strings (combined using the edgeSep argument, default is '::'). The value for each key is one of:

  1. Another dictionary representing the node that edge leads to, which can in turn have dictionary values...
  2. A string naming a destination node that's already represented elsewhere (or naming the current node for self-edges).

Any node present in the specified external set will be linked to instead of listed out, even if it exists in the graph. The external set will be modified by this function to include all visited nodes in the graph.

If an explorationOrder is provided, it must be a tuple specifying a start node followed by a sequence of edges that indicates the path taken, and the edges will be visited according to that order (this only matters in Python 3.7+ where dictionaries have consistent order). A ValueError will be raised if an invalid exploration order is provided. The path list will be ignored if edgeOrders is provided explicitly.

TODO: What about unexplorable graphs (allow node names in place of edge names in exploration order?)?!?

If edgeOrders is provided directly, it will override the path part of the explorationOrder to determine the ordering of edges at each node. If not and explorationOrder is provided, it will be deduced from the explorationOrder. If neither is present, ordering will follow whatever natural order is in the graph, which in most cases should be order-of-creation.

Notes:

  • For the format to avoid ambiguity, the edgeSep value must be a string which does not appear in any node or edge names.
  • Nodes and edge values will be converted to strings to build the map.
  • Node and edge properties are not represented in the resulting object.
  • For a variety of reasons, the result cannot be converted back to a graph object. This is not intended for use as a JSON serialization route (see the networkx.readwrite.json_graph module for some built-in options).
  • To get a string representation, one could do: json.dumps(graph.textMapObj())

Examples

>>> from exploration import graphs as eg
>>> import json
>>> g = eg.UniqueExitsGraph()
>>> g.add_edges_from([
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ])
>>> print(json.dumps(g.textMapObj(), indent=2))
{
  "A::up": {
    "B::down": "A",
    "B::self": "B",
    "B::next": {
      "C::prev": "B"
    }
  },
  "A::up2": "B"
}
Inherited Members
networkx.classes.multidigraph.MultiDiGraph
MultiDiGraph
edge_key_dict_factory
adj
succ
pred
add_edge
remove_edge
edges
out_edges
in_edges
degree
in_degree
out_degree
is_multigraph
is_directed
to_undirected
reverse
networkx.classes.multigraph.MultiGraph
to_directed_class
to_undirected_class
has_edge
get_edge_data
copy
to_directed
number_of_edges
networkx.classes.digraph.DiGraph
graph
has_successor
has_predecessor
successors
neighbors
predecessors
networkx.classes.graph.Graph
node_dict_factory
node_attr_dict_factory
adjlist_outer_dict_factory
adjlist_inner_dict_factory
edge_attr_dict_factory
graph_attr_dict_factory
name
nodes
number_of_nodes
order
has_node
add_weighted_edges_from
update
adjacency
subgraph
edge_subgraph
size
nbunch_iter