diff --git a/pyproject.toml b/pyproject.toml index b2db06e..7d082b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "mypy>=1.7.0,<2", "pyinstaller==6.11.0", "ruff>=0.15.20", + "debugpy>=1.8.21", ] [build-system] diff --git a/src/pyallel/constants.py b/src/pyallel/constants.py index f0dde70..1812942 100644 --- a/src/pyallel/constants.py +++ b/src/pyallel/constants.py @@ -35,6 +35,9 @@ def lines() -> int: ICONS = ("/", "-", "\\", "|") +# The maximum time to wait between renders in seconds +MAX_WAIT_BETWEEN_RENDERS = 0.1 + # Unicode character bytes to render different symbols in the terminal TICK = "\u2714" X = "\u2718" diff --git a/src/pyallel/main.py b/src/pyallel/main.py index 0bbf4ae..8f41957 100644 --- a/src/pyallel/main.py +++ b/src/pyallel/main.py @@ -3,7 +3,6 @@ import importlib.metadata import logging import sys -import time import traceback from pyallel import constants @@ -64,12 +63,12 @@ def entry_point(*args: str) -> int: if exit_code == 1: logger.error("failed run with arguments:\n%s", parsed_args) - process_group = process_group_manager.get_cur_process_group_output() + process_group = process_group_manager.cur_process_group print(f"\n{colours.red_bold}ERROR: the following commands failed{colours.reset_colour}") - for process_output in process_group.processes: - process_poll = process_output.process.poll() + for process in process_group.processes: + process_poll = process.poll() if process_poll and process_poll > 0: - print(f" {colours.red_bold}{process_output.process.command}{colours.reset_colour}") + print(f" {colours.red_bold}{process.command}{colours.reset_colour}") else: logger.debug("finished run with arguments:\n%s", parsed_args) @@ -79,15 +78,16 @@ def entry_point(*args: str) -> int: def run(process_group_manager: ProcessGroupManager, printer: Printer) -> int: process_group_manager.run() while True: - process_group_manager.stream() - printer.print(process_group_manager) + output = process_group_manager.stream() + printer.print(output) poll = process_group_manager.poll() if poll is not None: - # If we still have new output to print after the process group manager has completed, + # If we still have new output to print after the process group has completed, # make sure to print it here before continuing - if process_group_manager.stream().has_output(): - printer.print(process_group_manager) + process_group_manager.wait_for_update(constants.MAX_WAIT_BETWEEN_RENDERS) + output = process_group_manager.stream() + printer.print(output, done=True) if poll > 0: return poll @@ -96,7 +96,7 @@ def run(process_group_manager: ProcessGroupManager, printer: Printer) -> int: if not process_group_manager.next(): return 0 - time.sleep(0.1) + process_group_manager.wait_for_update(constants.MAX_WAIT_BETWEEN_RENDERS) if __name__ == "__main__": diff --git a/src/pyallel/printer.py b/src/pyallel/printer.py index 6c834a7..2c66b8c 100644 --- a/src/pyallel/printer.py +++ b/src/pyallel/printer.py @@ -9,162 +9,111 @@ from pyallel.constants import HIDE_CURSOR, SHOW_CURSOR if TYPE_CHECKING: - from pyallel.process import Process, ProcessOutput + from pyallel.process import ProcessOutput from pyallel.process_group import ProcessGroupOutput - from pyallel.process_group_manager import ProcessGroupManager logger = logging.getLogger(__name__) class Printer(Protocol): - def print(self, process_group_manager: ProcessGroupManager) -> None: - """Print output obtained from the provided process group manager. - - Args: - process_group_manager: manager to obtain output from - """ + def print(self, output: ProcessGroupOutput, *, done: bool = False) -> None: ... class ConsolePrinter: - def __init__(self, colours: Colours | None = None, *, timer: bool = False) -> None: + def __init__(self, colours: Colours | None = None, *, include_timer: bool = False) -> None: self._colours = colours or Colours() - self._timer = timer + self._include_timer = include_timer self._prefix = f"{self._colours.dim_on}=>{self._colours.dim_off} " - self._icon = 0 - self._to_print: list[tuple[bool, str, str]] = [] - - def write( - self, - line: str, - *, - include_prefix: bool = False, - end: str = "\n", - flush: bool = False, - truncate: bool = False, - columns: int | None = None, - ) -> None: - truncate_num = 0 - prefix = self._prefix if include_prefix else "" - columns = columns or constants.columns() - if prefix: - truncate_num = 6 - if prefix and truncate: - columns = columns - truncate_num - if self.get_num_lines(line, columns) > 1: - line = self.truncate_line(line, columns) - self._output(f"{self._colours.reset_colour}{prefix}{line}", end=end, flush=flush) - def _output(self, s: str, *, end: str = "", flush: bool = False) -> None: - print(s, end=end, flush=flush) - def generate_process_output( - self, - output: ProcessOutput, - *, - tail_output: bool = False, - include_cmd: bool = True, - include_output: bool = True, - include_progress: bool = True, - include_timer: bool | None = None, - append_newlines: bool = False, - ) -> list[tuple[bool, str, str]]: - out: list[tuple[bool, str, str]] = [] - line_parts: tuple[bool, str, str] +class InteractiveConsolePrinter(ConsolePrinter): + def __init__(self, colours: Colours | None = None, *, timer: bool = False) -> None: + super().__init__(colours, include_timer=timer) + self._cur_output: ProcessGroupOutput | None = None + self._last_printed: list[tuple[bool, str, str]] = [] + self._buffer: list[str] = [] + self._last_progress_spinner_render = 0.0 + self._icon = 0 - if tail_output and output.process.lines == 0: - return out + def print(self, output: ProcessGroupOutput, *, done: bool = False) -> None: + if self._cur_output is None or self._cur_output.id != output.id: + self._cur_output = output + else: + self._cur_output.merge(output) - if include_cmd: - status = self.generate_process_output_status( - output, include_progress=include_progress, include_timer=include_timer - ) - line_parts = (False, status, "\n") - out.append(line_parts) - self._to_print.append(line_parts) - - if include_output: - lines = output.data.splitlines(keepends=True) - - if tail_output: - output_lines = output.process.lines - 1 - lines = [] if output_lines == 0 else lines[-output_lines:] - - for line in lines: - prefix = True - end = line[-1] - if append_newlines and end != "\n": - end = "\n" - else: - line = line[:-1] # noqa: PLW2901 - - try: - prev_line = self._to_print[-1] - except IndexError: - pass - else: - if prev_line[2] != "\n": - prefix = False - - line_parts = (prefix, line, end) - out.append(line_parts) - self._to_print.append(line_parts) + self.print_process_group_output(self._cur_output, interrupt_count=output.interrupt_count) - return out + if done: + self.clear_last_printed_lines() + self.reset() + self.print_process_group_output(self._cur_output, interrupt_count=output.interrupt_count, tail_output=False) + self.reset() - def generate_process_output_status( + def print_process_group_output( self, - output: ProcessOutput, + output: ProcessGroupOutput, *, - include_progress: bool = True, - include_timer: bool | None = None, - columns: int | None = None, - ) -> str: - include_timer = include_timer if include_timer is not None else self._timer - columns = columns or constants.columns() + interrupt_count: int = 0, + tail_output: bool = True, + ) -> None: + columns = constants.columns() + to_print = self.generate_process_group_output(output, interrupt_count=interrupt_count, tail_output=tail_output) - passed = None - icon = "" - poll = output.process.poll() - if include_progress: - icon = constants.ICONS[self._icon] - if poll is not None: - passed = poll == 0 + num_lines_to_print = len(to_print) + num_last_printed_lines = len(self._last_printed) - if passed is True: - colour = self._colours.green_bold - msg = "done" - icon = constants.TICK - elif passed is False: - colour = self._colours.red_bold - msg = "failed" - icon = constants.X + # If we don't have any last printed lines or we don't want to tail the output, + # we just print all the new lines + if not num_last_printed_lines or not tail_output: + for include_prefix, line, end in to_print: + self._write(line, include_prefix=include_prefix, end=end, truncate=False, columns=columns) else: - colour = self._colours.white_bold - msg = "running" - - if not icon: - msg += "..." + # Compare the number of last lines and new lines and only update what has changed. + # + # Move the cursor up the amount the lines that were last printed so we can start + # comparing the last printed lines with the new lines that were generated + self._output(f"\033[{num_last_printed_lines}A") + cursor_line = 0 + for cur_line, line_parts in enumerate(self._last_printed[:num_lines_to_print]): + # If the current line is not the same as it's newly generated version, we update the line + if line_parts[1] != to_print[cur_line][1]: + include_prefix, line, end = to_print[cur_line] + # Jump to the line that needs to be changed + lines_to_jump = cur_line - cursor_line + if lines_to_jump: + self._output(f"\033[{lines_to_jump}B\r") + # Clear the current line + self._output(f"{constants.CLEAR_LINE}\r") + # Write the new line, this will move the cursor to the next line automatically + self._write(line, include_prefix=include_prefix, end=end, truncate=tail_output, columns=columns) + # Need to set the cursor_line to be the current line + 1 as the above write + # will move the cursor to the next line + cursor_line = cur_line + 1 - timer = "" - if include_timer: - end = output.process.end - if not output.process.end: - end = time.perf_counter() - elapsed = end - output.process.start - timer = f"({self.format_time_taken(elapsed)})" - - command = output.process.command - out = f"{self._colours.white_bold}[{self._colours.reset_colour}{self._colours.blue_bold}{command}{self._colours.reset_colour}{self._colours.white_bold}]{self._colours.reset_colour}{colour} {msg} {icon}{self._colours.reset_colour}" - if self.get_num_lines(out, columns) > 1: - columns = columns - (len(msg) + len(timer) + 9) - command = self.truncate_line(command, columns) - out = f"{self._colours.white_bold}[{self._colours.reset_colour}{self._colours.blue_bold}{command}{self._colours.reset_colour}{self._colours.white_bold}]{self._colours.reset_colour}{colour} {msg} {icon}{self._colours.reset_colour}" + if num_lines_to_print > num_last_printed_lines: + # Jump to the start of the new lines that needs to be printed + lines_to_jump = num_last_printed_lines - cursor_line + if lines_to_jump: + self._output(f"\033[{lines_to_jump}B\r") - if timer: - out += f" {self._colours.dim_on}{timer}{self._colours.dim_off}" + # Just print the new lines as normal + for line_parts in to_print[num_last_printed_lines:]: + include_prefix, line, end = line_parts + self._write(line, include_prefix=include_prefix, end=end, truncate=tail_output, columns=columns) + elif num_last_printed_lines > num_lines_to_print: + # Make sure to clear the remaining last printed lines at the end of the screen so they don't get left behind + self._output("\033[0J") + else: + # Jump to the end of the output since the num of lines printed hasn't changed + lines_to_jump = num_lines_to_print - cursor_line + if lines_to_jump: + self._output(f"\033[{lines_to_jump}B\r") - return out + # Write out the whole frame in a single flush so the terminal repaints + # atomically instead of tearing across several small writes + self._flush_buffer() + self._last_printed = to_print def generate_process_group_output( self, @@ -175,12 +124,13 @@ def generate_process_group_output( ) -> list[tuple[bool, str, str]]: self.set_process_lines(output, interrupt_count) + to_print: list[tuple[bool, str, str]] = [] for out in output.processes: - self.generate_process_output(out, tail_output=tail_output, append_newlines=True) + to_print.extend(self.generate_process_output(out, tail_output=tail_output)) if interrupt_count == 1: - self._to_print.append((False, "", "\n")) - self._to_print.append( + to_print.append((False, "", "\n")) + to_print.append( ( False, f"{self._colours.yellow_bold}Interrupt!{self._colours.reset_colour}", @@ -188,8 +138,8 @@ def generate_process_group_output( ) ) elif interrupt_count == 2: # noqa: PLR2004 - self._to_print.append((False, "", "\n")) - self._to_print.append( + to_print.append((False, "", "\n")) + to_print.append( ( False, f"{self._colours.red_bold}Abort!{self._colours.reset_colour}", @@ -197,11 +147,78 @@ def generate_process_group_output( ) ) - self._icon += 1 - if self._icon == len(constants.ICONS): - self._icon = 0 + return to_print + + def generate_process_output( + self, output: ProcessOutput, *, tail_output: bool = False + ) -> list[tuple[bool, str, str]]: + out: list[tuple[bool, str, str]] = [] + + if tail_output and output.allocated_lines == 0: + return out + + out.append((False, self.generate_process_output_status(output), "\n")) + + lines = output.data.splitlines() + + if tail_output: + output_lines = output.allocated_lines - 1 + lines = [] if output_lines == 0 else lines[-output_lines:] + + for line in lines: + end = line[-1] if line else "" + if end != "\n": + end = "\n" + + out.append((True, line, end)) + + return out + + def generate_process_output_status(self, output: ProcessOutput, *, columns: int | None = None) -> str: + columns = columns or constants.columns() + passed = None + icon = "" + cur_time = time.perf_counter() + end = output.end + if not end: + end = cur_time + elapsed = end - output.start + if output.poll is not None: + passed = output.poll == 0 + if elapsed - self._last_progress_spinner_render >= constants.MAX_WAIT_BETWEEN_RENDERS: + self._icon = (self._icon + 1) % len(constants.ICONS) + self._last_progress_spinner_render = elapsed + icon = constants.ICONS[self._icon] - return self._to_print + if passed is True: + colour = self._colours.green_bold + msg = "done" + icon = constants.TICK + elif passed is False: + colour = self._colours.red_bold + msg = "failed" + icon = constants.X + else: + colour = self._colours.white_bold + msg = "running" + if not icon: + msg += "..." + + timer = "" + if self._include_timer: + timer = f"({format_time_taken(elapsed)})" + + command = output.command + status = f"{self._colours.white_bold}[{self._colours.reset_colour}{self._colours.blue_bold}{command}{self._colours.reset_colour}{self._colours.white_bold}]{self._colours.reset_colour}{colour} {msg} {icon}{self._colours.reset_colour}" + if get_num_lines(status, columns) > 1: + columns = columns - (len(msg) + len(timer) + 9) + command = truncate_line(command, columns) + status = f"{self._colours.white_bold}[{self._colours.reset_colour}{self._colours.blue_bold}{command}{self._colours.reset_colour}{self._colours.white_bold}]{self._colours.reset_colour}{colour} {msg} {icon}{self._colours.reset_colour}" + + if timer: + status += f" {self._colours.dim_on}{timer}{self._colours.dim_off}" + + return status def set_process_lines( # noqa: PLR0915 self, @@ -221,12 +238,12 @@ def set_process_lines( # noqa: PLR0915 used_lines = 0 for process_output in output.processes: # This process output doesn't have percentage_lines set, so skip it - if not process_output.process.percentage_lines: + if not process_output.allocated_percentage_lines: processes_with_dynamic_lines.append(process_output) continue - process_output.process.lines = int(lines * process_output.process.percentage_lines) - used_lines += process_output.process.lines + process_output.allocated_lines = int(lines * process_output.allocated_percentage_lines) + used_lines += process_output.allocated_lines # Remove the used lines from the total available lines lines -= used_lines @@ -244,18 +261,18 @@ def set_process_lines( # noqa: PLR0915 # the total available terminal lines logger.debug( "process [%s] lines = %d, allocated = %d", - process_output.process.command, + process_output.command, process_output.lines, allocated_process_lines, ) if process_output.lines < allocated_process_lines: logger.debug( "process [%s] lines less than allocated, reducing allocated lines to %s", - process_output.process.command, + process_output.command, process_output.lines, ) - process_output.process.lines = process_output.lines - lines -= process_output.process.lines + process_output.allocated_lines = process_output.lines + lines -= process_output.allocated_lines logger.debug("new available screen lines = %d", lines) recalculate_lines = True continue @@ -272,10 +289,8 @@ def set_process_lines( # noqa: PLR0915 # All remaining processes exceed the number of terminal lines we will allocate them, so allocate them # their terminal lines as normal and break out of the while loop for process_output in processes_with_excess_output: - logger.debug( - "allocating %d lines to process [%s]", allocated_process_lines, process_output.process.command - ) - process_output.process.lines = allocated_process_lines + logger.debug("allocating %d lines to process [%s]", allocated_process_lines, process_output.command) + process_output.allocated_lines = allocated_process_lines lines -= allocated_process_lines logger.debug("new available screen lines = %d", lines) @@ -286,66 +301,66 @@ def set_process_lines( # noqa: PLR0915 process_with_most_lines: ProcessOutput | None = None most_lines = 0 for process_output in output.processes: - if process_output.process.lines > most_lines: + if process_output.allocated_lines > most_lines: process_with_most_lines = process_output - most_lines = process_output.process.lines + most_lines = process_output.allocated_lines if not process_with_most_lines: logger.debug( "no process found with most output, allocating remaining lines to first process [%s]", - process_output.process.command, + process_output.command, ) - process = output.processes[0].process - process.lines += lines - logger.debug("process [%s] allocated lines = %d", process.command, process.lines) + p_output = output.processes[0] + p_output.allocated_lines += lines + logger.debug("process [%s] allocated lines = %d", p_output.command, p_output.allocated_lines) else: logger.debug( "found process [%s] with most output, allocating remaining lines", - process_output.process.command, + process_output.command, + ) + process_with_most_lines.allocated_lines += lines + logger.debug( + "process [%s] allocated lines = %d", + process_with_most_lines.command, + process_with_most_lines.allocated_lines, ) - process = process_with_most_lines.process - process.lines += lines - logger.debug("process [%s] allocated lines = %d", process.command, process.lines) break logger.debug("all screen lines have been allocated") - def get_num_lines(self, line: str, columns: int | None = None) -> int: - lines = 0 - columns = columns or constants.columns() - line = constants.ANSI_ESCAPE.sub("", line) - length = len(line) - line_lines = 1 - if length > columns: - line_lines = length // columns - remainder = length % columns - if remainder: - line_lines += 1 - lines += 1 * line_lines - return lines - - def truncate_line(self, line: str, columns: int | None = None) -> str: - columns = columns or constants.columns() - escaped_line = constants.ANSI_ESCAPE.sub("", line) - return "".join(escaped_line[:columns]) + "..." - - def format_time_taken(self, time_taken: float) -> str: - time_taken = round(time_taken, 1) - seconds = time_taken % (24 * 3600) - - return f"{seconds}s" - + def clear_last_printed_lines(self) -> None: + # Clear all the lines that were just printed + self._output(f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}" * len(self._last_printed)) + self._flush_buffer() -class InteractiveConsolePrinter(ConsolePrinter): - def __init__(self, colours: Colours | None = None, *, timer: bool = False) -> None: - super().__init__(colours, timer=timer) - self._last_printed: list[tuple[bool, str, str]] = [] - self._buffer: list[str] = [] + def reset(self) -> None: + self._last_printed.clear() def show_cursor(self) -> None: print(constants.SHOW_CURSOR, end="", flush=True) + def _write( + self, + line: str, + *, + include_prefix: bool = False, + end: str = "\n", + flush: bool = False, + truncate: bool = False, + columns: int | None = None, + ) -> None: + truncate_num = 0 + prefix = self._prefix if include_prefix else "" + columns = columns or constants.columns() + if prefix: + truncate_num = 6 + if prefix and truncate: + columns = columns - truncate_num + if get_num_lines(line, columns) > 1: + line = truncate_line(line, columns) + self._output(f"{self._colours.reset_colour}{prefix}{line}", end=end, flush=flush) + def _output(self, s: str, *, end: str = "", flush: bool = False) -> None: # Buffer everything for the current frame so it can be written to the # terminal in a single flush, rather than one write per line/escape @@ -369,137 +384,153 @@ def _flush_buffer(self) -> None: ) self._buffer.clear() - def print(self, process_group_manager: ProcessGroupManager) -> None: - output = process_group_manager.get_cur_process_group_output() - self.print_process_group_output(output, interrupt_count=process_group_manager.interrupt_count) - poll = process_group_manager.poll() - if poll is not None: - self.clear_last_printed_lines() - self.reset() - self.print_process_group_output( - output, interrupt_count=process_group_manager.interrupt_count, tail_output=False - ) - self.reset() +class NonInteractiveConsolePrinter(ConsolePrinter): + def __init__(self, colours: Colours | None = None, *, timer: bool = False) -> None: + super().__init__(colours, include_timer=timer) + self._cur_pg_output: ProcessGroupOutput | None = None + self._p_new = True + self._p_index = 0 + self._generated_lines: list[tuple[bool, str, str]] = [] + + def print(self, output: ProcessGroupOutput, *, done: bool = False) -> None: + if self._cur_pg_output is None or self._cur_pg_output.id != output.id: + self._p_new = True + self._p_index = 0 + self._cur_pg_output = output + else: + self._cur_pg_output.merge(output) - def print_process_group_output( - self, - output: ProcessGroupOutput, - *, - interrupt_count: int = 0, - tail_output: bool = True, - ) -> None: - columns = constants.columns() - self.generate_process_group_output(output, interrupt_count=interrupt_count, tail_output=tail_output) + try: + p_output = output.processes[self._p_index] + except IndexError: + return - num_lines_to_print = len(self._to_print) - num_last_printed_lines = len(self._last_printed) + if self._p_new: + self._p_new = False + p_output = self._cur_pg_output.processes[self._p_index] + header = self.generate_process_header(p_output.command) + self._write(header) - # If we don't have any last printed lines or we don't want to tail the output, - # we just print all the new lines - if not num_last_printed_lines or not tail_output: - for include_prefix, line, end in self._to_print: - self.write(line, include_prefix=include_prefix, end=end, truncate=tail_output, columns=columns) + self.print_process_output(p_output) + + if p_output.poll is not None: + self._p_new = True + self._p_index += 1 + header = self.generate_process_footer(p_output) + self._write(header) + + def print_process_output(self, output: ProcessOutput) -> None: + for include_prefix, line, end in self.generate_process_output(output): + self._write(line, include_prefix=include_prefix, end=end) + + # Force a flush otherwise lines that don't end in a newline character will not get printed as they are read + print(end="", flush=True) + + def generate_process_header(self, command: str) -> str: + status = ( + f"{self._colours.white_bold}" + f"[{self._colours.reset_colour}" + f"{self._colours.blue_bold}{command}{self._colours.reset_colour}" + f"{self._colours.white_bold}]{self._colours.reset_colour}" + f"{self._colours.white_bold} running...{self._colours.reset_colour}" + ) + out = (False, status, "\n") + self._generated_lines.append(out) + + return status + + def generate_process_footer(self, output: ProcessOutput) -> str: + icon = "" + passed = None + if output.poll is not None: + passed = output.poll == 0 + + if passed: + colour = self._colours.green_bold + msg = "done" + icon = constants.TICK else: - # Compare the number of last lines and new lines and only update what has changed. - # - # Move the cursor up the amount the lines that were last printed so we can start - # comparing the last printed lines with the new lines that were generated - self._output(f"\033[{num_last_printed_lines}A") - cursor_line = 0 - for cur_line, line_parts in enumerate(self._last_printed[:num_lines_to_print]): - # If the current line is not the same as it's newly generated version, we update the line - if line_parts[1] != self._to_print[cur_line][1]: - include_prefix, line, end = self._to_print[cur_line] - # Jump to the line that needs to be changed - lines_to_jump = cur_line - cursor_line - if lines_to_jump: - self._output(f"\033[{lines_to_jump}B\r") - # Clear the current line - self._output(f"{constants.CLEAR_LINE}\r") - # Write the new line, this will move the cursor to the next line automatically - self.write(line, include_prefix=include_prefix, end=end, truncate=tail_output, columns=columns) - # Need to set the cursor_line to be the current line + 1 as the above write - # will move the cursor to the next line - cursor_line = cur_line + 1 + colour = self._colours.red_bold + msg = "failed" + icon = constants.X - if num_lines_to_print > num_last_printed_lines: - # Jump to the start of the new lines that needs to be printed - lines_to_jump = num_last_printed_lines - cursor_line - if lines_to_jump: - self._output(f"\033[{lines_to_jump}B\r") + timer = "" + if self._include_timer: + cur_time = time.perf_counter() + end = output.end + if not output.end: + end = cur_time + elapsed = end - output.start + timer = f"({format_time_taken(elapsed)})" + + status = ( + f"{self._colours.white_bold}" + f"[{self._colours.reset_colour}" + f"{self._colours.blue_bold}{output.command}{self._colours.reset_colour}" + f"{self._colours.white_bold}]{self._colours.reset_colour}" + f"{colour} {msg} {icon}{self._colours.reset_colour}" + ) - # Just print the new lines as normal - for line_parts in self._to_print[num_last_printed_lines:]: - include_prefix, line, end = line_parts - self.write(line, include_prefix=include_prefix, end=end, truncate=tail_output, columns=columns) - elif num_last_printed_lines > num_lines_to_print: - # Make sure to clear the remaining last printed lines at the end of the screen so they don't get left behind - self._output("\033[0J") + if timer: + status += f" {self._colours.dim_on}{timer}{self._colours.dim_off}" + + out = (False, status, "\n") + self._generated_lines.append(out) + + return status + + def generate_process_output(self, output: ProcessOutput) -> list[tuple[bool, str, str]]: + out: list[tuple[bool, str, str]] = [] + lines = output.data.splitlines(keepends=True) + + for line in lines: + prefix = True + content = line[:-1] + end = line[-1] + + try: + prev_line = self._generated_lines[-1] + except IndexError: + pass else: - # Jump to the end of the output since the num of lines printed hasn't changed - lines_to_jump = num_lines_to_print - cursor_line - if lines_to_jump: - self._output(f"\033[{lines_to_jump}B\r") + if prev_line[2] != "\n": + prefix = False - # Write out the whole frame in a single flush so the terminal repaints - # atomically instead of tearing across several small writes - self._flush_buffer() + line_parts = (prefix, content, end) + out.append(line_parts) + self._generated_lines.append(line_parts) - self._last_printed = self._to_print.copy() - self._to_print.clear() + return out - def clear_last_printed_lines(self) -> None: - # Clear all the lines that were just printed - self._output(f"{constants.CLEAR_LINE}{constants.UP_LINE}{constants.CLEAR_LINE}" * len(self._last_printed)) - self._flush_buffer() + def _write(self, line: str, *, include_prefix: bool = False, end: str = "\n", flush: bool = False) -> None: + prefix = self._prefix if include_prefix else "" + print(f"{self._colours.reset_colour}{prefix}{line}", end=end, flush=flush) - def reset(self) -> None: - self._last_printed.clear() - self._to_print.clear() +def format_time_taken(time_taken: float) -> str: + time_taken = round(time_taken, 1) + seconds = time_taken % (24 * 3600) -class NonInteractiveConsolePrinter(ConsolePrinter): - def __init__(self, colours: Colours | None = None, *, timer: bool = False) -> None: - super().__init__(colours, timer=timer) - self._current_process: Process | None = None - - def print(self, process_group_manager: ProcessGroupManager) -> None: - outputs = process_group_manager.cur_output - for pg in outputs.process_group_outputs.values(): - for output in pg.processes: - if self._current_process is None: - self._current_process = output.process - process_output = process_group_manager.get_process(output.id) - self.print_process_output(process_output, include_progress=False, include_timer=False) - elif self._current_process is not output.process: - continue - else: - self.print_process_output(output, include_cmd=False) + return f"{seconds}s" - if output.process.poll() is not None: - self.print_process_output(output, include_output=False) - self._current_process = None - def print_process_output( - self, - output: ProcessOutput, - *, - tail_output: bool = False, - include_cmd: bool = True, - include_output: bool = True, - include_progress: bool = True, - include_timer: bool | None = None, - ) -> None: - for include_prefix, line, end in self.generate_process_output( - output, - tail_output=tail_output, - include_cmd=include_cmd, - include_output=include_output, - include_progress=include_progress, - include_timer=include_timer, - ): - self.write(line, include_prefix=include_prefix, end=end) +def get_num_lines(line: str, columns: int | None = None) -> int: + lines = 0 + columns = columns or constants.columns() + line = constants.ANSI_ESCAPE.sub("", line) + length = len(line) + line_lines = 1 + if length > columns: + line_lines = length // columns + remainder = length % columns + if remainder: + line_lines += 1 + lines += 1 * line_lines + return lines - # Force a flush otherwise lines that don't end in a newline character will not get printed as they are read - print(end="", flush=True) + +def truncate_line(line: str, columns: int | None = None) -> str: + columns = columns or constants.columns() + escaped_line = constants.ANSI_ESCAPE.sub("", line) + return "".join(escaped_line[:columns]) + "..." diff --git a/src/pyallel/process.py b/src/pyallel/process.py index f31c418..4aeae91 100644 --- a/src/pyallel/process.py +++ b/src/pyallel/process.py @@ -2,26 +2,48 @@ import signal import subprocess -import threading import time -import typing +from io import BufferedReader +from typing import Any -from pyallel.errors import InvalidLinesModifierError +from typing_extensions import TypeGuard -if typing.TYPE_CHECKING: - from io import BufferedReader +from pyallel.errors import InvalidLinesModifierError class ProcessOutput: - def __init__(self, id: int, process: Process, data: str = "") -> None: # noqa: A002 + def __init__( + self, + id: int, # noqa: A002 + data: str = "", + allocated_lines: int = 0, + allocated_percentage_lines: float = 0.0, + start: float = 0.0, + end: float = 0.0, + poll: int | None = None, + command: str = "", + ) -> None: self.id = id self.data = data self.lines = len(data.splitlines()) + 1 - self.process = process + self.allocated_lines = allocated_lines + self.allocated_percentage_lines = allocated_percentage_lines + self.start = start + self.end = end + self.poll = poll + self.command = command def merge(self, other: ProcessOutput) -> None: + if self.id != other.id: + raise ValueError(f"Cannot merge process outputs with different ids: {self.id=}, {other.id=}") + self.data += other.data self.lines += len(other.data.splitlines()) + self.allocated_lines = other.allocated_lines + self.allocated_percentage_lines = other.allocated_percentage_lines + self.start = other.start + self.end = other.end + self.poll = other.poll class Process: @@ -34,7 +56,8 @@ def __init__(self, id: int, command: str, percentage_lines: float = 0.0) -> None self.percentage_lines = percentage_lines self._process: subprocess.Popen[bytes] self._buffer: bytes = b"" - self._lock = threading.Lock() + self._stdout: BufferedReader + self._drained = False def run(self) -> None: self.start = time.perf_counter() @@ -45,19 +68,20 @@ def run(self) -> None: stderr=subprocess.STDOUT, shell=True, ) + if not _is_buffered_reader(self._process.stdout): + raise TypeError(f"Expected stdout to be a BufferedReader, got {self._process.stdout.__class__}") + self._stdout = self._process.stdout - def _read_stdout() -> None: - if self._process.stdout: - stdout = typing.cast("BufferedReader", self._process.stdout) - while True: - data = stdout.read1(65536) - if not data: - break - with self._lock: - self._buffer += data + def fileno(self) -> int: + return self._stdout.fileno() - read_thread = threading.Thread(target=_read_stdout, daemon=True) - read_thread.start() + def fetch_stdout(self) -> bool: + data = self._stdout.read1(65536) + if not data: + return False + + self._buffer += data + return True def poll(self) -> int | None: poll = self._process.poll() @@ -66,22 +90,18 @@ def poll(self) -> int | None: return poll def read(self) -> bytes: - with self._lock: - buffer = self._buffer - self._buffer = b"" - + buffer = self._buffer + self._buffer = b"" return buffer def return_code(self) -> int | None: return self._process.returncode def interrupt(self) -> None: - if hasattr(self, "_process"): - self._process.send_signal(signal.SIGINT) + self._process.send_signal(signal.SIGINT) def kill(self) -> None: - if hasattr(self, "_process"): - self._process.send_signal(signal.SIGKILL) + self._process.send_signal(signal.SIGKILL) def wait(self) -> int: return self._process.wait() @@ -90,7 +110,7 @@ def wait(self) -> int: def from_command(cls, id: int, command: str) -> Process: # noqa: A002 cmd = command.split(" :::: ", maxsplit=1) if len(cmd) == 1: - return cls(id, cmd[0]) + return cls(id, cmd[0].strip()) args, *parts = cmd @@ -112,4 +132,8 @@ def from_command(cls, id: int, command: str) -> Process: # noqa: A002 break - return cls(id, " ".join(parts), round(percentage_lines / 100, 2)) + return cls(id, " ".join(parts).strip(), round(percentage_lines / 100, 2)) + + +def _is_buffered_reader(stdout: Any) -> TypeGuard[BufferedReader]: + return isinstance(stdout, BufferedReader) diff --git a/src/pyallel/process_group.py b/src/pyallel/process_group.py index 1c8e081..4f68a2d 100644 --- a/src/pyallel/process_group.py +++ b/src/pyallel/process_group.py @@ -1,5 +1,6 @@ from __future__ import annotations +import selectors from typing import Sequence from pyallel.errors import ( @@ -9,11 +10,14 @@ class ProcessGroupOutput: - def __init__(self, id: int, processes: Sequence[ProcessOutput]) -> None: # noqa: A002 + def __init__(self, id: int, processes: Sequence[ProcessOutput], interrupt_count: int = 0) -> None: # noqa: A002 self.id = id self.processes = processes + self.interrupt_count = interrupt_count def merge(self, other: ProcessGroupOutput) -> None: + if self.id != other.id: + raise ValueError(f"Cannot merge process group outputs with different ids: {self.id=}, {other.id=}") for i, _ in enumerate(self.processes): self.processes[i].merge(other.processes[i]) @@ -22,42 +26,9 @@ class ProcessGroup: def __init__(self, id: int, processes: list[Process]) -> None: # noqa: A002 self.id = id self.processes = processes - self._exit_code: int = 0 - self._interrupt_count: int = 0 - - def run(self) -> None: - for process in self.processes: - process.run() - - def poll(self) -> int | None: - polls: list[int | None] = [process.poll() for process in self.processes] - - running = [p for p in polls if p is None] - failed = [p for p in polls if p is not None and p > 0] - - if running: - return None - if failed: - return 1 - return 0 - - def stream(self) -> ProcessGroupOutput: - return ProcessGroupOutput( - id=self.id, - processes=[ - ProcessOutput(id=process.id, process=process, data=process.read().decode()) - for process in self.processes - ], - ) - - def handle_signal(self, _signum: int) -> None: - for process in self.processes: - if self._interrupt_count == 0: - process.interrupt() - else: - process.kill() - - self._interrupt_count += 1 + self._exit_code = 0 + self._interrupt_count = 0 + self._selector = selectors.DefaultSelector() @classmethod def from_commands(cls, id: int, process_id: int, *commands: str) -> ProcessGroup: # noqa: A002 @@ -86,3 +57,56 @@ def from_commands(cls, id: int, process_id: int, *commands: str) -> ProcessGroup ) return cls(id=id, processes=processes) + + def run(self) -> None: + for process in self.processes: + process.run() + self._selector.register(process.fileno(), selectors.EVENT_READ, data=process) + + def poll(self) -> int | None: + polls: list[int | None] = [process.poll() for process in self.processes] + + running = [p for p in polls if p is None] + failed = [p for p in polls if p is not None and p > 0] + + if running: + return None + if failed: + return 1 + return 0 + + def stream(self) -> ProcessGroupOutput: + process_outputs: list[ProcessOutput] = [] + for process in self.processes: + poll = process.poll() + data = process.read().decode() + process_outputs.append( + ProcessOutput( + id=process.id, + data=data, + allocated_lines=process.lines, + allocated_percentage_lines=process.percentage_lines, + start=process.start, + end=process.end, + poll=poll, + command=process.command, + ) + ) + + return ProcessGroupOutput(id=self.id, processes=process_outputs, interrupt_count=self._interrupt_count) + + def wait_for_update(self, timeout: float) -> None: + # Block until either process output is ready to read or the timeout elapses + for key, _ in self._selector.select(timeout): + process: Process = key.data + if not process.fetch_stdout(): + self._selector.unregister(key.fileobj) + + def handle_signal(self, _signum: int) -> None: + for process in self.processes: + if self._interrupt_count == 0: + process.interrupt() + else: + process.kill() + + self._interrupt_count += 1 diff --git a/src/pyallel/process_group_manager.py b/src/pyallel/process_group_manager.py index e094708..7b84bae 100644 --- a/src/pyallel/process_group_manager.py +++ b/src/pyallel/process_group_manager.py @@ -4,115 +4,15 @@ from typing import Any from pyallel.errors import NoCommandsForProcessGroupError -from pyallel.process import ProcessOutput from pyallel.process_group import ProcessGroup, ProcessGroupOutput -class ProcessGroupManagerOutput: - def __init__( - self, - process_group_outputs: dict[int, ProcessGroupOutput] | None = None, - cur_process_group_id: int = 1, - ) -> None: - self.process_group_outputs = process_group_outputs or {} - self.cur_process_group_id = cur_process_group_id - - def merge(self, other: ProcessGroupManagerOutput) -> None: - self.cur_process_group_id = other.cur_process_group_id - for key, value in other.process_group_outputs.items(): - if key in self.process_group_outputs: - self.process_group_outputs[key].merge(value) - else: - self.process_group_outputs[key] = value - - def has_output(self) -> bool: - for pg in self.process_group_outputs.values(): - for process in pg.processes: - if process.data: - return True - - return False - - class ProcessGroupManager: def __init__(self, process_groups: list[ProcessGroup]) -> None: self._exit_code = 0 self._interrupt_count = 0 - self._cur_process_group: ProcessGroup | None = None self._process_groups = process_groups - self._all_output = ProcessGroupManagerOutput( - process_group_outputs={ - pg.id: ProcessGroupOutput( - id=pg.id, - processes=[ProcessOutput(id=p.id, process=p) for p in pg.processes], - ) - for pg in self._process_groups - } - ) - self.cur_output = ProcessGroupManagerOutput() - - @property - def interrupt_count(self) -> int: - return self._interrupt_count - - def run(self) -> None: - if self._process_groups: - self._cur_process_group = self._process_groups.pop(0) - self._cur_process_group.run() - else: - self._cur_process_group = None - - def next(self) -> bool: - return bool(self._cur_process_group or self._process_groups) - - def stream(self) -> ProcessGroupManagerOutput: - if self._cur_process_group is None: - return ProcessGroupManagerOutput() - - output = ProcessGroupManagerOutput( - cur_process_group_id=self._cur_process_group.id, - process_group_outputs={self._cur_process_group.id: self._cur_process_group.stream()}, - ) - - self._all_output.merge(output) - self.cur_output = output - - return output - - def get_cur_process_group_output(self) -> ProcessGroupOutput: - if self._cur_process_group: - return self._all_output.process_group_outputs[self._cur_process_group.id] - - raise KeyError("no current process group output") - - def get_process(self, process_id: int) -> ProcessOutput: - for pg in self._all_output.process_group_outputs.values(): - for process in pg.processes: - if process.id == process_id: - return process - - raise KeyError(f"process with id '{process_id}' not found") - - def poll(self) -> int | None: - if self._cur_process_group is None: - return 0 - - poll = self._cur_process_group.poll() - - if poll is not None and self._exit_code: - return self._exit_code - - if self._interrupt_count > 1: - return self._exit_code - - return poll - - def handle_signal(self, signum: int, _frame: Any) -> None: - if self._cur_process_group is not None: - self._cur_process_group.handle_signal(signum) - - self._exit_code = 128 + signum - self._interrupt_count += 1 + self._cur_process_group: ProcessGroup | None = None @classmethod def from_args(cls, *args: str) -> ProcessGroupManager: @@ -146,3 +46,45 @@ def from_args(cls, *args: str) -> ProcessGroupManager: signal.signal(signal.SIGTERM, process_group_manager.handle_signal) return process_group_manager + + def run(self) -> None: + if self._process_groups: + self._cur_process_group = self._process_groups.pop(0) + self._cur_process_group.run() + else: + self._cur_process_group = None + + def next(self) -> bool: + return bool(self._cur_process_group or self._process_groups) + + def poll(self) -> int | None: + poll = self.cur_process_group.poll() + + if poll is not None and self._exit_code: + return self._exit_code + + if self._interrupt_count > 1: + return self._exit_code + + return poll + + def stream(self) -> ProcessGroupOutput: + return self.cur_process_group.stream() + + def wait_for_update(self, timeout: float) -> None: + self.cur_process_group.wait_for_update(timeout) + + def handle_signal(self, signum: int, _frame: Any) -> None: + self.cur_process_group.handle_signal(signum) + self._exit_code = 128 + signum + self._interrupt_count += 1 + + @property + def interrupt_count(self) -> int: + return self._interrupt_count + + @property + def cur_process_group(self) -> ProcessGroup: + if self._cur_process_group is None: + raise ValueError("cur_process_group is not set, did you forget to call run()?") + return self._cur_process_group diff --git a/tests/conftest.py b/tests/conftest.py index e19b6a5..9552dcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ @pytest.fixture(autouse=True) -def mock_signal() -> Generator[MagicMock]: +def mock_signal() -> Generator[MagicMock, None, None]: # Make sure we mock the signal module so interrupts work normally when running # the test suite via pytest with patch.object(signal, "signal") as mock: diff --git a/tests/test_main.py b/tests/test_main.py index ab30b84..c056890 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -140,7 +140,7 @@ def test_run_single_command(self, capsys: pytest.CaptureFixture[str]) -> None: compare_output( actual=captured.out.splitlines(), expected=[ - "[echo hi] running... ", + "[echo hi] running...", f"{PREFIX}hi", "[echo hi] done ✔", ], @@ -153,7 +153,7 @@ def test_run_single_command_no_quotes(self, capsys: pytest.CaptureFixture[str]) compare_output( actual=captured.out.splitlines(), expected=[ - "[echo hi] running... ", + "[echo hi] running...", f"{PREFIX}hi", "[echo hi] done ✔", ], @@ -166,7 +166,7 @@ def test_run_single_command_failure(self, capsys: pytest.CaptureFixture[str]) -> compare_output( actual=captured.out.splitlines(), expected=[ - "[exit 1] running... ", + "[exit 1] running...", "[exit 1] failed ✘", "", "ERROR: the following commands failed", @@ -181,7 +181,7 @@ def test_run_single_command_with_env(self, capsys: pytest.CaptureFixture[str]) - compare_output( actual=captured.out.splitlines(), expected=[ - "[TEST_VAR=1 echo hi] running... ", + "[TEST_VAR=1 echo hi] running...", f"{PREFIX}hi", "[TEST_VAR=1 echo hi] done ✔", ], @@ -194,10 +194,10 @@ def test_run_multiple_commands(self, capsys: pytest.CaptureFixture[str]) -> None compare_output( actual=captured.out.splitlines(), expected=[ - "[sleep 0.1; echo first] running... ", + "[sleep 0.1; echo first] running...", f"{PREFIX}first", "[sleep 0.1; echo first] done ✔", - "[echo hi] running... ", + "[echo hi] running...", f"{PREFIX}hi", "[echo hi] done ✔", ], @@ -210,10 +210,10 @@ def test_run_multiple_commands_no_quotes(self, capsys: pytest.CaptureFixture[str compare_output( actual=captured.out.splitlines(), expected=[ - "[echo first] running... ", + "[echo first] running...", f"{PREFIX}first", "[echo first] done ✔", - "[echo hi] running... ", + "[echo hi] running...", f"{PREFIX}hi", "[echo hi] done ✔", ], @@ -226,9 +226,9 @@ def test_run_multiple_commands_single_failure(self, capsys: pytest.CaptureFixtur compare_output( actual=captured.out.splitlines(), expected=[ - "[exit 1] running... ", + "[exit 1] running...", "[exit 1] failed ✘", - "[echo hi] running... ", + "[echo hi] running...", f"{PREFIX}hi", "[echo hi] done ✔", "", @@ -247,9 +247,9 @@ def test_run_multiple_commands_multiple_failures( compare_output( actual=captured.out.splitlines(), expected=[ - "[exit 1] running... ", + "[exit 1] running...", "[exit 1] failed ✘", - "[exit 1] running... ", + "[exit 1] running...", "[exit 1] failed ✘", "", "ERROR: the following commands failed", @@ -265,10 +265,10 @@ def test_run_mulitiple_dependant_commands(self, capsys: pytest.CaptureFixture[st compare_output( actual=captured.out.splitlines(), expected=[ - "[echo first] running... ", + "[echo first] running...", f"{PREFIX}first", "[echo first] done ✔", - "[echo hi] running... ", + "[echo hi] running...", f"{PREFIX}hi", "[echo hi] done ✔", ], @@ -281,7 +281,7 @@ def test_run_mulitiple_dependant_commands_single_failure(self, capsys: pytest.Ca compare_output( actual=captured.out.splitlines(), expected=[ - "[exit 1] running... ", + "[exit 1] running...", "[exit 1] failed ✘", "", "ERROR: the following commands failed", @@ -297,7 +297,7 @@ def test_run_timer_mode(self, capsys: pytest.CaptureFixture[str]) -> None: re.search( "".join( [ - r"\[echo hi\] running... \n", + r"\[echo hi\] running...\n", f"{PREFIX}hi\n", r"\[echo hi\] done ✔ \(0\..*\)\n", ] @@ -311,21 +311,11 @@ def test_run_with_longer_first_command(self, capsys: pytest.CaptureFixture[str]) exit_code = main.entry_point("sleep 1", "::", "echo hi", "-n", "--colour", "no") captured = capsys.readouterr() assert exit_code == 0, prettify_error(captured.out) - assert ( - re.search( - "".join( - [ - r"\[sleep 1\] running... \n", - r"\[sleep 1\] done ✔ \(1\..*s\)\n", - r"\[echo hi\] running... \n", - f"{PREFIX}hi\n", - r"\[echo hi\] done ✔ \(0\..*s\)\n", - ] - ), - captured.out, - ) - is not None - ), prettify_error(captured.out) + assert re.search(r"\[sleep 1\] running...\n", captured.out) is not None + assert re.search(r"\[sleep 1\] done ✔ \(1\..*s\)\n", captured.out) is not None + assert re.search(r"\[echo hi\] running...\n", captured.out) is not None + assert re.search(f"{PREFIX}hi\n", captured.out) is not None + assert re.search(r"\[echo hi\] done ✔ \(0\..*s\)\n", captured.out) is not None, prettify_error(captured.out) @pytest.mark.parametrize("wait", ["0.1", "0.5"]) def test_handles_single_command_output_with_delayed_newlines( @@ -337,7 +327,7 @@ def test_handles_single_command_output_with_delayed_newlines( compare_output( actual=captured.out.splitlines(), expected=[ - f"[printf hi; sleep {wait}; echo bye] running... ", + f"[printf hi; sleep {wait}; echo bye] running...", f"{PREFIX}hibye", f"[printf hi; sleep {wait}; echo bye] done ✔", ], @@ -361,10 +351,10 @@ def test_handles_multiple_command_output_with_delayed_newlines( compare_output( actual=captured.out.splitlines(), expected=[ - f"[printf hi; sleep {wait}; echo bye] running... ", + f"[printf hi; sleep {wait}; echo bye] running...", f"{PREFIX}hibye", f"[printf hi; sleep {wait}; echo bye] done ✔", - f"[printf hi; sleep {wait}; echo bye] running... ", + f"[printf hi; sleep {wait}; echo bye] running...", f"{PREFIX}hibye", f"[printf hi; sleep {wait}; echo bye] done ✔", ], diff --git a/tests/test_printer.py b/tests/test_printer.py index 05e0658..82d097c 100644 --- a/tests/test_printer.py +++ b/tests/test_printer.py @@ -1,300 +1,170 @@ from __future__ import annotations -from typing import Any - import pytest from pyallel.colours import Colours -from pyallel.printer import ConsolePrinter -from pyallel.process import Process, ProcessOutput +from pyallel.printer import InteractiveConsolePrinter, NonInteractiveConsolePrinter +from pyallel.process import ProcessOutput from pyallel.process_group import ProcessGroupOutput -@pytest.mark.parametrize( - ("output", "columns", "expected"), - [ - pytest.param("Hello Mr Anderson", 20, 1, id="output fits within 20 columns"), - pytest.param( - "Hello Mr Anderson\nIt is inevitable", - 20, - 2, - id="output wraps over 2 lines with 20 columns", - ), - pytest.param( - "Hello Mr Anderson\nIt is inevitable\nHAHAHAHAH", - 20, - 3, - id="output wraps over 3 lines with 20 columns", - ), - ], -) -def test_get_num_lines(output: str, columns: int, expected: int) -> None: - assert ConsolePrinter().get_num_lines(output, columns) == expected +class TestInteractiveConsolePrinter: + def test_generate_process_group_output(self) -> None: + printer = InteractiveConsolePrinter(colours=Colours.from_colour("no")) + output = printer.generate_process_group_output( + ProcessGroupOutput( + id=1, + processes=[ + ProcessOutput(id=1, command="echo first; echo second", poll=0, data="first\nsecond\n"), + ProcessOutput(id=2, command="echo third; echo fourth", poll=0, data="third\nfourth\n"), + ], + ), + ) -@pytest.mark.parametrize(("columns", "lines"), [(8, 3), (5, 4)]) -def test_get_num_lines_with_columns(columns: int, lines: int) -> None: - assert ConsolePrinter().get_num_lines("Hello Mr Anderson", columns=columns) == lines + assert output == [ + (False, "[echo first; echo second] done ✔", "\n"), + (True, "first", "\n"), + (True, "second", "\n"), + (False, "[echo third; echo fourth] done ✔", "\n"), + (True, "third", "\n"), + (True, "fourth", "\n"), + ] + def test_generate_process_output(self) -> None: + printer = InteractiveConsolePrinter(colours=Colours.from_colour("no")) -def test_get_num_lines_with_long_command() -> None: - # First line is a 800 length string, which divides evenly into `200` - line = "long" * 200 - assert ConsolePrinter().get_num_lines(f"{line}\nLong output", columns=200) == 5 + output = printer.generate_process_output( + ProcessOutput(id=1, command="echo first; echo second", poll=0, data="first\nsecond\n"), + ) + assert output == [ + (False, "[echo first; echo second] done ✔", "\n"), + (True, "first", "\n"), + (True, "second", "\n"), + ] -def test_get_num_lines_with_long_line() -> None: - assert ConsolePrinter().get_num_lines(" " * 250, columns=200) == 2 + def test_generate_process_output_status(self) -> None: + printer = InteractiveConsolePrinter(colours=Colours.from_colour("no")) + output = printer.generate_process_output_status( + ProcessOutput(id=1, command="echo first; echo second", poll=0, data="first\nsecond\n"), + ) -@pytest.mark.parametrize("chars", ["\x1b[0m", "\x1b(B"]) -def test_get_num_lines_ignores_ansi_chars(chars: str) -> None: - assert ConsolePrinter().get_num_lines(chars * 100, columns=10) == 1 + assert output == "[echo first; echo second] done ✔" + def test_printer_generate_process_output_status_handles_long_command(self) -> None: + printer = InteractiveConsolePrinter(colours=Colours.from_colour("no")) -def test_set_process_lines() -> None: - output = ProcessGroupOutput( - id=1, - processes=[ - ProcessOutput( - id=1, - process=Process(1, "echo first; echo second"), - data="first\nsecond\n", - ) - ], - ) - - ConsolePrinter().set_process_lines(output, lines=58) + output = printer.generate_process_output_status( + ProcessOutput(id=1, command="echo first; echo second", poll=0, data="first\nsecond\n"), columns=5 + ) - assert output.processes[0].process.lines == 58 + assert output == "[echo first; ech...] done ✔" + def test_set_process_lines(self) -> None: + output = ProcessGroupOutput(id=1, processes=[ProcessOutput(id=1, data="first\nsecond\n")]) + assert output.processes[0].allocated_lines == 0 -def test_set_process_lines_shares_lines_across_processes() -> None: - output = ProcessGroupOutput( - id=1, - processes=[ - ProcessOutput( - id=1, - process=Process(1, "echo first; echo second"), - data="first\nsecond\n", - ), - ProcessOutput( - id=2, - process=Process(2, "echo first; echo second"), - data="first\nsecond\n", - ), - ProcessOutput( - id=3, - process=Process(3, "echo first; echo second"), - data="first\nsecond\n", - ), - ], - ) + InteractiveConsolePrinter().set_process_lines(output, lines=58) - ConsolePrinter().set_process_lines(output, lines=59) + assert output.processes[0].allocated_lines == 58 - assert output.processes[0].process.lines == 53 - assert output.processes[1].process.lines == 3 - assert output.processes[2].process.lines == 3 + def test_set_process_lines_shares_lines_across_processes(self) -> None: + output = ProcessGroupOutput( + id=1, + processes=[ + ProcessOutput(id=1, data="first\nsecond\n"), + ProcessOutput(id=2, data="first\nsecond\n"), + ProcessOutput(id=3, data="first\nsecond\n"), + ], + ) + InteractiveConsolePrinter().set_process_lines(output, lines=59) -def test_set_process_lines_shares_lines_across_many_more_processes() -> None: - output = ProcessGroupOutput( - id=1, - processes=[ - ProcessOutput( - id=i, - process=Process(i, "echo first; echo second"), - data="first\nsecond\n", - ) - for i in range(1, 60) - ], - ) + assert output.processes[0].allocated_lines == 53 + assert output.processes[1].allocated_lines == 3 + assert output.processes[2].allocated_lines == 3 - ConsolePrinter().set_process_lines(output, lines=59) - - for i in range(59): - assert output.processes[i].process.lines == 1, f"process index {i}" - - -@pytest.mark.parametrize( - ("lines", "lines1", "lines2", "lines3", "expected_lines1", "expected_lines2", "expected_lines3"), - [ - pytest.param( - 59, - 0.4, - 0.2, - 0.2, - 37, - 11, - 11, - id="59 lines shared between 3 processes with the remainder going to the process with the most output", - ), - pytest.param(59, 1.0, 0.0, 0.0, 59, 0, 0, id="All lines given to first process"), - pytest.param(59, 0.5, 0.0, 0.0, 53, 3, 3, id="29 lines given to first process"), - ], -) -def test_set_process_lines_with_fixed_and_dynamic_lines( - lines: int, - lines1: float, - lines2: float, - lines3: float, - expected_lines1: int, - expected_lines2: int, - expected_lines3: int, -) -> None: - output = ProcessGroupOutput( - id=1, - processes=[ - ProcessOutput( - id=1, - process=Process(1, "echo first; echo second", lines1), - data="first\nsecond\n", - ), - ProcessOutput( - id=2, - process=Process(2, "echo first; echo second", lines2), - data="first\nsecond\n", - ), - ProcessOutput( - id=3, - process=Process(3, "echo first; echo second", lines3), - data="first\nsecond\n", + def test_set_process_lines_shares_lines_across_many_more_processes(self) -> None: + output = ProcessGroupOutput( + id=1, + processes=[ProcessOutput(id=i, data="first\nsecond\n") for i in range(1, 60)], + ) + + InteractiveConsolePrinter().set_process_lines(output, lines=59) + + for i in range(59): + assert output.processes[i].allocated_lines == 1, f"process index {i}" + + @pytest.mark.parametrize( + ("lines", "lines1", "lines2", "lines3", "expected_lines1", "expected_lines2", "expected_lines3"), + [ + pytest.param( + 59, + 0.4, + 0.2, + 0.2, + 37, + 11, + 11, + id="59 lines shared between 3 processes with the remainder going to the process with the most output", ), + pytest.param(59, 1.0, 0.0, 0.0, 59, 0, 0, id="All lines given to first process"), + pytest.param(59, 0.5, 0.0, 0.0, 53, 3, 3, id="29 lines given to first process"), ], ) + def test_set_process_lines_with_fixed_and_dynamic_lines( + self, + lines: int, + lines1: float, + lines2: float, + lines3: float, + expected_lines1: int, + expected_lines2: int, + expected_lines3: int, + ) -> None: + output = ProcessGroupOutput( + id=1, + processes=[ + ProcessOutput(id=1, data="first\nsecond\n", allocated_percentage_lines=lines1), + ProcessOutput(id=2, data="first\nsecond\n", allocated_percentage_lines=lines2), + ProcessOutput(id=3, data="first\nsecond\n", allocated_percentage_lines=lines3), + ], + ) - ConsolePrinter().set_process_lines(output, lines=lines) - - assert output.processes[0].process.lines == expected_lines1 - assert output.processes[1].process.lines == expected_lines2 - assert output.processes[2].process.lines == expected_lines3 - + InteractiveConsolePrinter().set_process_lines(output, lines=lines) -@pytest.mark.parametrize( - ("kwargs", "lines", "expected"), - [ - pytest.param( - {}, - 0, - [ - (False, "[echo first; echo second] done ✔", "\n"), - (True, "first", "\n"), - (True, "second", "\n"), - ], - id="no flags and no lines yields all output", - ), - pytest.param( - {"tail_output": True}, - 0, - [], - id="tail output with no lines", - ), - pytest.param( - {"tail_output": True}, - 1, - [(False, "[echo first; echo second] done ✔", "\n")], - id="tail output with 1 line yields only command status line", - ), - pytest.param( - {"tail_output": True}, - 3, - [ - (False, "[echo first; echo second] done ✔", "\n"), - (True, "first", "\n"), - (True, "second", "\n"), - ], - id="tail output with 3 lines yields command status line plus 2 output lines", - ), - ], -) -def test_printer_generate_process_output( - kwargs: dict[str, Any], lines: int, expected: list[tuple[bool, str, str]] -) -> None: - printer = ConsolePrinter(colours=Colours.from_colour("no")) - process = Process(1, "echo first; echo second") - process.lines = lines - process.run() - process.wait() - - output = printer.generate_process_output( - ProcessOutput(id=1, process=process, data="first\nsecond\n"), - **kwargs, - ) + assert output.processes[0].allocated_lines == expected_lines1 + assert output.processes[1].allocated_lines == expected_lines2 + assert output.processes[2].allocated_lines == expected_lines3 - assert output == expected - - -@pytest.mark.parametrize( - ("kwargs", "expected"), - [ - pytest.param( - {}, - "[echo first; echo second] done ✔", - id="no flags yields done command status", - ), - pytest.param( - {"include_progress": False}, - "[echo first; echo second] running... ", - id="don't include progress yields running status", - ), - pytest.param( - {"include_timer": True}, - "[echo first; echo second] done ✔ (0.0s)", - id="include timer yields timer", - ), - ], -) -def test_printer_generate_process_output_status(kwargs: dict[str, Any], expected: str) -> None: - printer = ConsolePrinter(colours=Colours.from_colour("no")) - process = Process(1, "echo first; echo second") - process.run() - process.wait() - - output = printer.generate_process_output_status( - ProcessOutput(id=1, process=process, data="first\nsecond\n"), **kwargs - ) - assert output == expected +class TestNonInteractiveConsolePrinter: + def test_generate_process_header(self) -> None: + printer = NonInteractiveConsolePrinter(colours=Colours.from_colour("no")) + output = printer.generate_process_header(command="echo first; echo second") -def test_printer_generate_process_output_status_handles_long_command() -> None: - printer = ConsolePrinter(colours=Colours.from_colour("no")) - process = Process(1, "echo first; echo second") - process.run() - process.wait() + assert output == "[echo first; echo second] running..." - output = printer.generate_process_output_status( - ProcessOutput(id=1, process=process, data="first\nsecond\n"), columns=5 - ) + def test_generate_process_footer(self) -> None: + printer = NonInteractiveConsolePrinter(colours=Colours.from_colour("no")) - assert output == "[echo first; ech...] done ✔" + output = printer.generate_process_footer( + ProcessOutput(id=1, command="echo first; echo second", poll=0, data="first\nsecond\n"), + ) + assert output == "[echo first; echo second] done ✔" -def test_printer_generate_process_group_output() -> None: - printer = ConsolePrinter(colours=Colours.from_colour("no")) - process1 = Process(1, "echo first; echo second") - process2 = Process(1, "echo third; echo fourth") - process1.run() - process2.run() - process1.wait() - process2.wait() + def test_generate_process_output(self) -> None: + printer = NonInteractiveConsolePrinter(colours=Colours.from_colour("no")) - output = printer.generate_process_group_output( - ProcessGroupOutput( - id=1, - processes=[ - ProcessOutput(id=1, process=process1, data="first\nsecond\n"), - ProcessOutput(id=2, process=process2, data="third\nfourth\n"), - ], - ), - ) + output = printer.generate_process_output( + ProcessOutput(id=1, command="echo first; echo second", poll=0, data="first\nsecond\n"), + ) - assert output == [ - (False, "[echo first; echo second] done ✔", "\n"), - (True, "first", "\n"), - (True, "second", "\n"), - (False, "[echo third; echo fourth] done ✔", "\n"), - (True, "third", "\n"), - (True, "fourth", "\n"), - ] + assert output == [ + (True, "first", "\n"), + (True, "second", "\n"), + ] diff --git a/tests/test_process.py b/tests/test_process.py index fb438a4..7725bf4 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -5,6 +5,7 @@ import pytest +from pyallel import process from pyallel.errors import InvalidLinesModifierError from pyallel.process import Process @@ -59,10 +60,25 @@ def test_from_command_with_lines_modifier_handles_multiple_separators() -> None: assert process.percentage_lines == 0.5 +@patch.object(process, "_is_buffered_reader", return_value=False) @patch.object(subprocess, "Popen") -def test_read(popen_mock: MagicMock) -> None: +def test_run_not_buffered_reader(popen_mock: MagicMock, is_buffered_reader_mock: MagicMock) -> None: + process = Process(1, "echo first; echo second") + with pytest.raises( + TypeError, match=r"Expected stdout to be a BufferedReader, got " + ): + process.run() + popen_mock.assert_called_once() + is_buffered_reader_mock.assert_called_once() + + +@patch.object(process, "_is_buffered_reader", return_value=True) +@patch.object(subprocess, "Popen") +def test_read(popen_mock: MagicMock, is_buffered_reader_mock: MagicMock) -> None: popen_mock.return_value.stdout.read1.side_effect = [b"first\nsecond\n", b""] process = Process(1, "echo first; echo second") process.run() + process.fetch_stdout() output = process.read() assert output == b"first\nsecond\n" + is_buffered_reader_mock.assert_called_once() diff --git a/tests/test_process_group.py b/tests/test_process_group.py index 762d5a7..b449d61 100644 --- a/tests/test_process_group.py +++ b/tests/test_process_group.py @@ -1,10 +1,12 @@ from __future__ import annotations +import os import subprocess from unittest.mock import MagicMock, patch import pytest +from pyallel import process from pyallel.errors import InvalidLinesModifierError from pyallel.process import Process, ProcessOutput from pyallel.process_group import ProcessGroup, ProcessGroupOutput @@ -65,9 +67,11 @@ def test_from_commands_with_lines_modifier_exceeds_100() -> None: ) +@patch.object(process, "_is_buffered_reader", return_value=True) @patch.object(subprocess, "Popen") -def test_stream(popen_mock: MagicMock) -> None: +def test_stream(popen_mock: MagicMock, is_buffered_reader_mock: MagicMock) -> None: popen_mock.return_value.stdout.read1.return_value = b"" + popen_mock.return_value.stdout.fileno.side_effect = lambda: os.pipe()[0] process_group = ProcessGroup( id=1, processes=[ @@ -79,19 +83,16 @@ def test_stream(popen_mock: MagicMock) -> None: process_group.run() output = process_group.stream() assert len(output.processes) == 3 + is_buffered_reader_mock.assert_called() def test_output_merge() -> None: output = ProcessGroupOutput( id=1, processes=[ - ProcessOutput( - id=1, - process=Process(id=1, command="echo first; echo hi"), - data="first\nhi\n", - ), - ProcessOutput(id=1, process=Process(id=2, command="echo second"), data="second\n"), - ProcessOutput(id=3, process=Process(id=3, command="echo third"), data="third\n"), + ProcessOutput(id=1, data="first\nhi\n"), + ProcessOutput(id=1, data="second\n"), + ProcessOutput(id=3, data="third\n"), ], ) @@ -99,13 +100,9 @@ def test_output_merge() -> None: ProcessGroupOutput( id=1, processes=[ - ProcessOutput( - id=1, - process=Process(id=1, command="echo first; echo hi"), - data="bye\n", - ), - ProcessOutput(id=1, process=Process(id=2, command="echo second"), data="hi\n"), - ProcessOutput(id=3, process=Process(id=3, command="echo third"), data="five\n"), + ProcessOutput(id=1, data="bye\n"), + ProcessOutput(id=1, data="hi\n"), + ProcessOutput(id=3, data="five\n"), ], ) ) diff --git a/tests/test_process_manager.py b/tests/test_process_group_manager.py similarity index 87% rename from tests/test_process_manager.py rename to tests/test_process_group_manager.py index 47652cc..94f1825 100644 --- a/tests/test_process_manager.py +++ b/tests/test_process_group_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import signal import subprocess import time @@ -7,15 +8,18 @@ import pytest +from pyallel import process from pyallel.errors import NoCommandsForProcessGroupError from pyallel.process import Process from pyallel.process_group import ProcessGroup from pyallel.process_group_manager import ProcessGroupManager +@patch.object(process, "_is_buffered_reader", return_value=True) @patch.object(subprocess, "Popen") -def test_stream(popen_mock: MagicMock) -> None: +def test_stream(popen_mock: MagicMock, is_buffered_reader_mock: MagicMock) -> None: popen_mock.return_value.stdout.read1.return_value = b"" + popen_mock.return_value.stdout.fileno.side_effect = lambda: os.pipe()[0] popen_mock.return_value.poll.return_value = 0 pg_manager = ProcessGroupManager( process_groups=[ @@ -36,19 +40,16 @@ def test_stream(popen_mock: MagicMock) -> None: ], ) pg_manager.run() - pg_manager.get_cur_process_group_output() output = pg_manager.stream() - assert len(output.process_group_outputs) == 1 - assert output.process_group_outputs[1].id == 1 - assert len(output.process_group_outputs[1].processes) == 2 + assert output.id == 1 + assert len(output.processes) == 2 assert pg_manager.poll() == 0 pg_manager.run() - pg_manager.get_cur_process_group_output() output = pg_manager.stream() - assert len(output.process_group_outputs) == 1 - assert output.process_group_outputs[2].id == 2 - assert len(output.process_group_outputs[2].processes) == 2 + assert output.id == 2 + assert len(output.processes) == 2 assert pg_manager.poll() == 0 + is_buffered_reader_mock.assert_called() def test_from_args(mock_signal: MagicMock) -> None: @@ -202,9 +203,11 @@ def test_from_args_with_bad_separator() -> None: ProcessGroupManager.from_args(":::", "echo hi") +@patch.object(process, "_is_buffered_reader", return_value=True) @patch.object(subprocess, "Popen") -def test_handle_signal(popen_mock: MagicMock) -> None: +def test_handle_signal(popen_mock: MagicMock, is_buffered_reader_mock: MagicMock) -> None: popen_mock.return_value.stdout.read1.side_effect = lambda _: time.sleep(1) + popen_mock.return_value.stdout.fileno.side_effect = lambda: os.pipe()[0] pg_manager = ProcessGroupManager.from_args("sleep 0.1", "::", "sleep 0.2") pg_manager.run() @@ -213,11 +216,14 @@ def test_handle_signal(popen_mock: MagicMock) -> None: send_signal_mock: MagicMock = popen_mock.return_value.send_signal assert send_signal_mock.call_count == 2 send_signal_mock.assert_has_calls([call(signal.SIGINT), call(signal.SIGINT)]) + is_buffered_reader_mock.assert_called() +@patch.object(process, "_is_buffered_reader", return_value=True) @patch.object(subprocess, "Popen") -def test_handle_signal_multiple(popen_mock: MagicMock) -> None: +def test_handle_signal_multiple(popen_mock: MagicMock, is_buffered_reader_mock: MagicMock) -> None: popen_mock.return_value.stdout.read1.side_effect = lambda _: time.sleep(1) + popen_mock.return_value.stdout.fileno.side_effect = lambda: os.pipe()[0] pg_manager = ProcessGroupManager.from_args("sleep 0.1", "::", "sleep 0.2") pg_manager.run() @@ -234,3 +240,4 @@ def test_handle_signal_multiple(popen_mock: MagicMock) -> None: call(signal.SIGKILL), ] ) + is_buffered_reader_mock.assert_called() diff --git a/uv.lock b/uv.lock index 1700343..d804446 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.9'", @@ -24,12 +24,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f3/6b1d4c71f4cbb5360009f928934a03b42906f28fc7b3f7f35f04e58acead/debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9", size = 2113873, upload-time = "2026-06-01T19:30:37.148Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f2/17c3bf91cebc173bfbf5734cd2669723d0a35c0cf9d2fd2124546efeae83/debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344", size = 3004715, upload-time = "2026-06-01T19:30:38.888Z" }, + { url = "https://files.pythonhosted.org/packages/5a/22/1f8efd80c7b5909e760f9cfd0c9e8681d2d35d532f7c0a40760cd4da4a19/debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73", size = 5303455, upload-time = "2026-06-01T19:30:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/da/ce/54c79abd6cccef92fa7b43d97e3acafedf4d645557267ece05e948b5e4b8/debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5", size = 5331751, upload-time = "2026-06-01T19:30:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/2179321843d2f15841c55e5dc748baf2036f17dde755aee27046a7f83703/debugpy-1.8.21-cp38-cp38-macosx_15_0_x86_64.whl", hash = "sha256:0042da0ecd0a8b50dc4a54395ecd870d258d73fa18776f50c91fdcabdcad2675", size = 2120396, upload-time = "2026-06-01T19:31:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/60/4d/81f781813448b461210d8f3a33098b0d9f724e4a7e32852dff30147ae6df/debugpy-1.8.21-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:ffd932c6796afadab6993ec96745918a8cb2444dbd392074f769db5ea40ab440", size = 3058314, upload-time = "2026-06-01T19:31:13.109Z" }, + { url = "https://files.pythonhosted.org/packages/2f/57/b2c0cb55527304d1985587cf58eba3d15d000075e781fad44103b6290d30/debugpy-1.8.21-cp38-cp38-win32.whl", hash = "sha256:4e7c2d784d78ad4b71a5f8cd7b59c167719ec8a7a0211dbb3eb1bfeda78bc4e2", size = 5308600, upload-time = "2026-06-01T19:31:14.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/97/f0fc3e9bdbefd1066e73608cccdd1641c6fe2979a603d9fafd0c806e5a6b/debugpy-1.8.21-cp38-cp38-win_amd64.whl", hash = "sha256:aa9d941d6dfe3d0407e4b3ca0b9ec466030e260fbf1174094f68785680f66db6", size = 5337562, upload-time = "2026-06-01T19:31:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/ad/84/8625c1ff37bb5509029962889e79603c450239b1cec63967795e0f1a1a76/debugpy-1.8.21-cp39-cp39-macosx_15_0_x86_64.whl", hash = "sha256:9f5171176a0084b95d2ebe55a4d1f7b2a75b74c5dbec577ebd3a85c740551c36", size = 2115083, upload-time = "2026-06-01T19:31:18.377Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4f/bc441d8b7d1cc239ffb5d1bfe294d9ce788110f9d108b110faf2d1dd2731/debugpy-1.8.21-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:f15c10084f9861b5e8414a48f18f8e4aadf51a98a59e72c16aa28281ca994672", size = 2999455, upload-time = "2026-06-01T19:31:20.007Z" }, + { url = "https://files.pythonhosted.org/packages/62/ed/e9f77c043d341db11c5cf009977c1706c2d0f82e626785e013691696681f/debugpy-1.8.21-cp39-cp39-win32.whl", hash = "sha256:4e70cc8b5079f885cb43910924ee0aab73b8b6b2a14eff23afdd9895d86e79eb", size = 5304139, upload-time = "2026-06-01T19:31:21.724Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b747736fdb98385b59ff2fc19670e712065d235507051595d429f584e095/debugpy-1.8.21-cp39-cp39-win_amd64.whl", hash = "sha256:e935f9dc0501be523c8a8e1853c39432e1354e9ece717ae5998fd2371c4542c3", size = 5332435, upload-time = "2026-06-01T19:31:23.394Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -44,7 +81,7 @@ resolution-markers = [ "python_full_version < '3.9'", ] dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ @@ -59,7 +96,7 @@ resolution-markers = [ "python_full_version >= '3.9'", ] dependencies = [ - { name = "zipp", version = "3.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "zipp", version = "3.23.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ @@ -180,6 +217,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "debugpy" }, { name = "mypy" }, { name = "pyinstaller" }, { name = "pytest" }, @@ -190,6 +228,7 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "debugpy", specifier = ">=1.8.21" }, { name = "mypy", specifier = ">=1.7.0,<2" }, { name = "pyinstaller", specifier = "==6.11.0" }, { name = "pytest", specifier = ">=7.4.3,<8" },