Skip to content

cartography_api.py

Cartography the API.

CartographyAPI (NodeVisitor)

Cartography the API.

Source code in complaince/tools/cartography_api.py
class CartographyAPI(ast.NodeVisitor):
    """Cartography the API."""

    def __init__(self):
        self.call_tree = defaultdict(lambda: defaultdict(dict))
        self.current_function = None
        self.function_defs = {}
        self.http_methods = {}
        self.endpoints = {}

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """Visit function definitions and store their body.

        Parameters
        ----------
        node
            The AST node to visit
        """
        self.function_defs[node.name] = node
        self.current_function = node.name

        for decorator in node.decorator_list:
            if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute):
                if decorator.func.attr in {"get", "post", "put", "delete", "patch"}:
                    self.http_methods[node.name] = decorator.func.attr
                    if decorator.args:
                        path = ast.literal_eval(decorator.args[0])
                        self.endpoints[node.name] = path

        self.generic_visit(node)
        self.current_function = None

    def visit_Call(self, node: ast.Call) -> None:
        """Capture function calls and map them to the caller.

        Parameters
        ----------
        node
            The AST node to visit
        """
        if isinstance(node.func, ast.Name):
            called_function = node.func.id

        if isinstance(node.func, ast.Attribute):
            called_function = node.func.attr

        if self.current_function:
            self.call_tree[self.current_function][called_function] = {}

        self.generic_visit(node)

    def resolve_hierarchy(self) -> None | dict:
        """Recursively build a function call hierarchy with HTTP method types."""

        def resolve(func_name, tree: dict[str, Any], visited: set[str]) -> None:
            """Recursively resolve the function call hierarchy.

            Parameters
            ----------
            func_name
                The function name to resolve

            tree
                The tree structure to build

            visited
                The set of visited functions
            """
            if func_name in visited:
                return None

            visited.add(func_name)

            if func_name in self.call_tree:
                for called_func in self.call_tree[func_name]:
                    tree[called_func] = {}
                    resolve(called_func, tree[called_func], visited)

        root_tree = {}
        for func, method in self.http_methods.items():
            endpoint = self.endpoints.get(func, f"/{func}/")
            root_tree[endpoint] = {"method": method.upper(), "functions": {}}
            resolve(func, root_tree[endpoint]["functions"], set())
        return root_tree

    def search_code_from_directory(self, dir_path: str = "", path: str = "") -> list[dict[str, str]]:
        """
        Search for FastAPI code in a local repository.

        Parameters
        ----------
        dir_path
            The repository to search

        path
            The path to search

        Returns
        -------
        A list of files containing "FastAPI" code
        """
        results = []
        for root, _, files in os.walk(os.path.join(os.getcwd(), dir_path, path)):
            for file in files:
                if file.endswith(".py"):
                    file_path = os.path.join(root, file)
                    try:
                        with open(file_path, "r", encoding="utf-8") as f:
                            file_content = f.read()
                            if "FastAPI" in file_content:
                                results.append({"path": file_path, "url": "", "snippet": file_content})
                    except Exception:
                        pass

        return results

    def get_api_from_github(self, repo_name: str) -> list[dict[str, str]]:
        """
        Get the FastAPI code from a GitHub repository.

        Parameters
        ----------
        repo_name
            The repository name

        Returns
        -------
        A list of dictionaries containing the path, url and snippet of the code

        Examples
        --------
        >>> import ast
        >>> from complaince.tools.cartography_api import CartographyAPI

        >>> mapper = CartographyAPI()
        >>> contents = mapper.get_api_from_github("marciovrl/fastapi")
        >>> tree = ast.parse(contents[0]["snippet"])
        >>> mapper.visit(tree)
        >>> tree_string = mapper.tree_as_string()
        """
        if os.getenv("GITHUB_TOKEN") is None:
            github = Github()
        else:
            auth = Auth.Token(str(os.getenv("GITHUB_TOKEN")))
            github = Github(auth=auth)

        repo = github.get_repo(repo_name)
        contents = search_code_from_github(repo)

        return contents

    def tree_as_string(self, tree: None | dict[str, dict[str, Any]] = None, prefix: str = "") -> str:
        """Convert the tree structure to a string representation.

        Parameters
        ----------
        tree
            The tree structure to convert

        prefix
            The prefix to add to each line

        Returns
        -------
        The tree structure as a list of strings

        Examples
        --------
        >>> import ast
        >>> from complaince.tools.cartography_api import CartographyAPI

        >>> with open("./data/fake_repo/fastapi_web_app.py", "r", encoding="utf-8") as f:
        ...    tree = ast.parse(f.read())

        >>> mapper = CartographyAPI()
        >>> mapper.visit(tree)
        >>> tree_string = mapper.tree_as_string()
        """
        if tree is None:
            new_tree = self.resolve_hierarchy()

        if new_tree is None:
            return ""

        lines = []
        for _, (key, value) in enumerate(new_tree.items()):
            lines.append(f"{prefix}{key} ({value['method']})")
            new_prefix = prefix + ('    ')

            for _, (func_name, _) in enumerate(value["functions"].items()):
                if func_name not in {"get", "post", "put", "delete", "patch"}:
                    lines.append(f"{new_prefix}└── {func_name}()")
                    new_prefix += "    "

            lines.append(f"{new_prefix}└── output")

        return "\n".join(lines)

    def plot_api(self, output_png: str = "api.png") -> None:
        """Plot the tree structure using matplotlib.

        Parameters
        ----------
        output_png
            The output png file to save the plot

        Examples
        --------
        >>> import os
        >>> import ast
        >>> from tempfile import TemporaryDirectory
        >>> from complaince.tools.cartography_api import CartographyAPI

        >>> with open("./data/fake_repo/fastapi_web_app.py", "r", encoding="utf-8") as f:
        ...    tree = ast.parse(f.read())

        >>> temp_dir = TemporaryDirectory(prefix="api_")

        >>> mapper = CartographyAPI()
        >>> mapper.visit(tree)
        >>> mapper.plot_api(os.path.join(temp_dir.name, "api.png"))
        """
        tree = self.resolve_hierarchy()

        if tree is not None:
            tree_text = self.tree_as_string()

            _, ax = plt.subplots(figsize=(8, len(tree) * 2))
            ax.text(0.01, 1, tree_text, fontsize=12, family="monospace", verticalalignment="top")

            ax.set_xlim(0, 1)
            ax.set_ylim(0, 1)
            ax.axis("off")

            plt.title("API Tree Structure")
            plt.savefig(output_png)

get_api_from_github(self, repo_name)

Get the FastAPI code from a GitHub repository.

Parameters:

Name Type Description Default
repo_name str

The repository name

required
Source code in complaince/tools/cartography_api.py
def get_api_from_github(self, repo_name: str) -> list[dict[str, str]]:
    """
    Get the FastAPI code from a GitHub repository.

    Parameters
    ----------
    repo_name
        The repository name

    Returns
    -------
    A list of dictionaries containing the path, url and snippet of the code

    Examples
    --------
    >>> import ast
    >>> from complaince.tools.cartography_api import CartographyAPI

    >>> mapper = CartographyAPI()
    >>> contents = mapper.get_api_from_github("marciovrl/fastapi")
    >>> tree = ast.parse(contents[0]["snippet"])
    >>> mapper.visit(tree)
    >>> tree_string = mapper.tree_as_string()
    """
    if os.getenv("GITHUB_TOKEN") is None:
        github = Github()
    else:
        auth = Auth.Token(str(os.getenv("GITHUB_TOKEN")))
        github = Github(auth=auth)

    repo = github.get_repo(repo_name)
    contents = search_code_from_github(repo)

    return contents

plot_api(self, output_png='api.png')

Plot the tree structure using matplotlib.

Parameters:

Name Type Description Default
output_png str

The output png file to save the plot

'api.png'
Source code in complaince/tools/cartography_api.py
def plot_api(self, output_png: str = "api.png") -> None:
    """Plot the tree structure using matplotlib.

    Parameters
    ----------
    output_png
        The output png file to save the plot

    Examples
    --------
    >>> import os
    >>> import ast
    >>> from tempfile import TemporaryDirectory
    >>> from complaince.tools.cartography_api import CartographyAPI

    >>> with open("./data/fake_repo/fastapi_web_app.py", "r", encoding="utf-8") as f:
    ...    tree = ast.parse(f.read())

    >>> temp_dir = TemporaryDirectory(prefix="api_")

    >>> mapper = CartographyAPI()
    >>> mapper.visit(tree)
    >>> mapper.plot_api(os.path.join(temp_dir.name, "api.png"))
    """
    tree = self.resolve_hierarchy()

    if tree is not None:
        tree_text = self.tree_as_string()

        _, ax = plt.subplots(figsize=(8, len(tree) * 2))
        ax.text(0.01, 1, tree_text, fontsize=12, family="monospace", verticalalignment="top")

        ax.set_xlim(0, 1)
        ax.set_ylim(0, 1)
        ax.axis("off")

        plt.title("API Tree Structure")
        plt.savefig(output_png)

resolve_hierarchy(self)

Recursively build a function call hierarchy with HTTP method types.

Source code in complaince/tools/cartography_api.py
def resolve_hierarchy(self) -> None | dict:
    """Recursively build a function call hierarchy with HTTP method types."""

    def resolve(func_name, tree: dict[str, Any], visited: set[str]) -> None:
        """Recursively resolve the function call hierarchy.

        Parameters
        ----------
        func_name
            The function name to resolve

        tree
            The tree structure to build

        visited
            The set of visited functions
        """
        if func_name in visited:
            return None

        visited.add(func_name)

        if func_name in self.call_tree:
            for called_func in self.call_tree[func_name]:
                tree[called_func] = {}
                resolve(called_func, tree[called_func], visited)

    root_tree = {}
    for func, method in self.http_methods.items():
        endpoint = self.endpoints.get(func, f"/{func}/")
        root_tree[endpoint] = {"method": method.upper(), "functions": {}}
        resolve(func, root_tree[endpoint]["functions"], set())
    return root_tree

search_code_from_directory(self, dir_path='', path='')

Search for FastAPI code in a local repository.

Parameters:

Name Type Description Default
dir_path str

The repository to search

''
path str

The path to search

''
Source code in complaince/tools/cartography_api.py
def search_code_from_directory(self, dir_path: str = "", path: str = "") -> list[dict[str, str]]:
    """
    Search for FastAPI code in a local repository.

    Parameters
    ----------
    dir_path
        The repository to search

    path
        The path to search

    Returns
    -------
    A list of files containing "FastAPI" code
    """
    results = []
    for root, _, files in os.walk(os.path.join(os.getcwd(), dir_path, path)):
        for file in files:
            if file.endswith(".py"):
                file_path = os.path.join(root, file)
                try:
                    with open(file_path, "r", encoding="utf-8") as f:
                        file_content = f.read()
                        if "FastAPI" in file_content:
                            results.append({"path": file_path, "url": "", "snippet": file_content})
                except Exception:
                    pass

    return results

tree_as_string(self, tree=None, prefix='')

Convert the tree structure to a string representation.

Parameters:

Name Type Description Default
tree None | dict[str, dict[str, Any]]

The tree structure to convert

None
prefix str

The prefix to add to each line

''
Source code in complaince/tools/cartography_api.py
def tree_as_string(self, tree: None | dict[str, dict[str, Any]] = None, prefix: str = "") -> str:
    """Convert the tree structure to a string representation.

    Parameters
    ----------
    tree
        The tree structure to convert

    prefix
        The prefix to add to each line

    Returns
    -------
    The tree structure as a list of strings

    Examples
    --------
    >>> import ast
    >>> from complaince.tools.cartography_api import CartographyAPI

    >>> with open("./data/fake_repo/fastapi_web_app.py", "r", encoding="utf-8") as f:
    ...    tree = ast.parse(f.read())

    >>> mapper = CartographyAPI()
    >>> mapper.visit(tree)
    >>> tree_string = mapper.tree_as_string()
    """
    if tree is None:
        new_tree = self.resolve_hierarchy()

    if new_tree is None:
        return ""

    lines = []
    for _, (key, value) in enumerate(new_tree.items()):
        lines.append(f"{prefix}{key} ({value['method']})")
        new_prefix = prefix + ('    ')

        for _, (func_name, _) in enumerate(value["functions"].items()):
            if func_name not in {"get", "post", "put", "delete", "patch"}:
                lines.append(f"{new_prefix}└── {func_name}()")
                new_prefix += "    "

        lines.append(f"{new_prefix}└── output")

    return "\n".join(lines)

visit_Call(self, node)

Capture function calls and map them to the caller.

Parameters:

Name Type Description Default
node Call

The AST node to visit

required
Source code in complaince/tools/cartography_api.py
def visit_Call(self, node: ast.Call) -> None:
    """Capture function calls and map them to the caller.

    Parameters
    ----------
    node
        The AST node to visit
    """
    if isinstance(node.func, ast.Name):
        called_function = node.func.id

    if isinstance(node.func, ast.Attribute):
        called_function = node.func.attr

    if self.current_function:
        self.call_tree[self.current_function][called_function] = {}

    self.generic_visit(node)

visit_FunctionDef(self, node)

Visit function definitions and store their body.

Parameters:

Name Type Description Default
node FunctionDef

The AST node to visit

required
Source code in complaince/tools/cartography_api.py
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
    """Visit function definitions and store their body.

    Parameters
    ----------
    node
        The AST node to visit
    """
    self.function_defs[node.name] = node
    self.current_function = node.name

    for decorator in node.decorator_list:
        if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute):
            if decorator.func.attr in {"get", "post", "put", "delete", "patch"}:
                self.http_methods[node.name] = decorator.func.attr
                if decorator.args:
                    path = ast.literal_eval(decorator.args[0])
                    self.endpoints[node.name] = path

    self.generic_visit(node)
    self.current_function = None

search_code_from_github(repo, path='')

Search for FastAPI code in a repository.

Parameters:

Name Type Description Default
repo Repository

The repository to search

required
path str

The path to search

''
Source code in complaince/tools/cartography_api.py
def search_code_from_github(repo: Repository, path: str = "") -> list[dict[str, str]]:
    """
    Search for FastAPI code in a repository.

    Parameters
    ----------
    repo
        The repository to search

    path
        The path to search

    Returns
    -------
    A list of dictionaries containing the path, url and snippet of the code
    """
    results = []
    contents = repo.get_contents(path)

    if isinstance(contents, list):
        for content_file in contents:
            if content_file.type == "dir":
                results += search_code_from_github(repo, content_file.path)
            elif content_file.type == "file" and content_file.name.endswith(".py"):
                try:
                    file_content = content_file.decoded_content.decode("utf-8")
                    if "FastAPI" in file_content:
                        results.append(
                            {"path": content_file.path, "url": content_file.html_url, "snippet": file_content}
                        )
                except Exception:
                    pass
    return results