Skip to content

Commit 7fb1196

Browse files
committed
initial version
0 parents  commit 7fb1196

File tree

1 file changed

+358
-0
lines changed

1 file changed

+358
-0
lines changed

python-pytest.el

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
;;; python-pytest.el --- helpers to run pytest -*- lexical-binding: t; -*-
2+
3+
;; Author: wouter bolsterlee <[email protected]>
4+
;; Version: 0.1.0
5+
;; Package-Requires: ((emacs "24.4") (dash "2.12.0") (magit-popup "2.12.0") (projectile "0.14.0") (s "1.12.0"))
6+
;; Keywords: pytest, test, python, languages, processes, tools
7+
;; URL: https://github.com/wbolster/emacs-python-pytest
8+
;;
9+
;; This file is not part of GNU Emacs.
10+
11+
;;; License:
12+
13+
;; 3-clause "new bsd"; see readme for details.
14+
15+
;;; Commentary:
16+
17+
;;; This package provides helpers to run pytest inside Emacs.
18+
19+
;;; Code:
20+
21+
(require 'comint)
22+
(require 'python)
23+
24+
(require 'dash)
25+
(require 'projectile)
26+
(require 's)
27+
(require 'magit-popup)
28+
29+
(defgroup pytest nil
30+
"direnv integration for emacs"
31+
:group 'python
32+
:prefix "pytest-")
33+
34+
(defcustom python-pytest-confirm nil
35+
"Whether to edit the command in the minibuffer before execution.
36+
37+
By default, pytest will be executed without showing a minibuffer prompt.
38+
This can be changed on a case by case basis by using a prefix argument
39+
\(\\[universal-argument]\) when invoking a command.
40+
41+
When t, this toggles the behaviour of the prefix argument."
42+
:group 'pytest
43+
:type 'boolean)
44+
45+
(defcustom python-pytest-executable "pytest"
46+
"The name of the pytest executable."
47+
:group 'pytest
48+
:type 'string)
49+
50+
(defcustom python-pytest-started-hooks nil
51+
"Hooks to run before a pytest process starts."
52+
:group 'pytest
53+
:type 'hook)
54+
55+
(defcustom python-pytest-finished-hooks nil
56+
"Hooks to run after a pytest process finishes."
57+
:group 'pytest
58+
:type 'hook)
59+
60+
(defcustom python-pytest-buffer-name "*pytest*"
61+
"Name of the pytest output buffer."
62+
:group 'pytest
63+
:type 'string)
64+
65+
(defcustom python-pytest-project-name-in-buffer-name t
66+
"Whether to include the project name in the buffer name.
67+
68+
This is useful when working on multiple projects simultaneously."
69+
:group 'pytest
70+
:type 'boolean)
71+
72+
(defcustom python-pytest-pdb-track t
73+
"Whether to automatically track output when pdb is spawned.
74+
75+
This results in automatically opening source files during debugging."
76+
:group 'pytest
77+
:type 'boolean)
78+
79+
(defvar python-pytest--history nil
80+
"History for pytest invocations.")
81+
82+
(defvar python-pytest--last-command nil
83+
"Command line used for the last run.")
84+
85+
;;;###autoload (autoload 'python-pytest-popup "pytest" nil t)
86+
(magit-define-popup python-pytest-popup
87+
"Show popup for running pytest."
88+
'python-pytest
89+
:switches
90+
'((?c "color" "--color" t)
91+
(?d "debug on error" "--pdb")
92+
(?f "failed first" "--failed-first")
93+
(?l "show locals" "--showlocals")
94+
(?q "quiet" "--quiet")
95+
(?s "do not capture output" "--capture=no")
96+
(?t "do not cut tracebacks" "--full-trace")
97+
(?v "verbose" "--verbose")
98+
(?x "exit" "--exitfirst"))
99+
:options
100+
'((?k "only names matching expression" "-k")
101+
(?m "only marks matching expression" "-m")
102+
(?t "traceback style" "--tb=" python-pytest--choose-traceback-style)
103+
(?n "exit after N failures or errors" "--maxfail="))
104+
:actions
105+
'("Run tests"
106+
(?t "Test all" python-pytest)
107+
(?x "Test last-failed" python-pytest-last-failed)
108+
"Run tests for current context"
109+
(?f "Test file" python-pytest-file-dwim)
110+
(?F "Test this file " python-pytest-file)
111+
(?d "Test def/class" python-pytest-function-dwim)
112+
"Repeat tests"
113+
(?r "Repeat last test run" python-pytest-repeat))
114+
:max-action-columns 3
115+
:default-action 'python-pytest-repeat)
116+
117+
;;;###autoload
118+
(defun python-pytest (&optional args)
119+
"Run pytest with ARGS.
120+
121+
With a prefix argument, allow editing."
122+
(interactive (list (python-pytest--arguments)))
123+
(python-pytest-run
124+
:args args
125+
:edit current-prefix-arg))
126+
127+
;;;###autoload
128+
(defun python-pytest-file (file &optional args)
129+
"Run pytest on FILE, using ARGS.
130+
131+
Additional ARGS are passed along to pytest.
132+
With a prefix argument, allow editing."
133+
(interactive
134+
(list
135+
(buffer-file-name)
136+
(python-pytest--arguments)))
137+
(when (file-name-absolute-p file)
138+
(setq file (file-relative-name file (python-pytest--project-root))))
139+
(python-pytest-run
140+
:args args
141+
:file file
142+
:edit current-prefix-arg))
143+
144+
;;;###autoload
145+
(defun python-pytest-file-dwim (file &optional args)
146+
"Run pytest on FILE, intelligently finding associated test modules.
147+
148+
When run interactively, this tries to work sensibly using
149+
the current file.
150+
151+
Additional ARGS are passed along to pytest.
152+
With a prefix argument, allow editing."
153+
(interactive
154+
(list
155+
(buffer-file-name)
156+
(python-pytest--arguments)))
157+
(python-pytest-file (python-pytest--sensible-test-file file) args))
158+
159+
;;;###autoload
160+
(defun python-pytest-function-dwim (file func args)
161+
"Run pytest on FILE with FUNC (or class).
162+
163+
When run interactively, this tries to work sensibly using
164+
the current file and function around point.
165+
166+
Additional ARGS are passed along to pytest.
167+
With a prefix argument, allow editing."
168+
(interactive
169+
(list
170+
(buffer-file-name)
171+
(python-pytest--current-defun)
172+
(python-pytest--arguments)))
173+
(unless func
174+
(user-error "No class/function found"))
175+
(let ((test-file (python-pytest--sensible-test-file file)))
176+
(when (file-name-absolute-p test-file)
177+
(setq test-file (file-relative-name test-file (python-pytest--project-root))))
178+
(unless (python-pytest--test-file-p file)
179+
(setq func (python-pytest--make-test-name func)))
180+
(setq func (s-replace "." "::" func))
181+
(python-pytest-run
182+
:args args
183+
:file test-file
184+
:func func
185+
:edit current-prefix-arg)))
186+
187+
;;;###autoload
188+
(defun python-pytest-last-failed (&optional args)
189+
"Run pytest, only executing previous test failures.
190+
191+
Additional ARGS are passed along to pytest.
192+
With a prefix argument, allow editing."
193+
(interactive (list (python-pytest--arguments)))
194+
(python-pytest-run
195+
:args (-snoc args "--last-failed")
196+
:edit current-prefix-arg))
197+
198+
;;;###autoload
199+
(defun python-pytest-repeat ()
200+
"Run pytest with the same argument as the most recent invocation.
201+
202+
With a prefix ARG, allow editing."
203+
(interactive)
204+
(unless python-pytest--last-command
205+
(user-error "No previous pytest run"))
206+
(python-pytest-run-command
207+
:command python-pytest--last-command
208+
:edit current-prefix-arg))
209+
210+
211+
;; internal helpers
212+
213+
(define-derived-mode python-pytest-mode
214+
comint-mode "pytest"
215+
"Major mode for pytest sessions (derived from comint-mode).")
216+
217+
(cl-defun python-pytest-run (&key args file func edit)
218+
"Run pytest for the given arguments."
219+
(let ((what))
220+
(setq args (cons python-pytest-executable args))
221+
(when file
222+
(setq what (shell-quote-argument file))
223+
(when func
224+
(setq what (format "%s::%s" what (shell-quote-argument func))))
225+
(setq args (-snoc args what)))
226+
(python-pytest-run-command
227+
:command (s-join " " args)
228+
:edit edit)))
229+
230+
(cl-defun python-pytest-run-command (&key command edit)
231+
"Run a pytest command line."
232+
(let* ((default-directory (python-pytest--project-root)))
233+
(when python-pytest-confirm
234+
(setq edit (not edit)))
235+
(when edit
236+
(setq command
237+
(read-from-minibuffer
238+
"Command: "
239+
command nil nil 'python-pytest--history)))
240+
(setq python-pytest--last-command command)
241+
(python-pytest-run-as-comint command)))
242+
243+
(defun python-pytest-run-as-comint (command)
244+
"Run a pytest comint session for COMMAND."
245+
(let* ((buffer (get-buffer-create (python-pytest--make-buffer-name)))
246+
(process (get-buffer-process buffer)))
247+
(with-current-buffer buffer
248+
(when (comint-check-proc buffer)
249+
(unless (or compilation-always-kill
250+
(yes-or-no-p "Kill running pytest process?"))
251+
(user-error "Aborting; pytest still running")))
252+
(when process
253+
(delete-process process))
254+
(erase-buffer)
255+
(kill-all-local-variables)
256+
(insert (format "cwd: %s\ncmd: %s\n\n" default-directory command))
257+
(python-pytest-mode)
258+
(when python-pytest-pdb-track
259+
(add-hook
260+
'comint-output-filter-functions
261+
'python-pdbtrack-comint-output-filter-function
262+
nil t))
263+
(make-comint-in-buffer "pytest" buffer "sh" nil "-c" command)
264+
(run-hooks 'python-pytest-started-hooks)
265+
(setq process (get-buffer-process buffer))
266+
(set-process-sentinel process #'python-pytest--process-sentinel)
267+
(display-buffer buffer))))
268+
269+
(defun python-pytest--make-buffer-name ()
270+
"Make a buffer name for the compilation buffer."
271+
(let ((name python-pytest-buffer-name))
272+
(when python-pytest-project-name-in-buffer-name
273+
(setq name (format "%s<%s>" name (python-pytest--project-name))))
274+
name))
275+
276+
(defun python-pytest--process-sentinel (proc _state)
277+
"Process sentinel helper to run hooks after PROC finishes."
278+
(with-current-buffer (process-buffer proc)
279+
(run-hooks 'python-pytest-finished-hooks)))
280+
281+
(defun python-pytest--arguments ()
282+
"Return the current arguments in a form understood by pytest."
283+
(let ((args (python-pytest-arguments)))
284+
(setq args (python-pytest--switch-to-option
285+
args "--color" "--color=yes" "--color=no"))
286+
args))
287+
288+
(defun python-pytest--switch-to-option (args name on-replacement off-replacement)
289+
"Look in ARGS for switch NAME and turn it into option with a value.
290+
291+
When present ON-REPLACEMENT is substituted, else OFF-REPLACEMENT is appended."
292+
(if (-contains-p args name)
293+
(-replace name on-replacement args)
294+
(-snoc args off-replacement)))
295+
296+
(defun python-pytest--choose-traceback-style (prompt _value)
297+
"Helper to choose a pytest traceback style using PROMPT."
298+
(completing-read
299+
prompt '("long" "short" "line" "native" "no") nil t))
300+
301+
302+
;; python helpers
303+
304+
(defun python-pytest--current-defun ()
305+
"Detect the current function/class (if any)."
306+
(save-excursion
307+
(let ((name (python-info-current-defun)))
308+
(unless name
309+
;; jumping seems to make it work on empty lines.
310+
;; todo: this could perhaps be improved.
311+
(python-nav-beginning-of-defun)
312+
(python-nav-forward-statement)
313+
(setq name (python-info-current-defun)))
314+
name)))
315+
316+
(defun python-pytest--make-test-name (func)
317+
"Turn function name FUNC into a corresponding test callable name.
318+
319+
Example: ‘MyABCThingy.__repr__’ becomes ‘test_my_abc_thingy_repr’."
320+
(-as->
321+
func s
322+
(s-replace "." "_" s)
323+
(s-snake-case s)
324+
(s-replace-regexp "_\+" "_" s)
325+
(s-chop-suffix "_" s)
326+
(s-chop-prefix "_" s)
327+
(format "test_%s" s)))
328+
329+
330+
;; file/directory helpers
331+
332+
(defun python-pytest--project-name ()
333+
"Find the project name."
334+
(projectile-project-name))
335+
336+
(defun python-pytest--project-root ()
337+
"Find the project root directory."
338+
(projectile-project-root))
339+
340+
(defun python-pytest--test-file-p (file)
341+
"Tell whether FILE is a test file."
342+
(projectile-test-file-p file))
343+
344+
(defun python-pytest--find-test-file (file)
345+
"Find a test file associated to FILE, if any."
346+
(let ((test-file (projectile-find-matching-test file)))
347+
(unless test-file
348+
(user-error "No test file found"))
349+
test-file))
350+
351+
(defun python-pytest--sensible-test-file (file)
352+
"Return a sensible test file name for FILE."
353+
(if (python-pytest--test-file-p file)
354+
(file-relative-name file (python-pytest--project-root))
355+
(python-pytest--find-test-file file)))
356+
357+
(provide 'python-pytest)
358+
;;; python-pytest.el ends here

0 commit comments

Comments
 (0)