Python unittests from Emacs
At the company I currently work for, most of my coworkers use PyCharm to develop the Python application we are working on. I tried PyCharm several times and although I can understand why it is so popular, I still prefer Emacs :) One of the nice PyCharm features is the functionality to run the unit test the cursor resides in. So I decided to support that functionality in Emacs and in this post I describe how. You can find the code I developed for that in my skempy repo at Bitbucket.
General idea
The general idea to be able to run the unit test "at point" was simple:
- Write a Python script that, given a Python source file and line number, returns the name of the unit test in that file at that line.
- In Emacs, call that Python script with the file name of the current buffer and the line number at point to retrieve the name of the unit test.
- In Emacs, use the compile-command to run the unit test and display the output in compilation mode.
It is easy to run a specific unit test using standard Python functionality, e.g. the command:
$> python -m unittest source_code.MyTestSuite.test_a
executes test method test_a of test class MyTestSuite in file source_code.py.
I wanted to have all the complexity in the Python script, so the output of the Python script had to be something like:
source_code.MyTestSuite.test_a
which the Emacs Lisp code could then pickup to build the compile-command that Emacs should use. This idea resulted in the Python package skempy and the command-line utility skempy-find-test.
skempy
The following text, which is from the skempy README, explains how to use skempy-find-test:
$ skempy-find-test --help usage: skempy-find-test [-h] [--version] file_path line_no Retrieve the method in the given Python file and at the given line. positional arguments: file_path Python file including path line_no line number optional arguments: -h, --help show this help message and exit --version show program's version number and exit
Assume you have the Python file tests/source_code.py:
import unittest class TestMe(unittest.TestCase): def test_a(self): print "Hello World!" return
The following snippet shows the output of skempy-find-test on that Python file at line 7, which is the line that contains the print statement:
$ skempy-find-test tests/source_code.py 7 source_code.TestMe.test_a
Emacs integration
The root of the repo contains the Emacs Lisp file skempy.el, which provides a function to retrieve the test method at point and executes that test as a compile command:
(defun sks-execute-python-test() (interactive) (let ((test-method (shell-command-to-string (format "skempy-find-test %s %d" (buffer-file-name) (line-number-at-pos))))) (compile (concat "python -m unittest " test-method))) )
If you bind it to a key then running the test at point is a single keystroke away, e.g.:
(add-hook 'python-mode-hook '(lambda () (local-set-key [C-f7] 'sks-execute-python-test)))
Implementation details
Initially I wanted to parse the Python file that contains the unit test, reading the file line-by-line and using regular expressions to do some pattern matching. You might know the quote [1]
Some people, when confronted with a problem, think "I know, I'll use regular expressions." Now they have two problems.
Indeed, before too long my spike in this direction was becoming overly complex.
I searched for another approach and this quickly lead to the Python ast module. This module "helps Python applications to process trees of the Python abstract syntax grammar". In other words, it helps you parse Python files.
To parse a Python file, I used the following exports of the ast module:
- function ast.parse to create a tree of syntax nodes of a given Python file;
- class ast.NodeVisitor which implements the visitor pattern to inspect the tree of nodes.
To put it bluntly, each syntax node represents a statement and contains additional information such as the line numbers of that statement. When you call ast.NodeVisitor.visit and pass the tree of nodes, visit calls the appropriate ast.NodeVisitor method for each node. If you want a specific behavior for a node type, you override the method for that node type. This resulted in the following code:
class LineFinder(ast.NodeVisitor): def __init__(self, line_no): self.line_no = line_no self.path = "" def visit_ClassDef(self, node): self.class_name = node.name if node.lineno <= self.line_no: self.path = self.class_name self.generic_visit(node) def visit_FunctionDef(self, node): max_lineno = node.lineno for statement_node in node.body: max_lineno = max(max_lineno, statement_node.lineno) if node.lineno <= self.line_no <= max_lineno: self.path = "%s.%s" % (self.class_name, node.name) if not self.path: self.generic_visit(node) def get_path_in_code(source_code, line_no): tree = ast.parse(source_code) line_finder = LineFinder(line_no) line_finder.visit(tree) return line_finder.path
This code does not support all possible edge cases but it supports the use cases I currently have, which is enough for me.
Making it complete
The ast code alone is not enough. For example, the previous code snippet only returns a class and method name. That is not enough for the Python unit testrunner, which wants a Python file package path. So we had to go from
TestMe.test_a
to
package.source_code.TestMe.test_a
It was easy to support this. You can find the complete code including unit tests, documentation, setup etc. in my skempy repo at Bitbucket. If you want to try it out, please have a look at the README, which explains how to install it.
[1] | This is the quote as you might know it and I only use to jest. The actual quote only warns against the overuse of regular expressions, as explained in this post on Coding Horror. |
Comments
Comments powered by Disqus