Is there a redundancy between the two functions of adding a start node and an end node to my graph? I feel like there is a redundency between:
def _determine_nodes_with_no_predecessors(nodes:List[Node])-&gt;List[str]:
    &quot;&quot;&quot;
    Given the list of nodes returns the identifiers of nodes that doesn't have any predecessor. 
    &quot;&quot;&quot;
    nodes_ids_without_prdecessors = []
    for node in nodes:
        if not node.predecessors: 
            nodes_ids_without_prdecessors.append(node.id_)
    return nodes_ids_without_prdecessors


def _add_start_node(graph:Graph):

    start_node = Node.create_start_node(graph)

    graph.add_node(start_node)

    nodes_ids_without_prdecessors = _determine_nodes_with_no_predecessors(graph.nodes)
    
    # Its successors are the nodes without predecessors in this graph
    for node_id in nodes_ids_without_prdecessors: 
        start_node.add_successor(graph.nodes[node_id])
            
    # Conversly, We add the `start_node` as a predecessor to the nodes without predecessors in this graph
    for node_id in nodes_ids_without_prdecessors: 
        node = graph.nodes[node_id]
        node.add_predecessor(start_node)

And this one:
def _determine_nodes_with_no_successors(nodes:List[Node])-&gt;List[str]:
    &quot;&quot;&quot;
    Given the list of nodes returns the identifiers of nodes that doesn't have any successor. 
    &quot;&quot;&quot;
    nodes_ids_without_successors = []
    for node in nodes:
        if not node.successors: 
            nodes_ids_without_successors.append(node.id_)
    return nodes_ids_without_successors

def _add_end_node(graph:Graph):
    
    end_node = Node.create_end_node() #constructor of node
    
    graph.add_node(end_node)
    
    end_node = graph.nodes[end_node.id_]

    nodes_ids_without_successors = _determine_nodes_with_no_successors(graph.nodes)

    # Its predecessors are the nodes without successors in this graph
    for node_id in nodes_ids_without_successors: 
        end_node.add_predecessor(graph.nodes[node_id])
            
    # Conversly, We add the `end_node` as a predecessor to the nodes without predecessors in this graph
    for node_id in nodes_ids_without_successors: 
        node = graph.nodes[node_id]
        node.add_successor(end_node)

Note: Node is a class that have the attributes predecessors, successors and id_ and the class methods create_dummy_start_node, create_dummy_end_node, add_predecessor and add_successor. Graph is a class that have the attribute nodes and the method add node.
How to write less code in this case? Is it possible to write only two functions that do the whole stuff (instead of four functions)?
You can use the built-in getattr function to extract an attribute by name. Likewise the setattr function to set attributes.
This means you can pass the name of the attribute to a private function, unifying the code:
def _find_nodes_with_no(pred_succ: str, nodes: List[Node]) -&gt; List[Node]:
    &quot;&quot;&quot; Find nodes with either no predecessors, or no successors. &quot;&quot;&quot;

    if pred_succ not in (&quot;predecessors&quot;, &quot;successors&quot;):
        raise ValueError(&quot;pred_succ must be either 'predecessors' or 'successors'&quot;)

    return [node for node in nodes if not getattr(node, pred_succ)]

Your add_start and add_end functions can thus be written to pass the appropriate names:
def add_start(graph: Graph):
    info = (&quot;create_start_node&quot;, &quot;predecessors&quot;, &quot;successors&quot;)
    _add_outer_node(graph, info)

def add_end(graph: Graph):
    info = (&quot;create_end_node&quot;, &quot;successors&quot;, &quot;predecessors&quot;)
    _add_outer_node(graph, info)

Then in _add_outer_node you can do things like:
cmeth, main_links, back_links = info
create_method = getattr(Node, cmeth)
new_node = create_method(graph)

# ...

outer_nodes = _find_nodes_with_no(main_links, graph)

One thing I do question it your &quot;manual&quot; connection of both the predecessor and successor links. It seems like you want those to be reflexive, so why not create an add_predecessor method that automatically creates the reciprocal link? Likewise with add_successor?