1
1
; ;; python-pytest.el --- helpers to run pytest -*- lexical-binding : t ; -*-
2
2
3
3
; ; Author: wouter bolsterlee <[email protected] >
4
- ; ; Version: 3.3 .0
4
+ ; ; Version: 3.5 .0
5
5
; ; Package-Requires: ((emacs "24.4") (dash "2.18.0") (transient "0.3.7") (s "1.12.0"))
6
6
; ; Keywords: pytest, test, python, languages, processes, tools
7
7
; ; URL: https://github.com/wbolster/emacs-python-pytest
29
29
30
30
(require 'projectile nil t )
31
31
(require 'project nil t )
32
+ (require 'treesit nil t )
32
33
33
34
(defgroup python-pytest nil
34
35
" pytest integration"
@@ -127,6 +128,16 @@ When non-nil only ‘test_foo()’ will match, and nothing else."
127
128
(set-default symbol value)
128
129
value))))
129
130
131
+ (defcustom python-pytest-use-treesit (featurep 'treesit )
132
+ " Whether to use treesit for getting the node ids of things at point.
133
+
134
+ Users that are running a version of Emacs that supports treesit
135
+ and have the Python language grammar for treesit should set this
136
+ variable to t. Users that are running a version of Emacs that
137
+ don't support treesit should set this variable to nil."
138
+ :group 'python-pytest
139
+ :type 'boolean )
140
+
130
141
(defvar python-pytest--history nil
131
142
" History for pytest invocations." )
132
143
@@ -178,8 +189,10 @@ When non-nil only ‘test_foo()’ will match, and nothing else."
178
189
(" F" " file (this)" python-pytest-file)]
179
190
[(" m" " files" python-pytest-files)
180
191
(" M" " directories" python-pytest-directories)]
181
- [(" d" " def/class (dwim)" python-pytest-function-dwim)
182
- (" D" " def/class (this)" python-pytest-function)]])
192
+ [(" d" " def at point (dwim)" python-pytest-run-def-or-class-at-point-dwim :if-not python-pytest--use-treesit-p)
193
+ (" D" " def at point" python-pytest-run-def-or-class-at-point :if-not python-pytest--use-treesit-p)
194
+ (" d" " def at point" python-pytest-run-def-at-point-treesit :if python-pytest--use-treesit-p)
195
+ (" c" " class at point" python-pytest-run-class-at-point-treesit :if python-pytest--use-treesit-p)]])
183
196
184
197
(define-obsolete-function-alias 'python-pytest-popup 'python-pytest-dispatch " 2.0.0" )
185
198
@@ -258,35 +271,60 @@ With a prefix argument, allow editing."
258
271
:edit current-prefix-arg))
259
272
260
273
;;;### autoload
261
- (defun python-pytest-function (file func args )
274
+ (defun python-pytest-run-def-at-point-treesit ()
275
+ " Run def at point."
276
+ (interactive )
277
+ (python-pytest--run
278
+ :args (transient-args 'python-pytest-dispatch )
279
+ :file (buffer-file-name )
280
+ :node-id (python-pytest--node-id-def-at-point-treesit)
281
+ :edit current-prefix-arg))
282
+
283
+ ;;;### autoload
284
+ (defun python-pytest-run-class-at-point-treesit ()
285
+ " Run class at point."
286
+ (interactive )
287
+ (python-pytest--run
288
+ :args (transient-args 'python-pytest-dispatch )
289
+ :file (buffer-file-name )
290
+ :node-id (python-pytest--node-id-class-at-point-treesit)
291
+ :edit current-prefix-arg))
292
+
293
+ ;;;### autoload
294
+ (defun python-pytest-run-def-or-class-at-point (file func args )
262
295
" Run pytest on FILE with FUNC (or class).
263
296
264
297
Additional ARGS are passed along to pytest.
265
298
With a prefix argument, allow editing."
266
299
(interactive
267
300
(list
268
301
(buffer-file-name )
269
- (python-pytest--current-defun )
302
+ (python-pytest--node-id-def-or-class-at-point )
270
303
(transient-args 'python-pytest-dispatch )))
271
304
(python-pytest--run
272
305
:args args
273
306
:file file
274
- :func func
307
+ :node-id func
275
308
:edit current-prefix-arg))
276
309
277
310
;;;### autoload
278
- (defun python-pytest-function -dwim (file func args )
279
- " Run pytest on FILE with FUNC (or class) .
311
+ (defun python-pytest-run-def-or-class-at-point -dwim (file func args )
312
+ " Run pytest on FILE using FUNC at point as the node-id .
280
313
281
- When run interactively, this tries to work sensibly using
282
- the current file and function around point.
314
+ If `python-pytest--test-file-p' returns t for FILE (i.e. the file
315
+ is a test file), then this function results in the same behavior
316
+ as calling `python-pytest-run-def-at-point' . If
317
+ `python-pytest--test-file-p' returns nil for FILE (i.e. the
318
+ current file is not a test file), then this function will try to
319
+ find related test files and test defs (i.e. sensible match) for
320
+ the current file and the def at point.
283
321
284
322
Additional ARGS are passed along to pytest.
285
323
With a prefix argument, allow editing."
286
324
(interactive
287
325
(list
288
326
(buffer-file-name )
289
- (python-pytest--current-defun )
327
+ (python-pytest--node-id-def-or-class-at-point )
290
328
(transient-args 'python-pytest-dispatch )))
291
329
(unless (python-pytest--test-file-p file)
292
330
(setq
@@ -313,7 +351,7 @@ With a prefix argument, allow editing."
313
351
(python-pytest--run
314
352
:args args
315
353
:file file
316
- :func func
354
+ :node-id func
317
355
:edit current-prefix-arg))
318
356
319
357
;;;### autoload
@@ -360,16 +398,22 @@ With a prefix ARG, allow editing."
360
398
map)
361
399
" Keymap for `python-pytest-mode' major mode." )
362
400
363
- (cl-defun python-pytest--run (&key args file func edit )
364
- " Run pytest for the given arguments."
401
+ (cl-defun python-pytest--run (&key args file node-id edit )
402
+ " Run pytest for the given arguments.
403
+
404
+ NODE-ID should be the node id of the test to run. pytest uses
405
+ double colon \" ::\" for separating components in node ids. For
406
+ example, the node-id for a function outside a class is the
407
+ function name, the node-id for a function inside a class is
408
+ TestClass::test_my_function, the node-id for a function inside a
409
+ class that is inside another class is
410
+ TestClassParent::TestClassChild::test_my_function."
365
411
(setq args (python-pytest--transform-arguments args))
366
412
(when (and file (file-name-absolute-p file))
367
413
(setq file (python-pytest--relative-file-name file)))
368
- (when func
369
- (setq func (s-replace " ." " ::" func)))
370
414
(let ((command)
371
415
(thing (cond
372
- ((and file func ) (format " %s ::%s " file func ))
416
+ ((and file node-id ) (format " %s ::%s " file node-id ))
373
417
(file file))))
374
418
(when thing
375
419
(setq args (-snoc args (python-pytest--shell-quote thing))))
@@ -429,6 +473,17 @@ With a prefix ARG, allow editing."
429
473
(setq process (get-buffer-process buffer))
430
474
(set-process-sentinel process #'python-pytest--process-sentinel ))))
431
475
476
+ (defun python-pytest--use-treesit-p ()
477
+ " Return t if python-pytest-use-treesit is t. Otherwise, return nil.
478
+
479
+ This function is passed to the parameter :if in
480
+ `python-pytest-dispatch' .
481
+
482
+ Although this function might look useless, the main reason why it
483
+ was defined was that the parameter that is provided to the
484
+ transient keyword :if must be a function."
485
+ python-pytest-use-treesit)
486
+
432
487
(defun python-pytest--shell-quote (s )
433
488
" Quote S for use in a shell command. Like `shell-quote-argument' , but prettier."
434
489
(if (s-equals-p s (shell-quote-argument s))
@@ -545,7 +600,140 @@ When present ON-REPLACEMENT is substituted, else OFF-REPLACEMENT is appended."
545
600
546
601
; ; python helpers
547
602
548
- (defun python-pytest--current-defun ()
603
+ (defun python-pytest--point-is-inside-def-treesit ()
604
+ (unless (treesit-language-available-p 'python )
605
+ (error " This function requires tree-sitter support for python, but it is not available. " ))
606
+ (save-restriction
607
+ (widen )
608
+ (catch 'return
609
+ (let ((current-node (treesit-node-at (point ) 'python )))
610
+ (while (setq current-node (treesit-node-parent current-node))
611
+ (when (equal (treesit-node-type current-node) " function_definition" )
612
+ (throw 'return t )))))))
613
+
614
+ (defun python-pytest--point-is-inside-class-treesit ()
615
+ (unless (treesit-language-available-p 'python )
616
+ (error " This function requires tree-sitter support for python, but it is not available. " ))
617
+ (save-restriction
618
+ (widen )
619
+ (catch 'return
620
+ (let ((current-node (treesit-node-at (point ) 'python )))
621
+ (while (setq current-node (treesit-node-parent current-node))
622
+ (when (equal (treesit-node-type current-node) " class_definition" )
623
+ (throw 'return t )))))))
624
+
625
+ (defun python-pytest--node-id-def-at-point-treesit ()
626
+ " Return the node id of the def at point.
627
+
628
+ + If the test function is not inside a class, its node id is the name
629
+ of the function.
630
+ + If the test function is defined inside a class, its node id would
631
+ look like: TestGroup::test_my_function.
632
+ + If the test function is defined inside a class that is defined
633
+ inside another class, its node id would look like:
634
+ TestGroupParent::TestGroupChild::test_my_function."
635
+ (unless (python-pytest--point-is-inside-def-treesit)
636
+ (error " The point is not inside a def. " ))
637
+ (save-restriction
638
+ (widen )
639
+ (let ((function
640
+ ; ; Move up to the outermost function
641
+ (catch 'return
642
+ (let ((current-node (treesit-node-at (point ) 'python ))
643
+ function-node)
644
+ (catch 'break
645
+ (while (setq current-node (treesit-node-parent current-node))
646
+ (when (equal (treesit-node-type current-node) " function_definition" )
647
+ (setq function-node current-node)
648
+ ; ; At this point, we know that we are on a
649
+ ; ; function. We need to move up to see if the
650
+ ; ; function is inside a function. If that's the
651
+ ; ; case, we move up. This way, we find the
652
+ ; ; outermost function. We need to do this because
653
+ ; ; pytest can't execute functions inside functions,
654
+ ; ; so we must get the function that is not inside
655
+ ; ; other function.
656
+ (while (setq current-node (treesit-node-parent current-node))
657
+ (when (equal (treesit-node-type current-node) " function_definition" )
658
+ (setq function-node current-node)))
659
+ (throw 'break nil ))))
660
+ (dolist (child (treesit-node-children function-node))
661
+ (when (equal (treesit-node-type child) " identifier" )
662
+ (throw 'return
663
+ (cons
664
+ ; ; Keep a reference to the node that is a
665
+ ; ; function_definition. We need this
666
+ ; ; reference because afterwards we need to
667
+ ; ; move up starting at the current node to
668
+ ; ; find the node id of the class (if there's
669
+ ; ; any) in which the function is defined.
670
+ function-node
671
+ (buffer-substring-no-properties
672
+ (treesit-node-start child)
673
+ (treesit-node-end child)))))))))
674
+ parents)
675
+ ; ; Move up through the parent nodes to see if the function is
676
+ ; ; defined inside a class and collect the classes to finally build
677
+ ; ; the node id of the current function. Remember that the node id
678
+ ; ; of a function that is defined within nested classes must have
679
+ ; ; the name of the nested classes.
680
+ (let ((current-node (car function)))
681
+ (while (setq current-node (treesit-node-parent current-node))
682
+ (when (equal (treesit-node-type current-node) " class_definition" )
683
+ (dolist (child (treesit-node-children current-node))
684
+ (when (equal (treesit-node-type child) " identifier" )
685
+ (push (buffer-substring-no-properties
686
+ (treesit-node-start child)
687
+ (treesit-node-end child))
688
+ parents))))))
689
+ (string-join `(,@parents ,(cdr function)) " ::" ))))
690
+
691
+ (defun python-pytest--node-id-class-at-point-treesit ()
692
+ " Return the node id of the class at point.
693
+
694
+ + If the class is not inside another class, its node id is the name
695
+ of the class.
696
+ + If the class is defined inside another class, the node id of the
697
+ class which is contained would be: TestGroupParent::TestGroupChild,
698
+ while the node id of the class which contains the other class would
699
+ be TestGroupParent."
700
+ (unless (python-pytest--point-is-inside-class-treesit)
701
+ (error " The point is not inside a class. " ))
702
+ (save-restriction
703
+ (widen )
704
+ (let ((class
705
+ ; ; Move up to the outermost function
706
+ (catch 'return
707
+ (let ((current-node (treesit-node-at (point ) 'python )))
708
+ (catch 'break
709
+ (while (setq current-node (treesit-node-parent current-node))
710
+ (when (equal (treesit-node-type current-node) " class_definition" )
711
+ (throw 'break nil ))))
712
+ (dolist (child (treesit-node-children current-node))
713
+ (when (equal (treesit-node-type child) " identifier" )
714
+ (throw 'return
715
+ (cons
716
+ ; ; Keep a reference to the node that is a
717
+ ; ; function_definition
718
+ current-node
719
+ (buffer-substring-no-properties
720
+ (treesit-node-start child)
721
+ (treesit-node-end child)))))))))
722
+ parents)
723
+ ; ; Move up through the parents to collect the list of classes in
724
+ ; ; which the class is contained. pytest supports running nested
725
+ ; ; classes, but it doesn't support runing nested functions.
726
+ (let ((current-node (car class)))
727
+ (while (setq current-node (treesit-node-parent current-node))
728
+ (when (equal (treesit-node-type current-node) " class_definition" )
729
+ (dolist (child (treesit-node-children current-node))
730
+ (when (equal (treesit-node-type child) " identifier" )
731
+ (push (buffer-substring-no-properties
732
+ (treesit-node-start child)
733
+ (treesit-node-end child))
734
+ parents))))))
735
+ (string-join `(,@parents ,(cdr class)) " ::" ))))
736
+ (defun python-pytest--node-id-def-or-class-at-point ()
549
737
" Detect the current function/class (if any)."
550
738
(let* ((name
551
739
(or (python-info-current-defun )
@@ -565,7 +753,7 @@ When present ON-REPLACEMENT is substituted, else OFF-REPLACEMENT is appended."
565
753
(if (s-lowercase? (substring name 0 1 ))
566
754
(car (s-split-up-to " \\ ." name 1 ))
567
755
name)))
568
- name))
756
+ (s-replace " . " " :: " name) ))
569
757
570
758
(defun python-pytest--make-test-name (func )
571
759
" Turn function name FUNC into a name (hopefully) matching its test name.
0 commit comments