docs: def add_edge(self, head_id, tail_id, edge_data=1, create_nodes=True):
          """
          Adds a directed edge going from head_id to tail_id.
          Arbitrary data can be attached to the edge via edge_data.
          It may create the nodes if adding edges between nonexisting ones.
  
          :param head_id: head node
          :param tail_id: tail node
          :param edge_data: (optional) data attached to the edge
          :param create_nodes: (optional) creates the head_id or tail_id node in case they did not exist
          """
          # shorcut
          edge = self.next_edge
  
          # add nodes if on automatic node creation
          if create_nodes:
              self.add_node(head_id)
              self.add_node(tail_id)
  
          # update the corresponding incoming and outgoing lists in the nodes
          # index 0 -> incoming edges
          # index 1 -> outgoing edges
  
          try:
              self.nodes[tail_id][0].append(edge)
              self.nodes[head_id][1].append(edge)
          except KeyError:
              raise GraphError('Invalid nodes %s -> %s' % (head_id, tail_id))
  
          # store edge information
          self.edges[edge] = (head_id, tail_id, edge_data)
  
  
          self.next_edge += 1