Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add better CLI #455

Merged
merged 1 commit into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ If you use this software in research, please cite:

- Strijbol, N., Van Petegem, C., Maertens, R., Sels, B., Scholliers, C., Dawyndt, P., & Mesuere, B. (2023). TESTed—An educational testing framework with language-agnostic test suites for programming exercises. SoftwareX, 22, 101404. [doi:10.1016/j.softx.2023.101404](https://doi.org/10.1016/j.softx.2023.101404)

> [!IMPORTANT]
> The documentation below is intended for running TESTed as a standalone tool.
> If you are looking to create exercises for Dodona, we have [more suitable documentation available](https://docs.dodona.be/nl/guides/exercises/).


## Installing TESTed

TESTed is implemented in Python, but has various dependencies for its language-specific modules.
Expand Down Expand Up @@ -162,23 +167,56 @@ optional arguments:
Adjust the configuration file if you want to evaluate the wrong submission.

For reference, the file `tested/dsl/schema.json` contains the JSON Schema of the test suite format.
Some other useful commands are:

## Running TESTed locally

The `python -m tested` command is intended for production use.
However, it is not always convenient to create a `config.json` file for each exercise to run.

TESTed supports two ways of running TESTed without a config file.
The first way is:

```bash
# Run a hard-coded exercise with logs enabled, useful for debugging
$ python -m tested.manual
# Convert a YAML test suite into JSON
$ python -m tested.dsl
```

This command is useful when debugging TESTed itself or a particularly challenging exercise.
It will execute a hardcoded config, which is set in `tested/manual.py`.

The second way is:

```bash
# Run an exercise with CLI paramaters
$ python -m tested.cli --help
usage: cli.py [-h] -e EXERCISE [-s SUBMISSION] [-t TESTSUITE] [-f] [-p PROGRAMMING_LANGUAGE]

Simple CLI for TESTed

options:
-h, --help show this help message and exit
-e EXERCISE, --exercise EXERCISE
Path to a directory containing an exercise
-s SUBMISSION, --submission SUBMISSION
Path to a submission to evaluate
-t TESTSUITE, --testsuite TESTSUITE
Path to a test suite
-f, --full If the output should be shown in full (default: false)
-p PROGRAMMING_LANGUAGE, --programming_language PROGRAMMING_LANGUAGE
The programming language to use

additional information: The CLI only looks at a config.json file in the exercise directory. It does not look in folders above the exercise directory.
```

This is the "CLI mode": here you can pass various options as command line parameters.
For example, for exercises following a standardized directory structure, the path to the exercise folder is often enough.

## TESTed repository

The repository of TESTed is organized as follows:

- `exercise`: exercises with preconfigured test suites; useful to play around with TESTed and also used by unit tests for TESTed itself
- `tested`: Python code of the actual judge (run by Dodona)
- `tests`: unit tests for TESTed
- `benchmarking`: utilities to benchmark TESTed

You can run the basic unit tests with:

Expand Down
240 changes: 240 additions & 0 deletions tested/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"""
A module that provides a simpler CLI to TESTed.
"""
import json
import os
import re
import shutil
import textwrap
import time
from argparse import ArgumentParser, ArgumentTypeError
from io import StringIO
from pathlib import Path

from tested.configs import DodonaConfig, GlobalConfig
from tested.languages import get_language
from tested.main import run
from tested.testsuite import Suite, SupportedLanguage


def dir_path(path):
if os.path.isdir(path):
return Path(path)

Check warning on line 22 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L21-L22

Added lines #L21 - L22 were not covered by tests
else:
raise ArgumentTypeError(f"{path} is not a valid path (cwd: {os.getcwd()})")

Check warning on line 24 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L24

Added line #L24 was not covered by tests


def find_submission() -> Path:
# First, if we have an explicit submission, use that.
if args.submission is not None:
return Path(args.submission)

Check warning on line 30 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L29-L30

Added lines #L29 - L30 were not covered by tests
# Attempt to determine the programming language of this exercise.
considered = []

Check warning on line 32 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L32

Added line #L32 was not covered by tests

Check notice

Code scanning / CodeQL

Unused local variable Note test

Variable considered is not used.
# There is a bit of a chicken-and-egg problem here.
# We need the language to complete the config, but the language needs a config.
# We thus create a dummy config.
global_config = GlobalConfig(

Check warning on line 36 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L36

Added line #L36 was not covered by tests
dodona=DodonaConfig(
resources=Path("."),
source=Path("."),
time_limit=10,
memory_limit=10,
natural_language="none",
programming_language=SupportedLanguage(programming_language),
workdir=Path("."),
judge=Path("."),
),
testcase_separator_secret="",
context_separator_secret="",
suite=Suite(),
)
language = get_language(global_config, programming_language)
solution_folder = exercise_path / "solution"
possible_submissions = list(solution_folder.glob(f"*.{language.file_extension()}"))

Check warning on line 53 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L51-L53

Added lines #L51 - L53 were not covered by tests

if len(possible_submissions) == 0:
raise FileNotFoundError(

Check warning on line 56 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L55-L56

Added lines #L55 - L56 were not covered by tests
f"Could not find a submission file in {solution_folder}/*.{language.file_extension()}.\n"
"Please ensure a submission file exists in the right location or provide an alternative path via the --submission parameter on the command line."
)

return possible_submissions[0]

Check warning on line 61 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L61

Added line #L61 was not covered by tests


def create_and_populate_workdir(config: DodonaConfig):
# Create workdir if needed.
config.workdir.mkdir(exist_ok=True)

Check warning on line 66 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L66

Added line #L66 was not covered by tests

# Delete content in work dir
for root, dirs, files in os.walk(config.workdir):
for f in files:
os.unlink(os.path.join(root, f))
for d in dirs:
shutil.rmtree(os.path.join(root, d), ignore_errors=True)

Check warning on line 73 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L69-L73

Added lines #L69 - L73 were not covered by tests

exercise_workdir = config.resources.parent / "workdir"

Check warning on line 75 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L75

Added line #L75 was not covered by tests

# Copy existing files to workdir if needed.
if exercise_workdir.is_dir():
shutil.copytree(exercise_workdir, config.workdir, dirs_exist_ok=True)

Check warning on line 79 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L78-L79

Added lines #L78 - L79 were not covered by tests


def split_output(to_split: str) -> list[str]:
return re.compile(r"(?<=})\s*(?={)").split(to_split)


class CommandDict(list):
def find_all(self, command: str) -> list[dict]:
return [x for x in self if x["command"] == command]

def find_next(self, command: str) -> dict:
return next(x for x in self if x["command"] == command)

def find_status_enum(self) -> list[str]:
commands = [
x
for x in self
if x["command"].startswith("close-") or x["command"] == "escalate-status"
]
return [x["status"]["enum"] for x in commands if "status" in x]


if __name__ == "__main__":
parser = ArgumentParser(

Check warning on line 103 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L103

Added line #L103 was not covered by tests
description="Simple CLI for TESTed",
epilog=textwrap.dedent(
"""
additional information:\n
The CLI only looks at a config.json file in the exercise directory.\n
It does not look in folders above the exercise directory.
"""
),
)
parser.add_argument(

Check warning on line 113 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L113

Added line #L113 was not covered by tests
"-e",
"--exercise",
type=dir_path,
help="Path to a directory containing an exercise",
required=True,
)
parser.add_argument(

Check warning on line 120 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L120

Added line #L120 was not covered by tests
"-s",
"--submission",
type=Path,
help="Path to a submission to evaluate",
default=None,
)
parser.add_argument(

Check warning on line 127 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L127

Added line #L127 was not covered by tests
"-t",
"--testsuite",
type=Path,
help="Path to a test suite",
default=None,
)
parser.add_argument(

Check warning on line 134 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L134

Added line #L134 was not covered by tests
"-f",
"--full",
action="store_false",
help="If the output should be shown in full (default: false)",
default=None,
)
parser.add_argument(

Check warning on line 141 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L141

Added line #L141 was not covered by tests
"-p",
"--programming_language",
help="The programming language to use",
default=None,
)
args = parser.parse_args()

Check warning on line 147 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L147

Added line #L147 was not covered by tests

exercise_path = args.exercise
evaluation_path = exercise_path / "evaluation"
judge_path = Path(__file__).parent.parent
config_path = exercise_path / "config.json"

Check warning on line 152 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L149-L152

Added lines #L149 - L152 were not covered by tests

try:
config_file = json.loads(config_path.read_text())
except FileNotFoundError:
config_file = dict()

Check warning on line 157 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L154-L157

Added lines #L154 - L157 were not covered by tests

if args.programming_language is not None:
programming_language = args.programming_language

Check warning on line 160 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L159-L160

Added lines #L159 - L160 were not covered by tests
else:
try:
programming_language = config_file["programming_language"]
except KeyError:
if config_path.exists():
raise Exception(

Check warning on line 166 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L162-L166

Added lines #L162 - L166 were not covered by tests
f"Could not determine the programming language for {exercise_path}, as there was no programming language in the config file (at {config_path}).\n"
"Please add a programming language to the config file or provide the programming language via the --programming_language parameter on the command line."
)
else:
raise Exception(

Check warning on line 171 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L171

Added line #L171 was not covered by tests
f"Could not determine the programming language for {exercise_path}, as there was no config file (at {config_path}) for this exercise.\n"
"Please create a config file or provide the programming language via the --programming_language parameter on the command line."
)

if args.testsuite is not None:
suite_path = args.testsuite.relative_to(evaluation_path)
elif "evaluation" in config_file and "test_suite" in config_file["evaluation"]:

Check warning on line 178 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L176-L178

Added lines #L176 - L178 were not covered by tests
# The config file contains a location, so try to use it.
suite_path = evaluation_path / config_file["evaluation"]["test_suite"]
if not suite_path.is_file():
raise FileNotFoundError(

Check warning on line 182 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L180-L182

Added lines #L180 - L182 were not covered by tests
f"The test suite at {suite_path} does not exist (read value from the config file).\n"
"Create the file, correct the config.json file or provide the path to the test suite via the --testsuite parameter on the command line."
)
else:
suite_path = evaluation_path / "suite.yaml"
if not suite_path.is_file():
raise FileNotFoundError(

Check warning on line 189 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L187-L189

Added lines #L187 - L189 were not covered by tests
f"The test suite at {suite_path} does not exist (used default value).\n"
"Create the file, add the location to the config.json file or provide the path to the test suite via the --testsuite parameter on the command line."
)
submission_path = find_submission()
workdir_path = judge_path / "workdir"

Check warning on line 194 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L193-L194

Added lines #L193 - L194 were not covered by tests

dodona_config = DodonaConfig(

Check warning on line 196 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L196

Added line #L196 was not covered by tests
resources=evaluation_path,
source=submission_path,
time_limit=60,
memory_limit=536870912,
natural_language="nl",
programming_language=SupportedLanguage(programming_language),
workdir=judge_path / "workdir",
judge=judge_path,
test_suite=suite_path,
)

output_handler = StringIO()

Check warning on line 208 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L208

Added line #L208 was not covered by tests

create_and_populate_workdir(dodona_config)

Check warning on line 210 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L210

Added line #L210 was not covered by tests

print(f"Locally executing exercise {exercise_path}...")
print("The following options will be used:")
print(f" - Test suite: {suite_path}")
print(f" - Submission: {submission_path}")
print("")
print(

Check warning on line 217 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L212-L217

Added lines #L212 - L217 were not covered by tests
f"The execution will happen in {workdir_path}. This folder will remain available after execution for inspection."
)

start = time.time()
run(dodona_config, output_handler)
end = time.time()
print()
print(f"Execution took {end - start} seconds (real time).")

Check warning on line 225 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L221-L225

Added lines #L221 - L225 were not covered by tests

if args.full:
print("Results:")
print(output_handler.getvalue())

Check warning on line 229 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L227-L229

Added lines #L227 - L229 were not covered by tests
else:
output = output_handler.getvalue()
updates = CommandDict()

Check warning on line 232 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L231-L232

Added lines #L231 - L232 were not covered by tests
# Every update should be valid.
for update in split_output(output):
update_object = json.loads(update)
updates.append(update_object)

Check warning on line 236 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L234-L236

Added lines #L234 - L236 were not covered by tests

results = updates.find_status_enum()
correct_enums = len([x for x in results if x == "correct"])
print(f"{correct_enums} of {len(results)} testcases were correct.")

Check warning on line 240 in tested/cli.py

View check run for this annotation

Codecov / codecov/patch

tested/cli.py#L238-L240

Added lines #L238 - L240 were not covered by tests
21 changes: 3 additions & 18 deletions tested/manual.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@
file, allowing rapid testing (and, most importantly, debugging).
"""
import logging
import os
import shutil
import sys
import time
from pathlib import Path

from tested.cli import create_and_populate_workdir

Check warning on line 10 in tested/manual.py

View check run for this annotation

Codecov / codecov/patch

tested/manual.py#L10

Added line #L10 was not covered by tests
from tested.configs import DodonaConfig, Options
from tested.main import run
from tested.testsuite import SupportedLanguage

exercise_dir = "/home/niko/Ontwikkeling/universal-judge/tests/exercises/global"
exercise_dir = "/home/niko/Ontwikkeling/universal-judge/tests/exercises/echo"

Check warning on line 15 in tested/manual.py

View check run for this annotation

Codecov / codecov/patch

tested/manual.py#L15

Added line #L15 was not covered by tests


def read_config() -> DodonaConfig:
Expand Down Expand Up @@ -51,21 +50,7 @@
logger = logging.getLogger("tested.parsing")
logger.setLevel(logging.INFO)

# Create workdir if needed.
config.workdir.mkdir(exist_ok=True)

# Delete content in work dir
for root, dirs, files in os.walk(config.workdir):
for f in files:
os.unlink(os.path.join(root, f))
for d in dirs:
shutil.rmtree(os.path.join(root, d), ignore_errors=True)

# Copy existing files to workdir if needed.
if Path(exercise_dir, "workdir").is_dir():
shutil.copytree(
Path(exercise_dir, "workdir"), config.workdir, dirs_exist_ok=True
)
create_and_populate_workdir(config)

Check warning on line 53 in tested/manual.py

View check run for this annotation

Codecov / codecov/patch

tested/manual.py#L53

Added line #L53 was not covered by tests

start = time.time()
run(config, sys.stdout)
Expand Down
22 changes: 1 addition & 21 deletions tests/manual_utils.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,17 @@
import json
import re
from io import StringIO
from pathlib import Path

from jsonschema import validate

from tested.cli import CommandDict, split_output
from tested.configs import DodonaConfig
from tested.languages import get_language
from tested.main import run
from tested.parsing import get_converter
from tested.utils import recursive_dict_merge


def split_output(output: str) -> list[str]:
return re.compile(r"(?<=})\s*(?={)").split(output)


class CommandDict(list):
def find_all(self, command: str) -> list[dict]:
return [x for x in self if x["command"] == command]

def find_next(self, command: str) -> dict:
return next(x for x in self if x["command"] == command)

def find_status_enum(self) -> list[str]:
commands = [
x
for x in self
if x["command"].startswith("close-") or x["command"] == "escalate-status"
]
return [x["status"]["enum"] for x in commands if "status" in x]


def assert_valid_output(output: str, config) -> CommandDict:
with open(Path(config.rootdir) / "tests/partial_output.json", "r") as f:
schema = json.load(f)
Expand Down
Loading