Do you want simpler Python code? You always start a project with the best intentions, a clean codebase, and a nice structure. But over time, there are changes to your apps, and things can get a little messy.
If you can write and maintain clean, simple Python code, then it’ll save you lots of time in the long term. You can spend less time testing, finding bugs, and making changes when your code is well laid out and simple to follow.
In this tutorial you’ll learn:
Throughout this tutorial, I’m going to use the theme of subterranean railway networks to explain complexity because navigating a subway system in a large city can be complicated! Some are well designed, and others seem overly complex.
The complexity of an application and its codebase is relative to the task it’s performing. If you’re writing code for NASA’s jet propulsion laboratory (literally rocket science), then it’s going to be complicated.
The question isn’t so much, “Is my code complicated?” as, “Is my code more complicated than it needs to be?”
The Tokyo railway network is one of the most extensive and complicated in the world. This is partly because Tokyo is a metropolis of over 30 million people, but it’s also because there are 3 networks overlapping each other.
There are the Toei and Tokyo Metro rapid-transport networks as well as the Japan Rail East trains going through Central Tokyo. To even the most experienced traveler, navigating central Tokyo can be mind-bogglingly complicated.
Here is a map of the Tokyo railway network to give you some perspective:
If your code is starting to look a bit like this map, then this is the tutorial for you.
First, we’ll go through 4 metrics of complexity that can give you a scale to measure your relative progress in the mission to make your code simpler:
After you’ve explored the metrics, you’ll learn about a tool called wily to automate calculating those metrics.
Much time and research have been put into analyzing the complexity of computer software. Overly complex and unmaintainable applications can have a very real cost.
The complexity of software correlates to the quality. Code that is easy to read and understand is more likely to be updated by developers in the future.
Here are some metrics for programming languages. They apply to many languages, not just Python.
LOC, or Lines of Code, is the crudest measure of complexity. It is debatable whether there is any direct correlation between the lines of code and the complexity of an application, but the indirect correlation is clear. After all, a program with 5 lines is likely simpler than one with 5 million.
When looking at Python metrics, we try to ignore blank lines and lines containing comments.
Lines of code can be calculated using the wc command on Linux and Mac OS, where file.py is the name of the file you want to measure:
Shell
If you want to add the combined lines in a folder by recursively searching for all .py files, you can combine wc with the find command:
Shell
For Windows, PowerShell offers a word count command in Measure-Object and a recursive file search in Get-ChildItem:
Windows PowerShell
In the response, you will see the total number of lines.
Why are lines of code used to quantify the amount of code in your application? The assumption is that a line of code roughly equates to a statement. Lines is a better measure than characters, which would include whitespace.
In Python, we are encouraged to put a single statement on each line. This example is 9 lines of code:
Python
If you used only lines of code as your measure of complexity, it could encourage the wrong behaviors.
Python code should be easy to read and understand. Taking that last example, you could reduce the number of lines of code to 3:
Python
But the result is hard to read, and PEP 8 has guidelines around maximum line length and line breaking. You can check out How to Write Beautiful Python Code With PEP 8 for more on PEP 8.
This code block uses 2 Python language features to make the code shorter:
;name = value if condition else value if condition2 else value2We have reduced the number of lines of code but violated one of the fundamental laws of Python:
“Readability counts”
— Tim Peters, Zen of Python
This shortened code is potentially harder to maintain because code maintainers are humans, and this short code is harder to read. We will explore some more advanced and useful metrics for complexity.
Cyclomatic complexity is the measure of how many independent code paths there are through your application. A path is a sequence of statements that the interpreter can follow to get to the end of the application.
One way to think of cyclomatic complexity and code paths is imagine your code is like a railway network.
For a journey, you may need to change trains to reach your destination. The Lisbon Metropolitan railway system in Portugal is simple and easy to navigate. The cyclomatic complexity for any trip is equal to the number of lines you need to travel on:
If you needed to get from Alvalade to Anjos, then you would travel 5 stops on the linha verde (green line):
This trip has a cyclomatic complexity of 1 because you only take 1 train. It’s an easy trip. That train is equivalent in this analogy to a code branch.
If you needed to travel from the Aeroporto (airport) to sample the food in the district of Belém, then it’s a more complicated journey. You would have to change trains at Alameda and Cais do Sodré:
This trip has a cyclomatic complexity of 3, because you take 3 trains. You might be better off taking a taxi!
Seeing as how you’re not navigating Lisbon, but rather writing code, the changes of train line become a branch in execution, like an if statement.
Let’s explore this example:
Python
There is only 1 way this code can be executed, so it has a cyclomatic complexity of 1.
If we add a decision, or branch to the code as an if statement, it increases the complexity:
Python
Even though there is only 1 way this code can be executed, as x is a constant, this has a cyclomatic complexity of 2. All of the cyclomatic complexity analyzers will treat an if statement as a branch.
This is also an example of overly complex code. The if statement is useless as x has a fixed value. You could simply refactor this example to the following:
Python
That was a toy example, so let’s explore something a little more real.
main() has a cyclomatic complexity of 5. I’ll comment each branch in the code so you can see where they are:
Python
There are certainly ways that code can be refactored into a far simpler alternative. We’ll get to that later.
In the following examples, we will use the radon library from PyPI to calculate metrics. You can install it now:
Shell
To calculate cyclomatic complexity using radon, you can save the example into a file called cyclomatic_example.py and use radon from the command line.
The radon command takes 2 main arguments:
cc for cyclomatic complexity)Execute the radon command with the cc analysis against the cyclomatic_example.py file. Adding -s will give the cyclomatic complexity in the output:
Shell
The output is a little cryptic. Here is what each part means:
F means function, M means method, and C means class.main is the name of the function.4 is the line the function starts on.B is the rating from A to F. A is the best grade, meaning the least complexity.6, is the cyclomatic complexity of the code. The Halstead complexity metrics relate to the size of a program’s codebase. They were developed by Maurice H. Halstead in 1977. There are 4 measures in the Halstead equations:
if, else, for or while.There are then 3 additional metrics with those measures:
All of this is very abstract, so let’s put it in relative terms:
For the cyclomatic_complexity.py example, operators and operands both occur on the first line:
Python
import is an operator, and sys is the name of the module, so it’s an operand.
In a slightly more complex example, there are a number of operators and operands:
Python
There are 5 operators in this example:
if()>:Furthermore, there are 2 operands:
sys.argv1Be aware that radon only counts a subset of operators. For example, parentheses are excluded in any calculations.
To calculate the Halstead measures in radon, you can run the following command:
Shell
The maintainability index brings the McCabe Cyclomatic Complexity and the Halstead Volume measures in a scale roughly between zero and one-hundred.
If you’re interested, the original equation is as follows:
In the equation, V is the Halstead volume metric, C is the cyclomatic complexity, and L is the number of lines of code.
If you’re as baffled as I was when I first saw this equation, here’s it means: it calculates a scale that includes the number of variables, operations, decision paths, and lines of code.
It is used across many tools and languages, so it’s one of the more standard metrics. However, there are numerous revisions of the equation, so the exact number shouldn’t be taken as fact. radon, wily, and Visual Studio cap the number between 0 and 100.
On the maintainability index scale, all you need to be paying attention to is when your code is getting significantly lower (toward 0). The scale considers anything lower than 25 as hard to maintain, and anything over 75 as easy to maintain. The Maintainability Index is also referred to as MI.
The maintainability index can be used as a measure to get the current maintainability of your application and see if you’re making progress as you refactor it.
To calculate the maintainability index from radon, run the following command:
Shell
In this result, A is the grade that radon has applied to the number 87.42 on a scale. On this scale, A is most maintainable and F the least.
wily to Capture and Track Your Projects’ Complexitywily is an open-source software project for collecting code-complexity metrics, including the ones we’ve covered so far like Halstead, Cyclomatic, and LOC. wily integrates with Git and can automate the collection of metrics across Git branches and revisions.
The purpose of wily is to give you the ability to see trends and changes in the complexity of your code over time. If you were trying to fine-tune a car or improve your fitness, you’d start off with measuring a baseline and tracking improvements over time.
wilywily is available on PyPI and can be installed using pip:
Shell
Once wily is installed, you have some commands available in your command-line:
wily build: iterate through the Git history and analyze the metrics for each filewily report: see the historical trend in metrics for a given file or folderwily graph: graph a set of metrics in an HTML fileBefore you can use wily, you need to analyze your project. This is done using the wily build command.
For this section of the tutorial, we will analyze the very popular requests package, used for talking to HTTP APIs. Because this project is open-source and available on GitHub, we can easily access and download a copy of the source code:
Shell
You will see a number of folders here, for tests, documentation, and configuration. We’re only interested in the source code for the requests Python package, which is in a folder called requests.
Call the wily build command from the cloned source code and provide the name of the source code folder as the first argument:
Shell
This will take a few minutes to analyze, depending on how much CPU power your computer has:
Once you have analyzed the requests source code, you can query any file or folder to see key metrics. Earlier in the tutorial, we discussed the following:
Those are the 3 default metrics in wily. To see those metrics for a specific file (such as requests/api.py), run the following command:
Shell
wily will print a tabular report on the default metrics for each Git commit in reverse date order. You will see the most recent commit at the top and the oldest at the bottom:
| Revision | Author | Date | MI | Lines of Code | Cyclomatic Complexity |
|---|---|---|---|---|---|
| f37daf2 | Nate Prewitt | 2019-01-13 | 100 (0.0) | 158 (0) | 9 (0) |
| 6dd410f | Ofek Lev | 2019-01-13 | 100 (0.0) | 158 (0) | 9 (0) |
| 5c1f72e | Nate Prewitt | 2018-12-14 | 100 (0.0) | 158 (0) | 9 (0) |
| c4d7680 | Matthieu Moy | 2018-12-14 | 100 (0.0) | 158 (0) | 9 (0) |
| c452e3b | Nate Prewitt | 2018-12-11 | 100 (0.0) | 158 (0) | 9 (0) |
| 5a1e738 | Nate Prewitt | 2018-12-10 | 100 (0.0) | 158 (0) | 9 (0) |
This tells us that the requests/api.py file has:
To see other metrics, you first need to know the names of them. You can see this by running the following command:
Shell
You will see a list of operators, modules that analyze the code, and the metrics they provide.
To query alternative metrics on the report command, add their names after the filename. You can add as many metrics as you wish. Here’s an example with the Maintainability Rank and the Source Lines of Code:
Shell
You will see the table now has 2 different columns with the alternative metrics.
Now that you know the names of the metrics and how to query them on the command line, you can also visualize them in graphs. wily supports HTML and interactive charts with a similar interface as the report command:
Shell
Your default browser will open with an interactive chart like this:
You can hover over specific data points, and it will show the Git commit message as well as the data.
If you want to save the HTML file in a folder or repository, you can add the -o flag with the path to a file:
Shell
There will now be a file called my_report.html that you can share with others. This command is ideal for team dashboards.
wily as a pre-commit Hookwily can be configured so that before you commit changes to your project, it can alert you to improvements or degradations in complexity.
wily has a wily diff command, that compares the last indexed data with the current working copy of a file.
To run a wily diff command, provide the names of the files you have changed. For example, if I made some changes to requests/api.py you will see the impact on the metrics by running wily diff with the file path:
Shell
In the response, you will see all of the changed metrics, as well as the functions or classes that have changed for cyclomatic complexity:
The diff command can be paired with a tool called pre-commit. pre-commit inserts a hook into your Git configuration that calls a script every time you run the git commit command.
To install pre-commit, you can install from PyPI:
Shell
Add the following to a .pre-commit-config.yaml in your projects root directory:
YAML
Once setting this, you run the pre-commit install command to finalize things:
Shell
Whenever you run the git commit command, it will call wily diff along with the list of files you’ve added to your staged changes.
wily is a useful utility to baseline the complexity of your code and measure the improvements you make when you start to refactor.
Refactoring is the technique of changing an application (either the code or the architecture) so that it behaves the same way on the outside, but internally has improved. These improvements can be stability, performance, or reduction in complexity.
One of the world’s oldest underground railways, the London Underground, started in 1863 with the opening of the Metropolitan line. It had gas-lit wooden carriages hauled by steam locomotives. On the opening of the railway, it was fit for purpose. 1900 brought the invention of the electric railways.
By 1908, the London Underground had expanded to 8 railways. During the Second World War, the London Underground stations were closed to trains and used as air-raid shelters. The modern London Underground carries millions of passengers a day with over 270 stations:
It’s almost impossible to write perfect code the first time, and requirements change frequently. If you would have asked the original designers of the railway to design a network fit for 10 million passengers a day in 2020, they would not design the network that exists today.
Instead, the railway has undergone a series of continuous changes to optimize its operation, design, and layout to match the changes in the city. It has been refactored.
In this section, you’ll explore how to safely refactor by leveraging tests and tools. You’ll also see how to use the refactoring functionality in Visual Studio Code and PyCharm:
If the point of refactoring is to improve the internals of an application without impacting the externals, how do you ensure the externals haven’t changed?
Before you charge into a major refactoring project, you need to make sure you have a solid test suite for your application. Ideally, that test suite should be mostly automated, so that as you make changes, you see the impact on the user and address it quickly.
If you want to learn more about testing in Python, Getting Started With Testing in Python is a great place to start.
There is no perfect number of tests to have on your application. But, the more robust and thorough the test suite, the more aggressively you can refactor your code.
The two most common tasks you will perform when doing refactoring are:
You can simply do this by hand using search and replace, but it is both time consuming and risky. Instead, there are some great tools to perform these tasks.
rope for Refactoringrope is a free Python utility for refactoring Python code. It comes with an extensive set of APIs for refactoring and renaming components in your Python codebase.
rope can be used in two ways:
To use rope as a library, first install rope by executing pip:
Shell
It is useful to work with rope on the REPL so that you can explore the project and see changes in real time. To start, import the Project type and instantiate it with the path to the project:
Python
The proj variable can now perform a series of commands, like get_files and get_file, to get a specific file. Get the file api.py and assign it to a variable called api:
Python
If you wanted to rename this file, you could simply rename it on the filesystem. However, any other Python files in your project that imported the old name would now be broken. Let’s rename the api.py to new_api.py:
Python
Running git status, you will see that rope made some changes to the repository:
Shell
The three changes made by rope are the following:
requests/api.py and created requests/new_api.pyrequests/__init__.py to import from new_api instead of api.ropeprojectTo reset the change, run git reset.
There are hundreds of other refactorings that can be done with rope.
Visual Studio Code opens up a small subset of the refactoring commands available in rope through its own UI.
You can:
Here is an example of using the Extract methods command from the command palette:
If you use or are considering using PyCharm as a Python editor, it’s worth taking note of the powerful refactoring capabilities it has.
You can access all the refactoring shortcuts with the Ctrl+T command on Windows and macOS. The shortcut to access refactoring in Linux is Ctrl+Shift+Alt+T.
Before you remove a method or class or change the way it behaves, you’ll need to know what code depends on it. PyCharm can search for all usages of a method, function, or class within your project.
To access this feature, select a method, class, or variable by right-clicking and select Find Usages:
All of the code that uses your search criteria is shown in a panel at the bottom. You can double-click on any item to navigate directly to the line in question.
Some of the other refactoring commands include the ability to:
Here is an example of renaming the same api.py module you renamed earlier using the rope module to new_api.py:
The rename command is contextualized to the UI, which makes refactoring quick and simple. It has updated the imports automatically in __init__.py with the new module name.
Another useful refactor is the Change Signature command. This can be used to add, remove, or rename arguments to a function or method. It will search for usages and update them for you:
You can set default values and also decide how the refactoring should handle the new arguments.
Refactoring is an important skill for any developer. As you’ve learned in this chapter, you aren’t alone. The tools and IDEs already come with powerful refactoring features to be able to make changes quickly.
Now that you know how complexity can be measured, how to measure it, and how to refactor your code, it’s time to learn 5 common anti-patterns that make code more complex than it need be:
If you can master these patterns and know how to refactor them, you’ll soon be on track (pun intended) to a more maintainable Python application.
Python supports procedural programming using functions and also inheritable classes. Both are very powerful and should be applied to different problems.
Take this example of a module for working with images. The logic in the functions has been removed for brevity:
Python
There are a few issues with this design:
It’s not clear if crop_image() and get_image_thumbnail() modify the original image variable or create new images. If you wanted to load an image then create both a cropped and thumbnail image, would you have to copy the instance first? You could read the source code in the functions, but you can’t rely on every developer doing this.
You have to pass the image variable as an argument in every call to the image functions.
This is how the calling code might look:
Python
Here are some symptoms of code using functions that could be refactored into classes:
h2 unique operandsHere is a refactored version of those 3 functions, where the following happens:
.__init__() replaces load_image().crop() becomes a class method.get_image_thumbnail() becomes a property.The thumbnail resolution has become a class property, so it can be changed globally or on that particular instance:
Python
If there were many more image-related functions in this code, the refactoring to a class could make a drastic change. The next consideration would be the complexity of the consuming code.
This is how the refactored example would look:
Python
In the resulting code, we have solved the original problems:
thumbnail returns a thumbnail since it is a property, and that it doesn’t modify the instance.Sometimes, the reverse is true. There is object-oriented code which would be better suited to a simple function or two.
Here are some tell-tale signs of incorrect use of classes:
.__init__())Take this example of an authentication class:
Python
It would make more sense to just have a simple function named authenticate() that takes username and password as arguments:
Python
You don’t have to sit down and look for classes that match these criteria by hand: pylint comes with a rule that classes should have a minimum of 2 public methods. For more on PyLint and other code quality tools, you can check out Python Code Quality and Writing Cleaner Python Code With PyLint.
To install pylint, run the following command in your console:
Shell
pylint takes a number of optional arguments and then the path to one or more files and folders. If you run pylint with its default settings, it’s going to give a lot of output as pylint has a huge number of rules. Instead, you can run specific rules. The too-few-public-methods rule id is R0903. You can look this up on the documentation website:
Shell
This output tells us that auth.py contains 2 classes that have only 1 public method. Those classes are on lines 72 and 100. There is also a class on line 60 of models.py with only 1 public method.
If you were to zoom out on your source code and tilt your head 90 degrees to the right, does the whitespace look flat like Holland or mountainous like the Himalayas? Mountainous code is a sign that your code contains a lot of nesting.
Here’s one of the principles in the Zen of Python:
“Flat is better than nested”
— Tim Peters, Zen of Python
Why would flat code be better than nested code? Because nested code makes it harder to read and understand what is happening. The reader has to understand and memorize the conditions as they go through the branches.
These are the symptoms of highly nested code:
Take this example that looks at the argument data for strings that match the word error. It first checks if the data argument is a list. Then, it iterates over each and checks if the item is a string. If it is a string and the value is "error", then it returns True. Otherwise, it returns False:
Python
This function would have a low maintainability index because it is small, but it has a high cyclomatic complexity.
Instead, we can refactor this function by “returning early” to remove a level of nesting and returning False if the value of data is not list. Then using .count() on the list object to count for instances of "error". The return value is then an evaluation that the .count() is greater than zero:
Python
Another technique for reducing nesting is to leverage list comprehensions. This common pattern of creating a new list, going through each item in a list to see if it matches a criterion, then adding all matches to the new list:
Python
This code can be replaced with a faster and more efficient list comprehension.
Refactor the last example into a list comprehension and an if statement:
Python
This new example is smaller, has less complexity, and is more performant.
If your data is not a single dimension list, then you can leverage the itertools package in the standard library, which contains functions for creating iterators from data structures. You can use it for chaining iterables together, mapping structures, cycling or repeating over existing iterables.
Itertools also contains functions for filtering data, like filterfalse().
For more on Itertools, check out Itertools in Python 3, By Example.
One of Python’s most powerful and widely used core types is the dictionary. It’s fast, efficient, scalable, and highly flexible.
If you’re new to dictionaries, or think you could leverage them more, you can read Dictionaries in Python for more information.
It does have one major side-effect: when dictionaries are highly nested, the code that queries them becomes nested too.
Take this example piece of data, a sample of the Tokyo Metro lines you saw earlier:
Python
If you wanted to get the line that matched a certain number, this could be achieved in a small function:
Python
Even though the function itself is small, calling the function is unnecessarily complicated because the data is so nested:
Python
There are third party tools for querying dictionaries in Python. Some of the most popular are JMESPath, glom, asq, and flupy.
JMESPath can help with our train network. JMESPath is a querying language designed for JSON, with a plugin available for Python that works with Python dictionaries. To install JMESPath, do the following:
Shell
Then open up a Python REPL to explore the JMESPath API, copying in the data dictionary. To get started, import jmespath and call search() with a query string as the first argument and the data as the second. The query string "network.lines" means return data['network']['lines']:
Python
When working with lists, you can use square brackets and provide a query inside. The “everything” query is simply *. You can then add the name of the attribute inside each matching item to return. If you wanted to get the line number for every line, you could do this:
Python
You can provide more complex queries, like a == or <. The syntax is a little unusual for Python developers, so keep the documentation handy for reference.
If we wanted to find the line with the number 3, this can be done in a single query:
Python
If we wanted to get the color of that line, you could add the attribute in the end of the query:
Python
JMESPath can be used to reduce and simplify code that queries and searches through complex dictionaries.
attrs and dataclasses to Reduce CodeAnother goal when refactoring is to simply reduce the amount of code in the codebase while achieving the same behaviors. The techniques shown so far can go a long way to refactoring code into smaller and simpler modules.
Some other techniques require a knowledge of the standard library and some third party libraries.
Boilerplate code is code that has to be used in many places with little or no alterations.
Taking our train network as an example, if we were to convert that into types using Python classes and Python 3 type hints, it might look something like this:
Python
Now, you might also want to add other magic methods, like .__eq__(). This code is boilerplate. There’s no business logic or any other functionality here: we’re just copying data from one place to another.
dataclassesIntroduced into the standard library in Python 3.7, with a backport package for Python 3.6 on PyPI, the dataclasses module can help remove a lot of boilerplate for these types of classes where you’re just storing data.
To convert the Line class above to a dataclass, convert all of the fields to class attributes and ensure they have type annotations:
Python
You can then create an instance of the Line type with the same arguments as before, with the same fields, and even .__str__(), .__repr__(), and .__eq__() are implemented:
Python
Dataclasses are a great way to reduce code with a single import that’s already available in the standard library. For a full walkthrough, you can checkout The Ultimate Guide to Data Classes in Python 3.7.
attrs Use Casesattrs is a third party package that’s been around a lot longer than dataclasses. attrs has a lot more functionality, and it’s available on Python 2.7 and 3.4+.
If you are using Python 3.5 or below, attrs is a great alternative to dataclasses. Also, it provides many more features.
The equivalent dataclasses example in attrs would look similar. Instead of using type annotations, the class attributes are assigned with a value from attrib(). This can take additional arguments, such as default values and callbacks for validating input:
Python
attrs can be a useful package for removing boilerplate code and input validation on data classes.
Now that you’ve learned how to identify and tackle complicated code, think back to the steps you can now take to make your application easier to change and manage:
wily.rope.Once you follow these steps and the best practices in this article, you can do other exciting things to your application, like adding new features and improving performance.