@@ -295,6 +295,10 @@ def mywriter(tags, args):
295295
296296 config .trace .root .setprocessor ("pytest:config" , mywriter )
297297
298+ if reporter .isatty ():
299+ plugin = TerminalProgressPlugin (reporter )
300+ config .pluginmanager .register (plugin , "terminalprogress" )
301+
298302
299303def getreportopt (config : Config ) -> str :
300304 reportchars : str = config .option .reportchars
@@ -454,6 +458,14 @@ def showfspath(self, value: bool | None) -> None:
454458 def showlongtestinfo (self ) -> bool :
455459 return self .config .get_verbosity (Config .VERBOSITY_TEST_CASES ) > 0
456460
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+
457469 def hasopt (self , char : str ) -> bool :
458470 char = {"xfailed" : "x" , "skipped" : "s" }.get (char , char )
459471 return char in self .reportchars
@@ -508,6 +520,9 @@ def wrap_write(
508520 def write (self , content : str , * , flush : bool = False , ** markup : bool ) -> None :
509521 self ._tw .write (content , flush = flush , ** markup )
510522
523+ def write_raw (self , content : str , * , flush : bool = False ) -> None :
524+ self ._tw .write_raw (content , flush = flush )
525+
511526 def flush (self ) -> None :
512527 self ._tw .flush ()
513528
@@ -681,7 +696,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
681696 @property
682697 def _is_last_item (self ) -> bool :
683698 assert self ._session is not None
684- return len ( self ._progress_nodeids_reported ) == self ._session .testscollected
699+ return self .reported_progress == self ._session .testscollected
685700
686701 @hookimpl (wrapper = True )
687702 def pytest_runtestloop (self ) -> Generator [None , object , object ]:
@@ -691,7 +706,7 @@ def pytest_runtestloop(self) -> Generator[None, object, object]:
691706 if (
692707 self .config .get_verbosity (Config .VERBOSITY_TEST_CASES ) <= 0
693708 and self ._show_progress_info
694- and self ._progress_nodeids_reported
709+ and self .reported_progress
695710 ):
696711 self ._write_progress_information_filling_space ()
697712
@@ -702,7 +717,7 @@ def _get_progress_information_message(self) -> str:
702717 collected = self ._session .testscollected
703718 if self ._show_progress_info == "count" :
704719 if collected :
705- progress = len ( self ._progress_nodeids_reported )
720+ progress = self .reported_progress
706721 counter_format = f"{{:{ len (str (collected ))} d}}"
707722 format_string = f" [{ counter_format } /{{}}]"
708723 return format_string .format (progress , collected )
@@ -739,7 +754,7 @@ def _get_progress_information_message(self) -> str:
739754 )
740755 return ""
741756 if collected :
742- return f" [{ len ( self ._progress_nodeids_reported ) * 100 // collected :3d} %]"
757+ return f" [{ self .reported_progress * 100 // collected :3d} %]"
743758 return " [100%]"
744759
745760 def _write_progress_information_if_past_edge (self ) -> None :
@@ -1641,3 +1656,92 @@ def _get_raw_skip_reason(report: TestReport) -> str:
16411656 elif reason == "Skipped" :
16421657 reason = ""
16431658 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