diff --git a/.gitignore b/.gitignore index 2b51df24..297182a7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__ .eggs venv venv2 +.venv build docker-compose.override.yml /workspace diff --git a/Changelog.md b/Changelog.md index 9b88535e..6a1179a9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented here. - Improved robustness of tester installation scripts and Docker configuration (#688) - Moved tidyverse installation steps from server Dockerfile into R tester requirements.system (#688) - Fixed Haskell tester installation using ghcup to install stack system-wide (#688) +- Updated tester schema generation to use msgspec datatypes (#689) ## [v2.9.0] - Install stack with GHCup (#626) @@ -45,13 +46,13 @@ All notable changes to this project will be documented here. - Update R tester to allow a renv.lock file (#581) - Improve display of Python package installation errors when creating environment (#585) - Update "setting up test environment" message with http response of status code 503 (#589) -- Change rlimit resource settings to apply each worker individually (#587) +- Change rlimit resource settings to apply each worker individually (#587) - Drop support for Python 3.8 (#590) - Use Python 3.13 in development (#590) - Update Docker configuration to install dependencies in a separate service (#590) - Improve error reporting with handled assertion errors (#591) - Add custom pytest markers to Python tester to record MarkUs metadata (#592) -- Stop the autotester from running tests if there are errors in test settings (#593) +- Stop the autotester from running tests if there are errors in test settings (#593) - Implement Redis backoff strategy (#594) ## [v2.6.0] @@ -125,7 +126,7 @@ All notable changes to this project will be documented here. - Add ability to clean up test scripts that haven't been used for X days (#379) ## [v2.1.2] -- Support dependencies on specific package versions and non-CRAN sources for R tester (#323) +- Support dependencies on specific package versions and non-CRAN sources for R tester (#323) ## [v2.1.1] - Remove the requirement for clients to send unique user name (#318) @@ -146,7 +147,7 @@ All notable changes to this project will be documented here. - Add Jupyter tester (#284) ## [v1.10.3] -- Fix bug where zip archive was unpacked two levels deep instead of just one (#271) +- Fix bug where zip archive was unpacked two levels deep instead of just one (#271) - Pass PGHOST and PGINFO environment variables to tests (#272) - Update to new version of markus-api that supports uploading binary files (#273) - Fix bug where environment variables were not string types (#274) diff --git a/client/autotest_client/form_management.py b/client/autotest_client/form_management.py index f3fef9f9..e6ed841a 100644 --- a/client/autotest_client/form_management.py +++ b/client/autotest_client/form_management.py @@ -135,6 +135,5 @@ def validate_against_schema(test_specs: Dict, schema: Dict, filenames: List[str] schema["definitions"]["files_list"]["enum"] = filenames # don't validate based on categories schema["definitions"]["test_data_categories"].pop("enum") - schema["definitions"]["test_data_categories"].pop("enumNames") error = _validate_with_defaults(schema, test_specs, best_only=True) return str(error) if error else None diff --git a/server/autotest_server/schema_skeleton.json b/server/autotest_server/schema_skeleton.json index b1b76916..5385932c 100644 --- a/server/autotest_server/schema_skeleton.json +++ b/server/autotest_server/schema_skeleton.json @@ -6,8 +6,7 @@ }, "test_data_categories": { "type": "string", - "enum": [], - "enumNames": [] + "enum": [] }, "extra_group_data": {}, "installed_testers": { diff --git a/server/autotest_server/testers/__init__.py b/server/autotest_server/testers/__init__.py index 8aed26dc..ad2c1151 100644 --- a/server/autotest_server/testers/__init__.py +++ b/server/autotest_server/testers/__init__.py @@ -1,12 +1,13 @@ +from __future__ import annotations + +import importlib import os _TESTERS = ("ai", "custom", "haskell", "java", "jupyter", "py", "pyta", "r", "racket") -def install(testers=_TESTERS): - import importlib - - settings = {} +def install(testers: list[str] = _TESTERS) -> tuple[dict, dict]: + installed_testers = [] for tester in testers: mod = importlib.import_module(f".{tester}.setup", package="autotest_server.testers") try: @@ -20,5 +21,25 @@ def install(testers=_TESTERS): " and then rerunning this function." ) raise Exception(msg) from e - settings[tester] = mod.settings() - return settings + installed_testers.append(tester) + return get_settings(installed_testers) + + +def get_settings(testers: list[str] = _TESTERS) -> tuple[dict, dict]: + """Return JSON schemas for the settings for the given testers. + + The return values are: + 1. A dictionary mapping tester name to JSON schema + 2. A dictionary of JSON schema definitions used by the tester schemas + """ + schemas = {} + definitions = {} + for tester in testers: + mod = importlib.import_module(f".{tester}.setup", package="autotest_server.testers") + tester_schema, tester_definitions = mod.settings() + if "title" in tester_schema and f"{tester_schema['title']}TesterSettings" in tester_definitions: + tester_definitions.pop(f"{tester_schema['title']}TesterSettings") + schemas[tester] = tester_schema + definitions.update(tester_definitions) + + return schemas, definitions diff --git a/server/autotest_server/testers/ai/setup.py b/server/autotest_server/testers/ai/setup.py index c3fbeee0..86eb30c9 100644 --- a/server/autotest_server/testers/ai/setup.py +++ b/server/autotest_server/testers/ai/setup.py @@ -48,7 +48,7 @@ def create_environment(settings_, env_dir, _default_env_dir): def settings(): with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: settings_ = json.load(f) - return settings_ + return settings_, {} def install(): diff --git a/server/autotest_server/testers/custom/schema.py b/server/autotest_server/testers/custom/schema.py new file mode 100644 index 00000000..25ce8f1f --- /dev/null +++ b/server/autotest_server/testers/custom/schema.py @@ -0,0 +1,21 @@ +from __future__ import annotations +from typing import Annotated + +from msgspec import Meta +from markus_autotesting_core.types import AutotestFile, BaseTestData, BaseTesterSettings + + +class CustomTesterSettings(BaseTesterSettings, tag="custom"): + """The settings for the custom tester.""" + + test_data: Annotated[list[CustomTestData], Meta(title="Test Groups", min_length=1)] + + +class CustomTestData(BaseTestData, kw_only=True): + """The `test_data` specification for the custom tester.""" + + script_files: Annotated[ + list[AutotestFile], + Meta(title="Test files", min_length=1, extra_json_schema={"uniqueItems": True}), + ] + """The file(s) that contain the tests to execute.""" diff --git a/server/autotest_server/testers/custom/settings_schema.json b/server/autotest_server/testers/custom/settings_schema.json deleted file mode 100644 index bec7ec1c..00000000 --- a/server/autotest_server/testers/custom/settings_schema.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "type": "object", - "properties": { - "tester_type": { - "type": "string", - "enum": [ - "custom" - ] - }, - "test_data": { - "title": "Test Groups", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "script_files", - "timeout" - ], - "properties": { - "script_files": { - "title": "Test files", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/files_list" - }, - "uniqueItems": true - }, - "category": { - "title": "Category", - "type": "array", - "items": { - "$ref": "#/definitions/test_data_categories" - }, - "uniqueItems": true - }, - "timeout": { - "title": "Timeout", - "type": "integer", - "default": 30 - }, - "feedback_file_names": { - "title": "Feedback files", - "type": "array", - "items": { - "type": "string" - } - }, - "extra_info": { - "$ref": "#/definitions/extra_group_data" - } - } - } - } - } -} diff --git a/server/autotest_server/testers/custom/setup.py b/server/autotest_server/testers/custom/setup.py index 59c8ea18..7d25d238 100644 --- a/server/autotest_server/testers/custom/setup.py +++ b/server/autotest_server/testers/custom/setup.py @@ -1,5 +1,7 @@ import os -import json + +from ..schema import generate_schema +from .schema import CustomTesterSettings def create_environment(_settings, _env_dir, default_env_dir): @@ -11,5 +13,4 @@ def install(): def settings(): - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: - return json.load(f) + return generate_schema(CustomTesterSettings) diff --git a/server/autotest_server/testers/haskell/config.py b/server/autotest_server/testers/haskell/config.py new file mode 100644 index 00000000..fe4a5803 --- /dev/null +++ b/server/autotest_server/testers/haskell/config.py @@ -0,0 +1,2 @@ +HASKELL_TEST_DEPS = ["tasty-discover", "tasty-quickcheck", "tasty-hunit"] +STACK_RESOLVER = "lts-21.21" diff --git a/server/autotest_server/testers/haskell/requirements.system b/server/autotest_server/testers/haskell/requirements.system index 8d23952a..559e854f 100755 --- a/server/autotest_server/testers/haskell/requirements.system +++ b/server/autotest_server/testers/haskell/requirements.system @@ -2,7 +2,7 @@ set -euxo pipefail # Install a system-wide ghc, which can be used as a default version in the Haskell tester. -# This should be synchronized with the LTS version and dependencies in setup.py +# This should be synchronized with the LTS version and dependencies in config.py if ! dpkg -l ghc cabal-install &> /dev/null; then apt-get -y update DEBIAN_FRONTEND=noninteractive apt-get install -y -o 'Dpkg::Options::=--force-confdef' -o 'Dpkg::Options::=--force-confold' ghc cabal-install diff --git a/server/autotest_server/testers/haskell/schema.py b/server/autotest_server/testers/haskell/schema.py new file mode 100644 index 00000000..8e73feec --- /dev/null +++ b/server/autotest_server/testers/haskell/schema.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from typing import Annotated + +from msgspec import Meta, Struct +from markus_autotesting_core.types import BaseTestData, BaseTesterSettings + +from .config import STACK_RESOLVER + + +class HaskellTesterSettings(BaseTesterSettings): + """The settings for the Haskell tester.""" + + env_data: Annotated[HaskellEnvData, Meta(title="Haskell environment")] + test_data: Annotated[list[HaskellTestData], Meta(title="Test Groups", min_length=1)] + + +class HaskellTestData(BaseTestData, kw_only=True): + """The `test_data` specification for the Haskell tester.""" + + test_timeout: Annotated[int, Meta(title="Per-test timeout")] = 10 + test_cases: Annotated[int, Meta(title="Number of test cases (QuickCheck)")] = 100 + + +class HaskellEnvData(Struct, kw_only=True): + """Settings for the Haskell environment""" + + resolver_version: Annotated[str, Meta(title="Stackage LTS resolver version")] = STACK_RESOLVER diff --git a/server/autotest_server/testers/haskell/settings_schema.json b/server/autotest_server/testers/haskell/settings_schema.json deleted file mode 100644 index 5fd49f3d..00000000 --- a/server/autotest_server/testers/haskell/settings_schema.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "type": "object", - "required": [ - "env_data" - ], - "properties": { - "tester_type": { - "type": "string", - "enum": [ - "haskell" - ] - }, - "env_data": { - "title": "Haskell environment", - "type": "object", - "required": [ - "resolver_version" - ], - "properties": { - "resolver_version": { - "title": "Resolver version", - "type": "string", - "default": null - } - } - }, - "test_data": { - "title": "Test Groups", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "script_files", - "timeout", - "test_timeout", - "test_cases" - ], - "properties": { - "script_files": { - "title": "Test files", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/files_list" - }, - "uniqueItems": true - }, - "category": { - "title": "Category", - "type": "array", - "items": { - "$ref": "#/definitions/test_data_categories" - }, - "uniqueItems": true - }, - "timeout": { - "title": "Timeout", - "type": "integer", - "default": 30 - }, - "test_timeout": { - "title": "Per-test timeout", - "type": "integer", - "default": 10 - }, - "test_cases": { - "title": "Number of test cases (QuickCheck)", - "type": "integer", - "default": 100 - }, - "feedback_file_names": { - "title": "Feedback files", - "type": "array", - "items": { - "type": "string" - } - }, - "extra_info": { - "$ref": "#/definitions/extra_group_data" - } - } - } - } - } -} diff --git a/server/autotest_server/testers/haskell/setup.py b/server/autotest_server/testers/haskell/setup.py index 3e50aa33..c3cb2104 100644 --- a/server/autotest_server/testers/haskell/setup.py +++ b/server/autotest_server/testers/haskell/setup.py @@ -1,9 +1,9 @@ import os -import json import subprocess -HASKELL_TEST_DEPS = ["tasty-discover", "tasty-quickcheck", "tasty-hunit"] -STACK_RESOLVER = "lts-21.21" +from .config import HASKELL_TEST_DEPS, STACK_RESOLVER +from ..schema import generate_schema +from .schema import HaskellTesterSettings def create_environment(_settings, _env_dir, default_env_dir): @@ -51,8 +51,4 @@ def install(): def settings(): - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: - settings_ = json.load(f) - resolver_versions = settings_["properties"]["env_data"]["properties"]["resolver_version"] - resolver_versions["default"] = STACK_RESOLVER - return settings_ + return generate_schema(HaskellTesterSettings) diff --git a/server/autotest_server/testers/java/schema.py b/server/autotest_server/testers/java/schema.py new file mode 100644 index 00000000..6629a451 --- /dev/null +++ b/server/autotest_server/testers/java/schema.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from typing import Annotated + +from msgspec import Meta +from markus_autotesting_core.types import BaseTestData, BaseTesterSettings + + +class JavaTesterSettings(BaseTesterSettings): + """The settings for the Java tester.""" + + test_data: Annotated[list[JavaTestData], Meta(title="Test Groups", min_length=1)] + + +class JavaTestData(BaseTestData, kw_only=True): + """The `test_data` specification for the Java tester.""" + + classpath: Annotated[str, Meta(title="Java Class Path")] = "." + sources_path: Annotated[str, Meta(title="Java Sources (glob)")] = "" diff --git a/server/autotest_server/testers/java/settings_schema.json b/server/autotest_server/testers/java/settings_schema.json deleted file mode 100644 index 073a729a..00000000 --- a/server/autotest_server/testers/java/settings_schema.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "type": "object", - "properties": { - "tester_type": { - "type": "string", - "enum": [ - "java" - ] - }, - "test_data": { - "title": "Test Groups", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "script_files", - "timeout" - ], - "properties": { - "classpath": { - "title": "Java Class Path", - "type": "string" - }, - "sources_path": { - "title": "Java Sources (glob)", - "type": "string" - }, - "script_files": { - "title": "Test files", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/files_list" - }, - "uniqueItems": true - }, - "category": { - "title": "Category", - "type": "array", - "items": { - "$ref": "#/definitions/test_data_categories" - }, - "uniqueItems": true - }, - "timeout": { - "title": "Timeout", - "type": "integer", - "default": 30 - }, - "feedback_file_names": { - "title": "Feedback files", - "type": "array", - "items": { - "type": "string" - } - }, - "extra_info": { - "$ref": "#/definitions/extra_group_data" - } - } - } - } - } -} diff --git a/server/autotest_server/testers/java/setup.py b/server/autotest_server/testers/java/setup.py index fab85aa1..dd3adc3a 100644 --- a/server/autotest_server/testers/java/setup.py +++ b/server/autotest_server/testers/java/setup.py @@ -1,8 +1,10 @@ import os -import json import subprocess import requests +from ..schema import generate_schema +from .schema import JavaTesterSettings + def create_environment(_settings, _env_dir, default_env_dir): return {"PYTHON": os.path.join(default_env_dir, "bin", "python3")} @@ -23,5 +25,4 @@ def install(): def settings(): - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: - return json.load(f) + return generate_schema(JavaTesterSettings) diff --git a/server/autotest_server/testers/jupyter/schema.py b/server/autotest_server/testers/jupyter/schema.py new file mode 100644 index 00000000..8b234741 --- /dev/null +++ b/server/autotest_server/testers/jupyter/schema.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from enum import Enum +import shutil +from typing import Annotated + +from msgspec import Meta, Struct +from markus_autotesting_core.types import AutotestFile, BaseTestData, BaseTesterSettings + + +PYTHON_VERSIONS = [f"3.{x}" for x in range(11, 14) if shutil.which(f"python3.{x}")] +PythonVersion = Enum("PythonVersion", {v.replace(".", "_"): v for v in PYTHON_VERSIONS}) + + +class JupyterTesterSettings(BaseTesterSettings): + """The settings for the Jupyter tester.""" + + env_data: Annotated[JupyterEnvData, Meta(title="Python environment")] + test_data: Annotated[list[JupyterTestData], Meta(title="Test Groups", min_length=1)] + + +class JupyterTestData(BaseTestData, kw_only=True): + """The `test_data` specification for the Jupyter tester.""" + + script_files: Annotated[list[JupyterScriptFile], Meta(title="Test files", min_length=1)] + """The file(s) that contain the tests to execute.""" + + +class JupyterScriptFile(Struct, kw_only=True): + """The configuration for a single Jupyter test file.""" + + test_file: Annotated[AutotestFile, Meta(title="Test file")] + student_file: Annotated[str, Meta(title="Student file")] + test_merge: Annotated[bool, Meta(title="Test that files can be merged")] = False + + +class JupyterEnvData(Struct, kw_only=True): + """The settings for the Python environment.""" + + python_version: Annotated[PythonVersion, Meta(title="Python version")] = PYTHON_VERSIONS[-1] + pip_requirements: Annotated[str, Meta(title="Package requirements")] = "" + pip_requirements_file: Annotated[str, Meta(title="Package requirements file")] = "" diff --git a/server/autotest_server/testers/jupyter/settings_schema.json b/server/autotest_server/testers/jupyter/settings_schema.json deleted file mode 100644 index f425b1bc..00000000 --- a/server/autotest_server/testers/jupyter/settings_schema.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "type": "object", - "required": [ - "env_data" - ], - "properties": { - "tester_type": { - "type": "string", - "enum": [ - "jupyter" - ] - }, - "env_data": { - "title": "Python environment", - "type": "object", - "required": [ - "python_version" - ], - "properties": { - "python_version": { - "title": "Python version", - "type": "string", - "enum": [] - }, - "pip_requirements": { - "title": "Package requirements", - "type": "string" - }, - "pip_requirements_file": { - "title": "Package requirements file", - "type": "string" - } - } - }, - "test_data": { - "title": "Test Groups", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "script_files", - "timeout" - ], - "properties": { - "script_files": { - "items": { - "type": "object", - "required": ["test_file", "student_file", "test_merge"], - "properties": { - "test_file": { - "title": "Test file", - "$ref": "#/definitions/files_list" - }, - "student_file": { - "title": "Student file", - "type": "string" - }, - "test_merge": { - "title": "Test that files can be merged", - "type": "boolean", - "default": false - } - } - }, - "minItems": 1, - "title": "Test files", - "type": "array", - "uniqueItems": true, - "default": [] - }, - "category": { - "title": "Category", - "type": "array", - "items": { - "$ref": "#/definitions/test_data_categories" - }, - "uniqueItems": true - }, - "timeout": { - "title": "Timeout", - "type": "integer", - "default": 30 - }, - "feedback_file_names": { - "title": "Feedback files", - "type": "array", - "items": { - "type": "string" - } - }, - "extra_info": { - "$ref": "#/definitions/extra_group_data" - } - } - } - } - } -} \ No newline at end of file diff --git a/server/autotest_server/testers/jupyter/setup.py b/server/autotest_server/testers/jupyter/setup.py index e48918a5..f4cb96b2 100644 --- a/server/autotest_server/testers/jupyter/setup.py +++ b/server/autotest_server/testers/jupyter/setup.py @@ -1,8 +1,9 @@ import os -import shutil -import json import subprocess +from ..schema import generate_schema +from .schema import JupyterTesterSettings + def create_environment(settings_, env_dir, _default_env_dir): env_data = settings_.get("env_data", {}) @@ -22,13 +23,7 @@ def create_environment(settings_, env_dir, _default_env_dir): def settings(): - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: - settings_ = json.load(f) - py_versions = [f"3.{x}" for x in range(11, 14) if shutil.which(f"python3.{x}")] - python_versions = settings_["properties"]["env_data"]["properties"]["python_version"] - python_versions["enum"] = py_versions - python_versions["default"] = py_versions[-1] - return settings_ + return generate_schema(JupyterTesterSettings) def install(): diff --git a/server/autotest_server/testers/py/py_tester.py b/server/autotest_server/testers/py/py_tester.py index 61ae13c7..d6973ba1 100644 --- a/server/autotest_server/testers/py/py_tester.py +++ b/server/autotest_server/testers/py/py_tester.py @@ -301,7 +301,7 @@ def _run_unittest_tests(self, test_file: str) -> List[Dict]: test_suite = self._load_unittest_tests(test_file) with open(os.devnull, "w") as nullstream: test_runner = unittest.TextTestRunner( - verbosity=self.specs["test_data", "output_verbosity"], + verbosity=self.specs["test_data", "output_verbosity"] or "2", stream=nullstream, resultclass=TextTestResults, ) @@ -317,7 +317,7 @@ def _run_pytest_tests(self, test_file: str) -> List[Dict]: with open(os.devnull, "w") as null_out: try: sys.stdout = null_out - verbosity = self.specs["test_data", "output_verbosity"] + verbosity = self.specs["test_data", "output_verbosity"] or "short" plugin = PytestPlugin() pytest.main([test_file, f"--tb={verbosity}"], plugins=[plugin]) results.extend(plugin.results.values()) diff --git a/server/autotest_server/testers/py/schema.py b/server/autotest_server/testers/py/schema.py new file mode 100644 index 00000000..91d554c8 --- /dev/null +++ b/server/autotest_server/testers/py/schema.py @@ -0,0 +1,35 @@ +from __future__ import annotations +from enum import Enum +import shutil +from typing import Annotated, Literal + +from msgspec import Meta, Struct +from markus_autotesting_core.types import BaseTestData, BaseTesterSettings + + +PYTHON_VERSIONS = [f"3.{x}" for x in range(11, 14) if shutil.which(f"python3.{x}")] +PythonVersion = Enum("PythonVersion", {v.replace(".", "_"): v for v in PYTHON_VERSIONS}) + + +class PyTesterSettings(BaseTesterSettings): + """The settings for the Python tester.""" + + env_data: Annotated[PythonEnvData, Meta(title="Python environment")] + test_data: Annotated[list[PyTestData], Meta(title="Test Groups", min_length=1)] + + +class PythonEnvData(Struct, kw_only=True): + """The settings for the Python environment.""" + + python_version: Annotated[PythonVersion, Meta(title="Python version")] = PYTHON_VERSIONS[-1] + pip_requirements: Annotated[str, Meta(title="Package requirements")] = "" + pip_requirements_file: Annotated[str, Meta(title="Package requirements file")] = "" + + +class PyTestData(BaseTestData, kw_only=True): + """The `test_data` specification for the Python tester.""" + + tester: Annotated[Literal["unittest", "pytest"], Meta(title="Test runner")] = "pytest" + output_verbosity: Annotated[ + Literal["", "0", "1", "2", "short", "auto", "long", "no", "line", "native"], Meta(title="Output verbosity") + ] = "" diff --git a/server/autotest_server/testers/py/settings_schema.json b/server/autotest_server/testers/py/settings_schema.json deleted file mode 100644 index b5e1f381..00000000 --- a/server/autotest_server/testers/py/settings_schema.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "type": "object", - "required": [ - "env_data" - ], - "properties": { - "tester_type": { - "type": "string", - "enum": [ - "py" - ] - }, - "env_data": { - "title": "Python environment", - "type": "object", - "required": [ - "python_version" - ], - "properties": { - "python_version": { - "title": "Python version", - "type": "string", - "enum": [] - }, - "pip_requirements": { - "title": "Package requirements", - "type": "string" - }, - "pip_requirements_file": { - "title": "Package requirements file", - "type": "string" - } - } - }, - "test_data": { - "title": "Test Groups", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "script_files", - "timeout", - "tester", - "output_verbosity" - ], - "properties": { - "script_files": { - "title": "Test files", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/files_list" - }, - "uniqueItems": true - }, - "category": { - "title": "Category", - "type": "array", - "items": { - "$ref": "#/definitions/test_data_categories" - }, - "uniqueItems": true - }, - "timeout": { - "title": "Timeout", - "type": "integer", - "default": 30 - }, - "tester": { - "title": "Test runner", - "type": "string", - "enum": [ - "pytest", - "unittest" - ], - "default": "pytest" - }, - "feedback_file_names": { - "title": "Feedback files", - "type": "array", - "items": { - "type": "string" - } - }, - "extra_info": { - "$ref": "#/definitions/extra_group_data" - } - }, - "dependencies": { - "tester": { - "oneOf": [ - { - "properties": { - "tester": { - "type": "string", - "enum": [ - "pytest" - ] - }, - "output_verbosity": { - "title": "Pytest output verbosity", - "type": "string", - "enum": [ - "short", - "auto", - "long", - "no", - "line", - "native" - ], - "default": "short" - } - } - }, - { - "properties": { - "tester": { - "type": "string", - "enum": [ - "unittest" - ] - }, - "output_verbosity": { - "title": "Unittest output verbosity", - "type": "integer", - "enum": [ - 2, - 1, - 0 - ], - "default": 2 - } - } - } - ] - } - } - } - } - } -} \ No newline at end of file diff --git a/server/autotest_server/testers/py/setup.py b/server/autotest_server/testers/py/setup.py index e48918a5..8de0b2ff 100644 --- a/server/autotest_server/testers/py/setup.py +++ b/server/autotest_server/testers/py/setup.py @@ -1,8 +1,9 @@ import os -import shutil -import json import subprocess +from ..schema import generate_schema +from .schema import PyTesterSettings + def create_environment(settings_, env_dir, _default_env_dir): env_data = settings_.get("env_data", {}) @@ -22,13 +23,47 @@ def create_environment(settings_, env_dir, _default_env_dir): def settings(): - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: - settings_ = json.load(f) - py_versions = [f"3.{x}" for x in range(11, 14) if shutil.which(f"python3.{x}")] - python_versions = settings_["properties"]["env_data"]["properties"]["python_version"] - python_versions["enum"] = py_versions - python_versions["default"] = py_versions[-1] - return settings_ + json_schema, components = generate_schema(PyTesterSettings) + + # Modify output_verbosity enum manually. msgspec does not support JSON schema generation for + # Literal type annotations that contain multiple types. + components["PyTestData"]["properties"]["output_verbosity"]["enum"] = [ + "", + 0, + 1, + 2, + "auto", + "line", + "long", + "native", + "no", + "short", + ] + + # Inject dependencies for output_verbosity for JSON Schema form + json_schema["properties"]["test_data"]["items"]["dependencies"] = { + "tester": { + "oneOf": [ + { + "properties": { + "tester": {"enum": ["pytest"]}, + "output_verbosity": { + "enum": ["short", "auto", "long", "no", "line", "native"], + "default": "short", + }, + } + }, + { + "properties": { + "tester": {"enum": ["unittest"]}, + "output_verbosity": {"enum": [0, 1, 2], "default": 2}, + } + }, + ] + } + } + + return json_schema, components def install(): diff --git a/server/autotest_server/testers/pyta/setup.py b/server/autotest_server/testers/pyta/setup.py index 431affca..de06ca27 100644 --- a/server/autotest_server/testers/pyta/setup.py +++ b/server/autotest_server/testers/pyta/setup.py @@ -36,7 +36,7 @@ def settings(): python_versions["default"] = py_versions[-1] pyta_version = settings_["properties"]["env_data"]["properties"]["pyta_version"] pyta_version["default"] = PYTA_VERSION - return settings_ + return settings_, {} def install(): diff --git a/server/autotest_server/testers/r/schema.py b/server/autotest_server/testers/r/schema.py new file mode 100644 index 00000000..2d9e2685 --- /dev/null +++ b/server/autotest_server/testers/r/schema.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from typing import Annotated + +from msgspec import field, Meta, Struct +from markus_autotesting_core.types import BaseTestData, BaseTesterSettings + + +class RTesterSettings(BaseTesterSettings): + """The settings for the R tester.""" + + env_data: Annotated[REnvData, Meta(title="R environment")] + test_data: Annotated[list[RTestData], Meta(title="Test Groups", min_length=1)] + + +class REnvData(Struct, kw_only=True): + """Settings for the R environment""" + + renv_lock: Annotated[bool, Meta(title="Use renv to set up environment")] = field(default=False, name="renv.lock") + requirements: Annotated[str, Meta(title="R package requirements")] = "" + + +class RTestData(BaseTestData, kw_only=True): + """The `test_data` specification for the R tester.""" + + pass diff --git a/server/autotest_server/testers/r/settings_schema.json b/server/autotest_server/testers/r/settings_schema.json deleted file mode 100644 index 9b9d786d..00000000 --- a/server/autotest_server/testers/r/settings_schema.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "type": "object", - "required": [ - "env_data" - ], - "properties": { - "tester_type": { - "type": "string", - "enum": [ - "r" - ] - }, - "env_data": { - "title": "R environment", - "type": "object", - "properties": { - "renv.lock": { - "title": "Use renv to set up environment", - "type": "boolean", - "default": false - }, - "requirements": { - "title": "Package requirements", - "type": "string" - } - } - }, - "test_data": { - "title": "Test Groups", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "script_files", - "timeout" - ], - "properties": { - "script_files": { - "title": "Test files", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/files_list" - }, - "uniqueItems": true - }, - "category": { - "title": "Category", - "type": "array", - "items": { - "$ref": "#/definitions/test_data_categories" - }, - "uniqueItems": true - }, - "timeout": { - "title": "Timeout", - "type": "integer", - "default": 30 - }, - "feedback_file_names": { - "title": "Feedback files", - "type": "array", - "items": { - "type": "string" - } - }, - "extra_info": { - "$ref": "#/definitions/extra_group_data" - } - } - } - } - } -} diff --git a/server/autotest_server/testers/r/setup.py b/server/autotest_server/testers/r/setup.py index e07af2a6..28af63d9 100644 --- a/server/autotest_server/testers/r/setup.py +++ b/server/autotest_server/testers/r/setup.py @@ -1,7 +1,9 @@ import os -import json import subprocess +from ..schema import generate_schema +from .schema import RTesterSettings + def create_environment(settings_, env_dir, default_env_dir): env_data = settings_.get("env_data", {}) @@ -34,8 +36,7 @@ def create_environment(settings_, env_dir, default_env_dir): def settings(): - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: - return json.load(f) + return generate_schema(RTesterSettings) def install(): diff --git a/server/autotest_server/testers/racket/schema.py b/server/autotest_server/testers/racket/schema.py new file mode 100644 index 00000000..47ac45f2 --- /dev/null +++ b/server/autotest_server/testers/racket/schema.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from typing import Annotated + +from msgspec import Meta, Struct +from markus_autotesting_core.types import AutotestFile, BaseTestData, BaseTesterSettings + + +class RacketTesterSettings(BaseTesterSettings): + """The settings for the Racket tester.""" + + test_data: Annotated[list[RacketTestData], Meta(title="Test Groups", min_length=1)] + + +class RacketTestData(BaseTestData, kw_only=True): + """The `test_data` specification for the Racket tester.""" + + script_files: Annotated[list[_RacketScriptFile], Meta(title="Test files", min_length=1)] + """The file(s) that contain the tests to execute.""" + + +class _RacketScriptFile(Struct, kw_only=True): + """The configuration for a single Racket test file.""" + + script_file: Annotated[AutotestFile, Meta(title="Test file")] + test_suite_name: Annotated[str, Meta(title="Test suite name")] = "all-tests" diff --git a/server/autotest_server/testers/racket/settings_schema.json b/server/autotest_server/testers/racket/settings_schema.json deleted file mode 100644 index eaa9b94a..00000000 --- a/server/autotest_server/testers/racket/settings_schema.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "type": "object", - "properties": { - "tester_type": { - "type": "string", - "enum": [ - "racket" - ] - }, - "test_data": { - "title": "Test Groups", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "script_files", - "timeout" - ], - "properties": { - "script_files": { - "title": "Test files", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "script_file": { - "title": "Test file", - "$ref": "#/definitions/files_list" - }, - "test_suite_name": { - "title": "Test suite name", - "type": "string", - "default": "all-tests" - } - } - } - }, - "category": { - "title": "Category", - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/test_data_categories" - }, - "uniqueItems": true - }, - "timeout": { - "title": "Timeout", - "type": "integer", - "default": 30 - }, - "feedback_file_names": { - "title": "Feedback files", - "type": "array", - "items": { - "type": "string" - } - }, - "extra_info": { - "$ref": "#/definitions/extra_group_data" - } - } - } - } - } -} diff --git a/server/autotest_server/testers/racket/setup.py b/server/autotest_server/testers/racket/setup.py index 218a611d..f3f9b1ec 100644 --- a/server/autotest_server/testers/racket/setup.py +++ b/server/autotest_server/testers/racket/setup.py @@ -1,15 +1,16 @@ import os -import json import subprocess +from ..schema import generate_schema +from .schema import RacketTesterSettings + def create_environment(_settings, _env_dir, default_env_dir): return {"PYTHON": os.path.join(default_env_dir, "bin", "python3")} def settings(): - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "settings_schema.json")) as f: - return json.load(f) + return generate_schema(RacketTesterSettings) def install(): diff --git a/server/autotest_server/testers/schema.py b/server/autotest_server/testers/schema.py new file mode 100644 index 00000000..82d987df --- /dev/null +++ b/server/autotest_server/testers/schema.py @@ -0,0 +1,25 @@ +"""Helper functions for defining tester schemas.""" + +from __future__ import annotations + +from markus_autotesting_core.types import BaseTesterSettings +import msgspec + + +def generate_schema(tester_class: type[BaseTesterSettings]) -> tuple[dict, list]: + """Generate a schema for a given tester class. This handles common post-processing for all tester classes. + + Returns a schema and list of definitions used by the schema. + """ + _, components = msgspec.json.schema_components([tester_class]) + tester_component = components[tester_class.__name__] + tester_name = tester_component["title"].removesuffix("TesterSettings") + + # Modify tester title + tester_component["title"] = tester_name + tester_component["properties"]["tester_type"]["default"] = tester_name.removesuffix("TesterSettings").lower() + + # Remove private schema properties + del tester_component["properties"]["_env"] + + return tester_component, components diff --git a/server/autotest_server/tests/fixtures/specs/custom/simple.json b/server/autotest_server/tests/fixtures/specs/custom/simple.json new file mode 100644 index 00000000..3637d593 --- /dev/null +++ b/server/autotest_server/tests/fixtures/specs/custom/simple.json @@ -0,0 +1,19 @@ +{ + "tester_type": "custom", + "test_data": [ + { + "script_files": [ + "autotest_01.sh" + ], + "category": [ + "instructor" + ], + "timeout": 30, + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Custom Test Group" + } + } + ] +} diff --git a/server/autotest_server/tests/fixtures/specs/haskell/simple.json b/server/autotest_server/tests/fixtures/specs/haskell/simple.json new file mode 100644 index 00000000..fcc61763 --- /dev/null +++ b/server/autotest_server/tests/fixtures/specs/haskell/simple.json @@ -0,0 +1,22 @@ +{ + "tester_type": "haskell", + "test_data": [ + { + "script_files": [ + "Test.hs" + ], + "category": [ + "instructor" + ], + "timeout": 30, + "test_timeout": 10, + "test_cases": 100, + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Haskell Test Group" + } + } + ], + "env_data": {} +} diff --git a/server/autotest_server/tests/fixtures/specs/java/simple.json b/server/autotest_server/tests/fixtures/specs/java/simple.json new file mode 100644 index 00000000..922bc5d9 --- /dev/null +++ b/server/autotest_server/tests/fixtures/specs/java/simple.json @@ -0,0 +1,33 @@ +{ + "tester_type": "java", + "test_data": [ + { + "script_files": [ + "Test1.java" + ], + "category": [ + "instructor" + ], + "timeout": 30, + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Java Test Group 1" + } + }, + { + "script_files": [ + "Test2.java" + ], + "category": [ + "instructor" + ], + "timeout": 30, + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Java Test Group 2" + } + } + ] +} \ No newline at end of file diff --git a/server/autotest_server/tests/fixtures/specs/jupyter/simple.json b/server/autotest_server/tests/fixtures/specs/jupyter/simple.json new file mode 100644 index 00000000..bc171211 --- /dev/null +++ b/server/autotest_server/tests/fixtures/specs/jupyter/simple.json @@ -0,0 +1,27 @@ +{ + "tester_type": "jupyter", + "env_data": { + "python_version": "3.13", + "pip_requirements": "matplotlib numpy" + }, + "test_data": [ + { + "script_files": [ + { + "student_file": "submission.ipynb", + "test_file": "test.ipynb", + "test_merge": true + } + ], + "timeout": 30, + "category": [ + "instructor" + ], + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Jupyter Test Group" + } + } + ] +} diff --git a/server/autotest_server/tests/fixtures/specs/py/simple.json b/server/autotest_server/tests/fixtures/specs/py/simple.json new file mode 100644 index 00000000..95603be0 --- /dev/null +++ b/server/autotest_server/tests/fixtures/specs/py/simple.json @@ -0,0 +1,41 @@ +{ + "tester_type": "py", + "env_data": { + "python_version": "3.13", + "pip_requirements": "hypothesis pytest-timeout" + }, + "test_data": [ + { + "script_files": [ + "test.py" + ], + "category": [ + "instructor" + ], + "timeout": 30, + "tester": "unittest", + "output_verbosity": 2, + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Python Test Group 1" + } + }, + { + "script_files": [ + "test2.py" + ], + "category": [ + "instructor" + ], + "timeout": 30, + "tester": "pytest", + "output_verbosity": "short", + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Python Test Group 2" + } + } + ] +} diff --git a/server/autotest_server/tests/fixtures/specs/r/simple.json b/server/autotest_server/tests/fixtures/specs/r/simple.json new file mode 100644 index 00000000..a538cadb --- /dev/null +++ b/server/autotest_server/tests/fixtures/specs/r/simple.json @@ -0,0 +1,35 @@ +{ + "env_data": { + "requirements": "knitr" + }, + "test_data": [ + { + "category": [ + "instructor" + ], + "extra_info": { + "criterion": "criterion", + "name": "R Test Group", + "display_output": "instructors" + }, + "script_files": [ + "test.R" + ], + "timeout": 30 + }, + { + "category": [ + "instructor" + ], + "extra_info": { + "name": "R Test Group for Rmarkdown", + "display_output": "instructors" + }, + "script_files": [ + "test_rmd.R" + ], + "timeout": 30 + } + ], + "tester_type": "r" +} diff --git a/server/autotest_server/tests/fixtures/specs/racket/simple.json b/server/autotest_server/tests/fixtures/specs/racket/simple.json new file mode 100644 index 00000000..611f67c3 --- /dev/null +++ b/server/autotest_server/tests/fixtures/specs/racket/simple.json @@ -0,0 +1,22 @@ +{ + "tester_type": "racket", + "test_data": [ + { + "script_files": [ + { + "test_suite_name": "all-tests", + "script_file": "test.rkt" + } + ], + "category": [ + "instructor" + ], + "timeout": 30, + "extra_info": { + "criterion": "criterion", + "display_output": "instructors", + "name": "Racket Test Group" + } + } + ] +} \ No newline at end of file diff --git a/server/autotest_server/tests/testers/test_schema.py b/server/autotest_server/tests/testers/test_schema.py new file mode 100644 index 00000000..cee4fec1 --- /dev/null +++ b/server/autotest_server/tests/testers/test_schema.py @@ -0,0 +1,69 @@ +import json +import os +import jsonschema +import pytest + + +from autotest_server.testers import get_settings + + +def create_refs(files_list: list[str]): + return { + "files_list": {"type": "string", "enum": files_list}, + "extra_group_data": { + "type": "object", + "properties": { + "name": {"type": "string", "title": "Test Group name", "default": "Test Group"}, + "display_output": { + "type": "string", + "oneOf": [ + {"const": "instructors", "title": "Never display test output to students"}, + { + "const": "instructors_and_student_tests", + "title": "Only display test output to students for student-run tests", + }, + {"const": "instructors_and_students", "title": "Always display test output to students"}, + ], + "default": "instructors", + "title": "Display test output to students?", + }, + }, + "required": ["display_output"], + }, + "test_data_categories": {"type": "string", "enum": ["instructor", "student"]}, + "criterion": { + "type": ["string", "null"], + "title": "Criterion", + "oneOf": [{"const": None, "title": "Not applicable"}, {"const": "criterion", "title": "criterion"}], + "default": None, + }, + } + + +@pytest.mark.parametrize( + "tester,files_list", + [ + ("custom", ["autotest_01.sh"]), + ("haskell", ["Test.hs"]), + ("java", ["Test1.java", "Test2.java"]), + ("jupyter", ["test.ipynb"]), + ("py", ["test.py", "test2.py"]), + ("r", ["test.R", "test_rmd.R"]), + ("racket", ["test.rkt"]), + ], +) +def test_valid_simple_schema(tester, files_list): + schemas, definitions = get_settings() + schema = schemas[tester] + if "definitions" not in schema: + schema["definitions"] = {} + schema["definitions"].update(create_refs(files_list=files_list)) + schema["$defs"] = definitions + + # Override the installed Python versions + definitions["PythonVersion"]["enum"] = ["3.13"] + + with open(os.path.join(os.path.dirname(__file__), "..", "fixtures", "specs", tester, "simple.json")) as f: + instance = json.load(f) + + jsonschema.validate(instance, schema) diff --git a/server/install.py b/server/install.py index 9d3fb706..4b3e9e86 100644 --- a/server/install.py +++ b/server/install.py @@ -76,13 +76,15 @@ def create_worker_log_dir(): def install_all_testers(): - settings = install_testers() + settings, definitions = install_testers() skeleton_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "autotest_server", "schema_skeleton.json") with open(skeleton_file) as f: skeleton = json.load(f) - skeleton["definitions"]["installed_testers"]["enum"] = list(settings.keys()) - skeleton["definitions"]["tester_schemas"]["oneOf"] = list(settings.values()) - REDIS_CONNECTION.set("autotest:schema", json.dumps(skeleton)) + + skeleton["definitions"]["installed_testers"]["enum"] = list(settings.keys()) + skeleton["definitions"]["tester_schemas"]["oneOf"] = list(settings.values()) + skeleton["$defs"] = definitions + REDIS_CONNECTION.set("autotest:schema", json.dumps(skeleton)) def install(): diff --git a/server/requirements.txt b/server/requirements.txt index 77b2e38b..98914165 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -2,6 +2,8 @@ rq==2.6.1 redis==7.1.0 pyyaml==6.0.3 jsonschema==4.25.1 +markus-autotesting-core==0.1.0 +msgspec==0.19.0 requests==2.32.5 psycopg2-binary==2.9.11 supervisor==4.3.0