Repository: eeshannarula29/structlinks (language: python)

Issue:
Graphs library 
implement graphs library 



Discussion / hints:

Fix patch:
diff --git a/Graph.py b/Graph.py
new file mode 100644
index 0000000..b9d0171
--- /dev/null
+++ b/Graph.py
@@ -0,0 +1,528 @@
+"""The file contains Graph object. The base code for the vertex and the graph object
+is taken from University of Toronto's CSC111 course."""
+
+from __future__ import annotations
+from typing import Any, Optional, Union, Callable
+
+
+class _Vertex:
+    """A vertex in a graph.
+
+    Instance Attributes:
+        - item: The data stored in this vertex.
+        - attributes: attributes of the vertex
+        - neighbours: The vertices that are adjacent to this vertex.
+    """
+    item: Any
+    attributes: dict
+    neighbours: dict
+
+    def __init__(self, item: Any,
+                 neighbours: Optional[dict] = None,
+                 attributes: Optional[dict] = None) -> None:
+        """Initialize a new vertex with the given item and neighbours."""
+        self.item = item
+        self.attributes = attributes if attributes else {}
+        self.neighbours = neighbours if neighbours else {}
+        self.view = _VertexView(self)
+
+    def check_connected(self, target_item: Any, visited: set[_Vertex]) -> bool:
+        """Return whether this vertex is connected to a vertex corresponding to the target_item,
+        WITHOUT using any of the vertices in visited.
+
+        Preconditions:
+            - self not in visited
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        if self.item == target_item:
+            # Our base case: the target_item is the current vertex
+            return True
+        else:
+            visited.add(self)  # Add self to the set of visited vertices
+            for u in self.neighbours:
+                u_vertex = self.neighbours[u]['item']
+                if u_vertex not in visited:  # Only recurse on vertices that haven't been visited
+                    if u_vertex.check_connected(target_item, visited):
+                        return True
+
+            return False
+
+    def get_connected(self, visited: set[_Vertex]) -> set:
+        """Return a set of all ITEMS connected to self by a path that does not use
+        any vertices in visited.
+
+        The items of the vertices in visited CANNOT appear in the returned set.
+
+        Preconditions:
+            - self not in visited
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+
+        all_in_visited = True
+
+        items_set = {self.item}
+
+        for neighbour in self.neighbours:
+
+            neighbour_vertex = self.neighbours[neighbour]['item']
+
+            if neighbour_vertex not in visited:
+                all_in_visited = False
+
+        if all_in_visited:
+            visited.add(self)
+            return items_set
+
+        else:
+            visited.add(self)
+
+            for neighbour in self.neighbours:
+
+                neighbour_vertex = self.neighbours[neighbour]['item']
+
+                if neighbour_vertex not in visited:
+                    items_set = items_set.union(neighbour_vertex.get_connected(visited))
+
+            return items_set
+
+    def spanning_graph(self, visited: set[_Vertex]) -> list[tuple]:
+        """Return a Graph that form a spanning tree of all vertices that are
+        connected to this vertex WITHOUT using any of the vertices in visited.
+
+        The edges are returned as a list of sets, where each set contains the two
+        ITEMS corresponding to an edge.
+
+        Preconditions:
+            - self not in visited
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        edges_so_far = []
+
+        visited.add(self)
+
+        for neighbour in self.neighbours:
+            # Only recurse on vertices that haven't been visited
+            if self.neighbours[neighbour]['item'] not in visited:
+                edges_so_far.append((self.item, neighbour,
+                                     self.neighbours[neighbour]['attributes']))
+                edges_so_far.extend(self.neighbours[neighbour]['item'].spanning_graph(visited))
+
+        return edges_so_far
+
+
+class _VertexView:
+    """Used to get/view a vertex"""
+
+    def __init__(self, _vertex: _Vertex) -> None:
+        self._vertex = _vertex
+
+    def __repr__(self) -> str:
+        """Return string representation of vertex"""
+
+        string_so_far = '{ \n'
+
+        for neighbour in self._vertex.neighbours:
+            string_so_far += \
+                '  {' + f'{neighbour}: {self._vertex.neighbours[neighbour]["attributes"]}' + '}, \n'
+
+        return string_so_far + '}'
+
+    def __setitem__(self, other: Any, properties: dict) -> None:
+        """Set a property of the edge between self and other
+
+        properties is of the form:
+        - {'property 1': value 1, 'property 2': value 2, ... 'property n': value n}
+        """
+        if other not in self._vertex.neighbours:
+            raise ValueError('The vertex value is not a neighbour to self, or does not exists')
+
+        if not isinstance(properties, dict):
+            raise TypeError('attribute properties should be a dictionary')
+
+        self._vertex.neighbours[other]['attributes'] = properties
+
+    def __getitem__(self, other: Any) -> dict:
+        """Return all the attributes of edge between self and other"""
+        if other not in self._vertex.neighbours:
+            return {}
+
+        return self._vertex.neighbours[other]['attributes']
+
+    @property
+    def attr(self) -> dict:
+        """Return the attribute dict of the vertex"""
+        return self._vertex.attributes
+
+    def check_connected(self, item: Any) -> bool:
+        """Check if vertex is connected to the item"""
+        return self._vertex.check_connected(item, set())
+
+    def get_connected(self) -> set:
+        """Return set of all elements connected to vertex"""
+        return self._vertex.get_connected(set())
+
+    def in_cycle(self) -> bool:
+        """Return whether vertex is in a cycle or not
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+
+        return any(self._vertex.neighbours[neighbour1]['item'].check_connected(neighbour2,
+                                                                               {self._vertex})
+                   for neighbour1 in self._vertex.neighbours
+                   for neighbour2 in self._vertex.neighbours
+                   if neighbour1 != neighbour2)
+
+    def adjacent(self, item: Any) -> bool:
+        """Return whether item is adjacent to self.
+        """
+        return item in self._vertex.neighbours
+
+    def get_neighbours(self) -> set:
+        """Return a set of the neighbours of vertex
+        """
+        return set(self._vertex.neighbours.keys())
+
+    @property
+    def degree(self) -> int:
+        """Return the degree of the vertex"""
+        return len(self._vertex.neighbours)
+
+    def create_spanning_graph(self) -> Graph:
+        """Create a spanning graph from this vertex"""
+        graph = Graph()
+        edges = self._vertex.spanning_graph(set())
+        graph.add_edges(edges)
+        return graph
+
+
+class Graph:
+    """A graph.
+
+    The base code for this class taken from University of Toronto's CSC111 course
+    """
+    # Private Instance Attributes:
+    #     - _vertices: A collection of the vertices contained in this graph.
+    #                  Maps item to _Vertex instance.
+    _vertices: dict[Any, _Vertex]
+
+    def __init__(self) -> None:
+        """Initialize an empty graph (no vertices or edges)."""
+        self._vertices = {}
+
+    def add_vertex(self, item: Any, attributes: Optional[dict] = None) -> None:
+        """Add a vertex with the given item to this graph.
+
+        The new vertex is not adjacent to any other vertices.
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        if item not in self._vertices:
+            self._vertices[item] = _Vertex(item, attributes=attributes if attributes else {})
+        else:
+            self._vertices[item].attributes.update(attributes if attributes else {})
+
+    def add_vertices(self, items: list[Any], global_attributes: Optional[dict] = None):
+        """Add multiple vertices
+
+        An items can look like:
+        - [item 1, item 2, ..., item n]
+        - [(item 1, attribute 1), (item 2, attribute 2), ..., (item n, attribute n)]
+        - [(item 1, attribute 1), item2, item3, ..., (item n, attribute n)]
+
+        global_attributes will be added to all the vertices
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        if not global_attributes:
+            global_attributes = {}
+
+        for item in items:
+
+            if isinstance(item, tuple) and len(item) == 2:
+                item_value, item_attributes = item
+                item_attributes.update(global_attributes)
+                self.add_vertex(item_value, attributes=item_attributes)
+            else:
+                self.add_vertex(item)
+
+    @property
+    def vertices(self) -> set:
+        """Return set of all the vertices in the graph"""
+        return set(self._vertices.keys())
+
+    def add_edge(self, item1: Any, item2: Any, attributes: Optional[dict] = None) -> None:
+        """Add an edge between the two vertices with the given items in this graph,
+        and with the give attributes. attributes here is the list of properties if
+        the edge between the two items.
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        # check if item1 != item2
+        if item1 != item2:
+            # if item1 is not in self._vertices then add it
+            if item1 not in self._vertices:
+                self.add_vertex(item1)
+            # if item2 is not in self._vertices then add it
+            if item2 not in self._vertices:
+                self.add_vertex(item2)
+            # extract the _Vertex object of item1 and item2
+            v1 = self._vertices[item1]
+            v2 = self._vertices[item2]
+
+            if item1 not in v2.neighbours and item2 not in v1.neighbours:
+                # Add the new edge
+                v1.neighbours[item2] = {'item': v2, 'attributes': attributes if attributes else {}}
+                v2.neighbours[item1] = {'item': v1, 'attributes': attributes if attributes else {}}
+            else:
+                v1.neighbours[item2]['attributes'].update(attributes if attributes else {})
+                v2.neighbours[item1]['attributes'].update(attributes if attributes else {})
+
+    def add_edges(self, edges: Union[list[tuple[Any, Any]], list[tuple[Any, Any, dict]]]) -> None:
+        """Add multiple edges to the graph
+
+        Instance Attributes:
+        - edges: it is a list of tuples representing an edge. The tuple could be of length 2 or 3
+
+                if edge is of length 2 then it would look like:
+                - (item1, item2)
+
+                if edge is of length 2 then it would look like:
+                - (item1, item2, edge-attributes)
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        for edge in edges:
+            if not (len(edge) == 2 or len(edge) == 3):
+                raise TypeError('The edges are not formatted properly')
+
+        for edge in edges:
+
+            if len(edge) == 2:
+                self.add_edge(edge[0], edge[1])
+
+            else:
+                self.add_edge(edge[0], edge[1], edge[2])
+
+    @property
+    def edges(self) -> set:
+        """Return set of all the edges"""
+
+        # Set of all the edges
+        set_so_far = set()
+        # set of all the elements which have been visited
+        visited = set()
+
+        for vertex in self._vertices:
+            # add vertex to visited
+            visited.add(vertex)
+
+            for neighbour in self._vertices[vertex].neighbours:
+                # only add edge if not previously added
+                if neighbour not in visited:
+                    set_so_far.add((vertex, neighbour))
+
+        return set_so_far
+
+    def clear(self) -> None:
+        """clear the graph"""
+        for vertex in self.vertices:
+            del self._vertices[vertex]
+        self._vertices = {}
+
+    def adjacent(self, item1: Any, item2: Any) -> bool:
+        """Return whether item1 and item2 are adjacent vertices in this graph.
+
+        Return False if item1 or item2 do not appear as vertices in this graph.
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        if item1 in self._vertices and item2 in self._vertices:
+            return item2 in self._vertices[item1].neighbours
+        else:
+            # We didn't find an existing vertex for both items.
+            return False
+
+    def get_neighbours(self, item: Any) -> set:
+        """Return a set of the neighbours of the given item.
+
+        Raise a ValueError if item does not appear as a vertex in this graph.
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        if item in self._vertices:
+            v = self._vertices[item]
+            return set(v.neighbours.keys())
+        else:
+            raise ValueError
+
+    @property
+    def vertices_count(self) -> int:
+        """Return the number of vertices"""
+        return len(self._vertices)
+
+    @property
+    def edges_count(self) -> int:
+        """Return the number of edges"""
+        return len(self.edges)
+
+    def get_degree(self, item: Union[set, Any]) -> Union[dict, int]:
+        """Return the degree of the item in the graph if item is a single element
+        otherwise if item is a set of elements then it would return a dict of
+        elements mapped to their degrees
+
+        Raise a ValueError if item does not appear as a vertex in this graph.
+        """
+        # If item is a set of elements
+        if isinstance(item, set):
+            dict_so_far = {}
+
+            for element in item:
+                dict_so_far[element] = self.get_degree(element)
+
+            return dict_so_far
+        # if item is a single element
+        if item in self._vertices:
+            return self[item].degree
+        else:
+            raise ValueError('The element or one of the elements is not on the graph')
+
+    def __getitem__(self, item) -> _VertexView:
+        """Returns a object containing all the information about the neighbours of vertex,
+        and used to set/get properties of edges
+        """
+        if item not in self._vertices:
+            raise ValueError
+
+        return self._vertices[item].view
+
+    def remove_vertex(self, item: Any) -> None:
+        """Delete item from the graph"""
+        if item in self._vertices:
+
+            for neighbour in self._vertices[item].neighbours:
+                self._vertices[neighbour].neighbours.pop(item)
+
+            self._vertices.pop(item)
+
+        else:
+            raise ValueError('The item is not in the graph')
+
+    def remove_edge(self, item1: Any, item2: Any) -> None:
+        """Remove edge between item1 and item2"""
+        if item1 in self._vertices and item2 in self._vertices:
+            if item1 in self.get_neighbours(item2):
+                self._vertices[item1].neighbours.pop(item2)
+                self._vertices[item2].neighbours.pop(item1)
+        else:
+            raise ValueError('One/both of item1, item2 is/are not in the graph')
+
+    def remove_vertices(self, items: list) -> None:
+        """Remove all the vertices in items"""
+        if not all(item in self._vertices for item in items):
+            raise ValueError('One or more items in the list are not present in the graph')
+
+        else:
+            for item in items:
+                self.remove_vertex(item)
+
+    def remove_edges(self, edges: list[tuple]) -> None:
+        """Remove all the edge pairs in <edges>"""
+        if not all(item1 in self._vertices and item2 in self._vertices
+                   for (item1, item2) in edges):
+            raise ValueError('One or more of the edge pairs have elements which are not in graph')
+
+        for (item1, item2) in edges:
+            self.remove_edge(item1, item2)
+
+    def add_global_attributes(self, attributes: dict) -> None:
+        """Add attributes to all the vertices in the graph"""
+        for item in self.vertices:
+            self[item].attr.update(attributes)
+
+    def sub_graph_by_value(self, predicate: Callable) -> Graph:
+        """Return a sub-graph of self, with vertices which pass the predicate function"""
+        subgraph = Graph()
+
+        subgraph.add_vertices([(item, self[item].attr)
+                               for item in self.vertices if predicate(item)])
+
+        vertices = subgraph.vertices
+
+        for (item1, item2) in self.edges:
+            if item1 in vertices and item2 in vertices:
+                subgraph.add_edge(item1, item2, self[item1][item2].copy())
+
+        return subgraph
+
+    def sub_graph_by_attribute(self, attribute: Any,
+                               predicate: Optional[Callable] = lambda x: True) -> Graph:
+        """Return a sub-graph of self, with vertices which contain attribute and
+         pass the predicate function for that attribute"""
+
+        subgraph = Graph()
+
+        subgraph.add_vertices([(item, self[item].attr)
+                               for item in self.vertices
+                               if attribute in self[item].attr and predicate(item)])
+
+        vertices = subgraph.vertices
+
+        for (item1, item2) in self.edges:
+            if item1 in vertices and item2 in vertices:
+                subgraph.add_edge(item1, item2, self[item1][item2].copy())
+
+        return subgraph
+
+    def union(self, other: Graph) -> Graph:
+        """Return a new graph which contains all the vertices of self and other. Attributes
+        for duplicates would be combined to also form a union."""
+
+        graph = Graph()
+
+        graph.add_vertices([(item, self[item].attr) for item in self.vertices])
+        graph.add_vertices([(item, other[item].attr) for item in other.vertices])
+
+        graph.add_edges([(item1, item2, self[item1][item2].copy())
+                         for (item1, item2) in self.edges])
+
+        graph.add_edges([(item1, item2, other[item1][item2].copy())
+                         for (item1, item2) in other.edges])
+
+        return graph
+
+    @property
+    def is_connected(self) -> bool:
+        return all(self[item1].check_connected(item2)
+                   for item1 in self.vertices for item2 in self.vertices)
+
+    def create_spanning_graph(self) -> Graph:
+        """Return a spanning graph of self
+
+        Precondition:
+        - self.is_connected
+
+        The base code is taken from University of Toronto's CSC111 course
+        """
+        if not self.is_connected:
+            raise TypeError('The graph should be connected')
+
+        if self.vertices_count > 0:
+            return self[list(self.vertices)[0]].create_spanning_graph()
+
+        else:
+            return Graph()
+
+    def __str__(self) -> str:
+        """Return a string representation of the graph"""
+        string_representation = '{ \n'
+
+        for item in self.vertices:
+            string_representation += f'{item}: {self[item].get_neighbours()} \n'
+
+        string_representation += '}'
+
+        return string_representation
diff --git a/README.md b/README.md
index 5632f43..cc01423 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ References are mentioned at the bottom of the file.
 ## Current DataTypes
 
 - [Binary Search Trees](#BinarySearchTrees)
+- [Graphs](#Graphs)  
 - [Linked Lists](#LinkedLists)
 - [Doubly Linked Lists](#DoublyLinkedLists)
 - [Stacks](#Stacks)
@@ -426,6 +427,329 @@ floor_bst = bst.floor()  # <- map floor to all element of bst
 ceil_bst = bst.ceil()  # <- map ceil to all element of bst
 ```
 
+## Graphs
+
+### Initialize a Graph
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+```
+
+### Adding Vertices
+
+#### Adding a Single Vertex
+A vertex can we added simply by calling `graph.add_vertex`. The function takes in the value of the vertex. 
+Additional `Attributes` of the vertex can be passed in as a dictionary. 
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+
+# Adding a vertex
+graph.add_vertex(2)
+
+# Add vertex with attributes 
+graph.add_vertex(3, attributes={'type': int, 'is active': True})
+```
+
+A error would **not** be raised if a vertex value is added again, instead the attributes 
+of the vertex would be updated by the attributes of the new call. 
+
+#### Adding Multiple Vertices
+Multiple vertices could be added using the `graph.add_vertices` function, which takes in a list of elements. To
+add vertices with attributes the format of the tuple should be `[ ... (value, attribute dictionary) ... ]`. A list
+contain both elements with no attributes and elements with attributes. 
+
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+
+graph.add_vertices([2, (3, {'type': int, 'is active': True}), 5, ('string', {'type': str})])
+
+print(graph.vertices)
+# Output:
+# {2, 3, 5, 'string'}
+```
+
+Similar to `add_vertex` if a vertex is added with existing value, the attributes of the vertex would be updated and 
+no error would be raised.
+
+Note that a vertex can be anything, as shown in the above example. It could be a number, string, dictionary or even
+a graph object itself.
+
+### Adding Edges
+
+#### Adding a Single Edge
+An edge can be added using the `add_edge` function. Similar to vertices, edges can have attributes which we can pass in as 
+an attribute. Note that if a vertex does not exists then the function does **not** raise an error, instead it creates
+a new vertex with that value, and adds an edge. Note That if an edge already exists, then similar to vertices, the `attributes`
+of the edge would be update if `add_edge` is called.
+
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+
+# Add vertices
+graph.add_vertices([2, 1, 3, 5])
+
+# Add edge 
+graph.add_edge(2, 1)
+
+# Add edge with attributes
+graph.add_edge(1, 3, attributes={'weight': 100})
+
+# Add edge between non existing vertex
+graph.add_edge(3, 100)
+
+print(graph.edges)
+# Output:
+# {(2, 1), (1, 3), (3, 100)}
+```
+Note that `100` was not a vertex, but the function created a new vertex with value `100`, and add an edge between `3` and `100`.
+
+#### Adding multiple Edges
+Multiple edges can be added using `add_edges` function. The function takes in a list of tuples, where each tuple represents 
+an edge. The format of the tuple could `[... (item1, item2) ...]` , and we can additionally add attributes to the edge 
+which would look like `[... (item1, item2, attributes dict) ...]`. Note that similar to `add edge`,  if a vertex does not exists then the function does **not** raise an error, instead it creates
+a new vertex with that value, and adds an edge. 
+
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+
+# Add vertices
+graph.add_vertices([2, 1, 3, 5])
+
+# Add edges
+graph.add_edges([(2, 1), (1, 3, {'weight': 100}), (5, 25)])
+
+print(graph.edges)
+# Output:
+# {(2, 1), (1, 3), (5, 25)}
+```
+
+### Clear the Graph
+A graph can be cleared using the `clear` function. This would remove all the vertices and edges of the graph.
+```python
+graph.clear()
+```
+
+### Examining elements of a graph
+
+We can read and examine the graph with some helpful function. As you have seen in a few examples above 
+`graph.vertices` and `graph.edges` are two properties used to get the unique set of vertices and edges. 
+
+To get the number of vertices and edges you can call `graph.vertices_count()` and `graph.edges_count()`. 
+
+Other functions are `graph.get_neighbours`, `graph.adjacent`, `graph.get_degree`
+
+```python
+print(graph.vertices)
+# Output: {2, 1, 3, 5}
+
+print(graph.edges)
+# Output: {(2, 1), (1, 3)}
+
+print(graph[1].get_neighbours())  # Same as graph.get_neighbours(1)
+# Output: {2, 3}
+
+print(graph[2].adjacent(1))  # Same as graph.adjacent(2, 1) or graph.adjacent(1, 2)
+# Output: True
+
+print(graph[5].degree)  # Same as graph.get_degree(5)
+# Output: 0
+
+# print degree for multiple elements
+print(graph.get_degree({1, 3}))
+# Output: {1: 2, 3: 1}
+```
+
+There are some other functions specific to only vertices like `check_connected`, `get_connected`, and `in_cycle`.
+
+```python
+graph[1].check_connected(5)  # check if 1 and 5 are connected
+# Output: False
+
+graph[1].get_connected()  # Return all the vertices connected to graph
+# Output: {1, 2, 3}
+
+graph[1].in_cycle()  # check if vertex is in a cycle
+# Output: False 
+
+# You can also check if the graph is connected or not
+print(graph.is_connected)  # check if all vertices in the graph are connected to each other
+# Output: False
+```
+
+### Remove Vertices and Edges
+
+Removing vertices and edges is similar to adding them. The functions used are `graph.remove_vertex`, `graph.remove_vertices`,
+`graph.remove_edge` and `graph.remove_edges`
+
+```python
+print(graph.vertices)
+# Output: {2, 1, 3, 5, 6, 7}
+
+print(graph.edges)
+# Output: {(2, 1), (1, 3), (3, 5), (1, 7), (7, 6), (6, 1)}
+
+# Remove a vertex
+graph.remove_vertex(2)  # <--------------- Removing single vertex
+
+# After removing:
+print(graph.vertices)
+# Output: {1, 3, 5, 6, 7}
+
+print(graph.edges)
+# Output: {(1, 3), (3, 5), (1, 7), (7, 6), (6, 1)}
+
+graph.remove_vertices([5, 3])  # <-------- Remove multiple vertices
+
+# After removing:
+print(graph.vertices)
+# Output: {1, 6, 7}
+
+print(graph.edges)
+# Output: {(1, 7), (7, 6), (6, 1)}
+
+graph.remove_edge(1, 7)  # <-------------- Remove a single edge
+
+# After removing:
+print(graph.vertices)
+# Output: {1, 6, 7}
+
+print(graph.edges)
+# Output: {(7, 6), (6, 1)}
+
+graph.remove_edges([(7, 6), (6, 1)]) # <-- Remove multiple edges
+
+# After removing:
+print(graph.vertices)
+# Output: {1, 6, 7}
+
+print(graph.edges)
+# Output: {}
+```
+
+### Get/Set the Attributes
+
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+
+# Add vertices
+graph.add_vertices([1, (2, {'type': int}), 3, ('string', {'type': str})])
+
+# Add edges 
+graph.add_edges([(1, 3, {'weight': 10}), (2, 1)])
+```
+
+#### Vertex Attributes
+```python
+print(graph[2].attr)
+# Output: {'type': int}
+
+print(graph['string'].attr['type'])
+# Output: str
+
+graph[1].attr.update({'type': int})  # Same as graph[1].attr['type'] = int
+```
+
+#### Edge Attributes
+```python
+print(graph[3][1])  # Represents an edge between 1 and 3 
+# Output: {'weight': 10}
+
+print(graph[1][2]) # Represents an edge between 1 and 2
+# Output: {}
+
+print(graph[1][3]['weight'])  # Return the weight attribute of the edge
+# Output: 10
+
+graph[1][2]['type'] = 'numbers'  # Same as graph[1][2].update({'type': 'numbers'})
+```
+
+#### Add Global Attributes to All the Vertices of the Graph
+```python
+graph.add_global_attributes({'weight': 1})  # add this attribute to all the vertices
+```
+
+### Graph Generator Functions
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+
+# Add vertices
+graph.add_vertices([1, (2, {'type': int}), 3, ('string', {'type': str})])
+
+# Add edges 
+graph.add_edges([(1, 3, {'weight': 10}), ('string', 1, {'weight': 1})])
+```
+#### Sub-Graph: Filter by Vertex Values
+```python
+filtered = graph.sub_graph_by_value(lambda x: isinstance(x, int))  # <-- only keep int vertices
+
+print(filtered.vertices)
+# Output: {1, 2, 3}
+
+print(filtered.edges)
+# Output: {(1, 3)}
+```
+
+#### Sub-Graph: Filter by Attribute Values
+```python
+filtered = graph.sub_graph_by_attribute(attribute='type')  # only keep vertices with attribute 'type'
+
+print(filtered.vertices)
+# Output: {2}
+
+print(filtered.edges)
+# Output: {}
+```
+
+#### Union
+
+```python
+other_graph = Graph()
+
+other_graph.add_edges([(1, 7), (9, 0), (3, 10)])
+
+union = graph.union(other_graph)
+
+print(union.vertices)
+# Output: {1, 2, 3, 'string', 7, 9, 0, 10}
+
+print(union.edges)
+# Output: {(1, 3), (1, 'string'), (1, 7), (9, 0), (3, 10)}
+```
+
+### Printing Graphs
+```python
+from NetLinks.Graph import Graph
+
+graph = Graph()
+
+graph.add_edges([(1, 2), (2, 3), (4, 5), (5, 6), (6, 1)])
+
+print(graph)
+# Output:
+# { 
+# 1: {2, 6} 
+# 2: {1, 3} 
+# 3: {2} 
+# 4: {5} 
+# 5: {4, 6} 
+# 6: {1, 5} 
+# }
+```
+
+
 ## LinkedLists
 ### Initializing a Linked list
 You can initialize a linked list by simply creating a linked list object


Test patch:
diff --git a/tests/test_Graph.py b/tests/test_Graph.py
new file mode 100644
index 0000000..cd88a09
--- /dev/null
+++ b/tests/test_Graph.py
@@ -0,0 +1,96 @@
+"""The file contains tests for graph library"""
+
+from Graph import Graph
+
+
+class TestGraph:
+
+    def test_add_vertex(self) -> None:
+        """test add_vertex function"""
+        graph = Graph()
+
+        graph.add_vertex(1)
+        graph.add_vertex('string')
+        graph.add_vertex(2, {'type': 'number'})
+        graph.add_vertex('string2', {'type': 'string', 'attribute2': 'value'})
+        graph.add_vertex(1, {'weight': 100})
+
+        assert graph.vertices == {1, 'string', 2, 'string2'}
+
+        assert graph[1].attr == {'weight': 100}
+        assert graph['string'].attr == {}
+        assert graph[2].attr == {'type': 'number'}
+
+        assert graph.vertices_count == 4
+
+    def test_add_vertices(self) -> None:
+        """test add_vertices function"""
+
+        graph = Graph()
+
+        graph.add_vertex(1)
+        graph.add_vertex(2)
+
+        graph.add_vertices([1, 2, (3, {'type': int}), ('string', {'type': str, 'value': 'David'})])
+
+        assert graph.vertices == {1, 2, 3, 'string'}
+
+        assert graph[1].attr == {}
+        assert graph['string'].attr == {'type': str, 'value': 'David'}
+        assert graph[3].attr == {'type': int}
+
+        assert graph.vertices_count == 4
+
+    def test_add_edge(self) -> None:
+        """test add_edge function"""
+        graph = Graph()
+
+        graph.add_vertices([1, 2, 3, 4, 5])
+
+        graph.add_edge(1, 2)
+
+        graph.add_edge(5, 3, {'weight': 100})
+
+        graph.add_edge(100, 2)
+
+        assert graph.vertices == {1, 2, 3, 4, 5, 100}
+
+        assert graph[1].adjacent(2)
+        assert graph[5].adjacent(3)
+        assert graph[100].adjacent(2)
+
+        assert graph.edges_count == 3
+
+        assert graph[2].degree == 2
+        assert graph[5].degree == 1
+        assert graph[4].degree == 0
+
+        assert graph[5][3] == {'weight': 100}
+        assert graph[3][1] == {}
+        assert graph[1][2] == {}
+
+    def test_add_edges(self) -> None:
+        """test add_edge function"""
+        graph = Graph()
+
+        graph.add_vertices([1, 2, 3, 4, 5])
+
+        graph.add_edge(1, 2)
+
+        graph.add_edges([(5, 3, {'weight': 100}), (100, 2), (1, 2, {'type': int})])
+
+        assert graph.vertices == {1, 2, 3, 4, 5, 100}
+
+        assert graph[1].adjacent(2)
+        assert graph[5].adjacent(3)
+        assert graph[100].adjacent(2)
+
+        assert graph.edges_count == 3
+
+        assert graph[2].degree == 2
+        assert graph[5].degree == 1
+        assert graph[4].degree == 0
+
+        assert graph[5][3] == {'weight': 100}
+        assert graph[3][1] == {}
+        assert graph[1][2] == {'type': int}