Pybites Logo

Ast visitor

Level: Advanced (score: 4)

In Bite 312 we learned how to identify builtin functions, modules, and keywords when provided with a list of strings.

In this Bite we take it a step further considering the following scenario: What if somebody gives you some python source code and asks you to identify which modules it uses and determine how frequently functions from those modules are used?

For a few small files you can solve this problem manually using python's regex facility or with command line tools like grep or awk. However the problem becomes more difficult to manage when you consider a library which may have many large modules.

The ast module can help you in this scenario.

Intro to the ast module

The ast module transforms any python source code into a tree representation of the underlying python grammar. Such tree is called Abstract Syntax Tree (AST), and it is a data structure used to reason about programming languages grammar in general.

Let's see the ast module in action on a simple example:

>>> import ast
>>> tree = ast.parse("one_plus_two = 1+2")
>>> ast.dump(tree)
"Module(body=[Assign(targets=[Name(id='a', ctx=Store())], value=BinOp(left=Num(n=1), op=Add(), right=Num(n=2)))])"

...ghhh, pretty condensed output, isn't it? Let's rearrange it manually to make it more readable:

"Module(
    body=[
        Assign(
            targets=[
                Name(id='one_plus_two', ctx=Store())
            ],
            value=BinOp(
                left=Num(n=1),
                op=Add(),
                right=Num(n=2))
            )
    ]
)"
Module
   |____ Assign
            |___ Name
            |___ BinOp
                   |__ Num
                   |__ Add
                   |__ Num

What can we observe?

- The outer most node Module is the root of our tree.

- The Module.body attribute is a list of nodes, one for each instruction of the input program. Our example program consists of one assignment instruction, so Module.body contains only one Assign node.

- The Assign node has two attributes, Assign.targets and Assign.value, which describe the left and right-hand sides of the assignment with respect to the equal sign.

    - The Assign.targets attribute is a list containing the different destinations of the assignment operation. In our case only the node Name is in the list since the assignment target is a single variable. Notice also that the Name.id attribute of the target node contains the name of the variable "one_plus_two" as indeed specified in the source code.

    - The Assign.value contains a BinOp node since the right hand side of the assignment is a binary operation between two operands. The attributes BinOp.left and BinOp.right specify the left and right operands for the BinOp.op operation. Remember, the expression on the right hand side of the assignment was 1 + 2, so it is not surprising that BinOp.left and BinOp.right nodes are Num nodes with values 1 and 2 respectively.

The ast module provides a large variety of nodes to cover the different aspects of the Python grammar (for more details, see the official python 3.9 doc of the ast module, as well as the external documentation Green Tree Snake as suggested by the official doc).

Overall, the rich functionality of the ast module makes it easy to work with an AST data structure to both inspect and manipulate python source code programmatically.

As a concrete example, mutpy, which we also introduced in Bite 244, is a mutation testing tool that artificially alters the code under testing to broaden the unit tests in an automated fashion. Under the hood, it uses the ast module to (i) create an AST, (ii) apply mutations, and (iii) convert the tree into a code object via the builtin function compile() before executing it (see mutpy.mutpy.utils.create_module() for details).

Our objective

In this bite we are going to see how the ast module makes it easy to visit an AST tree, and discover import and from ... import statements, and function calls. Specifically, we will work with ast.Import, ast.ImportFrom, and ast.Call node types.

Given a python program source code, determine the following:

- Which builtin functions are used.

- Which modules are imported.

- Which calls make direct use of imported modules or submodules.

To do so, we are going to use the ast module. Specifically, we take advantage of the NodeVisitor class to create our own AstVisitor class to visit the nodes of an AST.

We already provide you a template of the AstVisitor class with a .parse() method to create an AST tree and trigger a visit, and we registered three callbacks that are invoked when three specific node types are encountered during parsing:

- .visit_Import() called when visiting a node related to import xyz statements (see the ast.Import node documentation).

- .visit_ImportFrom() called when visiting a node related to from abc import xyz statements (see the ast.ImportFrom node documentation).

- .visit_Call() called during when visiting a node related to function call (see the ast.Call node documentation).

What is left to do is to complete the implementation of the three methods mentioned so to "track" both which builtins functions (if any) are used, which modules are imported (if any) and which calls they related to (if any). To be more specific, we want to know both the list of builtins / methods, but also the line number where each of tracked objects are located (see the ast.AST documentation). To report on the tracking you need to complete the following methods defined in the template:

- .builtins() returns a list of string, one for each builtin functions tracked.

- .builtins_lineno() returns a dictionary whose keys are builtins functions, and values are the line number where the related call occurred.

- .modules() returns a list of string, one for each imported module.

- .modules_lineno() returns a dictionary whose keys are module object calls, and values are the line number where the related call occurred.

The tracking you need to implement is subject to the following rules:

- __rule-1__: each of the methods support an optional input parameter named valid to specify a list of builtins or modules to restrict the search to. When valid=None, all builtins and modules will be reported in the output.

- __rule-2__: resolve aliased import statements and report their true names.

- __rule-3__: resolve submodule imports and express the module path using dotted notation.

- __rule-4__: when considering modules, valid= can contain alias names, but they need to be expanded to full names to comply with __rule-2__;

An example

>> code = '''
import pandas as pd
import numpy as np
from argparse import ArgumentParser
print("this is a test")
parser = ArgumentParser()
len("12345")
df_tmp = pd.DataFrame(range(10), column=["x"])
df_tmp.loc[:, "y"] = df_tmp[x] + 1
np.random.random()
'''

>> vst = AstVisitor(code)
>> vst.builtins()
["print", "range", "len"]
>> vst.modules()
["argparse", "numpy", "pandas"]
>> vst.builtins_lineno()
{"print": [6], "len": [9], "range": [11]}
>> vst.modules_lineno()
{"argparse.ArgumentParser": [8], "pandas.DataFrame": [11], "numpy.random.random": [14]}

Considering the output of vst.modules_lineno(), notice the following:

- The call ArgumentParser() is resolved into argparse.ArgumentParser to comply with __rule-3__.

- The pd.DataFrame() and np.random.random() calls are resolved into pandas.DataFrame and numpy.random.random respectively to comply with __rule-2__.

Let's see a few more examples using the code from the previous example, but this time using valid= to constrain the search output:

>> first = vst.builtins(valid=["iter"])
>> first
[]
>> second = vst.builtins_lineno(valid=["iter"])
>> second
{}
>> third = vst.builtins_lineno(valid=["iter", "range"])
>> third
{"range": [11]}
>> fourth = vst.modules(valid=["np"])
>> fourth
["numpy"]
>> fifth = vst.modules(valid=["numpy"])
>> fifth
["numpy"]
>> sixth = vst.modules_lineno(valid=["numpy"])
>> sixth
{"numpy.random.random": [14]}

Notice the following:

- first and second are empty since iter is not used in the source code.

- third contains only range since iter is not used in the source code.

- fourth contains numpy even if we specified valid=["np"] to comply with __rule-2__ and __rule-4__.

- fifth is identical to fourth as expected, but notice that this time we specified valid=["numpy"] while before we used valid=["np"].

- sixth contains only calls related to numpy as requested, but notice how we specified valid=["numpy"] while the original code has the statement np.random.random(), hence the search takes care of aliasing to comply with __rule-2__.

Final notes:

- Please refer to the python3.9 documentation or, as suggested by the doc itself, the external Green Tree Snake documentation. The official documentation for python version lower than 3.9 is not very complete. If you are coding outside the pybites platform, you should not have issues in solving this bite with any python versions higher than 3.6.

- Tracking name aliasing is the most complex part to implement successfully and depends on you understanding what the various node attributes mean.

- Beware of expressions like module.submodule.submodule.func() which may be present when handling Call nodes.

- Lastly, I recommend you spend some time with the python console trying to parse simple statements and investigate the AST tree manually. The ipython console console is great for this sort of exploration.

Good luck!

AST