From de5929d078cd14dbdac12dcdf66d9ee5d4dd535a Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Tue, 11 Jun 2024 08:47:03 -0400 Subject: [PATCH] Add type annotations --- .github/workflows/test.yaml | 17 +++++- setup.cfg | 31 +++++----- src/duct.py | 111 +++++++++++++++++++---------------- test/data/cat_to_err.py | 4 +- test/data/test_script.py | 9 ++- test/test_execution.py | 17 +++--- test/test_helpers.py | 9 ++- test/test_prepare_outputs.py | 23 ++++---- test/test_report.py | 17 +++--- test/test_tailpipe.py | 25 ++++---- test/utils.py | 7 ++- 11 files changed, 152 insertions(+), 118 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index de99895a..661299cd 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -39,6 +39,14 @@ jobs: - 'pypy-3.8' - 'pypy-3.9' - 'pypy-3.10' + toxenv: [py] + include: + - python-version: '3.8' + toxenv: lint + os: ubuntu-latest + - python-version: '3.8' + toxenv: typing + os: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 @@ -53,13 +61,20 @@ jobs: python -m pip install --upgrade pip wheel python -m pip install --upgrade --upgrade-strategy=eager tox - - name: Run tests + - name: Run tests with coverage + if: matrix.toxenv == 'py' run: tox -e py -- -vv --cov-report=xml + - name: Run generic tests + if: matrix.toxenv != 'py' + run: tox -e ${{ matrix.toxenv }} + - name: Upload coverage to Codecov + if: matrix.toxenv == 'py' uses: codecov/codecov-action@v4 with: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} name: ${{ matrix.python-version }} + # vim:set et sts=2: diff --git a/setup.cfg b/setup.cfg index 68f5951a..2e68507d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,19 +56,18 @@ python_requires = >= 3.8 console_scripts = duct = duct:main -# TODO(asmacdo) -# [mypy] -# ignore_missing_imports = False -# disallow_untyped_defs = True -# disallow_incomplete_defs = True -# no_implicit_optional = True -# warn_redundant_casts = True -# warn_return_any = True -# warn_unreachable = True -# local_partial_types = True -# no_implicit_reexport = True -# strict_equality = True -# show_error_codes = True -# show_traceback = True -# pretty = True -# +[mypy] +allow_incomplete_defs = False +allow_untyped_defs = False +ignore_missing_imports = False +# : +no_implicit_optional = True +implicit_reexport = False +local_partial_types = True +pretty = True +show_error_codes = True +show_traceback = True +strict_equality = True +warn_redundant_casts = True +warn_return_any = True +warn_unreachable = True diff --git a/src/duct.py b/src/duct.py index 52563e94..c3eadfb8 100755 --- a/src/duct.py +++ b/src/duct.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse from collections import defaultdict +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime import json @@ -12,7 +13,7 @@ import sys import threading import time -from typing import Any, Dict, Optional, TextIO, Tuple, Union +from typing import IO, Any, TextIO __version__ = "0.0.1" ENV_PREFIXES = ("PBS_", "SLURM_", "OSG") @@ -33,50 +34,43 @@ class Colors: class Report: """Top level report""" - start_time: float - command: str - session_id: int - gpus: Optional[list] - number: int - system_info: Dict[str, Any] # Use more specific types if possible - def __init__( self, command: str, - arguments, - session_id: int, + arguments: list[str], + session_id: int | None, output_prefix: str, - process, - datetime_filesafe, + process: subprocess.Popen, + datetime_filesafe: str, ) -> None: self.start_time = time.time() self._command = command self.arguments = arguments self.session_id = session_id - self.gpus = [] - self.env = None + self.gpus: list | None = [] + self.env: dict[str, str] | None = None self.number = 0 - self.system_info = {} + self.system_info: dict[str, Any] = {} # Use more specific types if possible self.output_prefix = output_prefix - self.max_values = defaultdict(dict) + self.max_values: dict[str, dict[str, Any]] = defaultdict(dict) self.process = process - self._sample = defaultdict(dict) + self._sample: dict[str, dict[str, Any]] = defaultdict(dict) self.datetime_filesafe = datetime_filesafe + self.end_time: float | None = None + self.run_time_seconds: str | None = None @property - def command(self): + def command(self) -> str: return " ".join([self._command] + self.arguments) @property - def elapsed_time(self): + def elapsed_time(self) -> float: return time.time() - self.start_time - def collect_environment(self): - self.env = ( - {k: v for k, v in os.environ.items() if k.startswith(ENV_PREFIXES)}, - ) + def collect_environment(self) -> None: + self.env = {k: v for k, v in os.environ.items() if k.startswith(ENV_PREFIXES)} - def get_system_info(self): + def get_system_info(self) -> None: """Gathers system information related to CPU, GPU, memory, and environment variables.""" self.system_info["uid"] = os.environ.get("USER") self.system_info["memory_total"] = os.sysconf("SC_PAGE_SIZE") * os.sysconf( @@ -106,7 +100,9 @@ def get_system_info(self): except subprocess.CalledProcessError: self.gpus = ["Failed to query GPU info"] - def calculate_total_usage(self, sample): + def calculate_total_usage( + self, sample: dict[str, dict[str, Any]] + ) -> dict[str, dict[str, float]]: pmem = 0.0 pcpu = 0.0 for _pid, pinfo in sample.items(): @@ -116,7 +112,9 @@ def calculate_total_usage(self, sample): return totals @staticmethod - def update_max_resources(maxes, sample): + def update_max_resources( + maxes: dict[str, dict[str, Any]], sample: dict[str, Any] + ) -> None: for pid in sample: if pid in maxes: for key, value in sample[pid].items(): @@ -124,8 +122,9 @@ def update_max_resources(maxes, sample): else: maxes[pid] = sample[pid].copy() - def collect_sample(self): - process_data = {} + def collect_sample(self) -> dict[str, dict[str, int | float | str]]: + assert self.session_id is not None + process_data: dict[str, dict[str, int | float | str]] = {} try: output = subprocess.check_output( [ @@ -140,7 +139,6 @@ def collect_sample(self): for line in output.splitlines()[1:]: if line: pid, pcpu, pmem, rss, vsz, etime, cmd = line.split(maxsplit=6) - process_data[pid] = { # %CPU "pcpu": float(pcpu), @@ -156,16 +154,16 @@ def collect_sample(self): pass return process_data - def write_pid_samples(self): + def write_pid_samples(self) -> None: resource_stats_log_path = f"{self.output_prefix}usage.json" with open(resource_stats_log_path, "a") as resource_statistics_log: resource_statistics_log.write(json.dumps(self._sample) + "\n") - def print_max_values(self): + def print_max_values(self) -> None: for pid, maxes in self.max_values.items(): print(f"PID {pid} Maximum Values: {maxes}") - def finalize(self): + def finalize(self) -> None: if not self.process.returncode: print(Colors.OKGREEN) else: @@ -181,7 +179,7 @@ def finalize(self): f"CPU Peak Usage: {self.max_values.get('totals', {}).get('pcpu', 'unknown')}%" ) - def __repr__(self): + def __repr__(self) -> str: return json.dumps( { "command": self.command, @@ -288,7 +286,13 @@ def from_argv(cls) -> Arguments: ) -def monitor_process(report, process, report_interval, sample_interval, stop_event): +def monitor_process( + report: Report, + process: subprocess.Popen, + report_interval: float, + sample_interval: float, + stop_event: threading.Event, +) -> None: while not stop_event.wait(timeout=sample_interval): while True: if process.poll() is not None: # the passthrough command has finished @@ -310,35 +314,37 @@ class TailPipe: TAIL_CYCLE_TIME = 0.01 - def __init__(self, file_path, buffer): + def __init__(self, file_path: str, buffer: IO[bytes]) -> None: self.file_path = file_path self.buffer = buffer - self.stop_event = None - self.infile = None - self.thread = None + self.stop_event: threading.Event | None = None + self.infile: IO[bytes] | None = None + self.thread: threading.Thread | None = None - def start(self): + def start(self) -> None: Path(self.file_path).touch() self.stop_event = threading.Event() self.infile = open(self.file_path, "rb") self.thread = threading.Thread(target=self._tail, daemon=True) self.thread.start() - def fileno(self): + def fileno(self) -> int: + assert self.infile is not None return self.infile.fileno() - def _catch_up(self): + def _catch_up(self) -> None: + assert self.infile is not None data = self.infile.read() if data: self.buffer.write(data) self.buffer.flush() - def _tail(self): + def _tail(self) -> None: + assert self.stop_event is not None try: while not self.stop_event.is_set(): self._catch_up() time.sleep(TailPipe.TAIL_CYCLE_TIME) - # After stop event, collect and passthrough data one last time self._catch_up() except Exception: @@ -346,7 +352,10 @@ def _tail(self): finally: self.buffer.flush() - def close(self): + def close(self) -> None: + assert self.stop_event is not None + assert self.thread is not None + assert self.infile is not None self.stop_event.set() self.thread.join() self.infile.close() @@ -354,9 +363,9 @@ def close(self): def prepare_outputs( capture_outputs: str, outputs: str, output_prefix: str -) -> Tuple[Union[TextIO, TailPipe, int], Union[TextIO, TailPipe, int]]: - stdout: Union[TextIO, TailPipe, int] - stderr: Union[TextIO, TailPipe, int] +) -> tuple[TextIO | TailPipe | int | None, TextIO | TailPipe | int | None]: + stdout: TextIO | TailPipe | int | None + stderr: TextIO | TailPipe | int | None if capture_outputs in ["all", "stdout"] and outputs in ["all", "stdout"]: stdout = TailPipe(f"{output_prefix}stdout", buffer=sys.stdout.buffer) @@ -380,7 +389,7 @@ def prepare_outputs( return stdout, stderr -def safe_close_files(file_list): +def safe_close_files(file_list: Iterable[Any]) -> None: for f in file_list: try: f.close() @@ -398,12 +407,12 @@ def ensure_directories(path: str) -> None: os.makedirs(directory, exist_ok=True) -def main(): +def main() -> None: args = Arguments.from_argv() execute(args) -def execute(args): +def execute(args: Arguments) -> None: """A wrapper to execute a command, monitor and log the process details.""" datetime_filesafe = datetime.now().strftime("%Y.%m.%dT%H.%M.%S") duct_pid = os.getpid() @@ -414,10 +423,12 @@ def execute(args): stdout, stderr = prepare_outputs( args.capture_outputs, args.outputs, formatted_output_prefix ) + stdout_file: TextIO | IO[bytes] | int | None if isinstance(stdout, TailPipe): stdout_file = open(stdout.file_path, "wb") else: stdout_file = stdout + stderr_file: TextIO | IO[bytes] | int | None if isinstance(stderr, TailPipe): stderr_file = open(stderr.file_path, "wb") else: diff --git a/test/data/cat_to_err.py b/test/data/cat_to_err.py index 8967b07f..15899896 100755 --- a/test/data/cat_to_err.py +++ b/test/data/cat_to_err.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 +from __future__ import annotations import argparse import sys +from typing import IO -def cat_to_stream(path, buffer): +def cat_to_stream(path: str, buffer: IO[bytes]) -> None: with open(path, "rb") as infile: buffer.write(infile.read()) diff --git a/test/data/test_script.py b/test/data/test_script.py index a036f527..84929c75 100755 --- a/test/data/test_script.py +++ b/test/data/test_script.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 - +from __future__ import annotations import argparse import sys import time -def consume_cpu(duration, load): +def consume_cpu(duration: int, load: int) -> None: """Function to consume CPU proportional to 'load' for 'duration' seconds""" end_time = time.time() + duration while time.time() < end_time: @@ -13,14 +13,14 @@ def consume_cpu(duration, load): pass # Busy-wait -def consume_memory(size): +def consume_memory(size: int) -> bytearray: """Function to consume amount of memory specified by 'size' in megabytes""" # Create a list of size MB bytes_in_mb = 1024 * 1024 return bytearray(size * bytes_in_mb) -def main(duration, cpu_load, memory_size): +def main(duration: int, cpu_load: int, memory_size: int) -> None: print("this is of test of STDOUT") print("this is of test of STDERR", file=sys.stderr) _mem_hold = consume_memory(memory_size) # noqa @@ -46,6 +46,5 @@ def main(duration, cpu_load, memory_size): default=10, help="Amount of memory to allocate in MB.", ) - args = parser.parse_args() main(args.duration, args.cpu_load, args.memory_size) diff --git a/test/test_execution.py b/test/test_execution.py index d0357076..bbedceb7 100644 --- a/test/test_execution.py +++ b/test/test_execution.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os from pathlib import Path from unittest import mock @@ -15,7 +16,7 @@ def temp_output_dir(tmp_path: Path) -> str: return str(tmp_path) + os.sep -def test_sanity_green(temp_output_dir): +def test_sanity_green(temp_output_dir: str) -> None: args = Arguments( command="echo", command_args=["hello", "world"], @@ -32,7 +33,7 @@ def test_sanity_green(temp_output_dir): assert_files(temp_output_dir, expected_files, exists=True) -def test_sanity_red(temp_output_dir): +def test_sanity_red(temp_output_dir: str) -> None: args = Arguments( command="false", command_args=[], @@ -55,7 +56,7 @@ def test_sanity_red(temp_output_dir): assert_files(temp_output_dir, not_expected_files, exists=False) -def test_outputs_full(temp_output_dir): +def test_outputs_full(temp_output_dir: str) -> None: args = Arguments( command=TEST_SCRIPT, command_args=["--duration", "1"], @@ -71,7 +72,7 @@ def test_outputs_full(temp_output_dir): assert_files(temp_output_dir, expected_files, exists=True) -def test_outputs_passthrough(temp_output_dir): +def test_outputs_passthrough(temp_output_dir: str) -> None: args = Arguments( command=TEST_SCRIPT, command_args=["--duration", "1"], @@ -89,7 +90,7 @@ def test_outputs_passthrough(temp_output_dir): assert_files(temp_output_dir, not_expected_files, exists=False) -def test_outputs_capture(temp_output_dir): +def test_outputs_capture(temp_output_dir: str) -> None: args = Arguments( command=TEST_SCRIPT, command_args=["--duration", "1"], @@ -107,7 +108,7 @@ def test_outputs_capture(temp_output_dir): assert_files(temp_output_dir, expected_files, exists=True) -def test_outputs_none(temp_output_dir): +def test_outputs_none(temp_output_dir: str) -> None: args = Arguments( command=TEST_SCRIPT, command_args=["--duration", "1"], @@ -128,7 +129,7 @@ def test_outputs_none(temp_output_dir): assert_files(temp_output_dir, not_expected_files, exists=False) -def test_exit_before_first_sample(temp_output_dir): +def test_exit_before_first_sample(temp_output_dir: str) -> None: args = Arguments( command="ls", command_args=[], @@ -146,7 +147,7 @@ def test_exit_before_first_sample(temp_output_dir): assert_files(temp_output_dir, not_expected_files, exists=False) -def test_run_less_than_report_interval(temp_output_dir): +def test_run_less_than_report_interval(temp_output_dir: str) -> None: args = Arguments( command="sleep", command_args=["0.01"], diff --git a/test/test_helpers.py b/test/test_helpers.py index 484dc803..38a9c511 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -1,3 +1,4 @@ +from __future__ import annotations from unittest import mock import pytest from duct import ensure_directories @@ -12,18 +13,20 @@ ], ) @mock.patch("duct.os.makedirs") -def test_ensure_directories_with_dirs(mock_mkdir, path): +def test_ensure_directories_with_dirs(mock_mkdir: mock.MagicMock, path: str) -> None: ensure_directories(path) mock_mkdir.assert_called_once_with(path, exist_ok=True) @mock.patch("duct.os.makedirs") -def test_ensure_directories_with_file(mock_mkdir): +def test_ensure_directories_with_file(mock_mkdir: mock.MagicMock) -> None: ensure_directories("just_a_file_name") mock_mkdir.assert_not_called() @mock.patch("duct.os.makedirs") -def test_ensure_directories_with_filepart_and_directory_part(mock_mkdir): +def test_ensure_directories_with_filepart_and_directory_part( + mock_mkdir: mock.MagicMock, +) -> None: ensure_directories("nested/dir/file_name") mock_mkdir.assert_called_once_with("nested/dir", exist_ok=True) diff --git a/test/test_prepare_outputs.py b/test/test_prepare_outputs.py index bb8d8d28..87eeb7c0 100644 --- a/test/test_prepare_outputs.py +++ b/test/test_prepare_outputs.py @@ -1,11 +1,12 @@ +from __future__ import annotations import subprocess from unittest.mock import MagicMock, call, patch from utils import MockStream from duct import prepare_outputs -@patch("sys.stdout", new_callable=lambda: MockStream()) -def test_prepare_outputs_all_stdout(mock_stdout): +@patch("sys.stdout", new_callable=MockStream) +def test_prepare_outputs_all_stdout(mock_stdout: MockStream) -> None: output_prefix = "test_outputs_" with patch("duct.TailPipe") as mock_tee_stream, patch( "builtins.open", new_callable=MagicMock @@ -19,8 +20,8 @@ def test_prepare_outputs_all_stdout(mock_stdout): assert stderr == mock_open.return_value -@patch("sys.stderr", new_callable=lambda: MockStream()) -def test_prepare_outputs_all_stderr(mock_stderr): +@patch("sys.stderr", new_callable=MockStream) +def test_prepare_outputs_all_stderr(mock_stderr: MockStream) -> None: output_prefix = "test_outputs_" with patch("duct.TailPipe") as mock_tee_stream, patch( "builtins.open", new_callable=MagicMock @@ -34,7 +35,7 @@ def test_prepare_outputs_all_stderr(mock_stderr): assert stderr == mock_tee_stream.return_value -def test_prepare_outputs_all_none(): +def test_prepare_outputs_all_none() -> None: output_prefix = "test_outputs_" with patch("builtins.open", new_callable=MagicMock) as mock_open: stdout, stderr = prepare_outputs("all", "none", output_prefix) @@ -47,23 +48,25 @@ def test_prepare_outputs_all_none(): assert stderr == mock_open.return_value -def test_prepare_outputs_none_stdout(): +def test_prepare_outputs_none_stdout() -> None: output_prefix = "test_outputs_" stdout, stderr = prepare_outputs("none", "stdout", output_prefix) assert stdout is None assert stderr == subprocess.DEVNULL -def test_prepare_outputs_none_stderr(): +def test_prepare_outputs_none_stderr() -> None: output_prefix = "test_outputs_" stdout, stderr = prepare_outputs("none", "stderr", output_prefix) assert stderr is None assert stdout == subprocess.DEVNULL -@patch("sys.stderr", new_callable=lambda: MockStream()) -@patch("sys.stdout", new_callable=lambda: MockStream()) -def test_prepare_outputs_all_all(mock_stdout, mock_stderr): +@patch("sys.stderr", new_callable=MockStream) +@patch("sys.stdout", new_callable=MockStream) +def test_prepare_outputs_all_all( + mock_stdout: MockStream, mock_stderr: MockStream +) -> None: output_prefix = "test_outputs_" with patch("duct.TailPipe") as mock_tee_stream: mock_tee_stream.return_value.start = MagicMock() diff --git a/test/test_report.py b/test/test_report.py index 44ed7d9b..5cdb9916 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -1,3 +1,4 @@ +from __future__ import annotations from collections import defaultdict from duct import Report @@ -7,27 +8,27 @@ ex2pids = {"pid1": {"pcpu": 0.0}, "pid2": {"pcpu": 0.0}} -def test_update_max_resources_initial_values_one_pid(): - maxes = defaultdict(dict) +def test_update_max_resources_initial_values_one_pid() -> None: + maxes: dict[str, dict[str, float]] = defaultdict(dict) Report.update_max_resources(maxes, ex0) assert maxes == ex0 -def test_update_max_resources_max_values_one_pid(): - maxes = defaultdict(dict) +def test_update_max_resources_max_values_one_pid() -> None: + maxes: dict[str, dict[str, float]] = defaultdict(dict) Report.update_max_resources(maxes, ex0) Report.update_max_resources(maxes, ex1) assert maxes == ex1 -def test_update_max_resources_initial_values_two_pids(): - maxes = defaultdict(dict) +def test_update_max_resources_initial_values_two_pids() -> None: + maxes: dict[str, dict[str, float]] = defaultdict(dict) Report.update_max_resources(maxes, ex2pids) assert maxes == ex2pids -def test_update_max_resources_max_update_values_two_pids(): - maxes = defaultdict(dict) +def test_update_max_resources_max_update_values_two_pids() -> None: + maxes: dict[str, dict[str, float]] = defaultdict(dict) Report.update_max_resources(maxes, ex2pids) Report.update_max_resources(maxes, ex1) Report.update_max_resources(maxes, ex2) diff --git a/test/test_tailpipe.py b/test/test_tailpipe.py index 74ecbdf0..f2a29659 100644 --- a/test/test_tailpipe.py +++ b/test/test_tailpipe.py @@ -1,4 +1,4 @@ -import os +from __future__ import annotations from pathlib import Path import subprocess import tempfile @@ -12,7 +12,9 @@ @pytest.fixture(scope="module", params=FIXTURE_LIST) -def fixture_path(request, tmp_path_factory): +def fixture_path( + request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory +) -> str: num_lines_exponent = int(request.param.split("_")[1]) base_temp_dir = tmp_path_factory.mktemp("fixture_data") file_path = base_temp_dir / f"{request.param}.txt" @@ -21,14 +23,11 @@ def fixture_path(request, tmp_path_factory): f.write(f"{i}\n") # print(f"10 ^ {num_lines_exponent}: {10 ** num_lines_exponent}") # print(f"Fixture file size: {os.path.getsize(file_path)} bytes") - yield str(file_path) + return str(file_path) - os.remove(file_path) - -@pytest.mark.parametrize("fixture_path", FIXTURE_LIST, indirect=True) -@patch("sys.stdout", new_callable=lambda: MockStream()) -def test_high_throughput_stdout(mock_stdout, fixture_path): +@patch("sys.stdout", new_callable=MockStream) +def test_high_throughput_stdout(mock_stdout: MockStream, fixture_path: str) -> None: with tempfile.NamedTemporaryFile(mode="wb") as tmpfile: process = subprocess.Popen( ["cat", fixture_path], @@ -45,9 +44,8 @@ def test_high_throughput_stdout(mock_stdout, fixture_path): assert mock_stdout.getvalue() == expected -@pytest.mark.parametrize("fixture_path", FIXTURE_LIST, indirect=True) -@patch("sys.stderr", new_callable=lambda: MockStream()) -def test_high_throughput_stderr(mock_stderr, fixture_path): +@patch("sys.stderr", new_callable=MockStream) +def test_high_throughput_stderr(mock_stderr: MockStream, fixture_path: str) -> None: with tempfile.NamedTemporaryFile(mode="wb") as tmpfile: process = subprocess.Popen( [Path(__file__).with_name("data") / "cat_to_err.py", fixture_path], @@ -65,10 +63,11 @@ def test_high_throughput_stderr(mock_stderr, fixture_path): assert mock_stderr.getvalue() == expected -@patch("sys.stdout", new_callable=lambda: MockStream()) -def test_close(mock_stdout): +@patch("sys.stdout", new_callable=MockStream) +def test_close(mock_stdout: MockStream) -> None: with tempfile.NamedTemporaryFile(mode="wb") as tmpfile: stream = TailPipe(tmpfile.name, mock_stdout.buffer) stream.start() stream.close() + assert stream.infile is not None assert stream.infile.closed diff --git a/test/utils.py b/test/utils.py index f5ba0d43..51f618bf 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,3 +1,4 @@ +from __future__ import annotations from io import BytesIO from pathlib import Path @@ -5,14 +6,14 @@ class MockStream: """Mocks stderr or stdout""" - def __init__(self): + def __init__(self) -> None: self.buffer = BytesIO() - def getvalue(self): + def getvalue(self) -> bytes: return self.buffer.getvalue() -def assert_files(parent_dir, file_list, exists=True): +def assert_files(parent_dir: str, file_list: list[str], exists: bool = True) -> None: if exists: for file_path in file_list: assert Path(