Skip to content

Instantly share code, notes, and snippets.

@kaste
Last active June 24, 2017 14:31
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kaste/7813083 to your computer and use it in GitHub Desktop.
Save kaste/7813083 to your computer and use it in GitHub Desktop.
PyTest magic test runner

py.test plugin for the sublime text editor

The plugin basically runs (and re-runs) some given tests, and annotates your files using the tracebacks.

common workflow

You usually set up at least two keyboard shortcuts.

{ "keys": ["ctrl+t"], "command": "pytest_rerun"},
{ "keys": ["ctrl+shift+t"], "command": "pytest_set_and_run"}

With that given, hit ctrl+shift+t while you're in a test and the file will be run. ctrl-t will re-run the command at any time. If you ctrl+shift+t while not editing a test, all tests will be run. Again ctrl+t will re-run the last command.

By default, the output panel will only show up if there are actually any failures. The traceback will be annotated in your source files.

install

Manually download/clone from github and put it in your Packages directory.

At least look at the global settings. You usually have to edit the pytest setting to point at your py.test from your current virtualenv (the default is to run your global py.test which is usually not what you want). E.g.

"pytest": "~/venvs/{project_base}/bin/py.test"
OR:
"pytest": ".env\\Scripts\\py.test"
OR even:
"pytest": "venv/path/to/python -m pytest"

The plugin will expand {project_path}, {project_base}, {file}, {file_path} and {file_base}.

[
{
"caption": "Preferences",
"mnemonic": "n",
"id": "preferences",
"children":
[
{
"caption": "Package Settings",
"mnemonic": "P",
"id": "package-settings",
"children":
[
{
"caption": "PyTest Runner",
"children":
[
// {
// "command": "open_file",
// "args": {"file": "${packages}/SublimeOnSaveBuild/README.md"},
// "caption": "README"
// },
// { "caption": "-" },
{
"command": "open_file",
"args": {"file": "${packages}/PyTest/PyTest.sublime-settings"},
"caption": "Settings – Default"
},
{
"command": "open_file",
"args": {"file": "${packages}/User/PyTest.sublime-settings"},
"caption": "Settings – User"
},
{
"command": "open_file_settings",
"caption": "Settings – Syntax Specific – User"
}
]
}
]
}
]
}
]
import sublime
import sublime_plugin
import sys
import os
import functools
class TemporaryStorage(object):
settings = {}
markers = []
LastRun = TemporaryStorage()
class Settings(object):
def __init__(self, name):
self.name = name
@property
def global_(self):
return sublime.load_settings(self.name + '.sublime-settings')
@property
def user(self):
try:
return (sublime.active_window().active_view()
.settings().get(self.name, {}))
except:
return {}
def get(self, key, default=None):
return self.user.get(key, self.global_.get(key, default))
Settings = Settings(__name__)
class PytestSetAndRunCommand(sublime_plugin.WindowCommand):
def run(self, options=None):
""""""
settings = self.get_settings()
if options:
settings['options'] = options
else:
settings['options'] = Settings.get('first')
self.window.run_command("pytest_run", settings)
LastRun.settings = settings
def get_settings(self):
rv = {}
for key in ['pytest', 'working_dir', 'file_regex']:
rv[key] = Settings.get(key)
view = self.window.active_view()
env = environment(view)
try:
filename = env['file_base']
if "_test" in filename or "test_" in filename:
rv['target'] = env['file']
else:
rv['target'] = Settings.get('tests_dir').format(**env)
except KeyError:
rv['target'] = Settings.get('tests_dir').format(**env)
return rv
class PytestRerunCommand(sublime_plugin.WindowCommand):
def run(self, options=None):
""""""
settings = LastRun.settings
if not settings:
self.window.run_command("pytest_set_and_run")
return
if options:
settings['options'] = options
else:
settings['options'] = Settings.get("then")
self.window.run_command("pytest_run", settings)
def environment(view):
window = view.window()
rv = {}
filename = view.file_name()
if filename:
rv['file'] = filename
rv['file_path'] = os.path.dirname(filename)
rv['file_base'] = os.path.basename(filename)
if window and window.folders():
rv['project_path'] = window.folders()[0]
elif filename:
rv['project_path'] = rv['file_path']
if 'project_path' in rv:
rv['project_base'] = os.path.basename(rv['project_path'])
return rv
class PytestRunCommand(sublime_plugin.WindowCommand):
def run(self, **kwargs):
"""
kwargs: pytest, options, target, working_dir, file_regex
"""
args = self.make_args(kwargs)
sublime.status_message("Running %s" % args['cmd'][0])
save = Settings.get('save_before_test')
if save is True:
av = self.window.active_view()
if av and av.is_dirty():
self.window.run_command("save")
elif save == 'all':
selfs.window.run_command("save_all")
output_view = Settings.get('output')
if output_view == 'panel':
self.window.run_command("test_exec", args)
if Settings.get('hide_panel_unless_failures'):
self.window.run_command("hide_panel", {"panel": "output.exec"})
elif output_view == 'view':
args['title'] = "Test Results"
self.window.run_command("test_xexec", args)
def make_args(self, kwargs):
env = environment(self.window.active_view())
for key in ['pytest', 'target', 'working_dir']:
kwargs[key] = kwargs[key].format(**env)
command = "{pytest} {options} {target}".format(**kwargs)
return {
"file_regex": kwargs['file_regex'],
"cmd": [command],
"shell": True,
"working_dir": kwargs['working_dir'],
"quiet": True
}
def annotate_view(view, markers):
filename = view.file_name()
if not filename:
return
view.erase_regions('PyTestRunner')
regions = []
for marker in markers:
fn, line, h = marker
if sys.platform == 'win32':
# we have a cygwin like path e.g. "/c/users" instead of "c:\"
fn = os.path.normpath(fn)
fn = fn[2:]
filename = os.path.splitdrive(filename)[1]
if fn == filename:
region = view.full_line(view.text_point(line-1, h))
regions.append(region)
view.add_regions('PyTestRunner', regions,
'markup.deleted.diff',
'bookmark',
sublime.DRAW_OUTLINED)
class PytestSetMarkersCommand(sublime_plugin.WindowCommand):
def run(self, markers=[]):
LastRun.markers = markers
# immediately paint the visible tabs
window = sublime.active_window()
views = [window.active_view_in_group(group)
for group in range(window.num_groups())]
# print markers
for view in views:
annotate_view(view, markers)
class PytestMarkCurrentViewCommand(sublime_plugin.EventListener):
def on_activated(self, view):
markers = LastRun.markers
# print markers
annotate_view(view, markers)
standard_exec = __import__('exec')
import xexec
class TestExecCommand(standard_exec.ExecCommand):
def run(self, **kw):
self.dots = ""
return super(TestExecCommand, self).run(**kw)
def finish(self, proc):
super(TestExecCommand, self).finish(proc)
sublime.status_message("Ran %s tests." % len(self.dots))
markers = self.output_view.find_all_results()
# we can't serialize a tuple in the settings, so we listify each marker
markers = [list(marker) for marker in markers]
sublime.active_window().run_command("pytest_set_markers",
{"markers": markers})
def append_dots(self, dot):
self.dots += dot
sublime.status_message("Testing " + self.dots[-400:])
if dot in 'FX':
sublime.active_window().run_command(
"show_panel", {"panel": "output.exec"})
def on_data(self, proc, data):
# print ">>", proc, ">>", data
if data in '.FxXs':
sublime.set_timeout(functools.partial(self.append_dots, data), 0)
super(TestExecCommand, self).on_data(proc, data)
class TestXexecCommand(xexec.ExecCommand):
def finish(self, proc):
super(TestXexecCommand, self).finish(proc)
markers = self.output_view.find_all_results()
# we can't serialize a tuple in the settings, so we listify each marker
markers = [list(marker) for marker in markers]
sublime.active_window().run_command("pytest_set_markers",
{"markers": markers})
{
// point to your py.test executable
// usually use the one from your virtualenv
// e.g. "~/venvs/{project_base}/scripts/py.test"
"pytest": "py.test",
"options": "--tb=long --lf",
// the options for the first run (t.i.not a re-run)
// I like to have a "--tb=line" here so I get an overview, probably I just
// have a couple of failures pointing to the same line
"first": "--tb=line",
// the options to use on consecutive calls
"then": "--tb=short",
// usually your project_path (which is the first folder in your project
// settings)
"working_dir": "{project_path}",
// where your tests are located
// usually below your project_path so use a relative path
// e.g. "tests" "foo/tests"
"tests_dir": "",
// false | true | 'all'
// if true saves the current view only
"save_before_test": true,
// determines the kind of the output view. Either 'panel' or 'view'
"output": "panel",
"hide_panel_unless_failures": true,
"file_regex": "^(.*):([0-9]+):"
}
import sublime
import os
standard_exec = __import__("exec")
class ExecCommand(standard_exec.ExecCommand):
def create_output_view(self, title):
view = self.window.new_file()
view.settings().set("no_history", True)
# view.settings().set("gutter", False)
view.settings().set("line_numbers", False)
view.settings().set("syntax", "Packages/Python/Python.tmLanguage")
view.set_name(title)
view.set_scratch(True)
return view
def run(self, cmd = [], file_regex = "", line_regex = "", working_dir = "",
encoding = "utf-8", env = {}, quiet = False, kill = False,
title = "", # xeno.by
# Catches "path" and "shell"
**kwargs):
if kill:
if self.proc:
self.proc.kill()
self.proc = None
self.append_data(None, "[Cancelled]")
return
# modified version of xeno.by:
wannabes = filter(lambda v: v.name() == (title or " ".join(cmd)),
self.window.views())
if len(wannabes):
self.output_view = wannabes[0]
self.output_view.show(self.output_view.size())
self.output_view.set_read_only(False)
edit = self.output_view.begin_edit()
self.output_view.erase(edit, sublime.Region(0, self.output_view.size()))
self.output_view.sel().clear()
self.output_view.sel().add(sublime.Region(self.output_view.size()))
self.output_view.end_edit(edit)
self.output_view.set_read_only(True)
else:
self.output_view = self.create_output_view(title or " ".join(cmd))
# Default the to the current files directory if no working directory was given
if (working_dir == "" and self.window.active_view()
and self.window.active_view().file_name()):
working_dir = os.path.dirname(self.window.active_view().file_name())
self.output_view.settings().set("result_file_regex", file_regex)
self.output_view.settings().set("result_line_regex", line_regex)
self.output_view.settings().set("result_base_dir", working_dir)
self.encoding = encoding
self.quiet = quiet
self.proc = None
if not self.quiet:
print "Running " + " ".join(cmd)
sublime.status_message("Running " + " ".join(cmd))
merged_env = env.copy()
if self.window.active_view():
user_env = self.window.active_view().settings().get('build_env')
if user_env:
merged_env.update(user_env)
# Change to the working dir, rather than spawning the process with it,
# so that emitted working dir relative path names make sense
if working_dir != "":
os.chdir(working_dir)
err_type = OSError
if os.name == "nt":
err_type = WindowsError
try:
# Forward kwargs to AsyncProcess
self.proc = standard_exec.AsyncProcess(cmd, merged_env, self, **kwargs)
except err_type as e:
self.append_data(None, str(e) + "\n")
self.append_data(None, "[cmd: " + str(cmd) + "]\n")
self.append_data(None, "[dir: " + str(os.getcwdu()) + "]\n")
if "PATH" in merged_env:
self.append_data(None, "[path: " + str(merged_env["PATH"]) + "]\n")
else:
self.append_data(None, "[path: " + str(os.environ["PATH"]) + "]\n")
if not self.quiet:
self.append_data(None, "[Finished]")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment