File: codemods_tutorial.rst

package info (click to toggle)
python-libcst 1.8.6-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 6,240 kB
  • sloc: python: 78,096; makefile: 15; sh: 2
file content (199 lines) | stat: -rw-r--r-- 8,703 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
=====================
Working With Codemods
=====================

Codemods are an abstraction on top of LibCST for performing large-scale changes
to an entire codebase. See :doc:`Codemods <codemods>` for the complete
documentation.

-------------------------------
Setting up and Running Codemods
-------------------------------

Let's say you were interested in converting legacy ``.format()`` calls to shiny new
Python 3.6 f-strings. LibCST ships with a command-line interface known as
``libcst.tool``. This includes a few provisions for working with codemods at the
command-line. It also includes a library of pre-defined codemods, one of which is
a transform that can convert most ``.format()`` calls to f-strings. So, let's use this
to give Python 3.6 f-strings a try.


You might be lucky enough that the defaults for LibCST perfectly match your coding
style, but chances are you want to customize LibCST to your repository. Initialize
your repository by running the following command in the root of your repository and
then edit the produced ``.libcst.codemod.yaml`` file::

    python3 -m libcst.tool initialize .

The file includes provisions for customizing any generated code marker, calling an
external code formatter such as `black <https://pypi.org/project/black/>`_, blacklisting
patterns of files you never wish to touch and a list of modules that contain valid
codemods that can be executed. If you want to write and run codemods specific to your
repository or organization, you can add an in-repo module location to the list of
modules and LibCST will discover codemods in all locations.

Now that your repository is initialized, let's have a quick look at what's currently
available for running. Run the following command from the root of your repository::

    python3 -m libcst.tool list

You'll see several codemods available to you, one of which is
``convert_format_to_fstring.ConvertFormatStringCommand``. The description to the right
of this codemod indicates that it converts ``.format()`` calls to f-strings, so let's
give it a whirl! Execute the codemod from the root of your repository like so::

    python3 -m libcst.tool codemod convert_format_to_fstring.ConvertFormatStringCommand .

If you want to try it out on only one file or a specific subdirectory, you can replace
the ``.`` in the above command with a relative directory, file, list of directories or
list of files. While LibCST is walking through your repository and codemodding files
you will see a progress indicator. If there's anything the codemod can't do or any
unexpected syntax errors, you will also see them on your console as it progresses.

If everything works out, you'll notice that your ``.format()`` calls have been
converted to f-strings!

-----------------
Writing a Codemod
-----------------

Codemods use the same principles as the rest of LibCST. They take LibCST's core,
metadata and matchers and package them up as a simple command-line interface. So,
anything you can do with LibCST in isolation you can also do with a codemod.

Let's say you need to clean up some legacy code which used magic values instead
of constants. You've already got a constants module called ``utils.constants``
and you want to assume that every reference to a raw string matching a particular
constant should be converted to that constant. For the simplest version of this
codemod, you'll need a command-line tool that takes as arguments the string to
replace and the constant to replace it with. You'll also need to ensure that
modified modules import the constant itself.

So, you can write something similar to the following::

    import argparse
    from ast import literal_eval
    from typing import Union

    import libcst as cst
    from libcst.codemod import CodemodContext, VisitorBasedCodemodCommand
    from libcst.codemod.visitors import AddImportsVisitor


    class ConvertConstantCommand(VisitorBasedCodemodCommand):

        # Add a description so that future codemodders can see what this does.
        DESCRIPTION: str = "Converts raw strings to constant accesses."

        @staticmethod
        def add_args(arg_parser: argparse.ArgumentParser) -> None:
            # Add command-line args that a user can specify for running this
            # codemod.
            arg_parser.add_argument(
                "--string",
                dest="string",
                metavar="STRING",
                help="String contents that we should look for.",
                type=str,
                required=True,
            )
            arg_parser.add_argument(
                "--constant",
                dest="constant",
                metavar="CONSTANT",
                help="Constant identifier we should replace strings with.",
                type=str,
                required=True,
            )

        def __init__(self, context: CodemodContext, string: str, constant: str) -> None:
            # Initialize the base class with context, and save our args. Remember, the
            # "dest" for each argument we added above must match a parameter name in
            # this init.
            super().__init__(context)
            self.string = string
            self.constant = constant

        def leave_SimpleString(
            self, original_node: cst.SimpleString, updated_node: cst.SimpleString
        ) -> Union[cst.SimpleString, cst.Name]:
            if literal_eval(updated_node.value) == self.string:
                # Check to see if the string matches what we want to replace. If so,
                # then we do the replacement. We also know at this point that we need
                # to import the constant itself.
                AddImportsVisitor.add_needed_import(
                    self.context, "utils.constants", self.constant,
                )
                return cst.Name(self.constant)
            # This isn't a string we're concerned with, so leave it unchanged.
            return updated_node

This codemod is pretty simple. It defines a command-line description, sets up to parse
a few required command-line args, initializes its own member variables with the
command-line args that were parsed for it by ``libcst.tool codemod`` and finally
replaces any string which matches our string command-line argument with a constant.
It also takes care of adding the import required for the constant to be defined properly.

Cool! Let's look at the command-line help for this codemod. Let's assume you saved it
as ``constant_folding.py``. You can get help for the
codemod by running the following command::

    python3 -m libcst.tool codemod -x constant_folding.ConvertConstantCommand --help

Notice that along with the default arguments, the ``--string`` and ``--constant``
arguments are present in the help, and the command-line description has been updated
with the codemod's description string. You'll notice that the codemod also shows up
on ``libcst.tool list``.

And ``-x`` flag allows to load any module as a codemod in addition to the standard ones.

----------------
Testing Codemods
----------------

Instead of iterating on a codemod by running it repeatedly on a codebase and seeing
what happens, we can write a series of unit tests that assert on desired
transformations. Given the above constant folding codemod that we wrote, we can test
it with some code similar to the following::

    from libcst.codemod import CodemodTest
    from libcst.codemod.commands.constant_folding import ConvertConstantCommand


    class TestConvertConstantCommand(CodemodTest):

        # The codemod that will be instantiated for us in assertCodemod.
        TRANSFORM = ConvertConstantCommand

        def test_noop(self) -> None:
            before = """
                foo = "bar"
            """
            after = """
                foo = "bar"
            """

            # Verify that if we don't have a valid string match, we don't make
            # any substitutions.
            self.assertCodemod(before, after, string="baz", constant="BAZ")

        def test_substitution(self) -> None:
            before = """
                foo = "bar"
            """
            after = """
                from utils.constants import BAR

                foo = BAR
            """

            # Verify that if we do have a valid string match, we make a substitution
            # as well as import the constant.
            self.assertCodemod(before, after, string="bar", constant="BAR")

If we save this as ``test_constant_folding.py`` inside ``libcst.codemod.commands.tests``
then we can execute the tests with the following line::

    python3 -m unittest libcst.codemod.commands.tests.test_constant_folding

That's all there is to it!