@@ -295,6 +295,10 @@ def mywriter(tags, args):
295
295
296
296
config .trace .root .setprocessor ("pytest:config" , mywriter )
297
297
298
+ if reporter .isatty ():
299
+ plugin = TerminalProgressPlugin (reporter )
300
+ config .pluginmanager .register (plugin , "terminalprogress" )
301
+
298
302
299
303
def getreportopt (config : Config ) -> str :
300
304
reportchars : str = config .option .reportchars
@@ -454,6 +458,14 @@ def showfspath(self, value: bool | None) -> None:
454
458
def showlongtestinfo (self ) -> bool :
455
459
return self .config .get_verbosity (Config .VERBOSITY_TEST_CASES ) > 0
456
460
461
+ @property
462
+ def reported_progress (self ) -> int :
463
+ """The amount of items reported in the progress so far.
464
+
465
+ :meta private:
466
+ """
467
+ return len (self ._progress_nodeids_reported )
468
+
457
469
def hasopt (self , char : str ) -> bool :
458
470
char = {"xfailed" : "x" , "skipped" : "s" }.get (char , char )
459
471
return char in self .reportchars
@@ -508,6 +520,9 @@ def wrap_write(
508
520
def write (self , content : str , * , flush : bool = False , ** markup : bool ) -> None :
509
521
self ._tw .write (content , flush = flush , ** markup )
510
522
523
+ def write_raw (self , content : str , * , flush : bool = False ) -> None :
524
+ self ._tw .write_raw (content , flush = flush )
525
+
511
526
def flush (self ) -> None :
512
527
self ._tw .flush ()
513
528
@@ -681,7 +696,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
681
696
@property
682
697
def _is_last_item (self ) -> bool :
683
698
assert self ._session is not None
684
- return len ( self ._progress_nodeids_reported ) == self ._session .testscollected
699
+ return self .reported_progress == self ._session .testscollected
685
700
686
701
@hookimpl (wrapper = True )
687
702
def pytest_runtestloop (self ) -> Generator [None , object , object ]:
@@ -691,7 +706,7 @@ def pytest_runtestloop(self) -> Generator[None, object, object]:
691
706
if (
692
707
self .config .get_verbosity (Config .VERBOSITY_TEST_CASES ) <= 0
693
708
and self ._show_progress_info
694
- and self ._progress_nodeids_reported
709
+ and self .reported_progress
695
710
):
696
711
self ._write_progress_information_filling_space ()
697
712
@@ -702,7 +717,7 @@ def _get_progress_information_message(self) -> str:
702
717
collected = self ._session .testscollected
703
718
if self ._show_progress_info == "count" :
704
719
if collected :
705
- progress = len ( self ._progress_nodeids_reported )
720
+ progress = self .reported_progress
706
721
counter_format = f"{{:{ len (str (collected ))} d}}"
707
722
format_string = f" [{ counter_format } /{{}}]"
708
723
return format_string .format (progress , collected )
@@ -739,7 +754,7 @@ def _get_progress_information_message(self) -> str:
739
754
)
740
755
return ""
741
756
if collected :
742
- return f" [{ len ( self ._progress_nodeids_reported ) * 100 // collected :3d} %]"
757
+ return f" [{ self .reported_progress * 100 // collected :3d} %]"
743
758
return " [100%]"
744
759
745
760
def _write_progress_information_if_past_edge (self ) -> None :
@@ -1641,3 +1656,92 @@ def _get_raw_skip_reason(report: TestReport) -> str:
1641
1656
elif reason == "Skipped" :
1642
1657
reason = ""
1643
1658
return reason
1659
+
1660
+
1661
+ class TerminalProgressPlugin :
1662
+ """Terminal progress reporting plugin using OSC 9;4 ANSI sequences.
1663
+
1664
+ Emits OSC 9;4 sequences to indicate test progress to terminal
1665
+ tabs/windows/etc.
1666
+
1667
+ Not all terminal emulators support this feature.
1668
+
1669
+ Ref: https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
1670
+ """
1671
+
1672
+ def __init__ (self , tr : TerminalReporter ) -> None :
1673
+ self ._tr = tr
1674
+ self ._session : Session | None = None
1675
+ self ._has_failures = False
1676
+
1677
+ def _emit_progress (
1678
+ self ,
1679
+ state : Literal ["remove" , "normal" , "error" , "indeterminate" , "paused" ],
1680
+ progress : int | None = None ,
1681
+ ) -> None :
1682
+ """Emit OSC 9;4 sequence for indicating progress to the terminal.
1683
+
1684
+ :param state:
1685
+ Progress state to set.
1686
+ :param progress:
1687
+ Progress value 0-100. Required for "normal", optional for "error"
1688
+ and "paused", otherwise ignored.
1689
+ """
1690
+ assert progress is None or 0 <= progress <= 100
1691
+
1692
+ # OSC 9;4 sequence: ESC ] 9 ; 4 ; state ; progress ST
1693
+ # ST can be ESC \ or BEL. ESC \ seems better supported.
1694
+ match state :
1695
+ case "remove" :
1696
+ sequence = "\x1b ]9;4;0;\x1b \\ "
1697
+ case "normal" :
1698
+ assert progress is not None
1699
+ sequence = f"\x1b ]9;4;1;{ progress } \x1b \\ "
1700
+ case "error" :
1701
+ if progress is not None :
1702
+ sequence = f"\x1b ]9;4;2;{ progress } \x1b \\ "
1703
+ else :
1704
+ sequence = "\x1b ]9;4;2;\x1b \\ "
1705
+ case "indeterminate" :
1706
+ sequence = "\x1b ]9;4;3;\x1b \\ "
1707
+ case "paused" :
1708
+ if progress is not None :
1709
+ sequence = f"\x1b ]9;4;4;{ progress } \x1b \\ "
1710
+ else :
1711
+ sequence = "\x1b ]9;4;4;\x1b \\ "
1712
+
1713
+ self ._tr .write_raw (sequence , flush = True )
1714
+
1715
+ @hookimpl
1716
+ def pytest_sessionstart (self , session : Session ) -> None :
1717
+ self ._session = session
1718
+ # Show indeterminate progress during collection.
1719
+ self ._emit_progress ("indeterminate" )
1720
+
1721
+ @hookimpl
1722
+ def pytest_collection_finish (self ) -> None :
1723
+ assert self ._session is not None
1724
+ if self ._session .testscollected > 0 :
1725
+ # Switch from indeterminate to 0% progress.
1726
+ self ._emit_progress ("normal" , 0 )
1727
+
1728
+ @hookimpl
1729
+ def pytest_runtest_logreport (self , report : TestReport ) -> None :
1730
+ if report .failed :
1731
+ self ._has_failures = True
1732
+
1733
+ # Let's consider the "call" phase for progress.
1734
+ if report .when != "call" :
1735
+ return
1736
+
1737
+ # Calculate and emit progress.
1738
+ assert self ._session is not None
1739
+ collected = self ._session .testscollected
1740
+ if collected > 0 :
1741
+ reported = self ._tr .reported_progress
1742
+ progress = min (reported * 100 // collected , 100 )
1743
+ self ._emit_progress ("error" if self ._has_failures else "normal" , progress )
1744
+
1745
+ @hookimpl
1746
+ def pytest_sessionfinish (self ) -> None :
1747
+ self ._emit_progress ("remove" )
0 commit comments