astroid/doc/extending.rst

251 lines
9.0 KiB
ReStructuredText

Extending astroid syntax tree
=============================
Sometimes astroid will miss some potentially important information
you may wish it supported instead, for instance with the libraries that rely
on dynamic features of the language. In some other cases, you may
want to customize the way inference works, for instance to explain **astroid**
that calls to `collections.namedtuple` are returning a class with some known
attributes.
Modifications in the AST are possible in a couple of ways.
AST transforms
^^^^^^^^^^^^^^
**astroid** has support for AST transformations, which given a node,
should return either the same node but modified, or a completely new node.
The transform functions needs to be registered with the underlying manager,
that is, a class that **astroid** uses internally for all things configuration
related. You can access the manager using `astroid.MANAGER`.
The transform functions need to receive three parameters, with the third one
being optional:
* the type of the node for which the transform will be applied
* the transform function itself
* optionally, but strongly recommended, a transform predicate function.
This function receives the node as an argument and it is expected to
return a boolean specifying if the transform should be applied to this node
or not.
AST transforms - example
------------------------
Let's see some examples!
Say that we love the new Python 3.6 feature called ``f-strings``, you might have
heard of them and now you want to use them in your Python 3.6+ project as well.
So instead of ``"your name is {}".format(name)"`` we'd want to rewrite this to
``f"your name is {name}"``.
One thing you could do with astroid is that you can rewrite partially a tree
and then dump it back on disk to get the new modifications. Let's see an
example in which we rewrite our code so that instead of using ``.format()`` we'll
use f-strings instead.
While there are some technicalities to be aware of, such as the fact that
astroid is an AST (abstract syntax tree), while for code round-tripping you
might want a CST instead (concrete syntax tree), for the purpose of this example
we'll just consider all the round-trip edge cases as being irrelevant.
First of all, let's write a simple function that receives a node and returns
the same node unmodified::
def format_to_fstring_transform(node):
return node
astroid.MANAGER.register_transform(...)
For the registration of the transform, we are most likely interested in registering
it for ``astroid.Call``, which is the node for function calls, so this now becomes::
def format_to_fstring_transform(node):
return node
astroid.MANAGER.register_transform(
astroid.Call,
format_to_fstring_transform,
)
The next step would be to do the actual transformation, but before dwelving
into that, let's see some important concepts that nodes in astroid have:
* they have a parent. Every time we build a node, we have to provide a parent
* most of the time they have a line number and a column offset as well
* a node might also have children that are nodes as well. You can check what
a node needs if you access its ``_astroid_fields``, ``_other_fields``, ``_other_other_fields``
properties. They are all tuples of strings, where the strings depicts attribute names.
The first one is going to contain attributes that are nodes (so basically children
of a node), the second one is going to contain non-AST objects (such as strings or
other objects), while the third one can contain both AST and non-AST objects.
When instantiating a node, the non-AST parameters are usually passed via the
constructor, while the AST parameters are provided via the ``postinit()`` method.
The only exception is that the parent is also passed via the constructor.
Instantiating a new node might look as in::
new_node = FunctionDef(
name='my_new_function',
doc='the docstring of this function',
lineno=3,
col_offset=0,
parent=the_parent_of_this_function,
)
new_node.postinit(
args=args,
body=body,
returns=returns,
)
Now, with this knowledge, let's see how our transform might look::
def format_to_fstring_transform(node):
f_string_node = astroid.JoinedStr(
lineno=node.lineno,
col_offset=node.col_offset,
parent=node.parent,
)
formatted_value_node = astroid.FormattedValue(
lineno=node.lineno,
col_offset=node.col_offset,
parent=node.parent,
)
formatted_value_node.postinit(value=node.args[0])
# Removes the {} since it will be represented as
# formatted_value_node
string = astroid.Const(node.func.expr.value.replace('{}', ''))
f_string_node.postinit(values=[string, formatted_value_node])
return f_string_node
astroid.MANAGER.register_transform(
astroid.Call,
format_to_fstring_transform,
)
There are a couple of things going on, so let's see what we did:
* ``JoinedStr`` is used to represent the f-string AST node.
The catch is that the ``JoinedStr`` is formed out of the strings
that don't contain a formatting placeholder, followed by the ``FormattedValue``
nodes, which contain the f-strings formatting placeholders.
* ``node.args`` will hold a list of all the arguments passed in our function call,
so ``node.args[0]`` will actually point to the name variable that we passed.
* ``node.func.expr`` will be the string that we use for formatting.
* We call ``postinit()`` with the value being the aforementioned name. This will result
in the f-string being now complete.
You can now check to see if your transform did its job correctly by getting the
string representation of the node::
from astroid import parse
tree = parse('''
"my name is {}".format(name)
''')
print(tree.as_string())
The output should print ``f"my name is {name}"``, and that's how you do AST transformations
with astroid!
AST inference tip transforms
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Another interesting transform you can do with the AST is to provide the
so called ``inference tip``. **astroid** can be used as more than an AST library,
it also offers some basic support of inference, it can infer what names might
mean in a given context, it can be used to solve attributes in a highly complex
class hierarchy, etc. We call this mechanism generally ``inference`` throughout the
project.
An inference tip (or ``brain tip`` as another alias we might use), is a normal
transform that's only called when we try to infer a particular node.
Say for instance you want to infer the result of a particular function call. Here's
a way you'd setup an inference tip. As seen, you need to wrap the transform
with ``inference_tip``. Also it should receive an optional parameter ``context``,
which is the inference context that will be used for that particular block of inference,
and it is supposed to return an iterator::
def infer_my_custom_call(call_node, context=None):
# Do some transformation here
return iter((new_node, ))
MANAGER.register_transform(
nodes.Call,
inference_tip(infer_my_custom_call),
_looks_like_my_custom_call,
)
This transform is now going to be triggered whenever **astroid** figures out
a node for which the transform pattern should apply.
Module extender transforms
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Another form of transforms is the module extender transform. This one
can be used to partially alter a module without going through the intricacies
of writing a transform that operates on AST nodes.
The module extender transform will add new nodes provided by the transform
function to the module that we want to extend.
To register a module extender transform, use the ``astroid.register_module_extender``
method. You'll need to pass a manager instance, the fully qualified name of the
module you want to extend and a transform function. The transform function
should not receive any parameters and it is expected to return an instance
of ``astroid.Module``.
Here's an example that might be useful::
def my_custom_module():
return astroid.parse('''
class SomeClass:
...
class SomeOtherClass:
...
''')
register_module_extender(astroid.MANAGER, 'mymodule', my_custom_module)
Failed import hooks
^^^^^^^^^^^^^^^^^^^^
If you want to control the behaviour of astroid when it cannot import
some import, you can use ``MANAGER.register_failed_import_hook`` to register
a transform that's called whenever an import failed.
The transform receives the module name that failed and it is expected to
return an instance of :class:`astroid.Module`, otherwise it must raise
``AstroidBuildingError``, as seen in the following example::
def failed_custom_import(modname):
if modname != 'my_custom_module':
# Don't know about this module
raise AstroidBuildingError(modname=modname)
return astroid.parse('''
class ThisIsAFakeClass:
pass
''')
MANAGER.register_failed_import_hook(failed_custom_import)