-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpytest.el
248 lines (210 loc) · 8.66 KB
/
pytest.el
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
;;; pytest --- stuff
;;; Commentary:
;;; Code:
(require 'python)
(require 'project)
(require 'transient)
(defun expanded-project-root ()
(expand-file-name (project-root (project-current))))
(defcustom pytest-cmd "docker-compose run --rm app pytest"
"The pytest executable that will be used by pytest mode."
:group 'pytest-mode)
(defcustom pytest-test-dir ""
"Explicitly set the test directory rather than relying on pytests discovery."
:group 'pytest-mode)
(defcustom pytest-history-ring-size 20
"Number of pytest invocations to save before overwritting last entries in history."
:group 'pytest-mode)
;; for re-running previous pytest invocations we keep a history of per-projet
(defvar pytest-history (make-hash-table :test 'equal))
(defvar pytest-mode-map
(let ((map (make-keymap "pytest-mode-map")))
(define-key map (kbd "C-q") 'quit-window)
(define-key map (kbd "C-t") 'pytest-runner)
(define-key map (kbd "C-<return>") 'pytest-jump-to-failed)
map))
(define-minor-mode pytest-mode
"Pytest mode."
:lighter pytest
:keymap pytest-mode-map)
;;
;; -- Pytest transient
;;
(defclass pytest-extra-arg (transient-argument)
((reader :initarg :reader)
(argument :initarg :argument :initform "")
(value :initarg :value :initform "")))
(cl-defmethod transient-format-value ((obj pytest-extra-arg))
"Implementation of the value formatter for the OBJ instance of PYTEST-EXTRA-ARG class."
(if-let ((value (oref obj value)))
(propertize value 'face 'transient-value)
(propertize "unset" 'face 'transient-inactive-value)))
(transient-define-argument pytest-extra-args ()
:description "extra-flags"
:shortarg "-e"
:class 'pytest-extra-arg
:reader #'(lambda (prompt _initial history)
(completing-read prompt '() nil nil _initial history)))
(transient-define-prefix pytest-runner ()
"Pytest Runner Interface"
["Arguments"
("-v" "verbose" "-vv")
("-d" "pdb" "--pdb")
("-l" "no-sqla-logs" "--no-sqla-logs")
(pytest-extra-args)]
[["Test Current"
:if pytest-is-test-file-p
("cb" "buffer" pytest-run-current-file)
("cf" "function" pytest-run-current-test)]
["Past Invocations"
:if pytest-has-invocations-p
("p" "run previous" pytest-run-previous)]
["Failed Tests"
:if pytest-has-failed-tests-p
("f" "run failed tests" pytest-run-failed)
("s" "run selection" pytest-run-failed-selection)]
["General"
("r" "run tests" pytest-run)
("b" "pytest buffer" pytest-open-buffer)]])
(defun pytest-is-test-file-p ()
"Non-nil if current file has 'test_' prefix in name."
(string-prefix-p "test_" (file-name-nondirectory (or (buffer-file-name) ""))))
(defun pytest-has-invocations-p ()
"Non-nil if there are previous pytest invocations for proejct."
(not (eq (gethash (project-name (project-current)) pytest-history) nil)))
(defun pytest-has-failed-tests-p ()
"Non-nil if buffer has failed test strings."
(> (length (pytest-get-failed-in-string (buffer-string))) 0))
(defun transient-pytest-args ()
"Transient args getter."
(transient-args 'pytest-runner))
;;
;; -- Pytest Helpers
;;
(defun pytest-window-split (window)
"Prefer horizontal split of WINDOW regardless of layout."
(split-window (frame-root-window) (frame-root-window) 'below))
(defun pytest-buffer-name ()
"Generate buffer name for test invocation."
(format "*%s-test*" (project-name (project-current))))
(defun pytest-push-history (command)
"Save the currently executed COMMAND for future history lookup."
(let* ((project (project-name (project-current)))
(history (gethash project pytest-history))
(command-string (mapconcat 'identity command " ")))
(puthash project
(add-to-history 'history command-string pytest-history-ring-size)
pytest-history)))
(defun pytest-run-without-focus (params)
"Spawn the pytest process with the given PARAMS.
Returns the buffer in which the process is spawned."
(pytest-push-history params)
(let* ((default-directory (project-root (project-current)))
(args (append (split-string-and-unquote pytest-cmd) params))
(process-name (pytest-buffer-name))
(*buffer* (get-buffer-create process-name)))
;; erase buffer if there is no process running and it has previous output
(with-current-buffer *buffer*
(when (and (not (term-check-proc *buffer*))
(> (buffer-size *buffer*) 0))
(let ((inhibit-read-only t))
(erase-buffer)))
;; try spawn the process -- nop if process is already running
;; use term-ansi-make-term for better interactive support when pdb is spawned.
(apply 'term-ansi-make-term process-name (car args) nil (cdr args))
(term-char-mode)
(pytest-mode))
*buffer*))
(defun pytest-command-runner (params)
"Spawn the pytest process with given PARAMS and open proces buffer."
(let ((split-window-preferred-function 'pytest-window-split)
(*buffer* (pytest-run-without-focus params)))
(when (not (get-buffer-window *buffer*))
(switch-to-buffer-other-window *buffer*))))
(defun pytest-parent-class ()
"Find the first occurance of a python class above current point."
(save-match-data
(when (re-search-backward "^class \\(.*?\\):" nil t)
(match-string-no-properties 1))))
(defun pytest-get-failed-in-string (string)
"Parse the failed test(s) from the STRING and re-run just the test(s) extracted."
(let ((pos 0)
(matches)
(params))
(save-match-data
(while (string-match "^\\(FAILED\\|ERROR\\) \\(.*?\\.py::.*?\\)\\( - \\|$\\)" string pos)
(push (match-string 2 string) matches)
(setq pos (match-end 2))))
matches))
;;
;; -- Interactives
;;
(defun pytest-run-current-test (&optional flags)
"Run the test above current point with optional FLAGS."
(interactive (list (transient-pytest-args)))
(save-excursion
(mark-defun)
(let ((region (buffer-substring (region-beginning) (region-end)))
(test-name (string-remove-prefix (expanded-project-root) buffer-file-name)))
(deactivate-mark)
(save-match-data
(string-match "^\\(\s\\{4\\}\\)?def \\(test_.*?\\)(" region 0)
(let ((test-func (match-string 2 region)))
;; when function is indented, attempt to find parent test class
(when (match-string 1 region)
(setq test-name (concat test-name (format "::%s" (pytest-parent-class)))))
(if test-func
(setq test-name (concat test-name (format "::%s" test-func)))
(message "no test found"))
(pytest-command-runner
(append (split-string-and-unquote test-name) flags)))))))
(defun pytest-jump-to-failed ()
"Jump the the failed test start section."
(interactive)
(let ((content (buffer-substring (line-beginning-position) (line-end-position))))
(save-match-data
(string-match "^\\(FAILED\\|ERROR\\)\s.*?.py::\\(.*?\\)\\(\s-\\|$\\)" content 0)
(search-backward
(format "%s ___" (replace-regexp-in-string "::" "." (match-string 2 content)))))))
(defun pytest-run-failed (&optional flags)
"Re-run failed test(s) in matched in the test runner buffer with provided FLAGS."
(interactive (list (transient-pytest-args)))
(pytest-command-runner (append (pytest-get-failed-in-string (buffer-string)) flags)))
(defun pytest-run-failed-selection (&optional flags)
"Re-run fialed test(s) with provded FLAGS in marked-region or current line."
(interactive (list (transient-pytest-args)))
(let (start end)
(if (use-region-p)
(progn
(setq start (region-beginning) end (region-end))
(deactivate-mark))
(setq start (line-beginning-position) end (line-end-position)))
(pytest-command-runner
(append (pytest-get-failed-in-string (buffer-substring start end)) flags))))
(defun pytest-run-previous ()
"Run the previous command again."
(interactive)
(if-let* ((history (gethash (project-name (project-current)) pytest-history)))
(pytest-command-runner
(split-string-and-unquote (completing-read "command:" history)))
(error "No previous pytest invocations")))
(defun pytest-run (&optional flags)
"Run pytest with optional FLAGS."
(interactive (list (transient-pytest-args)))
(pytest-command-runner (append (split-string-and-unquote pytest-test-dir) flags)))
(defun pytest-run-current-file (&optional flags)
"Run pytest with the current file and optional FLAGS."
(interactive (list (transient-pytest-args)))
(pytest-command-runner
(append (split-string-and-unquote
(string-remove-prefix (expanded-project-root) buffer-file-name)) flags)))
(defun pytest-open-buffer ()
"Open the pytest buffer for the current project."
(interactive)
(let ((split-window-preferred-function 'pytest-window-split)
(buffer (pytest-buffer-name)))
(if (get-buffer buffer)
(switch-to-buffer-other-window buffer)
(message (concat "No pytest buffer for project " (project-name (project-current)))))))
(provide 'pytest)
;;; pytest.el ends here