Skip to content
Merged
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented here.
- Update python, pyta and jupyter testers to allow a requirements file (#580)
- Update R tester to allow a renv.lock file (#581)
- Improve display of Python package installation errors when creating environment (#585)
- Change rlimit resource settings to apply each worker individually (#587)

## [v2.6.0]
- Update python versions in docker file (#568)
Expand Down
11 changes: 8 additions & 3 deletions server/autotest_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
from types import TracebackType

from .config import config
from .utils import loads_partial_json, set_rlimits_before_test, extract_zip_stream, recursive_iglob, copy_tree
from .utils import (
loads_partial_json,
get_resource_settings,
extract_zip_stream,
recursive_iglob,
copy_tree,
)

DEFAULT_ENV_DIR = "defaultvenv"
TEST_SCRIPT_DIR = os.path.join(config["workspace"], "scripts")
Expand Down Expand Up @@ -103,7 +109,7 @@ def _create_test_script_command(tester_type: str) -> str:
f'sys.path.append("{os.path.dirname(os.path.abspath(__file__))}")',
import_line,
"from testers.specs import TestSpecs",
"Tester(specs=TestSpecs.from_json(sys.stdin.read())).run()",
f"Tester(resource_settings={get_resource_settings(config)}, specs=TestSpecs.from_json(sys.stdin.read())).run()",
]
python_str = "; ".join(python_lines)
return f"\"${{PYTHON}}\" -c '{python_str}'"
Expand Down Expand Up @@ -221,7 +227,6 @@ def _run_test_specs(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE,
preexec_fn=set_rlimits_before_test,
universal_newlines=True,
env={**os.environ, **env_vars, **env},
executable="/bin/bash",
Expand Down
4 changes: 2 additions & 2 deletions server/autotest_server/testers/custom/custom_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@


class CustomTester(Tester):
def __init__(self, specs: TestSpecs) -> None:
def __init__(self, specs: TestSpecs, resource_settings: list[tuple[int, tuple[int, int]]] | None = None) -> None:
"""Initialize a CustomTester"""
super().__init__(specs, test_class=None)
super().__init__(specs, test_class=None, resource_settings=resource_settings)

@Tester.run_decorator
def run(self) -> None:
Expand Down
3 changes: 2 additions & 1 deletion server/autotest_server/testers/haskell/haskell_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,14 @@ def __init__(
self,
specs: TestSpecs,
test_class: Type[HaskellTest] = HaskellTest,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
) -> None:
"""
Initialize a Haskell tester using the specifications in specs.

This tester will create tests of type test_class.
"""
super().__init__(specs, test_class)
super().__init__(specs, test_class, resource_settings=resource_settings)

def _test_run_flags(self, test_file: str) -> List[str]:
"""
Expand Down
9 changes: 7 additions & 2 deletions server/autotest_server/testers/java/java_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,18 @@ class JavaTester(Tester):
JUNIT_JUPITER_RESULT = "TEST-junit-jupiter.xml"
JUNIT_VINTAGE_RESULT = "TEST-junit-vintage.xml"

def __init__(self, specs: TestSpecs, test_class: Type[JavaTest] = JavaTest) -> None:
def __init__(
self,
specs: TestSpecs,
test_class: Type[JavaTest] = JavaTest,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
) -> None:
"""
Initialize a Java tester using the specifications in specs.

This tester will create tests of type test_class.
"""
super().__init__(specs, test_class)
super().__init__(specs, test_class, resource_settings=resource_settings)
classpath = self.specs.get("test_data", "classpath", default=".") or "."
self.java_classpath = ":".join(self._parse_file_paths(classpath))
self.out_dir = tempfile.TemporaryDirectory(dir=os.getcwd())
Expand Down
3 changes: 2 additions & 1 deletion server/autotest_server/testers/jupyter/jupyter_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,14 @@ def __init__(
self,
specs: TestSpecs,
test_class: Type[JupyterTest] = JupyterTest,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
):
"""
Initialize a jupyter tester using the specifications in specs.

This tester will create tests of type test_class.
"""
super().__init__(specs, test_class)
super().__init__(specs, test_class, resource_settings=resource_settings)

@staticmethod
def _run_jupyter_tests(test_file: str) -> List[Dict]:
Expand Down
3 changes: 2 additions & 1 deletion server/autotest_server/testers/py/py_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,14 @@ def __init__(
self,
specs: TestSpecs,
test_class: Type[PyTest] = PyTest,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
):
"""
Initialize a python tester using the specifications in specs.

This tester will create tests of type test_class.
"""
super().__init__(specs, test_class)
super().__init__(specs, test_class, resource_settings=resource_settings)

@staticmethod
def _load_unittest_tests(test_file: str) -> unittest.TestSuite:
Expand Down
9 changes: 7 additions & 2 deletions server/autotest_server/testers/pyta/pyta_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,18 @@ def run(self) -> str:
class PytaTester(Tester):
test_class: Type[PytaTest]

def __init__(self, specs: TestSpecs, test_class: Type[PytaTest] = PytaTest):
def __init__(
self,
specs: TestSpecs,
test_class: Type[PytaTest] = PytaTest,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
):
"""
Initialize a Python TA tester using the specifications in specs.

This tester will create tests of type test_class.
"""
super().__init__(specs, test_class)
super().__init__(specs, test_class, resource_settings=resource_settings)
self.upload_annotations = self.specs.get("test_data", "upload_annotations")
self.pyta_config = self.update_pyta_config()
self.annotations = []
Expand Down
3 changes: 2 additions & 1 deletion server/autotest_server/testers/r/r_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,14 @@ def __init__(
self,
specs: TestSpecs,
test_class: Type[RTest] = RTest,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
) -> None:
"""
Initialize a R tester using the specifications in specs.

This tester will create tests of type test_class.
"""
super().__init__(specs, test_class)
super().__init__(specs, test_class, resource_settings=resource_settings)

def run_r_tests(self) -> Dict[str, List[Dict[str, Union[int, str]]]]:
"""
Expand Down
9 changes: 7 additions & 2 deletions server/autotest_server/testers/racket/racket_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ def run(self) -> str:
class RacketTester(Tester):
ERROR_MSGS = {"bad_json": "Unable to parse test results: {}"}

def __init__(self, specs, test_class: Type[RacketTest] = RacketTest) -> None:
def __init__(
self,
specs,
test_class: Type[RacketTest] = RacketTest,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
) -> None:
"""
Initialize a racket tester using the specifications in specs.

This tester will create tests of type test_class.
"""
super().__init__(specs, test_class)
super().__init__(specs, test_class, resource_settings=resource_settings)

def run_racket_test(self) -> Dict[str, str]:
"""
Expand Down
14 changes: 14 additions & 0 deletions server/autotest_server/testers/tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Optional, Callable, Any, Type, Dict, List
from .specs import TestSpecs
import traceback
import resource


class TestError(Exception):
Expand Down Expand Up @@ -230,10 +231,16 @@ def __init__(
self,
specs: TestSpecs,
test_class: Optional[Type[Test]] = Test,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
) -> None:
self.specs = specs
self.test_class = test_class

if resource_settings is None:
self.resource_settings = []
else:
self.resource_settings = resource_settings

@staticmethod
def error_all(message: str, points_total: int = 0, expected: bool = False) -> str:
"""
Expand All @@ -257,13 +264,20 @@ def before_tester_run(self) -> None:
Callback invoked before running this tester.
Use this for tester initialization steps that can fail, rather than using __init__.
"""
self.set_resource_limits(self.resource_settings)

def after_tester_run(self) -> None:
"""
Callback invoked after running this tester, including in case of exceptions.
Use this for tester cleanup steps that should always be executed, regardless of errors.
"""

@staticmethod
def set_resource_limits(resource_settings: list[tuple[int, tuple[int, int]]]) -> None:
"""Sets system resource limits using the `resource` package."""
for resource_name, rlimit in resource_settings:
resource.setrlimit(resource_name, rlimit)

@staticmethod
def run_decorator(run_func: Callable) -> Callable:
"""
Expand Down
81 changes: 81 additions & 0 deletions server/autotest_server/tests/test_rlimit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import unittest
from unittest.mock import patch, MagicMock
import resource

from ..config import _Config
from ..utils import validate_rlimit, get_resource_settings


class TestValidateRlimit(unittest.TestCase):
def test_normal_limits(self):
"""Test validate_rlimit with normal positive values."""
self.assertEqual(validate_rlimit(100, 200, 150, 250), (100, 200))
self.assertEqual(validate_rlimit(200, 300, 100, 250), (100, 250))

def test_soft_limit_exceeding_hard_limit(self):
"""Test validate_rlimit where soft limit would exceed hard limit."""
self.assertEqual(validate_rlimit(500, 400, 300, 350), (300, 350))

def test_infinity_values(self):
"""Test validate_rlimit with -1 (resource.RLIM_INFINITY) values."""
self.assertEqual(validate_rlimit(-1, 200, 100, 150), (100, 150))
self.assertEqual(validate_rlimit(100, -1, 150, 200), (100, 200))
self.assertEqual(validate_rlimit(-1, -1, 100, 200), (100, 200))
self.assertEqual(validate_rlimit(100, 200, -1, 150), (100, 150))
self.assertEqual(validate_rlimit(100, 200, 150, -1), (100, 200))
self.assertEqual(validate_rlimit(100, 200, -1, -1), (100, 200))

def test_both_negative(self):
"""Test validate_rlimit where both config and current are negative."""
self.assertEqual(validate_rlimit(-1, -1, -1, -1), (-1, -1))

def test_mixed_negative_cases(self):
"""Test validate_rlimit with various mixed cases with negative values."""
self.assertEqual(validate_rlimit(-1, 200, -1, 300), (-1, 200))
self.assertEqual(validate_rlimit(100, -1, -1, -1), (100, -1))


class TestGetResourceSettings(unittest.TestCase):
@patch("resource.getrlimit")
def test_empty_config(self, _):
"""Test get_resource_settings with an empty config."""
config = _Config()
config.get = MagicMock(return_value={})

self.assertEqual(get_resource_settings(config), [])

@patch("resource.getrlimit")
def test_with_config_values(self, mock_getrlimit):
"""Test get_resource_settings with config containing values."""
config = _Config()
rlimit_settings = {"nofile": (1024, 2048), "nproc": (30, 60)}

# Setup config.get to return our rlimit_settings when called with "rlimit_settings"
config.get = lambda key, default=None: rlimit_settings if key == "rlimit_settings" else default

# Setup mock for resource.getrlimit to return different values
mock_getrlimit.side_effect = lambda limit: {
resource.RLIMIT_NOFILE: (512, 1024),
resource.RLIMIT_NPROC: (60, 90),
}[limit]

expected = [(resource.RLIMIT_NOFILE, (512, 1024)), (resource.RLIMIT_NPROC, (30, 60))]

self.assertEqual(get_resource_settings(config), expected)

@patch("resource.getrlimit")
def test_with_infinity_values(self, mock_getrlimit):
"""Test get_resource_settings with some infinity (-1) values in the mix."""
config = _Config()
rlimit_settings = {"nofile": (1024, -1), "nproc": (-1, 60)}

config.get = lambda key, default=None: rlimit_settings if key == "rlimit_settings" else default

mock_getrlimit.side_effect = lambda limit: {
resource.RLIMIT_NOFILE: (512, 1024),
resource.RLIMIT_NPROC: (60, 90),
}[limit]

expected = [(resource.RLIMIT_NOFILE, (512, 1024)), (resource.RLIMIT_NPROC, (60, 60))]

self.assertEqual(get_resource_settings(config), expected)
60 changes: 60 additions & 0 deletions server/autotest_server/tests/test_tester.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import unittest
from unittest.mock import patch, call
import resource
from typing import Type

from ..testers.specs import TestSpecs
from ..testers.tester import Tester, Test


class MockTester(Tester):
def __init__(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please omit the initializer entirely (as it isn't doing anything other than forwarding the arguments)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please clarify how you want me to do this, because I've tried a few things, but none of them worked.

I've tried:

class MockTester(Tester):
    def run(self) -> None:
        pass

In the above code I get TypeError: Can't instantiate abstract class MockTester without an implementation for abstract method '__init__'.

I've also tried:

class MockTester(Tester):
    def __init__(
        self,
        specs: TestSpecs,
        test_class: Type[Test] | None = Test,
        resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
    ) -> None:
        pass

    def run(self) -> None:
        pass

Which raises AttributeError: 'MockTester' object has no attribute 'resource_settings'.

Then I asked chat, which suggested to do:

class MockTester(Tester):
    __init__ = Tester.__init__

    def run(self) -> None:
        pass

But I still get TypeError: Can't instantiate abstract class MockTester without an implementation for abstract method '__init__'

I think this is because the Tester.__init__ method is defined as abstract, so it has to be implemented in the MockTester class. However, Tester.__init__ also does some setup, so all classes inhering from Tester must either call Tester.__init__ or reimplement that setup logic.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ch-iv you're right about this, I had missed the @abstractmethod decorator on the initializer. I'm good to leave this as-is!

self,
specs: TestSpecs,
test_class: Type[Test] | None = Test,
resource_settings: list[tuple[int, tuple[int, int]]] | None = None,
) -> None:
super().__init__(specs, test_class, resource_settings)

def run(self) -> None:
pass


class TestResourceLimits(unittest.TestCase):

@patch("resource.setrlimit")
def test_set_resource_limits_single_limit(self, mock_setrlimit):
"""Test setting a single resource limit."""
# Arrange
tester = MockTester(specs=TestSpecs(), resource_settings=[(resource.RLIMIT_CPU, (10, 20))])

# Act
tester.set_resource_limits(tester.resource_settings)

# Assert
mock_setrlimit.assert_called_once_with(resource.RLIMIT_CPU, (10, 20))

@patch("resource.setrlimit")
def test_set_resource_limits_multiple_limits(self, mock_setrlimit):
"""Test setting multiple resource limits."""
# Arrange
tester = MockTester(
specs=TestSpecs(),
resource_settings=[
(resource.RLIMIT_CPU, (10, 20)),
(resource.RLIMIT_NOFILE, (1024, 2048)),
(resource.RLIMIT_AS, (1024 * 1024 * 100, 1024 * 1024 * 200)),
],
)

# Act
tester.set_resource_limits(tester.resource_settings)

# Assert
expected_calls = [
call(resource.RLIMIT_CPU, (10, 20)),
call(resource.RLIMIT_NOFILE, (1024, 2048)),
call(resource.RLIMIT_AS, (1024 * 1024 * 100, 1024 * 1024 * 200)),
]
mock_setrlimit.assert_has_calls(expected_calls, any_order=False)
self.assertEqual(mock_setrlimit.call_count, 3)
Loading