diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 7c72e92..aabef78 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -9,8 +9,11 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
- unit-tests:
+ unit-tests-core:
runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: core
steps:
- uses: actions/checkout@v4
- name: Set up Python
@@ -25,13 +28,71 @@ jobs:
- name: Test with PyUnit and collect coverage
run: |
coverage run
- coverage combine
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+
+ unit-tests-python:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: languages/python
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.12
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+ cd ../../core
+ pip install .
+
+ - name: Test with PyUnit and collect coverage
+ run: |
+ coverage run
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ fail_ci_if_error: true
+ token: ${{ secrets.CODECOV_TOKEN }}
+
+ unit-tests-ipython:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: languages/python
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.12
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+ cd ../python
+ pip install .
+ cd ../../core
+ pip install .
+
+ - name: Test with PyUnit and collect coverage
+ run: |
+ coverage run
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ fail_ci_if_error: true
+ token: ${{ secrets.CODECOV_TOKEN }}
+
e2e-tests:
name: Run End to End Tests
runs-on: ubuntu-latest
@@ -45,8 +106,14 @@ jobs:
with:
python-version: "3.12"
- - name: Build container for tests
- run: docker compose build
+ - name: Build Core
+ run: |
+ docker build . -f ./docker/core.dockerfile -t ghcr.io/csci128/128autograder/core:local
+ docker build . -f ./docker/core.dockerfile -t ghcr.io/csci128/128autograder/core:latest
+ - name: Build iPython
+ run: docker build . -f ./docker/ipython.dockerfile -t ghcr.io/csci128/128autograder/ipython:local
+ - name: Build Python
+ run: docker build . -f ./docker/python.dockerfile -t ghcr.io/csci128/128autograder/python:local
- name: Run tests
run: docker compose up
@@ -54,9 +121,9 @@ jobs:
- name: Verify test outputs
run: bash tests/e2e/verify.sh
- build-and-push-image-generic:
+ build-and-push-image-core:
if: ${{ startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/main') }}
- needs: [ unit-tests, e2e-tests ]
+ needs: [ unit-tests-core, e2e-tests ]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -76,7 +143,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/generic
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/core
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
@@ -89,7 +156,7 @@ jobs:
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
- subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}/generic
+ subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}/core
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
@@ -97,8 +164,11 @@ jobs:
runs-on: ubuntu-latest
permissions:
id-token: write
- if: ${{ startsWith(github.ref, 'refs/tags/v') }}
- needs: [ unit-tests, e2e-tests ]
+ defaults:
+ run:
+ working-directory: core
+ if: ${{ startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/version_') }}
+ needs: [ unit-tests-core, e2e-tests ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
diff --git a/.github/workflows/macos-test.yaml b/.github/workflows/macos-test.yaml
deleted file mode 100644
index ee79528..0000000
--- a/.github/workflows/macos-test.yaml
+++ /dev/null
@@ -1,30 +0,0 @@
-# This config runs the unit tests on macOS
-
-name: Run unittests on macOS
-
-# WE ARE updating this to only run on PRs so that the we run the integreation pipeline less
-on:
- pull_request:
-
-jobs:
- build:
-
- runs-on: "macos-latest"
- strategy:
- matrix:
- python-version: ["3.11", "3.12" ]
-
- steps:
- - uses: actions/checkout@v4
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install dependencies
- run: |
- pip install -e .
-
- - name: Test with PyUnit
- run:
- python3 -m unittest
diff --git a/.github/workflows/platform-integration.yaml b/.github/workflows/platform-integration.yaml
new file mode 100644
index 0000000..34a2a82
--- /dev/null
+++ b/.github/workflows/platform-integration.yaml
@@ -0,0 +1,162 @@
+name: Platforms Integration Tests
+
+on:
+ pull_request:
+
+jobs:
+ macos-core-unittests:
+ runs-on: "macos-latest"
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12" ]
+ defaults:
+ run:
+ working-directory: core
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+
+ - name: Run tests
+ run: |
+ python3 -m unittest
+
+ windows-core-unittests:
+ runs-on: "windows-latest"
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12" ]
+ defaults:
+ run:
+ working-directory: core
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+
+ - name: Run tests
+ run: |
+ python3 -m unittest
+
+ macos-python-unittests:
+ runs-on: "macos-latest"
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12" ]
+ defaults:
+ run:
+ working-directory: languages/python
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+ cd ../../core
+ pip install .
+
+ - name: Run Tests
+ run: |
+ python3 -m unittest
+
+
+ windows-python-unittests:
+ runs-on: "windows-latest"
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12" ]
+ defaults:
+ run:
+ working-directory: languages/python
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+ cd ../../core
+ pip install .
+
+ - name: Run Tests
+ run: |
+ python3 -m unittest
+
+ windows-ipython-unittests:
+ runs-on: "windows-latest"
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12" ]
+ defaults:
+ run:
+ working-directory: languages/python
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+ cd ../python
+ pip install .
+ cd ../../core
+ pip install .
+
+ - name: Run Tests
+ run: |
+ python3 -m unittest
+
+ macos-ipython-unittests:
+ runs-on: "macos-latest"
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12" ]
+ defaults:
+ run:
+ working-directory: languages/python
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ pip install -e '.[dev]'
+ cd ../python
+ pip install .
+ cd ../../core
+ pip install .
+
+ - name: Run Tests
+ run: |
+ python3 -m unittest
diff --git a/.github/workflows/windows-test.yaml b/.github/workflows/windows-test.yaml
deleted file mode 100644
index 869721c..0000000
--- a/.github/workflows/windows-test.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-# This config runs the unit tests on windows
-
-name: Run unittests on Windows
-
-on:
- pull_request:
-
-jobs:
- build:
-
- runs-on: "windows-latest"
- strategy:
- matrix:
- python-version: ["3.11", "3.12" ]
-
- steps:
- - uses: actions/checkout@v4
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install dependencies
- run: |
- pip install -e .
-
- - name: Test with PyUnit
- run:
- python3 -m unittest
diff --git a/.gitignore b/.gitignore
index 21659b0..42de288 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,5 @@ tests/e2e/*/output
!.keep
build
-*.egg-info
\ No newline at end of file
+*.egg-info
+sandbox/
\ No newline at end of file
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..f08a21d
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,23 @@
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the OS, Python version, and other tools you might need
+build:
+ os: ubuntu-24.04
+ tools:
+ python: "3.13"
+
+# Build documentation in the "docs/" directory with Sphinx
+sphinx:
+ configuration: docs/source/conf.py
+
+# Optionally, but recommended,
+# declare the Python requirements required to build your documentation
+# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+python:
+ install:
+ - requirements: docs/requirements.txt
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e711a85
--- /dev/null
+++ b/README.md
@@ -0,0 +1,100 @@
+
+
+# The 128 Autograder Platform
+> The battle tested code autograding platform for introductory students
+
+Initially developed by Gregory Bell, currently maintained by Gregory Bell (gjbell [at] mines).
+
+If you need support, please open an issue in this repo,
+send the maintainers an email,
+or reach to out the maintainers on Teams (preferred).
+
+If you are a student having an issue with the autograder:
+please reach out to your course staff for support as it is likely an issue with their implementation.
+
+If you are a student trying to install the autograder as part of your course setup:
+you are likely in the wrong place.
+CSCI128 students can find the course setup scripts in [CSCI128/CourseSetup](CSCI128/CourseSetup).
+
+## What is this?
+This is the monorepo for the 128 Autograder Platform source code.
+
+Currently, there are language bindings for Python and IPython (via Jupyter Notebooks).
+If you have a language request,
+please reach out to Gregory Bell to discuss your needs and if you would be best served by this platform.
+
+## What is this not?
+This is not where you should be developing assignments or autograders for assignments.
+
+## Who is this for?
+Anyone who wants to save time grading programming assignments and spending more time supporting students.
+
+This has been deployed for thousands of introductory level python students since Fall of 2023,
+and has saved CS@Mines thousands of grading hours allowing course staff to spend more time supporting students.
+
+## What sets this platform apart?
+It has native support for running locally on students' machines (we have deployed on Windows, macOS, many flavors of Linux, and even ChromeOS).
+This allows students to get rapid feedback on their code with public test cases.
+
+It has native support for Gradescope's autograder,
+allowing students to submit their final code for grading against all the public *and private* test cases.
+
+It has native support for PrairieLearn's external grading platform,
+allowing all the benefits of PrairieLearn's learning through mastery philosophy.
+
+It supports:
+- Transparently mocking out external libraries (like matplotlib)
+- Dynamically injecting code into student's submissions
+- Flexible execution modes
+- Grabbing variables from the student's submission
+- Complex file IO
+- Dynamically installing external libraries
+- Allowing students to define their own test cases
+- Complex submission structures
+
+If have an idea for how you want to test a student's submission, odds are, this platform can accommodate you.
+
+## Installation (for students)
+
+We highly recommend
+that course instructors give students a setup script to automatically install the dependencies needed for their course.
+
+You can see CSCI128's setup scripts here: [CSCI128/CourseSetup](CSCI128/CourseSetup).
+
+Note: Python 3.12 now refuses to install into the system directory by default (for good reason).
+You may need to pass the `--break-system-packages` flag to allow the installation
+(not recommended) or use virtual environments
+(slightly more recommended).
+However, with CSCI128,
+we lean on the `--break-system-packages` method
+as students found the usage of virtual environments to be very confusing.
+
+You can also likely use `pipx` but, that has not been tested so your milage may vary.
+
+If you needed to install the 128Autograder for a Python class, you would run:
+
+```shell
+pip install 128Autograder[python]
+```
+Note the name of the language in the brackets, those are called 'extras' in pip-land.
+
+They allow you to only install the dependencies that you need.
+
+Each language that this platform supports is provided as an extra
+so that you don't need to install extra stuff that you aren't going to use.
+
+
+## Installation (for assignment developers)
+
+```shell
+pip install 128Autograder[python-dev]
+```
+
+## Installation (for maintainers)
+
+```shell
+cd core
+pip install -e '.[dev]'
+cd ../language_binds/python
+pip install -e '.[dev]'
+```
diff --git a/core/.coverage b/core/.coverage
new file mode 100644
index 0000000..2130593
Binary files /dev/null and b/core/.coverage differ
diff --git a/core/pyproject.toml b/core/pyproject.toml
new file mode 100644
index 0000000..a1f4e4b
--- /dev/null
+++ b/core/pyproject.toml
@@ -0,0 +1,81 @@
+[build-system]
+ requires = ["setuptools >= 61"]
+ build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where=["source"]
+exclude =["tests"]
+
+[tool.setuptools.dynamic]
+ version = {attr = "autograder_platform.__version__"}
+
+[project]
+ name = "128Autograder"
+ authors = [
+ { name = "Gregory Bell" }
+ ]
+ maintainers = [
+ { name = "Gregory Bell" }
+ ]
+
+ dynamic = ["version"]
+
+ requires-python = ">=3.11.0"
+
+ dependencies = [
+ "HybridJSONTestRunner==1.0.0",
+ "Better-PyUnit-Format==0.2.3",
+ "schema==0.7.5",
+ "requests==2.31.0",
+ "tomli==2.0.1",
+ ]
+
+[project.optional-dependencies]
+ dev = [
+ "coverage[toml]",
+ "build",
+ ]
+
+[project.scripts]
+ run_gradescope = "autograder_cli.run_gradescope:tool"
+ run_prairielearn = "autograder_cli.run_prairielearn:tool"
+ test_my_work = "autograder_cli.run_local:tool"
+ run_autograder = "autograder_cli.run_local:tool"
+ create_gradescope_upload = "autograder_cli.create_upload:tool"
+ build_autograder = "autograder_cli.build_autograder:tool"
+
+[tool.pyright]
+include = ["source"]
+
+exclude = ["**__pycache__**"]
+scrict = ["."]
+
+executionEnvironments = [
+ { root = "tests", extraPaths = ["source", "source/utils/student", "source/utils"] }
+]
+
+[tool.coverage.run]
+command_line = "-m unittest"
+omit = [
+ # TODO write unit tests for the CLI
+ "tests/**",
+ "source/autograder_cli/*",
+ "__getstate__*",
+ "__setstate__*",
+]
+
+[tool.coverage.report]
+fail_under = 90
+skip_empty = true
+exclude_also = [
+ # Dont flag error conditions that cant be reached or that are just defensive
+ "raise AssertionError",
+ "raise NotImplementedError",
+ "raise EnvironmentError",
+ "raise AttributeError",
+ "raise InvalidRunner",
+ "if __name__ == .__main__.:",
+ # Don't complain about abstract methods, they aren't run:
+ "@(abc\\.)?abstractmethod",
+]
+
diff --git a/source/autograder_cli/__init__.py b/core/source/autograder_cli/__init__.py
similarity index 100%
rename from source/autograder_cli/__init__.py
rename to core/source/autograder_cli/__init__.py
diff --git a/source/autograder_cli/build_autograder.py b/core/source/autograder_cli/build_autograder.py
similarity index 100%
rename from source/autograder_cli/build_autograder.py
rename to core/source/autograder_cli/build_autograder.py
diff --git a/source/autograder_cli/create_upload.py b/core/source/autograder_cli/create_upload.py
similarity index 100%
rename from source/autograder_cli/create_upload.py
rename to core/source/autograder_cli/create_upload.py
diff --git a/source/autograder_cli/run_gradescope.py b/core/source/autograder_cli/run_gradescope.py
similarity index 100%
rename from source/autograder_cli/run_gradescope.py
rename to core/source/autograder_cli/run_gradescope.py
diff --git a/source/autograder_cli/run_local.py b/core/source/autograder_cli/run_local.py
similarity index 93%
rename from source/autograder_cli/run_local.py
rename to core/source/autograder_cli/run_local.py
index f288c14..5eaf1f6 100644
--- a/source/autograder_cli/run_local.py
+++ b/core/source/autograder_cli/run_local.py
@@ -146,7 +146,7 @@ def get_autograder_version(path) -> Optional[str]:
def compare_autograder_versions(self, required_version: str) -> bool:
version = list(map(int, required_version.split(".")))
- actual_version = list(map(int, self.get_version().split(".")))
+ actual_version = list(map(int, self.get_version().split(".")[:3]))
status = False
if version[0] != actual_version[0]:
@@ -174,7 +174,7 @@ def update_autograder(self, version) -> bool:
def select_root(self) -> Optional[str]:
autograders = []
- full_path = os.path.abspath(".")
+ full_path = os.path.abspath("")
self.print_info_message(f"Discovering autograders in {full_path}...")
@@ -222,7 +222,9 @@ def configure_options(self): # pragma: no cover
help="The location for the tests for the autograder relative to the submission root")
self.parser.add_argument("--bypass-version-check", action="store_true", default=False,
help="Bypass autograder version verification. Note: This may cause the autograder to fail!")
- self.parser.add_argument("--version", action="store_true", default=False, help="Print out version and exit")
+
+ self.parser.add_argument("--bypass-submission-check", action="store_true", default=False,
+ help="Bypass submission presence check. Note: This may cause the autograder to fail!")
def set_config_arguments(self, configBuilder: AutograderConfigurationBuilder[AutograderConfiguration]): # pragma: no cover
pass
@@ -259,10 +261,13 @@ def run(self) -> bool: # pragma: no cover
"Update failed! Please see above for failure reason or rerun as 'test_my_work --bypass-version-check'")
return True
- if not self.verify_student_work_present(os.path.join(root_directory, self.arguments.submission_directory)):
+ if not self.arguments.bypass_submission_check and not self.verify_student_work_present(os.path.join(root_directory, self.arguments.submission_directory)):
return True
- fileChanged = self.verify_file_changed(os.path.join(root_directory, self.arguments.submission_directory))
+ self.discover_installed_language_binds(self.arguments.additional_languages)
+
+ # assume submission has changed if we are disabling submission checks
+ fileChanged = self.verify_file_changed(os.path.join(root_directory, self.arguments.submission_directory)) if not self.arguments.bypass_submission_check else True
self.config = AutograderConfigurationBuilder() \
.fromTOML(self.config_location) \
diff --git a/source/autograder_cli/run_prairielearn.py b/core/source/autograder_cli/run_prairielearn.py
similarity index 100%
rename from source/autograder_cli/run_prairielearn.py
rename to core/source/autograder_cli/run_prairielearn.py
diff --git a/source/autograder_platform/Executors/Environment.py b/core/source/autograder_platform/Executors/Environment.py
similarity index 99%
rename from source/autograder_platform/Executors/Environment.py
rename to core/source/autograder_platform/Executors/Environment.py
index cadfca8..9f716ce 100644
--- a/source/autograder_platform/Executors/Environment.py
+++ b/core/source/autograder_platform/Executors/Environment.py
@@ -21,7 +21,7 @@ def __getitem__(self, file: str) -> Union[str, bytes]:
readFile: Union[str, bytes] = ""
try:
- with open(self.files[file], 'r') as r:
+ with open(self.files[file], 'r', encoding="utf-8") as r:
readFile = r.read()
except UnicodeDecodeError:
with open(self.files[file], 'rb') as rb:
diff --git a/source/autograder_platform/Executors/Executor.py b/core/source/autograder_platform/Executors/Executor.py
similarity index 100%
rename from source/autograder_platform/Executors/Executor.py
rename to core/source/autograder_platform/Executors/Executor.py
diff --git a/source/autograder_platform/Executors/__init__.py b/core/source/autograder_platform/Executors/__init__.py
similarity index 100%
rename from source/autograder_platform/Executors/__init__.py
rename to core/source/autograder_platform/Executors/__init__.py
diff --git a/source/autograder_platform/Executors/common.py b/core/source/autograder_platform/Executors/common.py
similarity index 100%
rename from source/autograder_platform/Executors/common.py
rename to core/source/autograder_platform/Executors/common.py
diff --git a/core/source/autograder_platform/Registration/LanguageRegistration.py b/core/source/autograder_platform/Registration/LanguageRegistration.py
new file mode 100644
index 0000000..434af5b
--- /dev/null
+++ b/core/source/autograder_platform/Registration/LanguageRegistration.py
@@ -0,0 +1,38 @@
+import dataclasses
+from typing import Generic, TypeVar, Optional, Tuple, Any, Type
+from collections.abc import Callable
+
+from autograder_platform.Executors.Environment import ImplEnvironment
+from autograder_platform.StudentSubmission.AbstractStudentSubmission import AbstractStudentSubmission
+from autograder_platform.StudentSubmission.ISubmissionProcess import ISubmissionProcess
+from autograder_platform.StudentSubmission.SubmissionProcessFactory import SubmissionProcessFactory
+from autograder_platform.config import Config
+from autograder_platform.config.Config import AutograderConfiguration, AutograderConfigurationSchema
+from autograder_platform.config.BaseSchema import BaseSchema
+
+LanguageConfigType = TypeVar('LanguageConfigType')
+
+
+@dataclasses.dataclass
+class LanguageRegistrationMetadata(Generic[LanguageConfigType]):
+ name: str
+ version: str
+
+ on_submission_process_factory_registration: Callable[
+ [],
+ Tuple[
+ Type[AbstractStudentSubmission[Any]],
+ Type[ISubmissionProcess],
+ Optional[Type[ImplEnvironment]],
+ Optional[Callable[[ImplEnvironment, AutograderConfiguration], None]]
+ ],
+ ]
+
+ on_config_registration: Optional[Callable[[], BaseSchema[LanguageConfigType]]] = None
+
+def register_language(metadata: LanguageRegistrationMetadata):
+ if metadata.on_config_registration is not None:
+ AutograderConfigurationSchema.register_sub_schema(metadata.name, metadata.on_config_registration())
+
+ SubmissionProcessFactory.register(*metadata.on_submission_process_factory_registration())
+
diff --git a/core/source/autograder_platform/Registration/Registrar.py b/core/source/autograder_platform/Registration/Registrar.py
new file mode 100644
index 0000000..a18ac61
--- /dev/null
+++ b/core/source/autograder_platform/Registration/Registrar.py
@@ -0,0 +1,3 @@
+from autograder_platform.Registration.LanguageRegistration import LanguageRegistrationMetadata
+
+
diff --git a/source/autograder_platform/StudentSubmission/__init__.py b/core/source/autograder_platform/Registration/__init__.py
similarity index 100%
rename from source/autograder_platform/StudentSubmission/__init__.py
rename to core/source/autograder_platform/Registration/__init__.py
diff --git a/source/autograder_platform/StudentSubmission/AbstractStudentSubmission.py b/core/source/autograder_platform/StudentSubmission/AbstractStudentSubmission.py
similarity index 61%
rename from source/autograder_platform/StudentSubmission/AbstractStudentSubmission.py
rename to core/source/autograder_platform/StudentSubmission/AbstractStudentSubmission.py
index 15918e5..f4ac3f0 100644
--- a/source/autograder_platform/StudentSubmission/AbstractStudentSubmission.py
+++ b/core/source/autograder_platform/StudentSubmission/AbstractStudentSubmission.py
@@ -1,6 +1,7 @@
import abc
-from typing import Generic, List, Set, TypeVar, Dict
+from typing import Generic, List, Set, TypeVar, Dict, Type
+from autograder_platform.StudentSubmission.ITransformer import ITransformer
from autograder_platform.StudentSubmission.common import ValidationError, ValidationHook
from autograder_platform.StudentSubmission.AbstractValidator import AbstractValidator
@@ -9,7 +10,7 @@
T = TypeVar("T")
# for some reason this has to be TBuilder??
-TBuilder = TypeVar("TBuilder", bound="AbstractStudentSubmission[Any]")
+Builder = TypeVar("Builder", bound="AbstractStudentSubmission[Any]")
class AbstractStudentSubmission(abc.ABC, Generic[T]):
@@ -18,29 +19,30 @@ class AbstractStudentSubmission(abc.ABC, Generic[T]):
===========
This class contains the abstract student submission.
- Basically, this enables more of a plug and play architechture for different submission models.
+ Basically, this enables more of a plug and play architecture for different submission models.
This model also allows a cleaner and more consistent way to implement validation of the submission.
- You can implement :ref:`AbstractValidator` for your purpose and assign it a hook.
- Then when we reach that phase of the submission, that hook will be validated.
+ You can implement: ref:`AbstractValidator` for your purpose and assign it a hook.
+ Then, when we reach that phase of the submission, that hook will be validated.
Subclasses must implement ``doLoad`` and ``doBuild``.
Subclasses must also implement ``getExecutableSubmission`` which should return the submission in a state that can be executed
by whatever runner is implemented.
- For an example, take a look at the Python (or coming soon, the C / C++) implementation.
+ For example, take a look at the Python (or coming soon, the C / C++) implementation.
"""
def __init__(self):
self.submissionRoot: str = "."
self.validators: Dict[ValidationHook, Set[AbstractValidator]] = {}
self.validationErrors: List[Exception] = []
+ self.submissionTransformers: List[ITransformer] = []
# default validators
self.addValidator(SubmissionPathValidator())
- def setSubmissionRoot(self: TBuilder, submissionRoot: str) -> TBuilder:
+ def setSubmissionRoot(self: Builder, submissionRoot: str) -> Builder:
"""
Description
---
@@ -55,7 +57,7 @@ def setSubmissionRoot(self: TBuilder, submissionRoot: str) -> TBuilder:
self.submissionRoot = submissionRoot
return self
- def addValidator(self: TBuilder, validator: AbstractValidator) -> TBuilder:
+ def addValidator(self: Builder, validator: AbstractValidator) -> Builder:
"""
Description
---
@@ -68,7 +70,8 @@ def addValidator(self: TBuilder, validator: AbstractValidator) -> TBuilder:
The hook to use is determined by the abstract static method ``AbstractValidator.getValidationHook()``.
Only one validator of each type is allowed, ie: If we pass two validators of type ValidateAST,
- only the last one added will be run. This enforces single responsibity.
+ only the last one added will be run.
+ This enforces the single responsibility principal.
:param validator: the validator to add subject to the above information.
:returns: self
@@ -81,6 +84,54 @@ def addValidator(self: TBuilder, validator: AbstractValidator) -> TBuilder:
self.validators[hook].add(validator)
return self
+ def addTransformer(self: Builder, transformer: ITransformer) -> Builder:
+ self.submissionTransformers.append(transformer)
+
+ return self
+
+ def runManualValidationHook(self, validatorType: Type[AbstractValidator]):
+ if ValidationHook.MANUAL not in self.validators.keys():
+ raise RuntimeError("Request to run manual hook, but no manual hooks have been defined!")
+
+ ran = False
+ for validator in self.validators[ValidationHook.MANUAL]:
+ if isinstance(validator, validatorType):
+ validator.setup(self)
+ validator.run()
+ self.validationErrors.extend(validator.collectErrors())
+ # we are only allowed to have one of each type, so once we execute, we should break
+ ran = True
+ break
+
+ if not ran:
+ raise RuntimeError(f"Request to run manual hook {validatorType}, but that manual hook has not been defined!")
+
+ if self.validationErrors:
+ raise ValidationError(self.validationErrors)
+
+ def runTransformers(self, submissionText: str) -> str:
+ """
+ Description
+ ---
+
+ Runs all transformers in the order added.
+ This functionality allows the user to mutate student submissions, BUT should be used sparely.
+
+ This is used with the iPython bindings to remove all the magic commands so that cells can be parsed into their
+ AST representation.
+
+ Transformers operate on the entire submission text, so keep that in mind when writing.
+
+ :param submissionText: The student's entire submission text.
+ Generally, should only be one file but is dependent on the implementation.
+ :returns: The transformed submission.
+ """
+
+ for transformer in self.submissionTransformers:
+ submissionText = transformer.transform(submissionText)
+
+ return submissionText
+
def _validate(self, validationHook: ValidationHook):
if validationHook not in self.validators.keys():
return
@@ -102,7 +153,7 @@ def doLoad(self):
def doBuild(self):
raise NotImplementedError()
- def load(self: TBuilder) -> TBuilder:
+ def load(self: Builder) -> Builder:
"""
Description
---
@@ -128,7 +179,7 @@ def load(self: TBuilder) -> TBuilder:
self._validate(ValidationHook.POST_LOAD)
return self
- def build(self: TBuilder) -> TBuilder:
+ def build(self: Builder) -> Builder:
"""
Description
---
@@ -154,7 +205,7 @@ def build(self: TBuilder) -> TBuilder:
self._validate(ValidationHook.POST_BUILD)
return self
- def validate(self: TBuilder) -> TBuilder:
+ def validate(self: Builder) -> Builder:
"""
Description
---
diff --git a/source/autograder_platform/StudentSubmission/AbstractValidator.py b/core/source/autograder_platform/StudentSubmission/AbstractValidator.py
similarity index 89%
rename from source/autograder_platform/StudentSubmission/AbstractValidator.py
rename to core/source/autograder_platform/StudentSubmission/AbstractValidator.py
index 81a21e0..8669a8d 100644
--- a/source/autograder_platform/StudentSubmission/AbstractValidator.py
+++ b/core/source/autograder_platform/StudentSubmission/AbstractValidator.py
@@ -13,7 +13,7 @@ def __init__(self):
self.errors: List[Exception] = []
@abc.abstractmethod
- # this should be typed, but its a weird cross depenacny issue
+ # this should be typed, but its cross-dependency issue
def setup(self, studentSubmission):
pass
diff --git a/source/autograder_platform/StudentSubmission/GenericValidators.py b/core/source/autograder_platform/StudentSubmission/GenericValidators.py
similarity index 87%
rename from source/autograder_platform/StudentSubmission/GenericValidators.py
rename to core/source/autograder_platform/StudentSubmission/GenericValidators.py
index deb4e18..55abf89 100644
--- a/source/autograder_platform/StudentSubmission/GenericValidators.py
+++ b/core/source/autograder_platform/StudentSubmission/GenericValidators.py
@@ -27,11 +27,7 @@ def run(self):
self.addError(
NotADirectoryError(f"{self.pathToValidate} is not a directory!")
)
-
- if not os.access(self.pathToValidate, os.R_OK):
- self.addError(
- PermissionError(f"Unable to read from {self.pathToValidate}!")
- )
+ return
if len(os.listdir(self.pathToValidate)) < 1:
self.addError(
diff --git a/source/autograder_platform/StudentSubmission/ISubmissionProcess.py b/core/source/autograder_platform/StudentSubmission/ISubmissionProcess.py
similarity index 100%
rename from source/autograder_platform/StudentSubmission/ISubmissionProcess.py
rename to core/source/autograder_platform/StudentSubmission/ISubmissionProcess.py
diff --git a/core/source/autograder_platform/StudentSubmission/ITransformer.py b/core/source/autograder_platform/StudentSubmission/ITransformer.py
new file mode 100644
index 0000000..af827b2
--- /dev/null
+++ b/core/source/autograder_platform/StudentSubmission/ITransformer.py
@@ -0,0 +1,6 @@
+import abc
+
+class ITransformer(abc.ABC):
+ @abc.abstractmethod
+ def transform(self, string: str) -> str:
+ raise NotImplementedError()
diff --git a/source/autograder_platform/StudentSubmission/SubmissionProcessFactory.py b/core/source/autograder_platform/StudentSubmission/SubmissionProcessFactory.py
similarity index 100%
rename from source/autograder_platform/StudentSubmission/SubmissionProcessFactory.py
rename to core/source/autograder_platform/StudentSubmission/SubmissionProcessFactory.py
diff --git a/source/autograder_platform/StudentSubmissionImpl/__init__.py b/core/source/autograder_platform/StudentSubmission/__init__.py
similarity index 100%
rename from source/autograder_platform/StudentSubmissionImpl/__init__.py
rename to core/source/autograder_platform/StudentSubmission/__init__.py
diff --git a/source/autograder_platform/StudentSubmission/common.py b/core/source/autograder_platform/StudentSubmission/common.py
similarity index 91%
rename from source/autograder_platform/StudentSubmission/common.py
rename to core/source/autograder_platform/StudentSubmission/common.py
index a0bec78..106d102 100644
--- a/source/autograder_platform/StudentSubmission/common.py
+++ b/core/source/autograder_platform/StudentSubmission/common.py
@@ -8,6 +8,7 @@ class ValidationHook(Enum):
PRE_BUILD = 3
POST_BUILD = 4
VALIDATION = 5
+ MANUAL = 6
class MissingFunctionDefinition(Exception):
@@ -20,10 +21,10 @@ def __init__(self, functionName: str):
self.functionName = functionName
# https://stackoverflow.com/questions/16244923/how-to-make-a-custom-exception-class-with-multiple-init-args-pickleable
- # Basically - reduce has to return something that we constuct the og class from
+ # Basically - reduce has to return something that we construct the og class from
def __reduce__(self):
# Need to be (something,) so that it actually gets processed as a tuple in the pickler
- return (MissingFunctionDefinition, (self.functionName,))
+ return MissingFunctionDefinition, (self.functionName,)
class InvalidTestCaseSetupCode(Exception):
diff --git a/source/autograder_platform/Tasks/Task.py b/core/source/autograder_platform/Tasks/Task.py
similarity index 100%
rename from source/autograder_platform/Tasks/Task.py
rename to core/source/autograder_platform/Tasks/Task.py
diff --git a/source/autograder_platform/Tasks/TaskRunner.py b/core/source/autograder_platform/Tasks/TaskRunner.py
similarity index 100%
rename from source/autograder_platform/Tasks/TaskRunner.py
rename to core/source/autograder_platform/Tasks/TaskRunner.py
diff --git a/source/autograder_platform/Tasks/__init__.py b/core/source/autograder_platform/Tasks/__init__.py
similarity index 100%
rename from source/autograder_platform/Tasks/__init__.py
rename to core/source/autograder_platform/Tasks/__init__.py
diff --git a/source/autograder_platform/Tasks/common.py b/core/source/autograder_platform/Tasks/common.py
similarity index 100%
rename from source/autograder_platform/Tasks/common.py
rename to core/source/autograder_platform/Tasks/common.py
diff --git a/source/autograder_platform/TestingFramework/Assertions.py b/core/source/autograder_platform/TestingFramework/Assertions.py
similarity index 100%
rename from source/autograder_platform/TestingFramework/Assertions.py
rename to core/source/autograder_platform/TestingFramework/Assertions.py
diff --git a/source/autograder_platform/TestingFramework/SingleFunctionMock.py b/core/source/autograder_platform/TestingFramework/SingleFunctionMock.py
similarity index 100%
rename from source/autograder_platform/TestingFramework/SingleFunctionMock.py
rename to core/source/autograder_platform/TestingFramework/SingleFunctionMock.py
diff --git a/source/autograder_platform/TestingFramework/__init__.py b/core/source/autograder_platform/TestingFramework/__init__.py
similarity index 100%
rename from source/autograder_platform/TestingFramework/__init__.py
rename to core/source/autograder_platform/TestingFramework/__init__.py
diff --git a/core/source/autograder_platform/__init__.py b/core/source/autograder_platform/__init__.py
new file mode 100644
index 0000000..3594c89
--- /dev/null
+++ b/core/source/autograder_platform/__init__.py
@@ -0,0 +1 @@
+__version__ = "6.0.0.RC-2"
diff --git a/source/autograder_platform/cli.py b/core/source/autograder_platform/cli.py
similarity index 73%
rename from source/autograder_platform/cli.py
rename to core/source/autograder_platform/cli.py
index b880f16..d1ddc13 100644
--- a/source/autograder_platform/cli.py
+++ b/core/source/autograder_platform/cli.py
@@ -1,16 +1,18 @@
import abc
import argparse
+import importlib
import unittest.loader
from argparse import ArgumentParser
-from typing import List, Callable, Dict, Optional
+from typing import List, Optional
from unittest import TestSuite
import autograder_platform
from autograder_platform.config.Config import AutograderConfigurationBuilder, AutograderConfigurationProvider, \
AutograderConfiguration
-class AutograderCLITool(abc.ABC):
+KNOWN_REGISTRATIONS_NAMES = ["language_binds.IPython", "language_binds.Python"]
+class AutograderCLITool(abc.ABC):
PACKAGE_ERROR: str = "Required Package Error"
SUBMISSION_ERROR: str = "Student Submission Error"
ENVIRONMENT_ERROR: str = "Environment Error"
@@ -50,6 +52,10 @@ def __init__(self, tool_name: str):
# required CLI arguments
self.parser.add_argument("--config-file", default="./config.toml",
help="Set the location of the config file")
+ self.parser.add_argument("--additional-languages", action="extend", nargs="+", default=[],
+ help="The import names for each additional language not provided in the base plugin set. The import should register via `Registration.Registrar` in `__init__.py`.")
+
+ self.parser.add_argument("--version", action="store_true", default=False, help="Print out version and exit")
@staticmethod
def get_version() -> str:
@@ -70,6 +76,8 @@ def run(self) -> bool:
def load_config(self): # pragma: no cover
self.arguments = self.parser.parse_args()
+ self.discover_installed_language_binds(self.arguments.additional_languages)
+
# load toml then override any options in toml with things that are passed to the runtime
builder = AutograderConfigurationBuilder() \
.fromTOML(file=self.arguments.config_file)
@@ -80,6 +88,17 @@ def load_config(self): # pragma: no cover
AutograderConfigurationProvider.set(self.config)
+ def discover_installed_language_binds(self, additional_languages: List[str]): # pragma: no cover
+ to_discover = KNOWN_REGISTRATIONS_NAMES
+ to_discover.extend(additional_languages)
+
+ for module in to_discover:
+ try:
+ mod = importlib.import_module(module)
+ # self.print_info_message(f"Successfully registered {module} at {mod.__version__}")
+ except ImportError:
+ pass
+
def discover_tests(self): # pragma: no cover
self.tests = unittest.loader.defaultTestLoader.discover(self.config.config.test_directory)
diff --git a/core/source/autograder_platform/config/BaseSchema.py b/core/source/autograder_platform/config/BaseSchema.py
new file mode 100644
index 0000000..0bfcf87
--- /dev/null
+++ b/core/source/autograder_platform/config/BaseSchema.py
@@ -0,0 +1,26 @@
+from typing import Dict, Generic, TypeVar
+from abc import ABC, abstractmethod
+
+T = TypeVar("T")
+
+BaseSchemaType = TypeVar('BaseSchemaType', bound='BaseSchema[Any]')
+
+class BaseSchema(Generic[T], ABC):
+ _registered_sub_schemas: Dict[str, BaseSchemaType] = {}
+
+ @classmethod
+ def register_sub_schema(cls, language_name: str, sub_schema: BaseSchemaType):
+ cls._registered_sub_schemas[language_name] = sub_schema
+
+ @classmethod
+ def deregister_sub_schema(cls, language_name: str):
+ del cls._registered_sub_schemas[language_name]
+
+ @abstractmethod
+ def validate(self, data: Dict) -> Dict:
+ raise NotImplementedError()
+
+ @abstractmethod
+ def build(self, data: Dict) -> T:
+ raise NotImplementedError()
+
diff --git a/source/autograder_platform/config/Config.py b/core/source/autograder_platform/config/Config.py
similarity index 74%
rename from source/autograder_platform/config/Config.py
rename to core/source/autograder_platform/config/Config.py
index 598f5c2..191c285 100644
--- a/source/autograder_platform/config/Config.py
+++ b/core/source/autograder_platform/config/Config.py
@@ -1,12 +1,15 @@
import importlib
import os
-from typing import Dict, Generic, List, Optional as OptionalType, TypeVar, Any
+from tomli import load
+from typing import Dict, Generic, Optional as OptionalType, TypeVar, Any
from dataclasses import dataclass
-from schema import And, Optional, Or, Regex, Schema, SchemaError
+from schema import And, Optional, Regex, Schema, SchemaError
-from autograder_platform.config.common import BaseSchema, MissingParsingLibrary, InvalidConfigException
+from autograder_platform.config.common import InvalidConfigException
+from autograder_platform.config.BaseSchema import BaseSchema
+LanguageConfigType = TypeVar('LanguageConfigType')
@dataclass(frozen=True)
class BuildConfiguration:
@@ -39,49 +42,6 @@ class BuildConfiguration:
public_tests_regex: str
"""The pattern that should be used to identify public tests"""
-
-@dataclass(frozen=True)
-class PythonConfiguration:
- """
- Python Configuration
- ====================
-
- This class defines extra parameters for when the autograder is running in Python
- """
- extra_packages: List[Dict[str, str]]
- """
- The extra packages that should be added to the autograder on build.
- Must be stored in 'package_name': 'version'. Similar to requirements.txt
- """
- buffer_size: int
- """
- The size of the output buffer when the autograder runs
- """
-
-
-@dataclass(frozen=True)
-class CConfiguration:
- """
- C/C++/C-Like Configuration
- ==========================
-
- This defines the extra parameters for when the autograder is running for c like languages
- """
-
- use_makefile: bool
- """
- If a makefile should be used for building
- """
- clean_target: str
- """
- The target that should be used to clean. Invoked as `make {clean_target}`
- """
- submission_name: str
- """
- The file name that should be executed
- """
-
-
@dataclass(frozen=True)
class BasicConfiguration:
"""
@@ -90,8 +50,8 @@ class BasicConfiguration:
This class defines the basic autograder configuration
"""
- impl_to_use: str
- """The StudentSubmission Implementation to use"""
+ language_to_use: str
+ """The language to use"""
student_submission_directory: str
"""The folder that the student submission is in"""
autograder_version: str
@@ -113,14 +73,9 @@ class BasicConfiguration:
The max score that students can get with extra credit.
Points greater than this will not be honored.
"""
- python: OptionalType[PythonConfiguration] = None
- """Extra python spefic configuration. See :ref:`PythonConfiguration` for options"""
- c: OptionalType[CConfiguration] = None
- """Extra C/C-like spefic configuration. See :ref:`CConfiguration` for options"""
-
@dataclass(frozen=True)
-class AutograderConfiguration:
+class AutograderConfiguration(Generic[LanguageConfigType]):
"""
Autograder Configuration
========================
@@ -137,6 +92,8 @@ class AutograderConfiguration:
"""The autograder's root directory where the config.toml file is located"""
config: BasicConfiguration
"""The basic settings for the autograder. See :ref:`BasicConfiguration` for options."""
+ language_config: OptionalType[LanguageConfigType]
+ """The language config for the autograder. See the the language's config for options."""
build: BuildConfiguration
"""The build configuration for the autograder. See :ref:`BuildConfiguration` for options."""
@@ -152,24 +109,20 @@ class AutograderConfigurationSchema(BaseSchema[AutograderConfiguration]):
This class builds to: ref:`AutograderConfiguration` for easy typing.
"""
- IMPL_SOURCE = "StudentSubmissionImpl"
- @staticmethod
- def validateImplSource(implName: str) -> bool:
- try:
- importlib.import_module(f"autograder_platform.{AutograderConfigurationSchema.IMPL_SOURCE}.{implName}")
- except ImportError:
- return False
- return True
+ @classmethod
+ def validateImplSource(cls, implName: str) -> bool:
+ return implName in cls._registered_sub_schemas
def __init__(self):
self.currentSchema: Schema = Schema(
{
"assignment_name": And(str, Regex(r"^(\w+-?)+$")),
"semester": And(str, Regex(r"^(F|S|SUM)\d{2}$")),
- Optional("autograder_root", default="."): And(os.path.exists, os.path.isdir, lambda path: "config.toml" in os.listdir(path)),
+ # TODO: need to make this use the current config file name
+ Optional("autograder_root", default="."): And(os.path.exists, os.path.isdir, lambda path: any([".toml" in file for file in os.listdir(path)])),
"config": {
- "impl_to_use": And(str, AutograderConfigurationSchema.validateImplSource),
+ "language_to_use": And(str, AutograderConfigurationSchema.validateImplSource),
Optional("student_submission_directory", default="."): And(str, os.path.exists, os.path.isdir),
"autograder_version": And(str, Regex(r"\d+\.\d+\.\d+")),
"test_directory": And(str, os.path.exists),
@@ -179,18 +132,6 @@ def __init__(self):
Optional("allow_extra_credit", default=False): bool,
"perfect_score": And(int, lambda x: x >= 1),
"max_score": And(int, lambda x: x >= 1),
- Optional("python", default=None): Or({
- Optional("extra_packages", default=lambda: []): [{
- "name": str,
- "version": str,
- }],
- Optional("buffer_size", default=2 ** 20): And(int, lambda x: x >= 2 ** 20)
- }, None),
- Optional("c", default=None): Or({
- "use_makefile": bool,
- "clean_target": str,
- "submission_name": And(str, lambda x: len(x) >= 1)
- }, None),
},
"build": {
"use_starter_code": bool,
@@ -204,9 +145,11 @@ def __init__(self):
Optional("student_work_folder", default="student_work"): str,
Optional("private_tests_regex", default=r"^test_private_?\w*\.py$"): str,
Optional("public_tests_regex", default=r"^test_?\w*\.py$"): str,
- }
+ },
+ # allow extra top level keys to be caught so we dont have to discard them
+ object: dict,
},
- ignore_extra_keys=False, name="ConfigSchema"
+ ignore_extra_keys=True, name="ConfigSchema"
)
def validate(self, data: Dict) -> Dict:
@@ -226,10 +169,13 @@ def validate(self, data: Dict) -> Dict:
except SchemaError as schemaError:
raise InvalidConfigException(str(schemaError))
- impl_to_use = validated["config"]["impl_to_use"].lower()
+ if validated["config"]['language_to_use'] in self._registered_sub_schemas:
+ validated = self._registered_sub_schemas[validated["config"]['language_to_use']].validate(validated)
- if impl_to_use not in validated["config"] or validated["config"][impl_to_use] is None:
- raise InvalidConfigException(f"Missing Implementation Config for config.{impl_to_use}")
+ impl_to_use = validated["config"]["language_to_use"]
+
+ if impl_to_use not in validated or validated[impl_to_use] is None:
+ raise InvalidConfigException(f"Missing Implementation Config for {impl_to_use}")
if validated["build"]["use_starter_code"] and validated["build"]["starter_code_source"] is None:
raise InvalidConfigException("Missing starter code file location")
@@ -239,22 +185,28 @@ def validate(self, data: Dict) -> Dict:
return validated
- def build(self, data: Dict) -> AutograderConfiguration:
+ def build(self, data: Dict) -> AutograderConfiguration[LanguageConfigType]:
"""
Description
---
This method builds the provided data into the known config format.
In this case, it builds into the ``AutograderConfiguration`` format.
- Data should be validated before calling this method as it uses dictionary expandsion to populate the config objects.
+ Data should be validated before calling this method as it uses dictionary expansion to populate the config objects.
Doing this allows us to have a strongly typed config format to be used later in the autograder.
"""
- if data["config"]["python"] is not None:
- data["config"]["python"] = PythonConfiguration(**data["config"]["python"])
- if data["config"]["c"] is not None:
- data["config"]["c"] = CConfiguration(**data["config"]["c"])
+ language_config: OptionalType[LanguageConfigType] = None
+
+ if data["config"]['language_to_use'] in self._registered_sub_schemas and data[data['config']['language_to_use']] is not None:
+ language_config = self._registered_sub_schemas[data['config']['language_to_use']].build(data)
+ else:
+ raise InvalidConfigException("Language to use has not been registered!")
+
+ data['language_config'] = language_config
+
+ del data[data["config"]['language_to_use']]
data["config"] = BasicConfiguration(**data["config"])
data["build"] = BuildConfiguration(**data["build"])
@@ -294,11 +246,6 @@ def fromTOML(self: Builder, file=DEFAULT_CONFIG_FILE, merge=True) -> Builder:
Attempt to load the autograder config from the TOML config file.
This file is assumed to be located in the same directory as the actual test cases
"""
- try:
- from tomli import load
- except ModuleNotFoundError:
- raise MissingParsingLibrary("tomlkit", "AutograderConfigurationBuilder.fromTOML")
-
with open(file, 'rb') as rb:
self.data = load(rb)
diff --git a/source/autograder_platform/config/__init__.py b/core/source/autograder_platform/config/__init__.py
similarity index 100%
rename from source/autograder_platform/config/__init__.py
rename to core/source/autograder_platform/config/__init__.py
diff --git a/core/source/autograder_platform/config/common.py b/core/source/autograder_platform/config/common.py
new file mode 100644
index 0000000..bf5852c
--- /dev/null
+++ b/core/source/autograder_platform/config/common.py
@@ -0,0 +1,4 @@
+
+class InvalidConfigException(Exception):
+ def __init__(self, msg):
+ super().__init__(msg)
diff --git a/source/config.toml b/core/source/config.toml
similarity index 100%
rename from source/config.toml
rename to core/source/config.toml
diff --git a/tests/__init__.py b/core/tests/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to core/tests/__init__.py
diff --git a/tests/cli_tests/__init__.py b/core/tests/cli_tests/__init__.py
similarity index 100%
rename from tests/cli_tests/__init__.py
rename to core/tests/cli_tests/__init__.py
diff --git a/tests/cli_tests/testBuildAutograder.py b/core/tests/cli_tests/testBuildAutograder.py
similarity index 100%
rename from tests/cli_tests/testBuildAutograder.py
rename to core/tests/cli_tests/testBuildAutograder.py
diff --git a/tests/cli_tests/testCreateUpload.py b/core/tests/cli_tests/testCreateUpload.py
similarity index 100%
rename from tests/cli_tests/testCreateUpload.py
rename to core/tests/cli_tests/testCreateUpload.py
diff --git a/tests/cli_tests/testRunGradescope.py b/core/tests/cli_tests/testRunGradescope.py
similarity index 100%
rename from tests/cli_tests/testRunGradescope.py
rename to core/tests/cli_tests/testRunGradescope.py
diff --git a/tests/cli_tests/testRunLocal.py b/core/tests/cli_tests/testRunLocal.py
similarity index 100%
rename from tests/cli_tests/testRunLocal.py
rename to core/tests/cli_tests/testRunLocal.py
diff --git a/tests/cli_tests/testRunPrarielearn.py b/core/tests/cli_tests/testRunPrarielearn.py
similarity index 100%
rename from tests/cli_tests/testRunPrarielearn.py
rename to core/tests/cli_tests/testRunPrarielearn.py
diff --git a/tests/platform_tests/__init__.py b/core/tests/platform_tests/__init__.py
similarity index 100%
rename from tests/platform_tests/__init__.py
rename to core/tests/platform_tests/__init__.py
diff --git a/core/tests/platform_tests/testAbstractStudentSubmission.py b/core/tests/platform_tests/testAbstractStudentSubmission.py
new file mode 100644
index 0000000..c1bea3f
--- /dev/null
+++ b/core/tests/platform_tests/testAbstractStudentSubmission.py
@@ -0,0 +1,276 @@
+import unittest
+import os
+import shutil
+
+from autograder_platform.StudentSubmission.AbstractStudentSubmission import AbstractStudentSubmission
+from autograder_platform.StudentSubmission.AbstractValidator import AbstractValidator
+from autograder_platform.StudentSubmission.ITransformer import ITransformer
+from autograder_platform.StudentSubmission.common import ValidationError, ValidationHook
+
+
+class StudentSubmission(AbstractStudentSubmission[str]):
+ def __init__(self):
+ super().__init__()
+ self.studentCode: str = "No code"
+
+ def doLoad(self):
+ self.studentCode = self.runTransformers("Loaded code!")
+
+ def doBuild(self):
+ self.studentCode = "Built code!"
+
+ def getExecutableSubmission(self) -> str:
+ return self.studentCode
+
+
+class CodeLoadedValidator(AbstractValidator):
+ @staticmethod
+ def getValidationHook() -> ValidationHook:
+ return ValidationHook.POST_LOAD
+
+ def __init__(self):
+ super().__init__()
+ self.code = ""
+
+ def setup(self, studentSubmission):
+ self.code = studentSubmission.studentCode
+
+ def run(self):
+ if not self.code:
+ self.addError(Exception("Code was not loaded!"))
+
+
+class FileExistsValidator(AbstractValidator):
+ @staticmethod
+ def getValidationHook() -> ValidationHook:
+ return ValidationHook.PRE_LOAD
+
+ def __init__(self, fileName):
+ super().__init__()
+ self.submissionRoot = ""
+ self.fileName = fileName
+
+ def setup(self, studentSubmission):
+ self.submissionRoot = studentSubmission.getSubmissionRoot()
+
+ def run(self):
+ if self.fileName not in self.submissionRoot:
+ self.addError(Exception(f"{self.fileName} does not exist!"))
+
+class ManualValidator(AbstractValidator):
+ @staticmethod
+ def getValidationHook() -> ValidationHook:
+ return ValidationHook.MANUAL
+
+ def __init__(self, raiseError=False):
+ super().__init__()
+ self.called = False
+ self.raiseError = raiseError
+
+ def setup(self, studentSubmission):
+ self.called = True
+
+ def run(self):
+ if not self.called:
+ self.addError(Exception("Setup was not called!"))
+ if self.raiseError:
+ self.addError(Exception("Manual validator error!"))
+
+
+class SimpleTransformer(ITransformer):
+ def __init__(self, textToSet: str):
+ self.textToSet = textToSet
+
+ def transform(self, string: str) -> str:
+ return self.textToSet
+
+
+class TestAbstractStudentSubmission(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.initialDirectory = os.getcwd()
+ cls.TEST_DIR = os.path.join(cls.initialDirectory, "sandbox")
+
+ def setUp(self) -> None:
+ if os.path.exists(self.TEST_DIR):
+ shutil.rmtree(self.TEST_DIR)
+
+ os.mkdir(self.TEST_DIR)
+ os.chdir(self.TEST_DIR)
+
+ def tearDown(self) -> None:
+ os.chdir(self.initialDirectory)
+
+ if os.path.exists(self.TEST_DIR):
+ shutil.rmtree(self.TEST_DIR)
+
+ def testSetSubmissionRootExists(self):
+ submissionRoot = "./submission"
+ os.mkdir(submissionRoot)
+ with open(os.path.join(submissionRoot, "file.txt"), 'w') as w:
+ w.write("FILE!")
+
+ studentSubmission = StudentSubmission()\
+ .setSubmissionRoot(submissionRoot)\
+ .addValidator(CodeLoadedValidator())\
+ .load()\
+ .build()\
+ .validate()
+
+ self.assertEqual("Built code!", studentSubmission.getExecutableSubmission())
+
+ def testSetSubmissionRootDoesntExist(self):
+ submissionRoot = "./submission"
+ with self.assertRaises(ValidationError) as validationError:
+ StudentSubmission()\
+ .setSubmissionRoot(submissionRoot)\
+ .load()
+
+ exceptionText = str(validationError.exception)
+
+ self.assertIn("Validation Errors:", exceptionText)
+ self.assertIn(f"1. FileNotFoundError: {submissionRoot} does not exist", exceptionText)
+
+ def testSubmissionRootIsNotDirectory(self):
+ file = os.path.join(self.TEST_DIR, "file")
+ with open(file, 'w') as w:
+ w.write("\n")
+
+ with self.assertRaises(ValidationError) as validationError:
+ StudentSubmission() \
+ .setSubmissionRoot(file) \
+ .load()
+
+ exceptionText = str(validationError.exception)
+
+ self.assertIn(f"1. NotADirectoryError: {file} is not a directory", exceptionText)
+
+ def testSubmissionRootEmptyDirectory(self):
+ with self.assertRaises(ValidationError) as validationError:
+ StudentSubmission() \
+ .setSubmissionRoot(self.TEST_DIR) \
+ .load()
+
+ exceptionText = str(validationError.exception)
+
+ self.assertIn(f"no files found", exceptionText.lower())
+
+
+ def testAddValidatorForExistingFile(self):
+ filename = "file.txt"
+
+ with self.assertRaises(ValidationError) as validationError:
+ StudentSubmission()\
+ .addValidator(FileExistsValidator(filename))\
+ .setSubmissionRoot("./dne")\
+ .load()
+
+ exceptionText = str(validationError.exception)
+
+ self.assertIn("2.", exceptionText)
+ self.assertIn(f"{filename} does not exist", exceptionText)
+
+
+ def testManualValidatorNotCalled(self):
+ manualValidator = ManualValidator()
+
+ submissionRoot = "./submission"
+ os.mkdir(submissionRoot)
+ with open(os.path.join(submissionRoot, "file.txt"), 'w') as w:
+ w.write("FILE!")
+
+ submission = StudentSubmission()\
+ .setSubmissionRoot(submissionRoot) \
+ .addValidator(manualValidator) \
+ .addValidator(CodeLoadedValidator()) \
+ .load() \
+ .build() \
+ .validate()
+
+ self.assertFalse(manualValidator.called)
+
+ def testManualValidatorCalled(self):
+ manualValidator = ManualValidator()
+
+ submissionRoot = "./submission"
+ os.mkdir(submissionRoot)
+ with open(os.path.join(submissionRoot, "file.txt"), 'w') as w:
+ w.write("FILE!")
+
+ submission = StudentSubmission() \
+ .setSubmissionRoot(submissionRoot) \
+ .addValidator(manualValidator) \
+ .addValidator(CodeLoadedValidator()) \
+ .load() \
+ .build() \
+ .validate()
+ submission.runManualValidationHook(ManualValidator)
+
+ self.assertTrue(manualValidator.called)
+
+ def testNoManualValidatorsDefined(self):
+ submissionRoot = "./submission"
+ os.mkdir(submissionRoot)
+ with open(os.path.join(submissionRoot, "file.txt"), 'w') as w:
+ w.write("FILE!")
+
+ submission = StudentSubmission() \
+ .setSubmissionRoot(submissionRoot) \
+ .addValidator(CodeLoadedValidator()) \
+ .load() \
+ .build() \
+ .validate()
+
+ with self.assertRaises(RuntimeError):
+ submission.runManualValidationHook(ManualValidator)
+
+ def testIncorrectManualValidatorCalled(self):
+ manualValidator = ManualValidator()
+ submissionRoot = "./submission"
+ os.mkdir(submissionRoot)
+ with open(os.path.join(submissionRoot, "file.txt"), 'w') as w:
+ w.write("FILE!")
+
+ submission = StudentSubmission() \
+ .setSubmissionRoot(submissionRoot) \
+ .addValidator(CodeLoadedValidator()) \
+ .addValidator(manualValidator) \
+ .load() \
+ .build() \
+ .validate()
+
+ with self.assertRaises(RuntimeError):
+ submission.runManualValidationHook(CodeLoadedValidator)
+
+ def testManualValidatorRaisesError(self):
+ manualValidator = ManualValidator(raiseError=True)
+ submissionRoot = "./submission"
+ os.mkdir(submissionRoot)
+ with open(os.path.join(submissionRoot, "file.txt"), 'w') as w:
+ w.write("FILE!")
+
+ submission = StudentSubmission() \
+ .setSubmissionRoot(submissionRoot) \
+ .addValidator(CodeLoadedValidator()) \
+ .addValidator(manualValidator) \
+ .load() \
+ .build() \
+ .validate()
+
+ with self.assertRaises(ValidationError):
+ submission.runManualValidationHook(ManualValidator)
+
+ def testRunTransformer(self):
+ expected = "this was transformed!"
+
+ submissionRoot = "./submission"
+ os.mkdir(submissionRoot)
+ with open(os.path.join(submissionRoot, "file.txt"), 'w') as w:
+ w.write("FILE!")
+
+ submission = StudentSubmission() \
+ .setSubmissionRoot(submissionRoot) \
+ .addTransformer(SimpleTransformer(expected))\
+ .load()
+
+ self.assertEqual(expected, submission.getExecutableSubmission())
diff --git a/tests/platform_tests/testAssertions.py b/core/tests/platform_tests/testAssertions.py
similarity index 100%
rename from tests/platform_tests/testAssertions.py
rename to core/tests/platform_tests/testAssertions.py
diff --git a/tests/platform_tests/testAutograderConfigMisc.py b/core/tests/platform_tests/testAutograderConfigMisc.py
similarity index 98%
rename from tests/platform_tests/testAutograderConfigMisc.py
rename to core/tests/platform_tests/testAutograderConfigMisc.py
index d1d89a3..ddc2f7b 100644
--- a/tests/platform_tests/testAutograderConfigMisc.py
+++ b/core/tests/platform_tests/testAutograderConfigMisc.py
@@ -5,7 +5,7 @@
import unittest
from autograder_platform.config.Config import AutograderConfigurationBuilder, AutograderConfigurationProvider
-from autograder_platform.config.common import BaseSchema
+from autograder_platform.config.BaseSchema import BaseSchema
@dataclass
diff --git a/tests/platform_tests/testAutograderConfigurationSchema.py b/core/tests/platform_tests/testAutograderConfigurationSchema.py
similarity index 50%
rename from tests/platform_tests/testAutograderConfigurationSchema.py
rename to core/tests/platform_tests/testAutograderConfigurationSchema.py
index a496478..ed3dcb5 100644
--- a/tests/platform_tests/testAutograderConfigurationSchema.py
+++ b/core/tests/platform_tests/testAutograderConfigurationSchema.py
@@ -1,75 +1,96 @@
import os
import shutil
import unittest
+from dataclasses import dataclass
+from typing import Dict, Optional as OptionalType
-from autograder_platform.config.Config import AutograderConfigurationSchema, InvalidConfigException
+from schema import Schema, Optional
+from autograder_platform.config.BaseSchema import BaseSchema
+from autograder_platform.config.Config import AutograderConfigurationSchema, InvalidConfigException, \
+ AutograderConfiguration
+
+
+@dataclass(frozen=True)
+class TestImplConfig:
+ a: bool
+ required: bool
+
+class SubSchema(BaseSchema[OptionalType[TestImplConfig]]):
+ def __init__(self):
+ self.schema: Schema = Schema({
+ "test_impl": {
+ Optional("a", default=False): bool,
+ "required": bool,
+ },
+ }, ignore_extra_keys=True, name="TestImplSchema")
+
+ def validate(self, data: Dict) -> Dict:
+ if "test_impl" not in data:
+ return data
+
+ data["test_impl"] = self.schema.validate(data)["test_impl"]
+
+ return data
+
+ def build(self, data: Dict) -> OptionalType[TestImplConfig]:
+ if "test_impl" not in data or not data["test_impl"]:
+ return None
+
+ return TestImplConfig(**data["test_impl"])
class TestAutograderConfigurationSchema(unittest.TestCase):
def setUp(self) -> None:
+ AutograderConfigurationSchema.register_sub_schema("test_impl", SubSchema())
self.configFile = {
"assignment_name": "HelloWold",
"semester": "F99",
"config": {
- "impl_to_use": "Python",
+ "language_to_use": "test_impl",
"autograder_version": "2.0.0",
"test_directory": ".",
"enforce_submission_limit": True,
"perfect_score": 10,
"max_score": 10,
- "python": {},
},
"build": {
"use_starter_code": False,
"use_data_files": False,
"build_student": True,
"build_gradescope": True,
- }
+ },
+ "test_impl": {
+ "required": False,
+ },
}
- @staticmethod
- def createAutograderConfigurationSchema() -> AutograderConfigurationSchema:
- return AutograderConfigurationSchema()
+ def tearDown(self):
+ AutograderConfigurationSchema.deregister_sub_schema("test_impl")
def testValidNoOptionalFields(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
actual = schema.validate(self.configFile)
self.assertIn("submission_limit", actual["config"])
- self.assertIn("buffer_size", actual["config"]["python"])
def testValidOptionalFields(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
- self.configFile["config"]["python"] = {}
+ self.configFile["config"]["take_highest"] = True
actual = schema.validate(self.configFile)
- self.assertIn("extra_packages", actual["config"]["python"])
- self.assertIn("buffer_size", actual["config"]["python"])
+ self.assertIn("take_highest", actual["config"])
+ self.assertEqual(False, actual["test_impl"]["a"])
def testInvalidOptionalFields(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
- self.configFile["config"]["python"] = {}
- self.configFile["config"]["python"]["extra_packages"] = [{"name": "package"}]
+ self.configFile["config"]["take_highest"] = 10
with self.assertRaises(InvalidConfigException):
schema.validate(self.configFile)
- def testValidOptionalNestedFields(self):
- schema = self.createAutograderConfigurationSchema()
-
- self.configFile["config"]["python"] = {}
- packages = [{"name": "package", "version": "1.0.0"}]
- self.configFile["config"]["python"]["extra_packages"] = packages
- self.configFile["config"]["python"]["buffer_size"] = 2 * 2 ** 20
-
- actual = schema.validate(self.configFile)
-
- self.assertEqual(packages, actual["config"]["python"]["extra_packages"])
- self.assertEqual(2*2**20, actual["config"]["python"]["buffer_size"])
-
def testExtraFields(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
self.configFile["new_field"] = "This field shouldn't exist"
@@ -77,7 +98,7 @@ def testExtraFields(self):
schema.validate(self.configFile)
def testInvalidAutograderVersion(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
self.configFile["config"]["autograder_version"] = "0.0"
@@ -85,7 +106,7 @@ def testInvalidAutograderVersion(self):
schema.validate(self.configFile)
def testBuildNoOptional(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
data = schema.validate(self.configFile)
@@ -94,54 +115,8 @@ def testBuildNoOptional(self):
self.assertEqual("F99", actual.semester)
self.assertEqual(1000, actual.config.submission_limit)
- def testBuildWithOptional(self):
- schema = self.createAutograderConfigurationSchema()
-
- self.configFile["config"]["python"] = {}
-
- data = schema.validate(self.configFile)
-
- actual = schema.build(data)
-
- if actual.config.python is None:
- self.fail("config.python was None when it shouldn't be!")
-
- self.assertIsNotNone(actual.config.python.extra_packages)
-
- @unittest.skip("C is no longer supported")
- def testBuildWithCImpl(self):
- schema = self.createAutograderConfigurationSchema()
- self.configFile["config"]["impl_to_use"] = "C"
- self.configFile["config"]["c"] = {}
- self.configFile["config"]["c"]["use_makefile"] = True
- self.configFile["config"]["c"]["clean_target"] = "clean"
- self.configFile["config"]["c"]["submission_name"] = "PROJECT"
-
- data = schema.validate(self.configFile)
-
- actual = schema.build(data)
-
- if actual.config.c is None:
- self.fail("config.c was None when it shouldn't be!")
-
-
- self.assertIsNotNone(actual.config.c.use_makefile)
- self.assertIsNotNone(actual.config.c.submission_name)
-
- def testBuildWithCImplInvalidName(self):
- schema = self.createAutograderConfigurationSchema()
- self.configFile["config"]["impl_to_use"] = "C"
-
- self.configFile["config"]["c"] = {}
- self.configFile["config"]["c"]["use_makefile"] = True
- self.configFile["config"]["c"]["clean_target"] = "clean"
- self.configFile["config"]["c"]["submission_name"] = ""
-
- with self.assertRaises(InvalidConfigException):
- schema.validate(self.configFile)
-
def testMissingLocationStarterCode(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
self.configFile["build"]["use_starter_code"] = True
@@ -149,7 +124,7 @@ def testMissingLocationStarterCode(self):
schema.validate(self.configFile)
def testMissingLocationDataFiles(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
self.configFile["build"]["use_data_files"] = True
@@ -157,15 +132,15 @@ def testMissingLocationDataFiles(self):
schema.validate(self.configFile)
def testMissingImplConfig(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
- self.configFile["config"]["python"] = None # type: ignore
+ self.configFile["test_impl"] = None # type: ignore
with self.assertRaises(InvalidConfigException):
schema.validate(self.configFile)
def testValidateImplValid(self):
- res = AutograderConfigurationSchema.validateImplSource("Python")
+ res = AutograderConfigurationSchema.validateImplSource("test_impl")
self.assertTrue(res)
@@ -175,9 +150,9 @@ def testValidateImplInvalid(self):
self.assertFalse(res)
def testAutograderRootDNE(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
- newDir = "autograder_root"
+ newDir = "DNE"
self.configFile["autograder_root"] = newDir
@@ -185,7 +160,7 @@ def testAutograderRootDNE(self):
schema.validate(self.configFile)
def testAutograderRootNoConfig(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
newDir = "autograder_root"
@@ -203,7 +178,7 @@ def testAutograderRootNoConfig(self):
shutil.rmtree(newDir)
def testAutograderRootValidWithConfig(self):
- schema = self.createAutograderConfigurationSchema()
+ schema = AutograderConfigurationSchema()
newDir = "autograder_root"
@@ -225,6 +200,52 @@ def testAutograderRootValidWithConfig(self):
self.assertEqual(newDir, actual["autograder_root"])
+ def testBuildSubSchema(self):
+ schema = AutograderConfigurationSchema()
+
+ self.configFile["test_impl"]["required"] = True
+
+ validated = schema.validate(self.configFile)
+
+ actual: AutograderConfiguration[TestImplConfig] = schema.build(validated)
+
+ if actual.language_config is None:
+ self.fail("language config was unexpectedly null")
+
+ self.assertEqual(self.configFile["test_impl"]["required"], actual.language_config.required)
+ self.assertEqual(False, actual.language_config.a)
+
+ def testUndefinedSubSchema(self):
+ schema = AutograderConfigurationSchema()
+ self.configFile["config"]["language_to_use"] = "DNE"
+
+ with self.assertRaises(InvalidConfigException):
+ schema.validate(self.configFile)
+
+ @unittest.skip("For now, I dont think I want this to be an error")
+ def testMultipleSubSchemas(self):
+ schema = AutograderConfigurationSchema()
+ self.configFile["new_sub_schema"] = {}
+
+ with self.assertRaises(InvalidConfigException):
+ schema.validate(self.configFile)
+
+ def testBuildUndefinedSubSchema(self):
+ schema = AutograderConfigurationSchema()
+ self.configFile["test_impl"] = None # type: ignore
+
+ with self.assertRaises(InvalidConfigException):
+ schema.build(self.configFile)
+
+
+ def testIncorrectImplConfig(self):
+ schema = AutograderConfigurationSchema()
+
+ self.configFile["new_sub_schema"] = {}
+ del self.configFile["test_impl"]
+
+ with self.assertRaises(InvalidConfigException):
+ schema.validate(self.configFile)
diff --git a/tests/platform_tests/testBuild.py b/core/tests/platform_tests/testBuild.py
similarity index 100%
rename from tests/platform_tests/testBuild.py
rename to core/tests/platform_tests/testBuild.py
diff --git a/tests/platform_tests/testEnvironment.py b/core/tests/platform_tests/testEnvironment.py
similarity index 52%
rename from tests/platform_tests/testEnvironment.py
rename to core/tests/platform_tests/testEnvironment.py
index ab274b7..bf11673 100644
--- a/tests/platform_tests/testEnvironment.py
+++ b/core/tests/platform_tests/testEnvironment.py
@@ -1,9 +1,21 @@
+import base64
+import dataclasses
import os
import shutil
import unittest
from autograder_platform.Executors.Environment import ExecutionEnvironment, ExecutionEnvironmentBuilder, Results, getResults
+@dataclasses.dataclass
+class TestImplEnvironment:
+ a_int: int
+
+class TestImplEnvironmentBuilder:
+ def build(self, a: int) -> TestImplEnvironment:
+ return TestImplEnvironment(a)
+
+
+
class TestEnvironmentBuilder(unittest.TestCase):
# For builds, we are only testing the non-trivial functions and the non-trivial cases
@@ -66,6 +78,22 @@ def test0Timeout(self):
.setTimeout(0) \
.build()
+ def testDefineImplEnvironment(self):
+ expected = 2
+ environment: ExecutionEnvironment[TestImplEnvironment, str] = ExecutionEnvironmentBuilder[TestImplEnvironment, str]()\
+ .setImplEnvironment(TestImplEnvironmentBuilder, lambda x: x.build(expected))\
+ .build()
+
+ self.assertIsNotNone(environment.impl_environment)
+ self.assertEqual(expected, environment.impl_environment.a_int)
+
+ def testDefineImplEnvironmentTwice(self):
+ with self.assertRaises(EnvironmentError):
+ ExecutionEnvironmentBuilder[TestImplEnvironment, str]() \
+ .setImplEnvironment(TestImplEnvironmentBuilder, lambda x: x.build(2)) \
+ .setImplEnvironment(TestImplEnvironmentBuilder, lambda x: x.build(2)) \
+ .build()
+
class TestEnvironmentGetResults(unittest.TestCase):
DATA_DIRECTORY: str = "./test_data"
@@ -121,3 +149,87 @@ def testGetOrAssertEmptyStdout(self):
exceptionText = str(error.exception)
self.assertIn("No OUTPUT was created by the student's submission.", exceptionText)
+
+ def testImplResultsUndefined(self):
+ self.environment.resultData = Results()
+
+ res = None
+ with self.assertRaises(AssertionError) as error:
+ res = getResults(self.environment).impl_results
+
+ self.assertIsNone(res)
+
+ msg = str(error.exception)
+
+ self.assertIn("no implementation results were set", msg.lower())
+
+ def testGetImplResultsDefined(self):
+ expected = "Impl results!"
+ self.environment.resultData = Results(impl_results=expected)
+
+ res = getResults(self.environment).impl_results
+
+ self.assertEqual(expected, res)
+
+
+ def testGetReturnValueUndefined(self):
+ self.environment.resultData = Results()
+
+ res = getResults(self.environment).return_val
+
+ self.assertIsNone(res)
+
+ def testGetReturnValue(self):
+ expected = 10
+ self.environment.resultData = Results(return_val=expected)
+
+ res = getResults(self.environment).return_val
+
+ self.assertEqual(expected, res)
+
+ def testGetStdoutDefined(self):
+ expected = ["some text"]
+ self.environment.resultData = Results(stdout=expected)
+
+ res = getResults(self.environment).stdout
+
+ self.assertEqual(expected, res)
+
+ def testGetParametersUndefined(self):
+ self.environment.resultData = Results()
+
+ res = None
+ with self.assertRaises(AssertionError) as error:
+ res = getResults(self.environment).parameter
+
+ self.assertIsNone(res)
+
+ msg = str(error.exception)
+
+ self.assertIn("no parameters were set", msg.lower())
+
+ def testGetParametersDefined(self):
+ expected = (10, 9, 8)
+ self.environment.resultData = Results(parameters=expected)
+
+ res = getResults(self.environment).parameter
+
+ self.assertEqual(expected, res)
+
+ def testGetFileNonUnicode(self):
+ # a 1x1 transparent png
+ expectedOutput = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+
+ with open(self.OUTPUT_FILE_LOCATION, 'wb') as w:
+ w.write(base64.b64decode(expectedOutput))
+
+ self.environment.resultData = Results(file_out={
+ os.path.basename(self.OUTPUT_FILE_LOCATION): self.OUTPUT_FILE_LOCATION
+ })
+
+ actualOutput = getResults(self.environment).file_out[os.path.basename(self.OUTPUT_FILE_LOCATION)]
+
+ self.assertIsInstance(actualOutput, bytes)
+
+ self.assertEqual(expectedOutput, base64.b64encode(actualOutput))
+
diff --git a/tests/platform_tests/testExecutor.py b/core/tests/platform_tests/testExecutor.py
similarity index 100%
rename from tests/platform_tests/testExecutor.py
rename to core/tests/platform_tests/testExecutor.py
diff --git a/tests/platform_tests/testSingleFunctionMock.py b/core/tests/platform_tests/testSingleFunctionMock.py
similarity index 100%
rename from tests/platform_tests/testSingleFunctionMock.py
rename to core/tests/platform_tests/testSingleFunctionMock.py
diff --git a/tests/platform_tests/testTasks.py b/core/tests/platform_tests/testTasks.py
similarity index 100%
rename from tests/platform_tests/testTasks.py
rename to core/tests/platform_tests/testTasks.py
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 15be55d..2e9e243 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,7 +1,8 @@
services:
test_many_non_main_files:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/python.dockerfile
context: .
entrypoint:
- run_gradescope
@@ -14,8 +15,9 @@ services:
- "./tests/e2e/integration_config.toml:/autograder/source/config.toml"
test_requirements_in_submission:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/python.dockerfile
context: .
entrypoint:
- run_gradescope
@@ -39,8 +41,9 @@ services:
test_metadata_attack:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/python.dockerfile
context: .
entrypoint:
- run_gradescope
@@ -56,8 +59,9 @@ services:
condition: service_completed_successfully
test_data_file_in_submission:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/python.dockerfile
context: .
entrypoint:
- run_gradescope
@@ -70,8 +74,9 @@ services:
- "./tests/e2e/integration_config.toml:/autograder/source/config.toml"
test_build:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/core.dockerfile
context: .
entrypoint: build_autograder
working_dir: /autograder/source
@@ -85,8 +90,9 @@ services:
- "./tests/e2e/test_build/starter_code/:/autograder/source/starter_code"
test_run_local:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/python.dockerfile
context: .
entrypoint: ["sh", "/app/entrypoint.sh"]
working_dir: /app/source
@@ -98,8 +104,9 @@ services:
- "./tests/e2e/test_run_test_my_work/test_my_work_config.toml:/app/source/config.toml"
test_run_local_nested:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/python.dockerfile
context: .
entrypoint: ["sh", "/app/entrypoint.sh"]
working_dir: /app/source
@@ -111,8 +118,9 @@ services:
- "./tests/e2e/test_run_local_in_nested_with_data/test_my_work_config.toml:/app/source/config.toml"
test_run_with_prairie_learn:
+ image: ghcr.io/csci128/128autograder/python:local
build:
- dockerfile: docker/generic.dockerfile
+ dockerfile: docker/python.dockerfile
context: .
entrypoint: ["sh", "/app/entrypoint.sh"]
working_dir: /app/source
@@ -124,5 +132,20 @@ services:
- "./tests/e2e/test_run_with_prairie_learn/data.json:/grade/data/data.json"
- "./tests/e2e/test_run_with_prairie_learn/pl_config.toml:/app/source/config.toml"
+ test_ipython_execution:
+ image: ghcr.io/csci128/128autograder/ipython:local
+ build:
+ dockerfile: docker/ipython.dockerfile
+ context: .
+ entrypoint:
+ - run_gradescope
+ working_dir: /autograder/source
+ volumes:
+ - "./tests/e2e/test_ipython_execution/results:/autograder/results"
+ - "./tests/e2e/test_ipython_execution/student_tests:/autograder/source/student_tests"
+ - "./tests/e2e/test_ipython_execution/submission:/autograder/submission"
+ - "./tests/e2e/test_ipython_execution/submission_metadata.json:/autograder/submission_metadata.json"
+ - "./tests/e2e/test_ipython_execution/config.toml:/autograder/source/config.toml"
+
volumes:
metadata_attack_vol:
\ No newline at end of file
diff --git a/docker/generic.dockerfile b/docker/core.dockerfile
similarity index 67%
rename from docker/generic.dockerfile
rename to docker/core.dockerfile
index 5ca2e0f..e792192 100644
--- a/docker/generic.dockerfile
+++ b/docker/core.dockerfile
@@ -2,11 +2,10 @@ FROM python:3.12-alpine
RUN apk add --no-cache git && \
- apk add --no-cache build-base && \
apk add --no-cache bash
-ADD . /tmp/source
-RUN python3 -m pip install --break-system-packages /tmp/source
+ADD core /tmp/core
+RUN python3 -m pip install --break-system-packages '/tmp/core/'
RUN mkdir -p /autograder/bin && \
diff --git a/docker/gradescope.dockerfile b/docker/gradescope.dockerfile
deleted file mode 100644
index bbeda02..0000000
--- a/docker/gradescope.dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-# You can change these variables to use a different base image, but
-# you must ensure that your base image inherits from one of ours.
-# You can also override these at build time with --build-arg flags
-ARG BASE_REPO=gradescope/autograder-base
-ARG TAG=latest
-
-FROM ${BASE_REPO}:${TAG}
-
-ADD source/setup.sh /autograder/setup.sh
-ADD ../source/requirements.txt /autograder/source/requirements.txt
-
-RUN apt update && \
- sh /autograder/setup.sh \
- apt clean
-
-ADD ../source /autograder/source
-
-RUN cp /autograder/source/run_autograder /autograder/run_autograder
-
-# Ensure that scripts are Unix-friendly and executable
-RUN chmod +x /autograder/run_autograder
-
-WORKDIR /autograder
-
-ENTRYPOINT [ "/autograder/run_autograder" ]
diff --git a/docker/ipython.dockerfile b/docker/ipython.dockerfile
new file mode 100644
index 0000000..bf992ea
--- /dev/null
+++ b/docker/ipython.dockerfile
@@ -0,0 +1,7 @@
+FROM ghcr.io/csci128/128autograder/core
+
+ADD languages/python /tmp/python
+ADD languages/ipython /tmp/ipython
+
+RUN pip install /tmp/python && \
+ pip install /tmp/ipython
diff --git a/docker/python.dockerfile b/docker/python.dockerfile
new file mode 100644
index 0000000..5dc3a7b
--- /dev/null
+++ b/docker/python.dockerfile
@@ -0,0 +1,5 @@
+FROM ghcr.io/csci128/128autograder/core
+
+ADD languages/python /tmp/python
+
+RUN pip install /tmp/python
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d0c3cbf
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = source
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..dc1312a
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..6f2bc4b
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,4 @@
+sphinx==8.2.3
+sphinx-book-theme==1.1.4
+# must be run from repo base path
+./core/
diff --git a/docs/source/branding/PNG/Artboard 1@2x.png b/docs/source/branding/PNG/Artboard 1@2x.png
new file mode 100644
index 0000000..94913cf
Binary files /dev/null and b/docs/source/branding/PNG/Artboard 1@2x.png differ
diff --git a/docs/source/branding/PNG/Artboard 1@4x.png b/docs/source/branding/PNG/Artboard 1@4x.png
new file mode 100644
index 0000000..947e03e
Binary files /dev/null and b/docs/source/branding/PNG/Artboard 1@4x.png differ
diff --git a/docs/source/branding/platform_logo_rectangle.ai b/docs/source/branding/platform_logo_rectangle.ai
new file mode 100644
index 0000000..f3a5624
--- /dev/null
+++ b/docs/source/branding/platform_logo_rectangle.ai
@@ -0,0 +1,3229 @@
+%PDF-1.6
%âãÏÓ
+1 0 obj
<>/OCGs[28 0 R 29 0 R 30 0 R]>>/Pages 3 0 R/Type/Catalog>>
endobj
2 0 obj
<>stream
+
+
+
+
+ application/pdf
+
+
+ platform_logo_rectangle
+
+
+ 2025-09-01T00:31:51-06:00
+ 2025-09-01T00:31:51-06:00
+ 2025-09-01T00:31:50-06:00
+ Adobe Illustrator 29.7 (Windows)
+
+
+
+ 256
+ 132
+ JPEG
+ /9j/4AAQSkZJRgABAgEAAAAAAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAAAAAAAAEA
AQAAAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK
DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f
Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAhAEAAwER
AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA
AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB
UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE
1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ
qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy
obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp
0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo
+DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7
FXYq7FXYq7FXYq7FXYqtaRR3xVJ9S84+WdNYre6nbwyLsYjIpcf7AEt+GY+TV4ofVID4tU88I8yE
ob83PICsVOqCo8ILgj7xHmMe1tP/ADvsP6mn8/h/nfYVe1/NHyJctxj1eJT/AMWrJEPvkVclHtPT
y5TH2j70x1uI/wATIrLUbC+i9ayuYrmL/fkLrIv3qSMzIZIyFxII8nIjIS3BtEZNk7FXYq7FXYq7
FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq0WAxVY9xDGpZ3CKOrMaD8cbSATyUTqlh
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
VdirsVdirsVdir//2Q==
+
+
+
+ uuid:931e181e-dad6-4c61-9290-c0d78f0ead47
+ xmp.did:614a04cc-34bd-d345-a0e8-dc0429cb5717
+ uuid:5D20892493BFDB11914A8590D31508C8
+ proof:pdf
+
+ uuid:e30f08f2-2e2c-4228-adfa-077c0211305c
+ xmp.did:c08258e3-7826-a74a-b54b-b5b970daf7af
+ uuid:5D20892493BFDB11914A8590D31508C8
+ default
+
+
+
+
+ saved
+ xmp.iid:19531bb4-e3af-394f-8c27-cd87dc08ee11
+ 2025-07-19T17:56:55-06:00
+ Adobe Illustrator 29.6 (Windows)
+ /
+
+
+ saved
+ xmp.iid:614a04cc-34bd-d345-a0e8-dc0429cb5717
+ 2025-07-19T18:03:10-06:00
+ Adobe Illustrator 29.6 (Windows)
+ /
+
+
+
+ Document
+ Print
+ AIRobin
+ False
+ False
+ True
+ 1
+
+ 256.000000
+ 128.000000
+ Pixels
+
+
+
+
+ OpenSans-Regular
+ Open Sans
+ Regular
+ Open Type
+ Version 3.000
+ False
+ OpenSans[wdth,wght].ttf
+
+
+
+
+
+ Cyan
+ Magenta
+ Yellow
+ Black
+
+
+
+
+
+ Default Swatch Group
+ 0
+
+
+
+ White
+ RGB
+ PROCESS
+ 255
+ 255
+ 255
+
+
+ Black
+ RGB
+ PROCESS
+ 35
+ 31
+ 32
+
+
+ CMYK Red
+ RGB
+ PROCESS
+ 237
+ 28
+ 36
+
+
+ CMYK Yellow
+ RGB
+ PROCESS
+ 255
+ 242
+ 0
+
+
+ CMYK Green
+ RGB
+ PROCESS
+ 0
+ 166
+ 81
+
+
+ CMYK Cyan
+ RGB
+ PROCESS
+ 0
+ 174
+ 239
+
+
+ CMYK Blue
+ RGB
+ PROCESS
+ 46
+ 49
+ 146
+
+
+ CMYK Magenta
+ RGB
+ PROCESS
+ 236
+ 0
+ 140
+
+
+ C=15 M=100 Y=90 K=10
+ RGB
+ PROCESS
+ 190
+ 30
+ 45
+
+
+ C=0 M=90 Y=85 K=0
+ RGB
+ PROCESS
+ 239
+ 65
+ 54
+
+
+ C=0 M=80 Y=95 K=0
+ RGB
+ PROCESS
+ 241
+ 90
+ 41
+
+
+ C=0 M=50 Y=100 K=0
+ RGB
+ PROCESS
+ 247
+ 148
+ 29
+
+
+ C=0 M=35 Y=85 K=0
+ RGB
+ PROCESS
+ 251
+ 176
+ 64
+
+
+ C=5 M=0 Y=90 K=0
+ RGB
+ PROCESS
+ 249
+ 237
+ 50
+
+
+ C=20 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 215
+ 223
+ 35
+
+
+ C=50 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 141
+ 198
+ 63
+
+
+ C=75 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 57
+ 181
+ 74
+
+
+ C=85 M=10 Y=100 K=10
+ RGB
+ PROCESS
+ 0
+ 148
+ 68
+
+
+ C=90 M=30 Y=95 K=30
+ RGB
+ PROCESS
+ 0
+ 104
+ 56
+
+
+ C=75 M=0 Y=75 K=0
+ RGB
+ PROCESS
+ 43
+ 182
+ 115
+
+
+ C=80 M=10 Y=45 K=0
+ RGB
+ PROCESS
+ 0
+ 167
+ 157
+
+
+ C=70 M=15 Y=0 K=0
+ RGB
+ PROCESS
+ 39
+ 170
+ 225
+
+
+ C=85 M=50 Y=0 K=0
+ RGB
+ PROCESS
+ 28
+ 117
+ 188
+
+
+ C=100 M=95 Y=5 K=0
+ RGB
+ PROCESS
+ 43
+ 57
+ 144
+
+
+ C=100 M=100 Y=25 K=25
+ RGB
+ PROCESS
+ 38
+ 34
+ 98
+
+
+ C=75 M=100 Y=0 K=0
+ RGB
+ PROCESS
+ 102
+ 45
+ 145
+
+
+ C=50 M=100 Y=0 K=0
+ RGB
+ PROCESS
+ 146
+ 39
+ 143
+
+
+ C=35 M=100 Y=35 K=10
+ RGB
+ PROCESS
+ 158
+ 31
+ 99
+
+
+ C=10 M=100 Y=50 K=0
+ RGB
+ PROCESS
+ 218
+ 28
+ 92
+
+
+ C=0 M=95 Y=20 K=0
+ RGB
+ PROCESS
+ 238
+ 42
+ 123
+
+
+ C=25 M=25 Y=40 K=0
+ RGB
+ PROCESS
+ 194
+ 181
+ 155
+
+
+ C=40 M=45 Y=50 K=5
+ RGB
+ PROCESS
+ 155
+ 133
+ 121
+
+
+ C=50 M=50 Y=60 K=25
+ RGB
+ PROCESS
+ 114
+ 102
+ 88
+
+
+ C=55 M=60 Y=65 K=40
+ RGB
+ PROCESS
+ 89
+ 74
+ 66
+
+
+ C=25 M=40 Y=65 K=0
+ RGB
+ PROCESS
+ 196
+ 154
+ 108
+
+
+ C=30 M=50 Y=75 K=10
+ RGB
+ PROCESS
+ 169
+ 124
+ 80
+
+
+ C=35 M=60 Y=80 K=25
+ RGB
+ PROCESS
+ 139
+ 94
+ 60
+
+
+ C=40 M=65 Y=90 K=35
+ RGB
+ PROCESS
+ 117
+ 76
+ 41
+
+
+ C=40 M=70 Y=100 K=50
+ RGB
+ PROCESS
+ 96
+ 57
+ 19
+
+
+ C=50 M=70 Y=80 K=70
+ RGB
+ PROCESS
+ 60
+ 36
+ 21
+
+
+
+
+
+ Grays
+ 1
+
+
+
+ C=0 M=0 Y=0 K=100
+ RGB
+ PROCESS
+ 35
+ 31
+ 32
+
+
+ C=0 M=0 Y=0 K=90
+ RGB
+ PROCESS
+ 65
+ 64
+ 66
+
+
+ C=0 M=0 Y=0 K=80
+ RGB
+ PROCESS
+ 88
+ 89
+ 91
+
+
+ C=0 M=0 Y=0 K=70
+ RGB
+ PROCESS
+ 109
+ 110
+ 113
+
+
+ C=0 M=0 Y=0 K=60
+ RGB
+ PROCESS
+ 128
+ 130
+ 133
+
+
+ C=0 M=0 Y=0 K=50
+ RGB
+ PROCESS
+ 147
+ 149
+ 152
+
+
+ C=0 M=0 Y=0 K=40
+ RGB
+ PROCESS
+ 167
+ 169
+ 172
+
+
+ C=0 M=0 Y=0 K=30
+ RGB
+ PROCESS
+ 188
+ 190
+ 192
+
+
+ C=0 M=0 Y=0 K=20
+ RGB
+ PROCESS
+ 209
+ 211
+ 212
+
+
+ C=0 M=0 Y=0 K=10
+ RGB
+ PROCESS
+ 230
+ 231
+ 232
+
+
+ C=0 M=0 Y=0 K=5
+ RGB
+ PROCESS
+ 241
+ 242
+ 242
+
+
+
+
+
+ Brights
+ 1
+
+
+
+ C=0 M=100 Y=100 K=0
+ RGB
+ PROCESS
+ 237
+ 28
+ 36
+
+
+ C=0 M=75 Y=100 K=0
+ RGB
+ PROCESS
+ 242
+ 101
+ 34
+
+
+ C=0 M=10 Y=95 K=0
+ RGB
+ PROCESS
+ 255
+ 222
+ 23
+
+
+ C=85 M=10 Y=100 K=0
+ RGB
+ PROCESS
+ 0
+ 161
+ 75
+
+
+ C=100 M=90 Y=0 K=0
+ RGB
+ PROCESS
+ 33
+ 64
+ 154
+
+
+ C=60 M=90 Y=0 K=0
+ RGB
+ PROCESS
+ 127
+ 63
+ 152
+
+
+
+
+
+
+ Adobe PDF library 17.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+endstream
endobj
3 0 obj
<>
endobj
5 0 obj
<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/Thumb 40 0 R/TrimBox[0.0 0.0 256.0 128.0]/Type/Page/PieceInfo<>>>
endobj
32 0 obj
<>stream
+H‰tVËn\7Ý߯вYH#JÔkÙ8i7
Z£dLc…'Fìôÿ{Hé>'Á s%J¤ø8<ÒéÏ;súpçÍÛwwf:ÝýíÍåÕþÌëåëtú¢Ç×ÉoBʆB5/Ÿ§‡éýQèêÔÕ¿a—¬Ë>;6~4_Uþ(–¨[:ÿqúíêÍ»çé~ú†ƒÄ6™’]Ê™
7ÄÂ媇^'KÉQˆÛP‡‚C\ô¼LÇç2Í.%3ë%v51ÔÇà2éÏóÒÑäþÄ¡èõwAî'ïZ,Æ»Â
ÿrÏÛ1.dÎcPoƒi’Àšk8ÆRt±µu>¾Úpv¬Áu]›Úˆn!¼ýÒAóÆôáìÅ©£†¯X,œdxlÏŽjX¦«¹!àèr fQäÅϹö°fªöƶ=¾ZPã$?Äu¼m ½–HJÃRF` RÙ•¨¢¾Š½È.Æ$`–éøÀµ1JÑ…ŒäÅ\
+ÔûµÙŠ÷*G‹ûG¶Ð«…rT w¾ÕŸÄì!O\KÙ¼‹¬õˆÙÕØÅ-¬óñ•І$œ%è캶ÄÜ ö»…ƒÚÝÃÁ‹G{à´%Ë8ÒØ‚¨´LWsC 4Å
+Źv.ݯ؃ž½1l'ÛÕ§
ê~ˆ¬àÁ>ÁŸ\(m[vu…˜«yšlfç%¹\\ƒ$þ%R‡24!©ÑU¡¸&*ZÍÅ ºMÁåf ¬’BÃrެuh4Ÿ•Ãø6K(o÷€=¢ YЉ•!)¹K¼øóåçíz»A›§ GzQ´\HˆARÜ#.U#UE’é|åe:>¨Ï!rNÕÌŠµ¹Äúc ¼ÏiY «T¤¥’C’#ân$}É rØD]¢’e¡$Á£öîsrh~BÅ¡M£
+ˆ˜%W²æ—FBmUSmdøå.IM7°cÂ¥=Éop=ÆÌ±Ï¼öDNRVP"ao&³ØBÈŽ¡È
ÛçŒTWàc~öé[ÒºÉM9À:Öm5À¸¾W£ É€%¥Ìë||¥CˆÊ]×6FB”LÇŒ±_:hÞ˜^äIÆ•à¥IûFLpðƒ€¶-p´ *]€d
+¤]˲úT† lª?sëBI*º xÜëm–”Y¢ÐèM-ŒzãJî½ø
èµÔí²ÜÑv“lAD$ìƒòH•ž¦1€Š „Í2r{+W¡l À@YUáêÄÝQ$1RÅ<ðìÖPÛ]ë†ÒŸ!ÕñLRP 4ª“PW.êkç >U!KÄZ4…QžFMY¡Ñ¼§Éõ
.ÞyRÆ-!j†¨–Y"œ$’–Gûp¯|ó*@à‚ŒÒ¥¨ì§-i+ªØ€¥z5(ìådMU•ÄÚC D[ðºÐÅvêpHÔ3®$ÑŸZHÄíåi%¯ y
+t¤Iól¦‡@ŠŽ'oìIPjQ„Ñ¥'q³f÷šöÆò"bËÑ,v×¾¿ï§·çùú#¼#¤¿ÝÏgééó^½¿«<Ǩ¢€ÕùÈÅœ¯Ó/òRÿõ¿ïÏ/Ÿþùübþzúôýáùåúæüo?äýyú_€ hª]@
+endstream
endobj
33 0 obj
<>
endobj
40 0 obj
<>stream
+8;W"]b72B4$q.(0:>$[K5ln^JJSXa]Vk*
+endstream
endobj
9 0 obj
<>
endobj
11 0 obj
<>
endobj
12 0 obj
<>stream
+%!PS-Adobe-3.0
+%%Creator: Adobe Illustrator(R) 24.0
+%%AI8_CreatorVersion: 29.7.1
+%%For: (Gregory Bell) ()
+%%Title: (platform_logo_rectangle.ai)
+%%CreationDate: 9/1/2025 12:31 AM
+%%Canvassize: 16383
+%%BoundingBox: 0 -192 256 -62
+%%HiResBoundingBox: 0 -192 256 -62.7600000000002
+%%DocumentProcessColors: Cyan Magenta Yellow Black
+%AI5_FileFormat 14.0
+%AI12_BuildNumber: 8
+%AI3_ColorUsage: Color
+%AI7_ImageSettings: 0
+%%RGBProcessColor: 0 0 0 ([Registration])
+%AI3_Cropmarks: 0 -192 256 -64
+%AI3_TemplateBox: 128.5 -128.5 128.5 -128.5
+%AI3_TileBox: -268 -434 524 178
+%AI3_DocumentPreview: None
+%AI5_ArtSize: 14400 14400
+%AI5_RulerUnits: 6
+%AI24_LargeCanvasScale: 1
+%AI9_ColorModel: 1
+%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0
+%AI5_TargetResolution: 800
+%AI5_NumLayers: 3
+%AI17_Begin_Content_if_version_gt:24 4
+%AI10_OpenToVie: -223 99 3 0 8168.12287581699 8144.04640522876 2256 1300 18 0 0 46 87 0 0 0 1 1 0 1 1 0 1
+%AI17_Alternate_Content
+%AI9_OpenToView: -223 99 3 2256 1300 18 0 0 46 87 0 0 0 1 1 0 1 1 0 1
+%AI17_End_Versioned_Content
+%AI5_OpenViewLayers: 773
+%AI17_Begin_Content_if_version_gt:24 4
+%AI17_Alternate_Content
+%AI17_End_Versioned_Content
+%%PageOrigin:-178 -524
+%AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142
+%AI9_Flatten: 1
+%AI12_CMSettings: 00.MS
+%%EndComments
+
+endstream
endobj
13 0 obj
<>stream
+%AI24_ZStandard_Data(µ/ý X\¶®{„&
+/Àfš6ˆJC!Œ8ÈqÔíRÙ=ŠôÿfÚ‡Ì)²»{s#?ˆê EQ` Qt Àë ø…q`Qdóìv±È²P(ŠIŠÂ¢Æq[Ôm)ãPŒÃ8ŒãÆq-bƱ¼Œ#‰qbƱùÝ[Û‡&ž‡Æ‘ _5<+«q,jQjZgøC\pYJ…6Y,Ý‹ãP(´éE-nd©\…ãÖf¸öDYøô6¹dY–á!&.¢,Pdi™ºye‘Š$kié©©®®~Q…kmqQ‘E:Ý7™\†H™ºÆÐÂ8\vÆ>c"â¥2X‡ÌÑ'Jtbé–MÑŠ«[hHV¯êõØÖÊZ²1ïd|¥!žUÚ!ÑuóDõSyÈ\2iÌ\µé,]K–i{´zòlgo+7o³4]¢³®(Él³5í°iq#†$²ˆ’(Ši"µÆA€TàbQ,Æ¡,É’,ˆ¢-æ…²PÊ¢ «ì-‰‚Ü#ô¢gD±Æ±¬’eH¸0Å¢,ˆ¬ )H¡P”YÚ33‘IA«cX±Â
++§]at>ÛCŒ†‡‰‹eå]‘¤VýB½µ‡lâÛñ©\qB
+ÉçõˆbËâ„Øå" ÇíÉJ¡’gÈ<`cc§éVE*T¡ŠT‡*®H‹B¡ -¼0\à"ºø"I‹EYÊ"Y$e±µ^Ú….ª(¢×ÕÔSS+©q,rÉ$‘|jf^ZV:¸`‚.*"§¸å’CnOïÎÎG§°Å»©G™•²(6ŸH&©ä’Š\$©µÔô5UuõŠh¢‹*t¡´ÞZÛ[Ü\ÝÝ/²É*»¬bK_¹l¡‹î²È‚\SuQ’$A’M4ÉäK*¡d(IYD‘D1„Å—+Oþïóø»Ý§/vÙd¿º¹·xWddtd@1ã@Ŷ0ã óÕÙÝáåéíýKN¹å·8Bcá!b¢ââÁ\P¤tVZ^bfjn^q…qI”EqË-Æqe‡¹è²
+UG*Pq
+S”¢Vb±(–eQ–dù²‹.¹àrK-^h¡E¡,Š¢$
+¢¬¢J*¨˜RŠR‰"Y%1GŒ#)É8’”’òè0‘Œƒ hÈÇh Á !AÅ’Ø’
Œc‚äACdChÒ)cŒ‘H40g§‡Œã…‡Œv‘ôRíó!ãÐ+=ÚâíqTé—¨îË½Š˜2dŒã.0‘ áaá€`¢Á!ƒ„ SåÀ6LPÈ`‚†˜@Áá!‚‚ L0y á&0 *D\0!Bi˜p¨0‘hˆ áPÀDÃ& Á ÁðÀ Ì‹
8H€p€À á ã€y`Bƒ(ÀDhØ€C ¢á‡
Ph` „ 8Xpƒ„‡‡Åó@ƒ„
+#1¡
D<4` ¡0
DD˜ QAÃ2!