diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/testr/__init__.py b/testr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testr/file_data.py b/testr/file_data.py new file mode 100644 index 0000000..c51b5c9 --- /dev/null +++ b/testr/file_data.py @@ -0,0 +1,79 @@ +import asyncio +from testr.runner import TestData, TestRunner, TestStatus, TestSuite, StatusCode +from pathlib import Path + + +class FileData(TestData): + """Backend to parse test data from files.""" + + def __init__(self, input_file: Path, output_file: Path): + if not input_file.is_file(): + raise ValueError(f"input_file must be a file, got '{input_file}'") + if not output_file.is_file(): + raise ValueError(f"output_file must be a file, got '{output_file}'") + self.input_file = input_file + self.output_file = output_file + + async def get_input(self) -> str: + with open(self.input_file, "r") as f: + return f.read() + + async def validate_output(self, output: str) -> bool: + with open(self.output_file, "r") as f: + correct = f.read() + return correct == output + + +class ExecutableRunner(TestRunner): + def __init__(self, executable: Path): + if not executable.is_file(): + raise ValueError(f"executable must be a file, got '{executable}'") + self.executable = executable + + async def run_test(self, data: TestData) -> TestStatus: + proc = await asyncio.create_subprocess_shell( + str(self.executable), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + ) + + input_data = (await data.get_input()).encode() + + try: + out_stream, err_stream = await asyncio.wait_for( + proc.communicate(input=input_data), timeout=5.0) + except TimeoutError: + proc.kill() + return TestStatus(code=StatusCode.TLE, stderr="", stdout="") + + stdout: str = out_stream.decode() + stderr: str = err_stream.decode() + + if proc.returncode != 0: + return TestStatus(code=StatusCode.IR, stdout=stdout, stderr=stderr) + + correct: bool = await data.validate_output(stdout) + ret_code = StatusCode.AC if correct else StatusCode.WA + return TestStatus(code=ret_code, stdout=stdout, stderr=stderr) + + +class DirectorySuite(TestSuite): + """Loader for .in, .out files in a directory.""" + + def __init__(self, test_dir: Path): + self.test_cases = [] + + if not test_dir.is_dir(): + raise ValueError(f"test_dir must be a directory, got '{test_dir}'") + for inp_file in test_dir.glob("*.in"): + if not inp_file.is_file: + continue + outp_file = inp_file.with_suffix("out") + if not outp_file.is_file(): + raise ValueError(f"output file '{outp_file}' is not a valid file") + self.test_cases.append(FileData(inp_file, outp_file)) + + + def __iter__(self): + return self.test_cases.__iter__() diff --git a/testr/runner.py b/testr/runner.py index 8209a12..9ef7a40 100644 --- a/testr/runner.py +++ b/testr/runner.py @@ -1,10 +1,12 @@ +from dataclasses import dataclass from abc import ABC, abstractmethod +from collections.abc import Iterable from enum import Enum, auto -class TestStatus(Enum): +class StatusCode(Enum): """ - Status of an individual test case. + Status codes for an individual test case. """ AC = auto() @@ -19,39 +21,52 @@ class TestStatus(Enum): """ Time Limit Exceeded: program took too long to execute this case. """ - WJ = auto() + IR = auto() """ - Waiting for Judgement: this test case has not been finished yet. + Invalid Return: program did not return 0 """ -class TestData(ABC): - """Input and output for single test case.""" +@dataclass +class TestStatus: + """ + Status of an individual test case. + """ + + code: StatusCode + stderr: str + stdout: str + + +class TestInput(ABC): + """Input provider for single test case.""" @abstractmethod - def get_input(self) -> str: pass + async def get_input(self) -> str: pass + + +class TestValidator(ABC): + """Output validator for single test case.""" @abstractmethod - async def validate_output(self) -> bool: pass + async def validate_output(self, output: str) -> bool: pass -class TestCase(ABC): - """Runner for a single test case.""" +class TestData(TestInput, TestValidator): + """Combined input/output for single test case""" - def __init__(self, data: TestData): - self.test_data = data - super().__init__() + pass - @property - @abstractmethod - def status(self) -> TestStatus: pass + +class TestRunner(ABC): + """Runner for test cases.""" @abstractmethod - async def run_test(self) -> None: pass + async def run_test(self, data: TestData) -> StatusCode: pass class TestSuite(ABC): """Loader for multiple test cases.""" @abstractmethod - def __next__(self) -> TestCase: pass + def __iter__(self) -> Iterable[TestData]: pass