diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index c3fb9bd..d12f992 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -17,22 +17,77 @@ jobs: python-version: ["3.10", "3.11", "3.12"] steps: + # 1. Check out the repository - uses: actions/checkout@v4 + + # 2. Set up Python environment - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + + # 3. Cache pip dependencies based on pyproject.toml and setup.cfg (if any) + - name: Cache pip + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml', '**/setup.cfg') }} + restore-keys: | + ${{ runner.os }}-pip- + + # 4. Install build requirements + - name: Install build requirements run: | python -m pip install --upgrade pip setuptools wheel + + # 5. Install project dependencies + - name: Install dependencies + run: | + pip install . + + # 6. Cache test dependencies + - name: Cache test dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-test-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip-test- + + # 7. Install test and lint dependencies + - name: Install test and lint dependencies + run: | + pip install .[test] pip install flake8 - - name: Lint with flake8 + + # 8. Lint with flake8 - Errors + - name: Lint with flake8 (Errors) run: | - # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + continue-on-error: false + + # 9. Lint with flake8 - Warnings + - name: Lint with flake8 (Warnings) + run: | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + continue-on-error: true + + # 10. Run tests with pytest - name: Test with pytest run: | - pip install .[test] - pytest + pytest --junitxml=reports/junit.xml --cov=graphedexcel --cov-report=xml + + # 11. Upload test coverage to Codecov (Optional) + - name: Upload results to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + # 12. Upload Test Reports (Optional) + - name: Upload Test Report + if: always() + uses: actions/upload-artifact@v3 + with: + name: junit-test-report + path: reports/junit.xml diff --git a/README.md b/README.md index 418b5fc..06e6679 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,10 @@ python -m graphedexcel ### Parameters from `--help` ``` -usage: graphedexcel [-h] [--remove-unconnected] [--as-directed-graph] [--no-visualize] - [--layout {spring,circular,kamada_kawai,shell,spectral}] [--config CONFIG] - [--output-path OUTPUT_PATH] [--open-image] +usage: graphedexcel [-h] [--as-directed-graph] [--no-visualize] + [--layout {spring,circular,kamada_kawai,shell,spectral}] + [--config CONFIG] [--output-path OUTPUT_PATH] + [--open-image] path_to_excel Process an Excel file to build and visualize dependency graphs. @@ -76,17 +77,18 @@ positional arguments: options: -h, --help show this help message and exit - --remove-unconnected, -r - Remove unconnected nodes from the dependency graph. --as-directed-graph, -d Treat the dependency graph as directed. - --no-visualize, -n Skip the visualization of the dependency graph. - --layout,-l {spring,circular,kamada_kawai,shell,spectral} - Layout algorithm for graph visualization (default: spring). - --config CONFIG, -c CONFIG - Path to the configuration file for visualization. See README for details. - --output-path OUTPUT_PATH, -o OUTPUT_PATH - Specify the output path for the generated graph image. + --no-visualize, -n Skip the visualization of the dependency + graph. + --layout, -l {spring,circular,kamada_kawai,shell,spectral} + Layout algorithm for graph visualization + (default: spring). + --config, -c CONFIG Path to the configuration file for + visualization. See README for details. + --output-path, -o OUTPUT_PATH + Specify the output path for the generated + graph image. --open-image Open the generated image after visualization. ``` diff --git a/pyproject.toml b/pyproject.toml index 31659c0..f09ace9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,13 @@ dependencies = [ ] [project.optional-dependencies] -test = ["black==21.9b0", "pytest==8.3"] - +test = [ + "black==21.9b0", + "pytest==8.3", + "pytest-cov>=3.0.0", # Added for coverage reporting + "flake8>=6.0.0", # Ensure flake8 is included here for consistency + "codecov>=2.1.11", # (Optional) If you intend to use Codecov's Python package] +] [project.urls] Homepage = "https://github.com/dalager/graphedexcel" Issues = "https://github.com/dalager/graphedexcel/issues" @@ -33,5 +38,5 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] minversion = "8.0" -addopts = "-ra" +addopts = "-ra --cov=graphedexcel --cov-report=xml --cov-report=term" testpaths = ["tests"] diff --git a/src/graphedexcel/__main__.py b/src/graphedexcel/__main__.py index de88bc9..74b3bb3 100644 --- a/src/graphedexcel/__main__.py +++ b/src/graphedexcel/__main__.py @@ -1,146 +1,5 @@ -import os -import sys -import argparse -import logging -from .graphbuilder import build_graph_and_stats -from .graph_summarizer import print_summary -from .graph_visualizer import visualize_dependency_graph +from graphedexcel.cli import main from graphedexcel import logger_config # noqa: F401 -logger = logging.getLogger("graphedexcel.main") - - -def parse_arguments(): - parser = argparse.ArgumentParser( - prog="graphedexcel", - description="Process an Excel file to build and visualize dependency graphs.", - ) - - # Positional argument for the path to the Excel file - parser.add_argument( - "path_to_excel", type=str, help="Path to the Excel file to process." - ) - - # Optional flags with shorthand aliases - parser.add_argument( - "--remove-unconnected", - "-r", - action="store_true", - help="Remove unconnected nodes from the dependency graph.", - ) - - parser.add_argument( - "--as-directed-graph", - "-d", - action="store_true", - help="Treat the dependency graph as directed.", - ) - - parser.add_argument( - "--no-visualize", - "-n", - action="store_true", - help="Skip the visualization of the dependency graph.", - ) - - parser.add_argument( - "--layout", - "-l", - type=str, - default="spring", - choices=["spring", "circular", "kamada_kawai", "shell", "spectral"], - help="Layout algorithm for graph visualization (default: spring).", - ) - - parser.add_argument( - "--config", - "-c", - type=str, - help="Path to the configuration file for visualization. See README for details.", - ) - - parser.add_argument( - "--output-path", - "-o", - type=str, - default=None, - help="Specify the output path for the generated graph image.", - ) - - parser.add_argument( - "--open-image", - action="store_true", - help="Open the generated image after visualization.", - ) - - return parser.parse_args() - - -def main(): - args = parse_arguments() - - path_to_excel = args.path_to_excel - - # Check if the file exists - if not os.path.exists(path_to_excel): - logger.error(f"File not found: {path_to_excel}") - sys.exit(1) - - # Build the dependency graph and gather statistics - dependency_graph, function_stats = build_graph_and_stats( - path_to_excel, - remove_unconnected=args.remove_unconnected, - as_directed=args.as_directed_graph, - ) - - # Print summary of the dependency graph - print_summary(dependency_graph, function_stats) - - if args.no_visualize: - logger.info("Skipping visualization as per the '--no-visualize' flag.") - sys.exit(0) - - logger.info("Visualizing the graph of dependencies. (This might take a while...)") - - # Determine layout - layout = args.layout - - # Configuration path - config_path = args.config - - # Determine output filename - if args.output_path: - filename = args.output_path - else: - # Create a default filename based on the Excel file name - base_name = os.path.splitext(os.path.basename(path_to_excel))[0] - filename = f"{base_name}_dependency_graph.png" - - # Visualize the dependency graph - visualize_dependency_graph(dependency_graph, filename, config_path, layout) - - logger.info(f"Dependency graph image saved to {filename}.") - - # Open the image file if requested - if args.open_image: - try: - os.startfile(filename) # Note: os.startfile is Windows-specific - except AttributeError: - # For macOS and Linux, use 'open' and 'xdg-open' respectively - import subprocess - import platform - - if platform.system() == "Darwin": # macOS - subprocess.call(["open", filename]) - elif platform.system() == "Linux": - subprocess.call(["xdg-open", filename]) - else: - logger.warning("Unable to open the image automatically on this OS.") - - if __name__ == "__main__": - try: - main() - except Exception as e: - logger.exception("An unexpected error occurred:", e) - sys.exit(1) + main() diff --git a/src/graphedexcel/cli.py b/src/graphedexcel/cli.py new file mode 100644 index 0000000..ddb6bd6 --- /dev/null +++ b/src/graphedexcel/cli.py @@ -0,0 +1,129 @@ +import os +import sys +import argparse +import logging +from .graphbuilder import build_graph_and_stats +from .graph_summarizer import print_summary +from .graph_visualizer import visualize_dependency_graph + +logger = logging.getLogger("graphedexcel.cli") + + +def parse_arguments(): + parser = argparse.ArgumentParser( + prog="graphedexcel", + description="Process an Excel file to build and visualize dependency graphs.", + ) + + # Positional argument for the path to the Excel file + parser.add_argument( + "path_to_excel", type=str, help="Path to the Excel file to process." + ) + + # Optional flags with shorthand aliases + parser.add_argument( + "--as-directed-graph", + "-d", + action="store_true", + help="Treat the dependency graph as directed.", + ) + + parser.add_argument( + "--no-visualize", + "-n", + action="store_true", + help="Skip the visualization of the dependency graph.", + ) + + parser.add_argument( + "--layout", + "-l", + type=str, + default="spring", + choices=["spring", "circular", "kamada_kawai", "shell", "spectral"], + help="Layout algorithm for graph visualization (default: spring).", + ) + + parser.add_argument( + "--config", + "-c", + type=str, + help="Path to the configuration file for visualization. See README for details.", + ) + + parser.add_argument( + "--output-path", + "-o", + type=str, + default=None, + help="Specify the output path for the generated graph image.", + ) + + parser.add_argument( + "--open-image", + action="store_true", + help="Open the generated image after visualization.", + ) + + return parser.parse_args() + + +def main(): + args = parse_arguments() + + path_to_excel = args.path_to_excel + + # Check if the file exists + if not os.path.exists(path_to_excel): + logger.error(f"File not found: {path_to_excel}") + sys.exit(1) + + # Build the dependency graph and gather statistics + dependency_graph, function_stats = build_graph_and_stats( + path_to_excel, + as_directed=args.as_directed_graph, + ) + + # Print summary of the dependency graph + print_summary(dependency_graph, function_stats) + + if args.no_visualize: + logger.info("Skipping visualization as per the '--no-visualize' flag.") + sys.exit(0) + + logger.info("Visualizing the graph of dependencies. (This might take a while...)") + + # Determine layout + layout = args.layout + + # Configuration path + config_path = args.config + + # Determine output filename + if args.output_path: + filename = args.output_path + else: + # Create a default filename based on the Excel file name + base_name = os.path.splitext(os.path.basename(path_to_excel))[0] + filename = f"{base_name}_dependency_graph.png" + + # Visualize the dependency graph + visualize_dependency_graph(dependency_graph, filename, config_path, layout) + + logger.info(f"Dependency graph image saved to {filename}.") + + # Open the image file if requested + if args.open_image: + try: + os.startfile(filename) # Note: os.startfile is Windows-specific + except AttributeError: + # For macOS and Linux, use 'open' and 'xdg-open' respectively + import subprocess + import platform + + if platform.system() == "Darwin": # macOS + subprocess.call(["open", filename]) + elif platform.system() == "Linux": + subprocess.call(["xdg-open", filename]) + else: + logger.warning("Unable to open the image automatically on this OS.") diff --git a/src/graphedexcel/graphbuilder.py b/src/graphedexcel/graphbuilder.py index 1a4e889..e442389 100644 --- a/src/graphedexcel/graphbuilder.py +++ b/src/graphedexcel/graphbuilder.py @@ -20,7 +20,6 @@ def build_graph_and_stats( file_path: str, - remove_unconnected: bool = False, as_directed: bool = False, ) -> tuple[nx.DiGraph, Dict[str, int]]: """ @@ -46,10 +45,10 @@ def build_graph_and_stats( else: logger.info("Preserving the graph as a directed graph.") - # remove unconnected nodes if --remove-unconnected flag is provided - if remove_unconnected: - logger.info("Removing unconnected nodes from the graph.") - graph.remove_nodes_from(list(nx.isolates(graph))) + # remove selfloops + graph.remove_edges_from(nx.selfloop_edges(graph)) + + graph.remove_nodes_from(list(nx.isolates(graph))) return graph, functions_dict @@ -61,6 +60,13 @@ def sanitize_sheetname(sheetname: str) -> str: return sheetname.replace("'", "") +def sanitize_nodename(nodename: str) -> str: + """ + Remove any special characters from the node name. + """ + return nodename.replace("'", "") + + def sanitize_range(rangestring: str) -> str: """ Remove any special characters from the range. @@ -90,6 +96,7 @@ def add_node(graph: nx.DiGraph, node: str, sheet: str) -> None: """ logger.debug(f"Adding node: {node} in sheet: {sheet}") sheet = sanitize_sheetname(sheet) + node = sanitize_nodename(node) graph.add_node(node, sheet=sheet) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..950e5ec --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,77 @@ +import pytest +import sys + +# from argparse import Namespace +from graphedexcel.cli import parse_arguments, main +from unittest.mock import patch, MagicMock + + +def test_parse_arguments_required(monkeypatch): + """ + Test that the required positional argument is parsed correctly. + """ + test_args = ["graphedexcel", "test.xlsx"] + with patch("sys.argv", test_args): + args = parse_arguments() + assert args.path_to_excel == "test.xlsx" + + +def test_parse_arguments_optional_flags(): + """ + Test that optional flags are parsed correctly. + """ + test_args = ["graphedexcel", "test.xlsx", "--as-directed-graph", "--no-visualize"] + with patch("sys.argv", test_args): + args = parse_arguments() + assert args.path_to_excel == "test.xlsx" + assert args.as_directed_graph is True + assert args.no_visualize is True + + +def test_parse_arguments_optional_arguments(): + """ + Test that optional arguments are parsed correctly. + """ + test_args = [ + "graphedexcel", + "test.xlsx", + "--layout", + "circular", + "--config", + "config.json", + "--output-path", + "output.png", + "--open-image", + ] + with patch("sys.argv", test_args): + args = parse_arguments() + assert args.path_to_excel == "test.xlsx" + assert args.layout == "circular" + assert args.config == "config.json" + assert args.output_path == "output.png" + assert args.open_image is True + + +def test_parse_arguments_default_values(): + """ + Test that default values are set correctly. + """ + test_args = ["graphedexcel", "test.xlsx"] + with patch("sys.argv", test_args): + args = parse_arguments() + assert args.layout == "spring" + assert args.config is None + assert args.output_path is None + assert args.as_directed_graph is False + assert args.no_visualize is False + assert args.open_image is False + + +def test_parse_arguments_invalid(): + """ + Test that invalid arguments raise a SystemExit. + """ + test_args = ["graphedexcel"] + with patch("sys.argv", test_args): + with pytest.raises(SystemExit): + parse_arguments() diff --git a/tests/test_excel_parser.py b/tests/test_excel_parser.py index 380ae75..cf5d252 100644 --- a/tests/test_excel_parser.py +++ b/tests/test_excel_parser.py @@ -1,5 +1,5 @@ import pytest -from src.graphedexcel.excel_parser import extract_references +from graphedexcel.excel_parser import extract_references # Helper function to assert references diff --git a/tests/test_graph_visualizer.py b/tests/test_graph_visualizer.py index 4c83c9b..ba13cc6 100644 --- a/tests/test_graph_visualizer.py +++ b/tests/test_graph_visualizer.py @@ -1,4 +1,5 @@ import json +import logging from graphedexcel.graph_visualizer import ( merge_configs, load_json_config, @@ -53,12 +54,69 @@ def test_get_node_colors_and_legend(): def test_visualize_dependency_graph(tmp_path): - G = nx.DiGraph() - G.add_node(1, sheet="Sheet1") - G.add_node(2, sheet="Sheet2") - G.add_edge(1, 2) + G = create_two_node_graph() file_path = tmp_path / "test_graph" visualize_dependency_graph(G, str(file_path)) assert file_path.with_suffix(".png").exists() + + +def test_provided_config_path(tmp_path): + G = create_two_node_graph() + + config_data = {"node_size": 50, "width": 0.2, "fig_size": [4, 4]} + config_file = tmp_path / "test_config.json" + with open(config_file, "w") as f: + json.dump(config_data, f) + + file_path = tmp_path / "test_graph" + visualize_dependency_graph(G, str(file_path), config_path=config_file) + + +def test_invalid_config_path_will_not_break(tmp_path, caplog): + G = create_two_node_graph() + + file_path = tmp_path / "test_graph" + with caplog.at_level(logging.ERROR): + visualize_dependency_graph(G, str(file_path), config_path="invalid_path.json") + assert "Config file not found" in caplog.text + assert file_path.with_suffix(".png").exists() + + +def test_invalid_json_in_config_will_not_break(tmp_path, caplog): + G = nx.DiGraph() + config_data = {"node_size": 50, "width": 0.2} + config_file = tmp_path / "test_config.json" + with open(config_file, "w") as f: + json.dump(config_data, f) + # remove first character from json file + with open(config_file, "r") as f: + data = f.read() + with open(config_file, "w") as f: + f.write(data[1:]) + file_path = tmp_path / "test_graph" + visualize_dependency_graph(G, str(file_path), config_path=config_file) + print(caplog) + assert "Invalid JSON format in config file" in caplog.text + assert file_path.with_suffix(".png").exists() + + +def test_all_layouts(): + G = create_two_node_graph() + + for layout in ["spring", "kamada_kawai", "circular", "shell", "spectral"]: + visualize_dependency_graph(G, layout=layout, output_path=layout + "_layout.png") + + +def test_unknown_layout_will_fallback(): + G = create_two_node_graph() + visualize_dependency_graph(G, layout="nosuchlayout", output_path="nosuchlayout.png") + + +def create_two_node_graph(): + G = nx.DiGraph() + G.add_node(1, sheet="Sheet1") + G.add_node(2, sheet="Sheet2") + G.add_edge(1, 2) + return G diff --git a/tests/test_graphbuilder.py b/tests/test_graphbuilder.py new file mode 100644 index 0000000..e9b716c --- /dev/null +++ b/tests/test_graphbuilder.py @@ -0,0 +1,190 @@ +from openpyxl import Workbook +import pytest + +# from unittest import mock +import networkx as nx + +# Import the functions and variables from your module +from graphedexcel.graphbuilder import ( + sanitize_sheetname, + sanitize_range, + stat_functions, + add_node, + build_graph_and_stats, + functions_dict, +) +from graphedexcel.graphbuilder import sanitize_nodename + + +@pytest.fixture(autouse=True) +def reset_functions_dict(): + """ + Fixture to reset the global functions_dict before each test. + """ + functions_dict.clear() + + +def test_sanitize_nodename(): + """ + Test the sanitize node name + """ + + assert sanitize_nodename("Sheet1!A1") == "Sheet1!A1" + assert sanitize_nodename("Sheet'2!B1") == "Sheet2!B1" + assert sanitize_nodename("Sheet'3!C1") == "Sheet3!C1" + + +def test_sanitize_sheetname(): + """ + Test the sanitize_sheetname function to ensure it removes single quotes. + """ + assert sanitize_sheetname("Sheet1") == "Sheet1" + assert sanitize_sheetname("Sheet'1") == "Sheet1" + assert sanitize_sheetname("O'Brien") == "OBrien" + assert sanitize_sheetname("Data!1") == "Data!1" # Only removes single quotes + + +def test_sanitize_range(): + """ + Test the sanitize_range function to ensure it + removes single quotes and handles sheet delimiters. + """ + assert sanitize_range("Sheet1!A1:B2") == "Sheet1!A1:B2" + assert sanitize_range("'Sheet1'!A1:B2") == "Sheet1!A1:B2" + assert sanitize_range("A1:B2") == "A1:B2" + assert sanitize_range("'Data Sheet'!C3") == "Data Sheet!C3" + + +def test_stat_functions(): + """ + Test the stat_functions function to ensure it correctly + parses function names and updates functions_dict. + """ + stat_functions("=SUM(A1:A10)") + assert functions_dict.get("SUM") == 1 + + stat_functions("=AVERAGE(B1:B5)") + assert functions_dict.get("AVERAGE") == 1 + + stat_functions("=SUM(A1:A10) + SUM(B1:B10)") + assert functions_dict.get("SUM") == 3 + + stat_functions("=IF(C1 > 0, SUM(D1:D10), 0)") + assert functions_dict.get("IF") == 1 + assert functions_dict.get("SUM") == 4 # SUM incremented again + + +def test_add_node(): + """ + Test the add_node function to ensure nodes are added with + correct attributes and sheet names are sanitized. + """ + graph = nx.DiGraph() + add_node(graph, "Sheet1!A1", "Sheet1") + add_node(graph, "Sheet1!B1", "Sheet1") + add_node(graph, "Sheet2!A1", "Sheet2") + + assert graph.has_node("Sheet1!A1") + assert graph.nodes["Sheet1!A1"]["sheet"] == "Sheet1" + + assert graph.has_node("Sheet1!B1") + assert graph.nodes["Sheet1!B1"]["sheet"] == "Sheet1" + + assert graph.has_node("Sheet2!A1") + assert graph.nodes["Sheet2!A1"]["sheet"] == "Sheet2" + + # Test sanitization + add_node(graph, "Sheet'3!C1", "Sheet'3") + + assert graph.has_node("Sheet3!C1") + assert graph.nodes["Sheet3!C1"]["sheet"] == "Sheet3" + + +@pytest.fixture +def create_excel_file(tmp_path): + def _create_excel_file(data): + file_path = tmp_path / "test.xlsx" + wb = Workbook() + for sheet_name, sheet_data in data.items(): + ws = wb.create_sheet(title=sheet_name) + for row in sheet_data: + ws.append(row) + wb.save(file_path) + return file_path + + return _create_excel_file + + +def test_build_graph_with_simple_formulas(create_excel_file): + data = { + "Sheet1": [ + ["41", "81", "71", "99"], + ["=A1+B1", "=C1+D1"], + ["=E1+F1", "=G1+H1"], + ] + } + file_path = create_excel_file(data) + graph, functions_dict = build_graph_and_stats(file_path) + + assert isinstance(graph, nx.Graph) + assert len(graph.nodes) == 12 + assert len(graph.edges) == 8 + assert functions_dict == {} + + +def test_build_graph_with_range_references(create_excel_file): + data = { + "Sheet1": [ + ["=SUM(A1:A3)", "=SUM(B1:B3)"], + ] + } + file_path = create_excel_file(data) + graph, functions_dict = build_graph_and_stats(file_path) + + assert isinstance(graph, nx.Graph) + assert len(graph.nodes) == 8 + assert len(graph.edges) == 6 + assert functions_dict == {"SUM": 2} + + +def test_self_loops_are_removed(create_excel_file): + data = {"sheet1": [["=A1", "=B1"]]} + file_path = create_excel_file(data) + graph, functions_dict = build_graph_and_stats(file_path) + selfloops = nx.selfloop_edges(graph) + for loop in selfloops: + print(loop) + + assert isinstance(graph, nx.Graph) + assert len(graph.edges) == 0 + assert len(graph.nodes) == 0 + + +def test_directed_graph(create_excel_file): + data = { + "Sheet1": [ + ["=B1", "=A1"], + ] + } + file_path = create_excel_file(data) + graph, functions_dict = build_graph_and_stats(file_path, as_directed=True) + + assert isinstance(graph, nx.DiGraph) + assert len(graph.nodes) == 2 + assert len(graph.edges) == 2 + assert functions_dict == {} + + +def test_undirected_graph(create_excel_file): + data = { + "Sheet1": [ + ["=B1", "=A1"], + ] + } + file_path = create_excel_file(data) + graph, functions_dict = build_graph_and_stats(file_path, as_directed=False) + + assert isinstance(graph, nx.Graph) + assert len(graph.nodes) == 2 + assert len(graph.edges) == 1 + assert functions_dict == {}