From a7bed7e4a64eeb4c5661164015e90c843a3b833d Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 21 Aug 2023 13:51:02 -0700 Subject: [PATCH 01/33] initial conversion of autotest code from 0.7.x to 0.9.x --- nbgrader/apps/generateassignmentapp.py | 32 +- nbgrader/apps/quickstartapp.py | 5 + nbgrader/converters/__init__.py | 4 +- nbgrader/converters/generate_assignment.py | 2 + .../converters/generate_source_with_tests.py | 49 ++ nbgrader/coursedir.py | 12 + .../creating_and_grading_assignments.ipynb | 53 ++ nbgrader/docs/source/user_guide/grades.csv | 4 +- ...ograder_tests_autogenerated_tests_jlab.png | Bin 0 -> 139935 bytes .../images/autograder_tests_autotest_jlab.png | Bin 0 -> 68453 bytes .../managing_assignment_files.ipynb | 7 +- .../user_guide/source/ps1/problem3.ipynb | 399 +++++++++ .../source/user_guide/source/ps2/jupyter.png | Bin 0 -> 5733 bytes .../user_guide/source/ps2/problem.ipynb | 241 ++++++ nbgrader/docs/source/user_guide/tests.yml | 305 +++++++ nbgrader/preprocessors/__init__.py | 2 + nbgrader/preprocessors/clearsolutions.py | 1 + nbgrader/preprocessors/instantiatetests.py | 758 ++++++++++++++++++ nbgrader/tests/__init__.py | 15 + .../apps/files/autotest-hashed-changed.ipynb | 112 +++ .../files/autotest-hashed-unchanged.ipynb | 110 +++ .../tests/apps/files/autotest-hashed.ipynb | 82 ++ .../files/autotest-hidden-changed-right.ipynb | 85 ++ .../files/autotest-hidden-changed-wrong.ipynb | 85 ++ .../files/autotest-hidden-unchanged.ipynb | 83 ++ .../tests/apps/files/autotest-hidden.ipynb | 80 ++ .../apps/files/autotest-multi-changed.ipynb | 267 ++++++ .../apps/files/autotest-multi-unchanged.ipynb | 277 +++++++ .../tests/apps/files/autotest-multi.ipynb | 164 ++++ .../apps/files/autotest-simple-changed.ipynb | 92 +++ .../files/autotest-simple-unchanged.ipynb | 90 +++ .../tests/apps/files/autotest-simple.ipynb | 74 ++ .../files/test-no-metadata-autotest.ipynb | 225 ++++++ nbgrader/tests/apps/files/tests.yml | 310 +++++++ .../tests/apps/test_nbgrader_autograde.py | 180 +++++ .../apps/test_nbgrader_generate_assignment.py | 82 ++ .../apps/test_nbgrader_generate_feedback.py | 35 + .../preprocessors/test_instantiatetests.py | 204 +++++ pyproject.toml | 1 + 39 files changed, 4520 insertions(+), 7 deletions(-) create mode 100644 nbgrader/converters/generate_source_with_tests.py create mode 100644 nbgrader/docs/source/user_guide/images/autograder_tests_autogenerated_tests_jlab.png create mode 100644 nbgrader/docs/source/user_guide/images/autograder_tests_autotest_jlab.png create mode 100644 nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb create mode 100644 nbgrader/docs/source/user_guide/source/ps2/jupyter.png create mode 100644 nbgrader/docs/source/user_guide/source/ps2/problem.ipynb create mode 100644 nbgrader/docs/source/user_guide/tests.yml create mode 100644 nbgrader/preprocessors/instantiatetests.py create mode 100644 nbgrader/tests/apps/files/autotest-hashed-changed.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-hashed-unchanged.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-hashed.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-hidden-changed-right.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-hidden-changed-wrong.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-hidden-unchanged.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-hidden.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-multi-changed.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-multi.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-simple-changed.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-simple-unchanged.ipynb create mode 100644 nbgrader/tests/apps/files/autotest-simple.ipynb create mode 100644 nbgrader/tests/apps/files/test-no-metadata-autotest.ipynb create mode 100644 nbgrader/tests/apps/files/tests.yml create mode 100644 nbgrader/tests/preprocessors/test_instantiatetests.py diff --git a/nbgrader/apps/generateassignmentapp.py b/nbgrader/apps/generateassignmentapp.py index 7acf78974..b46a7a96a 100644 --- a/nbgrader/apps/generateassignmentapp.py +++ b/nbgrader/apps/generateassignmentapp.py @@ -2,10 +2,11 @@ import sys -from traitlets import default +from traitlets import default, Bool +from textwrap import dedent from .baseapp import NbGrader, nbgrader_aliases, nbgrader_flags -from ..converters import BaseConverter, GenerateAssignment, NbGraderException +from ..converters import BaseConverter, GenerateAssignment, NbGraderException, GenerateSourceWithTests from traitlets.traitlets import MetaHasTraits from typing import List, Any from traitlets.config.loader import Config @@ -51,6 +52,12 @@ {'BaseConverter': {'force': True}}, "Overwrite an assignment/submission if it already exists." ), + 'source_with_tests': ( + {'GenerateAssignmentApp': {'source_with_tests': True}}, + "Generate intermediate notebooks that contain both the autogenerated test code and the solutions. " + "Results will be saved in the source_with_tests/ folder. " + "This is useful for instructors to debug problematic autogenerated test code." + ), }) @@ -62,6 +69,17 @@ class GenerateAssignmentApp(NbGrader): aliases = aliases flags = flags + source_with_tests = Bool( + False, + help=dedent( + """ + Generate intermediate notebooks that contain both the autogenerated test code and the solutions. + Results will be saved in the source_with_tests/ folder. + This is useful for instructors to debug issues in autogenerated test code. + """ + ) + ).tag(config=True) + examples = """ Produce the version of the assignment that is intended to be released to students. This performs several modifications to the original assignment: @@ -112,7 +130,7 @@ class GenerateAssignmentApp(NbGrader): @default("classes") def _classes_default(self) -> List[MetaHasTraits]: classes = super(GenerateAssignmentApp, self)._classes_default() - classes.extend([BaseConverter, GenerateAssignment]) + classes.extend([BaseConverter, GenerateAssignment, GenerateSourceWithTests]) return classes def _load_config(self, cfg: Config, **kwargs: Any) -> None: @@ -141,6 +159,14 @@ def start(self) -> None: elif len(self.extra_args) == 1: self.coursedir.assignment_id = self.extra_args[0] + + if self.source_with_tests: + converter = GenerateSourceWithTests(coursedir=self.coursedir, parent=self) + try: + converter.start() + except NbGraderException: + sys.exit(1) + converter = GenerateAssignment(coursedir=self.coursedir, parent=self) try: converter.start() diff --git a/nbgrader/apps/quickstartapp.py b/nbgrader/apps/quickstartapp.py index 20512b072..0af1460e4 100644 --- a/nbgrader/apps/quickstartapp.py +++ b/nbgrader/apps/quickstartapp.py @@ -122,6 +122,11 @@ def start(self): ignore_html = shutil.ignore_patterns("*.html") shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignore_html) + # copying the tests.yml file to the course directory + tests_file_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'tests.yml')) + shutil.copyfile(tests_file_path, os.path.join(course_path, 'tests.yml')) + # create the config file self.log.info("Generating example config file...") currdir = os.getcwd() diff --git a/nbgrader/converters/__init__.py b/nbgrader/converters/__init__.py index f0aab0f6a..7c42e28e3 100644 --- a/nbgrader/converters/__init__.py +++ b/nbgrader/converters/__init__.py @@ -5,6 +5,7 @@ from .feedback import Feedback from .generate_feedback import GenerateFeedback from .generate_solution import GenerateSolution +from .generate_source_with_tests import GenerateSourceWithTests __all__ = [ "BaseConverter", @@ -14,5 +15,6 @@ "Autograde", "Feedback", "GenerateFeedback", - "GenerateSolution" + "GenerateSolution", + "GenerateSourceWithTests" ] diff --git a/nbgrader/converters/generate_assignment.py b/nbgrader/converters/generate_assignment.py index 6d7601ef3..231e59bd4 100644 --- a/nbgrader/converters/generate_assignment.py +++ b/nbgrader/converters/generate_assignment.py @@ -8,6 +8,7 @@ from .base import BaseConverter, NbGraderException from ..preprocessors import ( IncludeHeaderFooter, + InstantiateTests, ClearSolutions, LockCells, ComputeChecksums, @@ -57,6 +58,7 @@ def _output_directory(self) -> str: preprocessors = List([ IncludeHeaderFooter, + InstantiateTests, LockCells, ClearSolutions, ClearOutput, diff --git a/nbgrader/converters/generate_source_with_tests.py b/nbgrader/converters/generate_source_with_tests.py new file mode 100644 index 000000000..346fc4f2e --- /dev/null +++ b/nbgrader/converters/generate_source_with_tests.py @@ -0,0 +1,49 @@ +import os +import re + +from traitlets import List, default + +from .base import BaseConverter +from ..preprocessors import ( + InstantiateTests, + ClearOutput, + CheckCellMetadata +) +from traitlets.config.loader import Config +from typing import Any +from ..coursedir import CourseDirectory + + +class GenerateSourceWithTests(BaseConverter): + + @default("permissions") + def _permissions_default(self) -> int: + return 664 if self.coursedir.groupshared else 644 + + @property + def _input_directory(self) -> str: + return self.coursedir.source_directory + + @property + def _output_directory(self) -> str: + return self.coursedir.release_directory + + preprocessors = List([ + InstantiateTests, + ClearOutput, + CheckCellMetadata + ]).tag(config=True) + + def _load_config(self, cfg: Config, **kwargs: Any) -> None: + super(GenerateSourceWithTests, self)._load_config(cfg, **kwargs) + + def __init__(self, coursedir: CourseDirectory = None, **kwargs: Any) -> None: + super(GenerateSourceWithTests, self).__init__(coursedir=coursedir, **kwargs) + + def start(self) -> None: + old_student_id = self.coursedir.student_id + self.coursedir.student_id = '.' + try: + super(GenerateSourceWithTests, self).start() + finally: + self.coursedir.student_id = old_student_id diff --git a/nbgrader/coursedir.py b/nbgrader/coursedir.py index bbbe6c54d..9242e56a7 100644 --- a/nbgrader/coursedir.py +++ b/nbgrader/coursedir.py @@ -142,6 +142,18 @@ def _validate_notebook_id(self, proposal: Bunch) -> str: ) ).tag(config=True) + source_with_tests_directory = Unicode( + 'source_with_tests', + help=dedent( + """ + The name of the directory that contains notebooks with both solutions + and instantiated test code (i.e., all AUTOTEST directives are removed + and replaced by actual test code). This corresponds to the + `nbgrader_step` variable in the `directory_structure` config option. + """ + ) + ).tag(config=True) + submitted_directory = Unicode( 'submitted', help=dedent( diff --git a/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb b/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb index 1699cc464..7c473c592 100644 --- a/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb +++ b/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb @@ -425,6 +425,59 @@ " These hidden tests are placed back into the \"Autograder tests\" cells when running ``nbgrader autograde``\n", " (see :ref:`autograde-assignments`)." ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + ".. _autograder-tests-cell-automatic-test-code:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### \"Autograder tests\" cells with automatically generated test code" + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + ".. versionadded:: 0.9.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Tests in \"Autograder tests\" cells can be automatically and dynamically generated through the use of a special syntax such as ``### AUTOTEST`` and ``### HASHED AUTOTEST``, for example:\n", + "\n", + "![autograder tests autotest tests](images/autograder_tests_autotest_jlab.png)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the release version, the above autotest statements get converted to the following test code that the students see:\n", + "![autograder tests autotest tests](images/autograder_tests_autogenerated_tests_jlab.png)" + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "When creating the release version (see :ref:`assign-and-release-an-assignment`), the autotest lines (lines starting with the special syntax) will transform into automatically generated test cases (i.e., assert statements). The value of the expression(s) following the special syntax will be evaluated in the solution version to generate test cases that are checked in the student version. If this special syntax is not used, then the contents of the cell will remain as is.\n", + "\n", + ".. note::\n", + "\n", + " Lines starting with ### AUTOTEST will generate test code where the answer is visible to students. To generate test code where the answers are *hashed* (not viewable by students), begin the line with the syntax ### HASHED AUTOTEST instead.\n", + " \n", + ".. note:: \n", + "\n", + " You can put multiple expressions to be tested on a single ### AUTOTEST line (or ### HASHED AUTOTEST line), separated by semicolons." + ] }, { "cell_type": "raw", diff --git a/nbgrader/docs/source/user_guide/grades.csv b/nbgrader/docs/source/user_guide/grades.csv index cc3ad44db..c80687689 100644 --- a/nbgrader/docs/source/user_guide/grades.csv +++ b/nbgrader/docs/source/user_guide/grades.csv @@ -1,3 +1,3 @@ assignment,duedate,timestamp,student_id,last_name,first_name,email,raw_score,late_submission_penalty,score,max_score -ps1,,2015-02-02 22:58:23.948203,bitdiddle,,,,1.5,0.0,1.5,13.0 -ps1,,2015-02-01 17:28:58.749302,hacker,,,,3.0,0.0,3.0,13.0 +ps1,,2015-02-02 22:58:23.948203,bitdiddle,,,,1.5,0.0,1.5,23.0 +ps1,,2015-02-01 17:28:58.749302,hacker,,,,3.0,0.0,3.0,23.0 diff --git a/nbgrader/docs/source/user_guide/images/autograder_tests_autogenerated_tests_jlab.png b/nbgrader/docs/source/user_guide/images/autograder_tests_autogenerated_tests_jlab.png new file mode 100644 index 0000000000000000000000000000000000000000..11262fa14aca73c612ec5e1f1a92aa1e47f85ba4 GIT binary patch literal 139935 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
&meKNi< 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

o`N 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 literal 0 HcmV?d00001 diff --git a/nbgrader/docs/source/user_guide/images/autograder_tests_autotest_jlab.png b/nbgrader/docs/source/user_guide/images/autograder_tests_autotest_jlab.png new file mode 100644 index 0000000000000000000000000000000000000000..b90893cff81ecec33e51ec5cf776b05705f43fa6 GIT binary patch literal 68453 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

J@jogG4KhwddUddl3 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 Apa1{> literal 0 HcmV?d00001 diff --git a/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb b/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb index 4347958f6..498a6190a 100644 --- a/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb +++ b/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb @@ -465,7 +465,8 @@ "total ##\n", "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] jupyter.png\n", "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem1.ipynb\n", - "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem2.ipynb\n" + "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem2.ipynb\n", + "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem3.ipynb\n" ] } ], @@ -817,9 +818,11 @@ " Expected:\n", " \tproblem1.ipynb: MISSING\n", " \tproblem2.ipynb: FOUND\n", + " \tproblem3.ipynb: FOUND\n", " Submitted:\n", " \tmyproblem1.ipynb: EXTRA\n", " \tproblem2.ipynb: OK\n", + " \tproblem3.ipynb: OK\n", "[SubmitApp | INFO] Submitted as: example_course ps1 [timestamp] UTC\n" ] } @@ -896,9 +899,11 @@ " Expected:\n", " \tproblem1.ipynb: MISSING\n", " \tproblem2.ipynb: FOUND\n", + " \tproblem3.ipynb: FOUND\n", " Submitted:\n", " \tmyproblem1.ipynb: EXTRA\n", " \tproblem2.ipynb: OK\n", + " \tproblem3.ipynb: OK\n", "[SubmitApp | ERROR] nbgrader submit failed\n" ] } diff --git a/nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb b/nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb new file mode 100644 index 000000000..16d713402 --- /dev/null +++ b/nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb @@ -0,0 +1,399 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "jupyter", + "locked": true, + "schema_version": 3, + "solution": false + } + }, + "source": [ + "For this problem set, we'll be using the Jupyter notebook:\n", + "\n", + "![](jupyter.png)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part A (2 points)\n", + "\n", + "Write a function that returns a list of numbers, such that $x_i=i^2$, for $1\\leq i \\leq n$. Make sure it handles the case where $n<1$ by raising a `ValueError`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "squares", + "locked": false, + "schema_version": 3, + "solution": true + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "def squares(n):\n", + " \"\"\"Compute the squares of numbers from 1 to n, such that the \n", + " ith element of the returned list equals i^2.\n", + " \n", + " \"\"\"\n", + " ### BEGIN SOLUTION\n", + " if n < 1:\n", + " raise ValueError(\"n must be greater than or equal to 1\")\n", + " return [i ** 2 for i in range(1, n + 1)]\n", + " ### END SOLUTION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Your function should print `[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]` for $n=10$. Check that it does:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "correct_squares", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that squares returns the correct output for several inputs\"\"\"\n", + "### AUTOTEST squares(1); squares(2)\n", + "### HASHED AUTOTEST squares(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "squares_invalid_input", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that squares raises an error for invalid inputs\"\"\"\n", + "def test_func_throws(func, ErrorType):\n", + " try:\n", + " func()\n", + " except ErrorType:\n", + " return True\n", + " else:\n", + " print('Did not raise right type of error!')\n", + " return False\n", + " \n", + "### AUTOTEST test_func_throws(lambda : squares(0), ValueError)\n", + "### AUTOTEST test_func_throws(lambda : squares(-4), ValueError);\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Part B (1 point)\n", + "\n", + "Using your `squares` function, write a function that computes the sum of the squares of the numbers from 1 to $n$. Your function should call the `squares` function -- it should NOT reimplement its functionality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "sum_of_squares", + "locked": false, + "schema_version": 3, + "solution": true + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "def sum_of_squares(n):\n", + " \"\"\"Compute the sum of the squares of numbers from 1 to n.\"\"\"\n", + " ### BEGIN SOLUTION\n", + " return sum(squares(n))\n", + " ### END SOLUTION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The sum of squares from 1 to 10 should be 385. Verify that this is the answer you get:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "sum_of_squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "correct_sum_of_squares", + "locked": false, + "points": 0.5, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that sum_of_squares returns the correct answer for various inputs.\"\"\"\n", + "### AUTOTEST sum_of_squares(1)\n", + "### AUTOTEST sum_of_squares(2); sum_of_squares(10) \n", + "### AUTOTEST sum_of_squares(11) \n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_uses_squares", + "locked": false, + "points": 0.5, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that sum_of_squares relies on squares.\"\"\"\n", + "\n", + "orig_squares = squares\n", + "del squares\n", + "\n", + "### AUTOTEST test_func_throws(lambda : sum_of_squares(1), NameError)\n", + "\n", + "squares = orig_squares\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part C (1 point)\n", + "\n", + "Using LaTeX math notation, write out the equation that is implemented by your `sum_of_squares` function." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_equation", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": true + } + }, + "source": [ + "$\\sum_{i=1}^n i^2$" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part D (2 points)\n", + "\n", + "Find a usecase for your `sum_of_squares` function and implement that usecase in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_application", + "locked": false, + "points": 2, + "schema_version": 3, + "solution": true + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "def pyramidal_number(n):\n", + " \"\"\"Returns the n^th pyramidal number\"\"\"\n", + " return sum_of_squares(n)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "cell-938593c4a215c6cc", + "locked": true, + "points": 4, + "schema_version": 3, + "solution": false, + "task": true + } + }, + "source": [ + "---\n", + "## Part E (4 points)\n", + "\n", + "State the formulae for an arithmetic and geometric sum and verify them numerically for an example of your choice." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part F (1 points)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "cell-d3df8cd59fd0eb74", + "locked": false, + "schema_version": 3, + "solution": true, + "task": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "my_dictionary = {\n", + " 'one' : 1,\n", + " 'two' : 2,\n", + " 'three' : 3\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "cell-6e9ff83aa5dfaf17", + "locked": true, + "points": 0, + "schema_version": 3, + "solution": false, + "task": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "### AUTOTEST my_dictionary\n", + "### AUTOTEST my_dictionary[\"one\"]" + ] + } + ], + "metadata": { + "celltoolbar": "Create Assignment", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/docs/source/user_guide/source/ps2/jupyter.png b/nbgrader/docs/source/user_guide/source/ps2/jupyter.png new file mode 100644 index 0000000000000000000000000000000000000000..201fc09ce423a6e83c74829d25b711f65ba47f1a GIT binary patch literal 5733 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 SF7+@s1{7peq$?y%g8u^szt&R# literal 0 HcmV?d00001 diff --git a/nbgrader/docs/source/user_guide/source/ps2/problem.ipynb b/nbgrader/docs/source/user_guide/source/ps2/problem.ipynb new file mode 100644 index 000000000..2286cd1eb --- /dev/null +++ b/nbgrader/docs/source/user_guide/source/ps2/problem.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "jupyter", + "locked": true, + "schema_version": 3, + "solution": false + } + }, + "source": [ + "For this problem set, we'll be using the Jupyter notebook:\n", + "\n", + "![](jupyter.png)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part A (2 points)\n", + "\n", + "Write a function that returns a vector of numbers, such that $x_i=i^2$, for $1\\leq i \\leq n$. Make sure it handles the case where $n<1$ by using `stop(\"n must be greater than or equal to 1\")`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "squares", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "squares <- function(n){\n", + " ### BEGIN SOLUTION\n", + " if (n < 1){\n", + " stop(\"n must be greater than or equal to 1\")\n", + " }\n", + " ret <- c()\n", + " for (i in 1:n){\n", + " ret <- append(ret, i**2)\n", + " }\n", + " return(ret)\n", + " ### END SOLUTION\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Your function should print `[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]` for $n=10$. Check that it does:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "correct_squares", + "locked": false, + "points": 2, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "### AUTOTEST squares(1); squares(2); squares(15)\n", + "### AUTOTEST squares(10)\n", + "### AUTOTEST squares(11)\n", + "### AUTOTEST 3\n", + "### AUTOTEST squares(3)\n", + "### HASHED AUTOTEST squares(3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Part B (1 point)\n", + "\n", + "Using your `squares` function, write a function that computes the sum of the squares of the numbers from 1 to $n$. Your function should call the `squares` function -- it should NOT reimplement its functionality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "sum_of_squares", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "sum_of_squares <- function(n) {\n", + " ### BEGIN SOLUTION\n", + " return(sum(squares(n)))\n", + " ### END SOLUTION\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The sum of squares from 1 to 10 should be 385. Verify that this is the answer you get:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sum_of_squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "correct_sum_of_squares", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "### AUTOTEST sum_of_squares(1)\n", + "### AUTOTEST sum_of_squares(2); sum_of_squares(10) \n", + "### AUTOTEST sum_of_squares(11) \n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part C (1 point)\n", + "\n", + "Using LaTeX math notation, write out the equation that is implemented by your `sum_of_squares` function." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_equation", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": true + } + }, + "source": [ + "$\\sum_{i=1}^n i^2$" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part D (2 points)\n", + "\n", + "Find a usecase for your `sum_of_squares` function and implement that usecase in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_application", + "locked": false, + "points": 2, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "pyramidal_number <- function(n){\n", + " return(sum_of_squares(n))\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part E (4 points)\n", + "\n", + "State the formulae for an arithmetic and geometric sum and verify them numerically for an example of your choice." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "R", + "language": "R", + "name": "ir" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/docs/source/user_guide/tests.yml b/nbgrader/docs/source/user_guide/tests.yml new file mode 100644 index 000000000..d1ed36e11 --- /dev/null +++ b/nbgrader/docs/source/user_guide/tests.yml @@ -0,0 +1,305 @@ +python3: + setup: "from hashlib import sha1" + hash: 'sha1({{snippet}}.encode("utf-8")+b"{{salt}}").hexdigest()' + dispatch: "type({{snippet}})" + normalize: "str({{snippet}})" + check: 'assert {{snippet}} == """{{value}}""", """{{message}}"""' + success: "print('Success!')" + + templates: + default: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + int: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + float: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not float. Please make sure it is float and not np.float64, etc. You can cast your value into a float using float()" + + - test: "round({{snippet}}, 2)" + fail: "value of {{snippet}} is not correct (rounded to 2 decimal places)" + + set: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not set. {{snippet}} should be a set" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + list: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not list. {{snippet}} should be a list" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + tuple: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not tuple. {{snippet}} should be a tuple" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + str: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not str. {{snippet}} should be an str" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}.lower()" + fail: "value of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "correct string value of {{snippet}} but incorrect case of letters" + + dict: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not dict. {{snippet}} should be a dict" + + - test: "len(list({{snippet}}.keys()))" + fail: "number of keys of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}.keys()))" + fail: "keys of {{snippet}} are not correct" + + - test: "sorted(map(str, {{snippet}}.values()))" + fail: "correct keys, but values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "correct keys and values, but incorrect correspondence in keys and values of {{snippet}}" + + bool: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not bool. {{snippet}} should be a bool" + + - test: "{{snippet}}" + fail: "boolean value of {{snippet}} is not correct" + + type: + - test: "{{snippet}}" + fail: "type of {{snippet}} is not correct" + + pandas.core.frame.DataFrame: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not pandas.core.frame.DataFrame. {{snippet}} should be a DataFrame" + + - test: "{{snippet}}.reindex(sorted({{snippet}}.columns), axis=1)" + fail: "some or all elements of {{snippet}} are not correct" + +# --------------------------------------------- + +python: + setup: "from hashlib import sha1" + hash: 'sha1({{snippet}}.encode("utf-8")+b"{{salt}}").hexdigest()' + dispatch: "type({{snippet}})" + normalize: "str({{snippet}})" + check: 'assert {{snippet}} == """{{value}}""", """{{message}}"""' + success: "print('Success!')" + + templates: + default: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + int: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + float: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not float. Please make sure it is float and not np.float64, etc. You can cast your value into a float using float()" + + - test: "round({{snippet}}, 2)" + fail: "value of {{snippet}} is not correct (rounded to 2 decimal places)" + + set: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not set. {{snippet}} should be a set" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + list: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not list. {{snippet}} should be a list" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + tuple: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not tuple. {{snippet}} should be a tuple" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + str: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not str. {{snippet}} should be an str" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}.lower()" + fail: "value of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "correct string value of {{snippet}} but incorrect case of letters" + + dict: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not dict. {{snippet}} should be a dict" + + - test: "len(list({{snippet}}.keys()))" + fail: "number of keys of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}.keys()))" + fail: "keys of {{snippet}} are not correct" + + - test: "sorted(map(str, {{snippet}}.values()))" + fail: "correct keys, but values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "correct keys and values, but incorrect correspondence in keys and values of {{snippet}}" + + bool: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not bool. {{snippet}} should be a bool" + + - test: "{{snippet}}" + fail: "boolean value of {{snippet}} is not correct" + + type: + - test: "{{snippet}}" + fail: "type of {{snippet}} is not correct" + + pandas.core.frame.DataFrame: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not pandas.core.frame.DataFrame. {{snippet}} should be a DataFrame" + + - test: "{{snippet}}.reindex(sorted({{snippet}}.columns), axis=1)" + fail: "some or all elements of {{snippet}} are not correct" + + +# -------------------------------------------------------------------------------------------------- +ir: + setup: 'library(digest)' + hash: 'digest(paste({{snippet}}, "{{salt}}"))' + dispatch: 'class({{snippet}})' + normalize: 'toString({{snippet}})' + check: 'stopifnot("{{message}}"= setequal({{snippet}}, "{{value}}"))' + success: "print('Success!')" + + templates: + default: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + integer: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not integer" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sort({{snippet}})" + fail: "values of {{snippet}} are not correct" + + numeric: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not double" + + - test: "round({{snippet}}, 2)" + fail: "value of {{snippet}} is not correct (rounded to 2 decimal places)" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sort({{snippet}})" + fail: "values of {{snippet}} are not correct" + + list: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not list" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sort(c(names({{snippet}})))" + fail: "values of {{snippet}} names are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + character: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not list" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "tolower({{snippet}})" + fail: "value of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "correct string value of {{snippet}} but incorrect case of letters" + + logical: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not logical" + + - test: "{{snippet}}" + fail: "logical value of {{snippet}} is not correct" diff --git a/nbgrader/preprocessors/__init__.py b/nbgrader/preprocessors/__init__.py index 1507ee0aa..874fc1e69 100644 --- a/nbgrader/preprocessors/__init__.py +++ b/nbgrader/preprocessors/__init__.py @@ -8,6 +8,7 @@ from .overwritecells import OverwriteCells from .checkcellmetadata import CheckCellMetadata from .execute import Execute +from .instantiatetests import InstantiateTests from .getgrades import GetGrades from .clearoutput import ClearOutput from .limitoutput import LimitOutput @@ -28,6 +29,7 @@ "OverwriteCells", "CheckCellMetadata", "Execute", + "InstantiateTests", "GetGrades", "ClearOutput", "LimitOutput", diff --git a/nbgrader/preprocessors/clearsolutions.py b/nbgrader/preprocessors/clearsolutions.py index 03ab3c13f..85a1fb7d2 100644 --- a/nbgrader/preprocessors/clearsolutions.py +++ b/nbgrader/preprocessors/clearsolutions.py @@ -15,6 +15,7 @@ class ClearSolutions(NbGraderPreprocessor): code_stub = Dict( dict(python="# YOUR CODE HERE\nraise NotImplementedError()", + R="# YOUR CODE HERE\nfail()", matlab="% YOUR CODE HERE\nerror('No Answer Given!')", octave="% YOUR CODE HERE\nerror('No Answer Given!')", sas="/* YOUR CODE HERE */\n %notImplemented;", diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py new file mode 100644 index 000000000..c208af9a8 --- /dev/null +++ b/nbgrader/preprocessors/instantiatetests.py @@ -0,0 +1,758 @@ +import os +import yaml +import jinja2 as j2 +import re +from .. import utils +from traitlets import Bool, List, Integer, Unicode, Dict, Callable +from textwrap import dedent +from . import Execute +import secrets +import asyncio +import inspect +import typing as t +from nbformat import NotebookNode +from queue import Empty +import datetime +from typing import Optional +from nbclient.exceptions import ( + CellControlSignal, + CellExecutionComplete, + CellExecutionError, + CellTimeoutError, + DeadKernelError, +) + +try: + from time import monotonic # Py 3 +except ImportError: + from time import time as monotonic # Py 2 + + +######################################################################################### +class CellExecutionComplete(Exception): + """ + Used as a control signal for cell execution across run_cell and + process_message function calls. Raised when all execution requests + are completed and no further messages are expected from the kernel + over zeromq channels. + """ + pass + + +######################################################################################### +class CellExecutionError(Exception): + """ + Custom exception to propagate exceptions that are raised during + notebook execution to the caller. This is mostly useful when + using nbconvert as a library, since it allows dealing with + failures gracefully. + """ + + # ------------------------------------------------------------------------------------- + def __init__(self, traceback): + super(CellExecutionError, self).__init__(traceback) + self.traceback = traceback + + # ------------------------------------------------------------------------------------- + def __str__(self): + s = self.__unicode__() + if not isinstance(s, str): + s = s.encode('utf8', 'replace') + return s + + # ------------------------------------------------------------------------------------- + def __unicode__(self): + return self.traceback + + # ------------------------------------------------------------------------------------- + @classmethod + def from_code_and_msg(cls, code, msg): + """Instantiate from a code cell object and a message contents + (message is either execute_reply or error) + """ + tb = '\n'.join(msg.get('traceback', [])) + return cls(exec_err_msg.format(code=code, traceback=tb)) + # ------------------------------------------------------------------------------------- + + +######################################################################################### +class CodeExecutionError(Exception): + """ + Custom exception to propagate exceptions that are raised during + code snippet execution to the caller. This is mostly useful when + using nbconvert as a library, since it allows dealing with + failures gracefully. + """ + + +######################################################################################### + +exec_err_msg = u"""\ +An error occurred while executing the following code: +------------------ +{code} +------------------ +{traceback} +""" + + +######################################################################################### +class InstantiateTests(Execute): + tests = None + + autotest_filename = Unicode( + "tests.yml", + help="The filename where automatic testing code is stored" + ).tag(config=True) + + autotest_delimiter = Unicode( + "AUTOTEST", + help="The delimiter prior to snippets to be autotested" + ).tag(config=True) + + hashed_delimiter = Unicode( + "HASHED", + help="The delimiter prior to an autotest block if snippet results should be protected by a hash function" + ).tag(config=True) + + use_salt = Bool( + True, + help="Whether to add a salt to digested answers" + ).tag(config=True) + + enforce_metadata = Bool( + True, + help=dedent( + """ + Whether or not to complain if cells containing autotest delimiters + are not marked as grade cells. WARNING: disabling this will potentially cause + things to break if you are using the full nbgrader pipeline. ONLY + disable this option if you are only ever planning to use nbgrader + assign. + """ + ) + ).tag(config=True) + + comment_strs = Dict( + key_trait=Unicode(), + value_trait=Unicode(), + default_value={ + 'ir': '#', + 'python': '#', + 'python3': '#' + }, + help=dedent( + """ + A dictionary mapping each Jupyter kernel's name to the comment string for that kernel. + For an example, one of the entries in this dictionary is "python" : "#", because # is the comment + character in python. + """ + ) + ).tag(config=True) + + sanitizers = Dict( + key_trait=Unicode(), + value_trait=Callable(), + default_value={ + 'ir': lambda s: re.sub(r'\[\d+\]\s+', '', s).strip('"').strip("'"), + 'python': lambda s: s.strip('"').strip("'"), + 'python3': lambda s: s.strip('"').strip("'") + }, + help=dedent( + """ + A dictionary mapping each Jupyter kernel's name to the function that is used to + sanitize the output from the kernel within InstantiateTests. + """ + ) + ).tag(config=True) + + sanitizer = None + global_tests_loaded = False + + def preprocess(self, nb, resources): + # avoid starting the kernel at all/processing the notebook if there are no autotest delimiters + for index, cell in enumerate(nb.cells): + # ignore non-code cells + if cell.cell_type != 'code': + continue + # look for an autotest delimiter in this cell's source; if we find one, process this notebook + if self.autotest_delimiter in cell.source: + nb, resources = super(InstantiateTests, self).preprocess(nb, resources) + return nb, resources + # if not, just return + return nb, resources + + def preprocess_cell(self, cell, resources, index): + # new_lines will store the replacement code after autotest template instantiation + new_lines = [] + + # first, run the cell normally + # cell, resources = super(InstantiateTests, self).preprocess_cell(cell, resources, index) + + kernel_name = self.nb.metadata.get("kernelspec", {}).get("name", "") + if kernel_name not in self.comment_strs: + raise ValueError( + "kernel '{}' has not been specified in " + "InstantiateTests.comment_strs".format(kernel_name)) + resources["kernel_name"] = kernel_name + + # if it's not a code cell, or it's empty, just return + if cell.cell_type != 'code': + return cell, resources + + # determine whether the cell is a grade cell + is_grade_flag = utils.is_grade(cell) + + # get the comment string for this language + comment_str = self.comment_strs[resources['kernel_name']] + + # split the code lines into separate strings + lines = cell.source.split("\n") + + setup_code_inserted_into_cell = False + + non_autotest_code_lines = [] + + if self.sanitizer is None: + self.log.debug('Setting sanitizer for language ' + resources['kernel_name']) + self.sanitizer = self.sanitizers.get(resources['kernel_name'], lambda x: x) + + for line in lines: + + # if the current line doesn't have the autotest_delimiter or is not a comment + # then just append the line to the new cell code and go to the next line + if self.autotest_delimiter not in line or line.strip()[:len(comment_str)] != comment_str: + new_lines.append(line) + non_autotest_code_lines.append(line) + continue + + # run all code lines prior to the current line containing the autotest_delimiter + asyncio.run(self._async_execute_code_snippet("\n".join(non_autotest_code_lines))) + non_autotest_code_lines = [] + + # there are autotests; we should check that it is a grading cell + if not is_grade_flag: + if not self.enforce_metadata: + self.log.warning( + "Autotest region detected in a non-grade cell; " + "please make sure all autotest regions are within " + "'Autograder tests' cells." + ) + else: + self.log.error( + "Autotest region detected in a non-grade cell; " + "please make sure all autotest regions are within " + "'Autograder tests' cells." + ) + raise Exception + + self.log.debug('') + self.log.debug('') + self.log.debug('Autotest delimiter found on line. Preprocessing...') + + # the first time we run into an autotest delimiter, obtain the + # tests object from the tests.yml template file for the assignment + # and append any setup code to the cell block we're in + # also figure out what language we're using + + # loading the template tests file + if not self.global_tests_loaded: + self.log.debug('Loading tests template file') + self._load_test_template_file(resources) + self.global_tests_loaded = True + + # if the setup_code is successfully obtained from the template file and + # the current cell does not already have the setup code, add the setup_code + if (self.setup_code is not None) and (not setup_code_inserted_into_cell): + new_lines.append(self.setup_code) + setup_code_inserted_into_cell = True + asyncio.run(self._async_execute_code_snippet(self.setup_code)) + + # decide whether to use hashing based on whether the self.hashed_delimiter token + # appears in the line before the self.autotest_delimiter token + use_hash = (self.hashed_delimiter in line[:line.find(self.autotest_delimiter)]) + if use_hash: + self.log.debug('Hashing delimiter found, using template: ' + self.hash_template) + else: + self.log.debug('Hashing delimiter not found') + + # take everything after the autotest_delimiter as code snippets separated by semicolons + snippets = [snip.strip() for snip in + line[line.find(self.autotest_delimiter) + len(self.autotest_delimiter):].strip(';').split(';')] + + # remove empty snippets + if '' in snippets: + snippets.remove('') + + # print autotest snippets to log + self.log.debug('Found snippets to autotest: ') + for snippet in snippets: + self.log.debug(snippet) + + # generate the test for each snippet + for snippet in snippets: + self.log.debug('Running autotest generation for snippet ' + snippet) + + # create a random salt for this test + if use_hash: + salt = secrets.token_hex(8) + self.log.debug('Using salt: ' + salt) + else: + salt = None + + # get the normalized(/hashed) template tests for this code snippet + self.log.debug( + 'Instantiating normalized' + ('/hashed ' if use_hash else ' ') + 'test templates based on type') + instantiated_tests, test_values, fail_messages = self._instantiate_tests(snippet, salt) + + # add all the lines to the cell + self.log.debug('Inserting test code into cell') + template = j2.Environment(loader=j2.BaseLoader).from_string(self.check_template) + for i in range(len(instantiated_tests)): + check_code = template.render(snippet=instantiated_tests[i], value=test_values[i], + message=fail_messages[i]) + self.log.debug('Test: ' + check_code) + new_lines.append(check_code) + + # add an empty line after this block of test code + new_lines.append('') + + # run the trailing non-autotest lines, if any remain + if len(non_autotest_code_lines) > 0: + asyncio.run(self._async_execute_code_snippet("\n".join(non_autotest_code_lines))) + + # add the final success message + if is_grade_flag and self.global_tests_loaded: + if self.autotest_delimiter in cell.source: + new_lines.append(self.success_code) + + # replace the cell source + cell.source = "\n".join(new_lines) + + # remove the execution metainfo + cell.pop('execution', None) + + return cell, resources + + # ------------------------------------------------------------------------------------- + def _load_test_template_file(self, resources): + """ + attempts to load the tests.yml file within the assignment directory. In case such file is not found + or perhaps cannot be loaded, it will attempt to load the default_tests.yaml file with the course_directory + """ + self.log.debug('loading template tests.yml...') + self.log.debug('kernel_name: ' + resources["kernel_name"]) + try: + with open(os.path.join(resources['metadata']['path'], self.autotest_filename), 'r') as tests_file: + tests = yaml.safe_load(tests_file) + self.log.debug(tests) + + except FileNotFoundError: + # if there is no tests file, just load a default tests dict + self.log.warning( + 'No tests.yml file found in the assignment directory. Loading the default tests.yml file in the course root directory') + # tests = {} + try: + with open(os.path.join(self.autotest_filename), 'r') as tests_file: + tests = yaml.safe_load(tests_file) + except FileNotFoundError: + # if there is no tests file, just create a default empty tests dict + self.log.warning( + 'No tests.yml file found. If AUTOTESTS appears in testing cells, an error will be thrown.') + tests = {} + except yaml.parser.ParserError as e: + self.log.error('tests.yml contains invalid YAML code.') + self.log.error(e.msg) + raise + + except yaml.parser.ParserError as e: + self.log.error('tests.yml contains invalid YAML code.') + self.log.error(e.msg) + raise + + # get kernel specific data + tests = tests[resources["kernel_name"]] + + # get the test templates + self.test_templates_by_type = tests['templates'] + + # get the test dispatch code template + self.dispatch_template = tests['dispatch'] + + # get the success message template + self.success_code = tests['success'] + + # get the hash code template + self.hash_template = tests['hash'] + + # get the hash code template + self.check_template = tests['check'] + + # get the hash code template + self.normalize_template = tests['normalize'] + + # get the setup code if it's there + self.setup_code = tests.get('setup', None) + + # ------------------------------------------------------------------------------------- + def _instantiate_tests(self, snippet, salt=None): + # get the type of the snippet output (used to dispatch autotest) + template = j2.Environment(loader=j2.BaseLoader).from_string(self.dispatch_template) + dispatch_code = template.render(snippet=snippet) + dispatch_result = asyncio.run(self._async_execute_code_snippet(dispatch_code)) + self.log.debug('Dispatch result returned by kernel: ', dispatch_result) + # get the test code; if the type isn't in our dict, just default to 'default' + # if default isn't in the tests code, this will throw an error + try: + tests = self.test_templates_by_type.get(dispatch_result, self.test_templates_by_type['default']) + except KeyError: + self.log.error('tests.yml must contain a top-level "default" key with corresponding test code') + raise + try: + test_templs = [t['test'] for t in tests] + fail_msgs = [t['fail'] for t in tests] + except KeyError: + self.log.error('each type in tests.yml must have a list of dictionaries with a "test" and "fail" key') + self.log.error('the "test" item should store the test template code, ' + 'and the "fail" item should store a failure message') + raise + + # + rendered_fail_msgs = [] + for templ in fail_msgs: + template = j2.Environment(loader=j2.BaseLoader).from_string(templ) + fmsg = template.render(snippet=snippet) + # escape double quotes + fmsg = fmsg.replace("\"", "\\\"") + rendered_fail_msgs.append(fmsg) + + # normalize the templates + normalized_templs = [] + for templ in test_templs: + template = j2.Environment(loader=j2.BaseLoader).from_string(self.normalize_template) + normalized_templs.append(template.render(snippet=templ)) + + # hashify the templates + processed_templs = [] + if salt is not None: + for templ in normalized_templs: + template = j2.Environment(loader=j2.BaseLoader).from_string(self.hash_template) + processed_templs.append(template.render(snippet=templ, salt=salt)) + else: + processed_templs = normalized_templs + + # instantiate and evaluate the tests + instantiated_tests = [] + test_values = [] + for templ in processed_templs: + # instantiate the template snippet + template = j2.Environment(loader=j2.BaseLoader).from_string(templ) + instantiated_test = template.render(snippet=snippet) + # run the instantiated template code + test_value = asyncio.run(self._async_execute_code_snippet(instantiated_test)) + instantiated_tests.append(instantiated_test) + test_values.append(test_value) + + return instantiated_tests, test_values, rendered_fail_msgs + + # ------------------------------------------------------------------------------------- + + ######################### + # async version of nbgrader interaction with kernel + # the below functions were adapted from the jupyter/nbclient GitHub repo, commit: + # https://github.com/jupyter/nbclient/commit/0c08e27c1ec655cffe9b35cf637da742cdab36e8 + ######################### + + # ------------------------------------------------------------------------------------- + # adapted from nbclient.util.ensure_async + async def _ensure_async(self, obj): + """Convert a non-awaitable object to a coroutine if needed, + and await it if it was not already awaited. + adapted from nbclient.util._ensure_async + """ + if inspect.isawaitable(obj): + try: + result = await obj + except RuntimeError as e: + if str(e) == 'cannot reuse already awaited coroutine': + return obj + raise + return result + return obj + + # ------------------------------------------------------------------------------------- + # adapted from nbclient.client._async_handle_timeout + async def _async_handle_timeout(self, timeout: int) -> None: + + self.log.error("Timeout waiting for execute reply (%is)." % timeout) + if self.interrupt_on_timeout: + self.log.error("Interrupting kernel") + assert self.km is not None + await _ensure_async(self.km.interrupt_kernel()) + else: + raise CellTimeoutError.error_from_timeout_and_cell( + "Cell execution timed out", timeout + ) + + # ------------------------------------------------------------------------------------- + # adapted from nbclient.client._async_check_alive + async def _async_check_alive(self) -> None: + assert self.kc is not None + if not await self._ensure_async(self.kc.is_alive()): + self.log.error("Kernel died while waiting for execute reply.") + raise DeadKernelError("Kernel died") + + # ------------------------------------------------------------------------------------- + # adapted from nbclient.client._async_poll_output_msg + async def _async_poll_output_msg_code( + self, parent_msg_id: str, code + ) -> None: + + assert self.kc is not None + while True: + msg = await self._ensure_async(self.kc.iopub_channel.get_msg(timeout=None)) + if msg['parent_header'].get('msg_id') == parent_msg_id: + try: + msg_type = msg['msg_type'] + self.log.debug("msg_type: %s", msg_type) + content = msg['content'] + self.log.debug("content: %s", content) + + if msg_type in {'execute_result', 'display_data', 'update_display_data'}: + return self.sanitizer(content['data']['text/plain']) + + if msg_type == 'error': + self.log.error("Failed to run code: \n%s", code) + self.log.error("Runtime error from the kernel: \n%s", content['evalue']) + raise CodeExecutionError() + + if msg_type == 'status': + if content['execution_state'] == 'idle': + raise CellExecutionComplete() + + except CellExecutionComplete: + return + + # ------------------------------------------------------------------------------------- + # adapted from nbclient.client.async_wait_for_reply + async def _async_wait_for_reply( + self, msg_id: str, cell: t.Optional[NotebookNode] = None + ) -> t.Optional[t.Dict]: + + assert self.kc is not None + # wait for finish, with timeout + timeout = self._get_timeout(cell) + cummulative_time = 0 + while True: + try: + msg = await _ensure_async( + self.kc.shell_channel.get_msg(timeout=self.shell_timeout_interval) + ) + except Empty: + await self._async_check_alive() + cummulative_time += self.shell_timeout_interval + if timeout and cummulative_time > timeout: + await self._async_async_handle_timeout(timeout, cell) + break + else: + if msg['parent_header'].get('msg_id') == msg_id: + return msg + return None + + # ------------------------------------------------------------------------------------- + # adapted from nbclient.client._async_poll_for_reply + async def _async_poll_for_reply_code( + self, + msg_id: str, + timeout: t.Optional[int], + task_poll_output_msg: asyncio.Future, + task_poll_kernel_alive: asyncio.Future, + ) -> t.Dict: + + assert self.kc is not None + + self.log.debug("Executing _async_poll_for_reply:\n%s", msg_id) + + if timeout is not None: + deadline = monotonic() + timeout + new_timeout = float(timeout) + + while True: + try: + shell_msg = await self._ensure_async(self.kc.shell_channel.get_msg(timeout=new_timeout)) + if shell_msg['parent_header'].get('msg_id') == msg_id: + try: + msg = await asyncio.wait_for(task_poll_output_msg, new_timeout) + except (asyncio.TimeoutError, Empty): + task_poll_kernel_alive.cancel() + raise CellExecutionError("Timeout waiting for IOPub output") + self.log.debug("Get _async_poll_for_reply:\n%s", msg) + + return msg if msg != None else "" + else: + if new_timeout is not None: + new_timeout = max(0, deadline - monotonic()) + except Empty: + self.log.debug("Empty _async_poll_for_reply:\n%s", msg_id) + task_poll_kernel_alive.cancel() + await self._async_check_alive() + await self._async_handle_timeout() + + # ------------------------------------------------------------------------------------- + # adapted from nbclient.client.async_execute_cell + async def _async_execute_code_snippet(self, code): + assert self.kc is not None + + self.log.debug("Executing cell:\n%s", code) + + parent_msg_id = await self._ensure_async(self.kc.execute(code, stop_on_error=not self.allow_errors)) + + task_poll_kernel_alive = asyncio.ensure_future(self._async_check_alive()) + + task_poll_output_msg = asyncio.ensure_future(self._async_poll_output_msg_code(parent_msg_id, code)) + + task_poll_for_reply = asyncio.ensure_future( + self._async_poll_for_reply_code(parent_msg_id, self.timeout, task_poll_output_msg, task_poll_kernel_alive)) + + try: + msg = await task_poll_for_reply + except asyncio.CancelledError: + # can only be cancelled by task_poll_kernel_alive when the kernel is dead + task_poll_output_msg.cancel() + raise DeadKernelError("Kernel died") + except Exception as e: + # Best effort to cancel request if it hasn't been resolved + try: + # Check if the task_poll_output is doing the raising for us + if not isinstance(e, CellControlSignal): + task_poll_output_msg.cancel() + finally: + raise + + return msg + + # ------------------------------------------------------------------------------------- + async def async_execute_cell( + self, + cell: NotebookNode, + cell_index: int, + execution_count: t.Optional[int] = None, + store_history: bool = True, + ) -> NotebookNode: + """ + Executes a single code cell. + + To execute all cells see :meth:`execute`. + + Parameters + ---------- + cell : nbformat.NotebookNode + The cell which is currently being processed. + cell_index : int + The position of the cell within the notebook object. + execution_count : int + The execution count to be assigned to the cell (default: Use kernel response) + store_history : bool + Determines if history should be stored in the kernel (default: False). + Specific to ipython kernels, which can store command histories. + + Returns + ------- + output : dict + The execution output payload (or None for no output). + + Raises + ------ + CellExecutionError + If execution failed and should raise an exception, this will be raised + with defaults about the failure. + + Returns + ------- + cell : NotebookNode + The cell which was just processed. + """ + assert self.kc is not None + + await run_hook(self.on_cell_start, cell=cell, cell_index=cell_index) + + if cell.cell_type != 'code' or not cell.source.strip(): + self.log.debug("Skipping non-executing cell %s", cell_index) + return cell + + if self.skip_cells_with_tag in cell.metadata.get("tags", []): + self.log.debug("Skipping tagged cell %s", cell_index) + return cell + + if self.record_timing: # clear execution metadata prior to execution + cell['metadata']['execution'] = {} + + self.log.debug("Executing cell:\n%s", cell.source) + + cell_allows_errors = (not self.force_raise_errors) and ( + self.allow_errors or "raises-exception" in cell.metadata.get("tags", []) + ) + + await run_hook(self.on_cell_execute, cell=cell, cell_index=cell_index) + parent_msg_id = await _ensure_async( + self.kc.execute( + cell.source, store_history=store_history, stop_on_error=not cell_allows_errors + ) + ) + await run_hook(self.on_cell_complete, cell=cell, cell_index=cell_index) + # We launched a code cell to execute + self.code_cells_executed += 1 + exec_timeout = self._get_timeout(cell) + + cell.outputs = [] + self.clear_before_next_output = False + + task_poll_kernel_alive = asyncio.ensure_future(self._async_poll_kernel_alive()) + task_poll_output_msg = asyncio.ensure_future( + self._async_poll_output_msg_code(parent_msg_id, code) + ) + self.task_poll_for_reply = asyncio.ensure_future( + self._async_poll_for_reply_code( + parent_msg_id, exec_timeout, task_poll_output_msg, task_poll_kernel_alive + ) + ) + try: + exec_reply = await self.task_poll_for_reply + except asyncio.CancelledError: + # can only be cancelled by task_poll_kernel_alive when the kernel is dead + task_poll_output_msg.cancel() + raise DeadKernelError("Kernel died") + except Exception as e: + # Best effort to cancel request if it hasn't been resolved + try: + # Check if the task_poll_output is doing the raising for us + if not isinstance(e, CellControlSignal): + task_poll_output_msg.cancel() + finally: + raise + + if execution_count: + cell['execution_count'] = execution_count + await self._check_raise_for_error(cell, cell_index, exec_reply) + self.nb['cells'][cell_index] = cell + return cell + # ------------------------------------------------------------------------------------- + + +def timestamp(msg: Optional[Dict] = None) -> str: + if msg and 'header' in msg: # The test mocks don't provide a header, so tolerate that + msg_header = msg['header'] + if 'date' in msg_header and isinstance(msg_header['date'], datetime.datetime): + try: + # reformat datetime into expected format + formatted_time = datetime.datetime.strftime( + msg_header['date'], '%Y-%m-%dT%H:%M:%S.%fZ' + ) + if ( + formatted_time + ): # docs indicate strftime may return empty string, so let's catch that too + return formatted_time + except Exception: + pass # fallback to a local time + + return datetime.datetime.utcnow().isoformat() + 'Z' diff --git a/nbgrader/tests/__init__.py b/nbgrader/tests/__init__.py index 5f57d10ff..82992b1ba 100644 --- a/nbgrader/tests/__init__.py +++ b/nbgrader/tests/__init__.py @@ -251,3 +251,18 @@ def get_free_ports(n): for s in sockets: s.close() return ports + +def create_autotest_solution_cell(): + source = """ + answer = 'answer' + """ + cell = new_code_cell(source=source) + return cell + + +def create_autotest_test_cell(): + source = """ + ### AUTOTEST answer + """ + cell = new_code_cell(source=source) + return cell diff --git a/nbgrader/tests/apps/files/autotest-hashed-changed.ipynb b/nbgrader/tests/apps/files/autotest-hashed-changed.ipynb new file mode 100644 index 000000000..8ddbb8633 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-hashed-changed.ipynb @@ -0,0 +1,112 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "a0a263f3cd77437ecaaaa68ffd10ca2f", + "grade": true, + "grade_id": "test_hashed", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "from hashlib import sha1\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"9231f34cdc3da9ea\").hexdigest() == \"659113e142e34b819add5d3c95d33a1cf10a09ae\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"9231f34cdc3da9ea\").hexdigest() == \"455510a3efa0017fa35841cd77fb1554ee665d0a\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(b)).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"a7a6d540df4104f3adb629c5c3ea3c7c461eaa6f\", \"type of b is not str. b should be an str\"\n", + "assert sha1(str(len(b)).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"b8eda0d698f28aedc80f0f318c35af3447c18f29\", \"length of b is not correct\"\n", + "assert sha1(str(b.lower()).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"2755a4e47f3b60f679787659f804488276ec718e\", \"value of b is not correct\"\n", + "assert sha1(str(b).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"2755a4e47f3b60f679787659f804488276ec718e\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert sha1(str(type(c)).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"2da3504fe36c50ab1d44ffb61bd4a02af8f0fdbd\", \"type of c is not list. c should be a list\"\n", + "assert sha1(str(len(c)).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"5f8b7c79f0b7ee5fafa410947a40589acbf2b644\", \"length of c is not correct\"\n", + "assert sha1(str(sorted(map(str, c))).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"10c943e426df5e88a394da50cc9cc07f6cb2fa33\", \"values of c are not correct\"\n", + "assert sha1(str(c).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"c538ca013d170994051b3d55fb2c488d2fadb776\", \"order of elements of c is not correct\"\n", + "\n", + "\n", + "# differing spacings, num comment characters, trailing whitespace\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"cfe4dae897378d1a\").hexdigest() == \"463fc87d8b90bcf558e04b5bff454d0fe10f0447\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"cfe4dae897378d1a\").hexdigest() == \"586e477f328cc5e3c852148517751d072d36afee\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"ec2be017656b8d95\").hexdigest() == \"2abb3c781a9ed14b1dec5a00736dc170f3301ce2\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"ec2be017656b8d95\").hexdigest() == \"b86b5db1c86084688de67ed5b3e2001206365a44\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"da663014407e8b68\").hexdigest() == \"4c5e40a4f0ec62a3dbf5232e9d22faba40b1d394\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"da663014407e8b68\").hexdigest() == \"419c839a51a681a8fe1bd658d02c285ab657a1ef\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"f97fab4e29c2f008\").hexdigest() == \"21248e96318cac95a37157dcded2b3e9b3778668\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"f97fab4e29c2f008\").hexdigest() == \"0e27de567679deef32bd63ebd416cb835db76ba4\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"72eea5ecf1bd1e19\").hexdigest() == \"6008755168651743936153fa68a21c1c4369b61a\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"72eea5ecf1bd1e19\").hexdigest() == \"b5d66cbe4aaaaf8a45e1ceef0e2e03f70fcefa59\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"93205c8cd03cafd5\").hexdigest() == \"2292f651355b47a594a97fe2e04253414aefe4bd\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"93205c8cd03cafd5\").hexdigest() == \"f5eba7f0391b94b32a57691d8f982659d50fbf9f\", \"value of a is not correct\"\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-hashed-unchanged.ipynb b/nbgrader/tests/apps/files/autotest-hashed-unchanged.ipynb new file mode 100644 index 000000000..86f485097 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-hashed-unchanged.ipynb @@ -0,0 +1,110 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "raise NotImplementedError()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "a0a263f3cd77437ecaaaa68ffd10ca2f", + "grade": true, + "grade_id": "test_hashed", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "from hashlib import sha1\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"9231f34cdc3da9ea\").hexdigest() == \"659113e142e34b819add5d3c95d33a1cf10a09ae\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"9231f34cdc3da9ea\").hexdigest() == \"455510a3efa0017fa35841cd77fb1554ee665d0a\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(b)).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"a7a6d540df4104f3adb629c5c3ea3c7c461eaa6f\", \"type of b is not str. b should be an str\"\n", + "assert sha1(str(len(b)).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"b8eda0d698f28aedc80f0f318c35af3447c18f29\", \"length of b is not correct\"\n", + "assert sha1(str(b.lower()).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"2755a4e47f3b60f679787659f804488276ec718e\", \"value of b is not correct\"\n", + "assert sha1(str(b).encode(\"utf-8\")+b\"13937391b113e128\").hexdigest() == \"2755a4e47f3b60f679787659f804488276ec718e\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert sha1(str(type(c)).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"2da3504fe36c50ab1d44ffb61bd4a02af8f0fdbd\", \"type of c is not list. c should be a list\"\n", + "assert sha1(str(len(c)).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"5f8b7c79f0b7ee5fafa410947a40589acbf2b644\", \"length of c is not correct\"\n", + "assert sha1(str(sorted(map(str, c))).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"10c943e426df5e88a394da50cc9cc07f6cb2fa33\", \"values of c are not correct\"\n", + "assert sha1(str(c).encode(\"utf-8\")+b\"e33308cee3b48f12\").hexdigest() == \"c538ca013d170994051b3d55fb2c488d2fadb776\", \"order of elements of c is not correct\"\n", + "\n", + "\n", + "# differing spacings, num comment characters, trailing whitespace\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"cfe4dae897378d1a\").hexdigest() == \"463fc87d8b90bcf558e04b5bff454d0fe10f0447\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"cfe4dae897378d1a\").hexdigest() == \"586e477f328cc5e3c852148517751d072d36afee\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"ec2be017656b8d95\").hexdigest() == \"2abb3c781a9ed14b1dec5a00736dc170f3301ce2\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"ec2be017656b8d95\").hexdigest() == \"b86b5db1c86084688de67ed5b3e2001206365a44\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"da663014407e8b68\").hexdigest() == \"4c5e40a4f0ec62a3dbf5232e9d22faba40b1d394\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"da663014407e8b68\").hexdigest() == \"419c839a51a681a8fe1bd658d02c285ab657a1ef\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"f97fab4e29c2f008\").hexdigest() == \"21248e96318cac95a37157dcded2b3e9b3778668\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"f97fab4e29c2f008\").hexdigest() == \"0e27de567679deef32bd63ebd416cb835db76ba4\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"72eea5ecf1bd1e19\").hexdigest() == \"6008755168651743936153fa68a21c1c4369b61a\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"72eea5ecf1bd1e19\").hexdigest() == \"b5d66cbe4aaaaf8a45e1ceef0e2e03f70fcefa59\", \"value of a is not correct\"\n", + "\n", + "assert sha1(str(type(a)).encode(\"utf-8\")+b\"93205c8cd03cafd5\").hexdigest() == \"2292f651355b47a594a97fe2e04253414aefe4bd\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert sha1(str(a).encode(\"utf-8\")+b\"93205c8cd03cafd5\").hexdigest() == \"f5eba7f0391b94b32a57691d8f982659d50fbf9f\", \"value of a is not correct\"\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-hashed.ipynb b/nbgrader/tests/apps/files/autotest-hashed.ipynb new file mode 100644 index 000000000..1b4df7b83 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-hashed.ipynb @@ -0,0 +1,82 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "### BEGIN SOLUTION\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]\n", + "### END SOLUTION" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "test_hashed", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "### HASHED AUTOTEST a\n", + "### HASHED AUTOTEST b\n", + "### HASHED AUTOTEST c\n", + "\n", + "# differing spacings, num comment characters, trailing whitespace\n", + "### HASHED AUTOTEST a \n", + "#HASHED AUTOTEST a\n", + "# HASHED AUTOTEST a\n", + "## HASHED AUTOTEST a\n", + "# HASHED AUTOTEST a\n", + "# # # # HASHED AUTOTEST a" + ] + } + ], + "metadata": { + "celltoolbar": "Create Assignment", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-hidden-changed-right.ipynb b/nbgrader/tests/apps/files/autotest-hidden-changed-right.ipynb new file mode 100644 index 000000000..d17fa7735 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-hidden-changed-right.ipynb @@ -0,0 +1,85 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "2fd15c162690346636dd1f2881f6b31e", + "grade": true, + "grade_id": "test_hidden", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "\n", + "assert str(type(a)) == \"\", \"type of type(a) is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of type(b) is not correct\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of type(c) is not correct\"\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-hidden-changed-wrong.ipynb b/nbgrader/tests/apps/files/autotest-hidden-changed-wrong.ipynb new file mode 100644 index 000000000..e84452ef5 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-hidden-changed-wrong.ipynb @@ -0,0 +1,85 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "a = 7\n", + "b = \"notright\"\n", + "c = [3, 4, \"hi\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "2fd15c162690346636dd1f2881f6b31e", + "grade": true, + "grade_id": "test_hidden", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "\n", + "assert str(type(a)) == \"\", \"type of type(a) is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of type(b) is not correct\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of type(c) is not correct\"\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-hidden-unchanged.ipynb b/nbgrader/tests/apps/files/autotest-hidden-unchanged.ipynb new file mode 100644 index 000000000..be153756b --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-hidden-unchanged.ipynb @@ -0,0 +1,83 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "raise NotImplementedError()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "2fd15c162690346636dd1f2881f6b31e", + "grade": true, + "grade_id": "test_hidden", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "\n", + "assert str(type(a)) == \"\", \"type of type(a) is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of type(b) is not correct\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of type(c) is not correct\"\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-hidden.ipynb b/nbgrader/tests/apps/files/autotest-hidden.ipynb new file mode 100644 index 000000000..c2e6e8e23 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-hidden.ipynb @@ -0,0 +1,80 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "### BEGIN SOLUTION\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]\n", + "### END SOLUTION" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "test_hidden", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "### BEGIN HIDDEN TESTS\n", + "### AUTOTEST a\n", + "### AUTOTEST b\n", + "### AUTOTEST c\n", + "### END HIDDEN TESTS\n", + "\n", + "### AUTOTEST type(a)\n", + "### AUTOTEST type(b)\n", + "### AUTOTEST type(c)" + ] + } + ], + "metadata": { + "celltoolbar": "Create Assignment", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-multi-changed.ipynb b/nbgrader/tests/apps/files/autotest-multi-changed.ipynb new file mode 100644 index 000000000..04470cca4 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-multi-changed.ipynb @@ -0,0 +1,267 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]\n", + "d = {1 : 6, 3: 5} #right type, wrong value\n", + "e = [5, -3.3, 'd'] #right values, wrong type\n", + "f = True # wrong value\n", + "def fun(x):\n", + " if x < 0:\n", + " raise ValueError\n", + " else:\n", + " return x" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "620860a42fa05b556da01013ef99ed8c", + "grade": true, + "grade_id": "test_multi", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "# the basic tests from the simple notebook\n", + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of b is not str. b should be an str\"\n", + "assert str(len(b)) == \"5\", \"length of b is not correct\"\n", + "assert str(b.lower()) == \"hello\", \"value of b is not correct\"\n", + "assert str(b) == \"hello\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of c is not list. c should be a list\"\n", + "assert str(len(c)) == \"3\", \"length of c is not correct\"\n", + "assert str(sorted(map(str, c))) == \"['1', '2', 'test']\", \"values of c are not correct\"\n", + "assert str(c) == \"[1, 2, 'test']\", \"order of elements of c is not correct\"\n", + "\n", + "print('Success!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "c46928757ed303ce204ef97299b39f82", + "grade": true, + "grade_id": "cell-f2803ba7c42d03ab", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "# multiple expressions per line\n", + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of type(a) is not correct\"\n", + "\n", + "assert str(type(str(a))) == \"\", \"type of str(a) is not str. str(a) should be an str\"\n", + "assert str(len(str(a))) == \"1\", \"length of str(a) is not correct\"\n", + "assert str(str(a).lower()) == \"5\", \"value of str(a) is not correct\"\n", + "assert str(str(a)) == \"5\", \"correct string value of str(a) but incorrect case of letters\"\n", + "\n", + "assert str(type(a == \"5\")) == \"\", \"type of a == \\\"5\\\" is not bool. a == \\\"5\\\" should be a bool\"\n", + "assert str(a == \"5\") == \"False\", \"boolean value of a == \\\"5\\\" is not correct\"\n", + "\n", + "assert str(type([ch for ch in b])) == \"\", \"type of [ch for ch in b] is not list. [ch for ch in b] should be a list\"\n", + "assert str(len([ch for ch in b])) == \"5\", \"length of [ch for ch in b] is not correct\"\n", + "assert str(sorted(map(str, [ch for ch in b]))) == \"['e', 'h', 'l', 'l', 'o']\", \"values of [ch for ch in b] are not correct\"\n", + "assert str([ch for ch in b]) == \"['h', 'e', 'l', 'l', 'o']\", \"order of elements of [ch for ch in b] is not correct\"\n", + "\n", + "assert str(type(len(c))) == \"\", \"type of len(c) is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(len(c)) == \"3\", \"value of len(c) is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "\n", + "# intervening regular code\n", + "print(\"hello!\")\n", + "\n", + "# differing spacings, numbers of comment characters, trailing whitespace\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "\n", + "\n", + "print('Success!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "2fc8066fae8e7aeb865116c88d1be31a", + "grade": true, + "grade_id": "cell-693350420ec62f1b", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "# a few common types\n", + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of b is not str. b should be an str\"\n", + "assert str(len(b)) == \"5\", \"length of b is not correct\"\n", + "assert str(b.lower()) == \"hello\", \"value of b is not correct\"\n", + "assert str(b) == \"hello\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of c is not list. c should be a list\"\n", + "assert str(len(c)) == \"3\", \"length of c is not correct\"\n", + "assert str(sorted(map(str, c))) == \"['1', '2', 'test']\", \"values of c are not correct\"\n", + "assert str(c) == \"[1, 2, 'test']\", \"order of elements of c is not correct\"\n", + "\n", + "assert str(type(d)) == \"\", \"type of d is not dict. d should be a dict\"\n", + "assert str(len(list(d.keys()))) == \"2\", \"number of keys of d is not correct\"\n", + "assert str(sorted(map(str, d.keys()))) == \"['3', 'a']\", \"keys of d are not correct\"\n", + "assert str(sorted(map(str, d.values()))) == \"['[1.2, 3]', 'f']\", \"correct keys, but values of d are not correct\"\n", + "assert str(d) == \"{'a': 'f', 3: [1.2, 3]}\", \"correct keys and values, but incorrect correspondence in keys and values of d\"\n", + "\n", + "assert str(type(e)) == \"\", \"type of e is not tuple. e should be a tuple\"\n", + "assert str(len(e)) == \"3\", \"length of e is not correct\"\n", + "assert str(sorted(map(str, e))) == \"['-3.3', '5', 'd']\", \"values of e are not correct\"\n", + "assert str(e) == \"(5, -3.3, 'd')\", \"order of elements of e is not correct\"\n", + "\n", + "assert str(type(f)) == \"\", \"type of f is not bool. f should be a bool\"\n", + "assert str(f) == \"False\", \"boolean value of f is not correct\"\n", + "\n", + "print('Success!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "cc9122036c0a86a93446786109a3558e", + "grade": true, + "grade_id": "cell-13479eb0e5fff152", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "\n", + "# a function that checks whether a function throws an error\n", + "\"\"\"Check that squares raises an error for invalid inputs\"\"\"\n", + "def test_func_throws(func, ErrorType):\n", + " try:\n", + " func()\n", + " except ErrorType:\n", + " return True\n", + " else:\n", + " print('Did not raise right type of error!')\n", + " return False\n", + "\n", + "# test a custom function\n", + "from hashlib import sha1\n", + "assert str(type(fun(3))) == \"\", \"type of fun(3) is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(fun(3)) == \"3\", \"value of fun(3) is not correct\"\n", + "\n", + "assert str(type(test_func_throws(lambda : fun(-4), ValueError))) == \"\", \"type of test_func_throws(lambda : fun(-4), ValueError) is not bool. test_func_throws(lambda : fun(-4), ValueError) should be a bool\"\n", + "assert str(test_func_throws(lambda : fun(-4), ValueError)) == \"True\", \"boolean value of test_func_throws(lambda : fun(-4), ValueError) is not correct\"\n", + "\n", + "assert str(type(test_func_throws(lambda : fun(0), ValueError))) == \"\", \"type of test_func_throws(lambda : fun(0), ValueError) is not bool. test_func_throws(lambda : fun(0), ValueError) should be a bool\"\n", + "assert str(test_func_throws(lambda : fun(0), ValueError)) == \"False\", \"boolean value of test_func_throws(lambda : fun(0), ValueError) is not correct\"\n", + "\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb b/nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb new file mode 100644 index 000000000..212fee372 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb @@ -0,0 +1,277 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "raise NotImplementedError()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "620860a42fa05b556da01013ef99ed8c", + "grade": true, + "grade_id": "test_multi", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Success!\n" + ] + } + ], + "source": [ + "# the basic tests from the simple notebook\n", + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of b is not str. b should be an str\"\n", + "assert str(len(b)) == \"5\", \"length of b is not correct\"\n", + "assert str(b.lower()) == \"hello\", \"value of b is not correct\"\n", + "assert str(b) == \"hello\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of c is not list. c should be a list\"\n", + "assert str(len(c)) == \"3\", \"length of c is not correct\"\n", + "assert str(sorted(map(str, c))) == \"['1', '2', 'test']\", \"values of c are not correct\"\n", + "assert str(c) == \"[1, 2, 'test']\", \"order of elements of c is not correct\"\n", + "\n", + "print('Success!')" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "c46928757ed303ce204ef97299b39f82", + "grade": true, + "grade_id": "cell-f2803ba7c42d03ab", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [ + { + "ename": "AssertionError", + "evalue": "value of str(a) is not correct", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAssertionError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/tmp/ipykernel_645570/554255095.py\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtype\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"type of str(a) is not str. str(a) should be an str\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 6\u001b[0m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"1\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"length of str(a) is not correct\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 7\u001b[0;31m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mlower\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"5\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"value of str(a) is not correct\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 8\u001b[0m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"5\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"correct string value of str(a) but incorrect case of letters\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 9\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mAssertionError\u001b[0m: value of str(a) is not correct" + ] + } + ], + "source": [ + "# multiple expressions per line\n", + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of type(a) is not correct\"\n", + "\n", + "assert str(type(str(a))) == \"\", \"type of str(a) is not str. str(a) should be an str\"\n", + "assert str(len(str(a))) == \"1\", \"length of str(a) is not correct\"\n", + "assert str(str(a).lower()) == \"5\", \"value of str(a) is not correct\"\n", + "assert str(str(a)) == \"5\", \"correct string value of str(a) but incorrect case of letters\"\n", + "\n", + "assert str(type(a == \"5\")) == \"\", \"type of a == \\\"5\\\" is not bool. a == \\\"5\\\" should be a bool\"\n", + "assert str(a == \"5\") == \"False\", \"boolean value of a == \\\"5\\\" is not correct\"\n", + "\n", + "assert str(type([ch for ch in b])) == \"\", \"type of [ch for ch in b] is not list. [ch for ch in b] should be a list\"\n", + "assert str(len([ch for ch in b])) == \"5\", \"length of [ch for ch in b] is not correct\"\n", + "assert str(sorted(map(str, [ch for ch in b]))) == \"['e', 'h', 'l', 'l', 'o']\", \"values of [ch for ch in b] are not correct\"\n", + "assert str([ch for ch in b]) == \"['h', 'e', 'l', 'l', 'o']\", \"order of elements of [ch for ch in b] is not correct\"\n", + "\n", + "assert str(type(len(c))) == \"\", \"type of len(c) is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(len(c)) == \"3\", \"value of len(c) is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "\n", + "# intervening regular code\n", + "print(\"hello!\")\n", + "\n", + "# differing spacings, numbers of comment characters, trailing whitespace\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "\n", + "\n", + "print('Success!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "2fc8066fae8e7aeb865116c88d1be31a", + "grade": true, + "grade_id": "cell-693350420ec62f1b", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "# a few common types\n", + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of b is not str. b should be an str\"\n", + "assert str(len(b)) == \"5\", \"length of b is not correct\"\n", + "assert str(b.lower()) == \"hello\", \"value of b is not correct\"\n", + "assert str(b) == \"hello\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of c is not list. c should be a list\"\n", + "assert str(len(c)) == \"3\", \"length of c is not correct\"\n", + "assert str(sorted(map(str, c))) == \"['1', '2', 'test']\", \"values of c are not correct\"\n", + "assert str(c) == \"[1, 2, 'test']\", \"order of elements of c is not correct\"\n", + "\n", + "assert str(type(d)) == \"\", \"type of d is not dict. d should be a dict\"\n", + "assert str(len(list(d.keys()))) == \"2\", \"number of keys of d is not correct\"\n", + "assert str(sorted(map(str, d.keys()))) == \"['3', 'a']\", \"keys of d are not correct\"\n", + "assert str(sorted(map(str, d.values()))) == \"['[1.2, 3]', 'f']\", \"correct keys, but values of d are not correct\"\n", + "assert str(d) == \"{'a': 'f', 3: [1.2, 3]}\", \"correct keys and values, but incorrect correspondence in keys and values of d\"\n", + "\n", + "assert str(type(e)) == \"\", \"type of e is not tuple. e should be a tuple\"\n", + "assert str(len(e)) == \"3\", \"length of e is not correct\"\n", + "assert str(sorted(map(str, e))) == \"['-3.3', '5', 'd']\", \"values of e are not correct\"\n", + "assert str(e) == \"(5, -3.3, 'd')\", \"order of elements of e is not correct\"\n", + "\n", + "assert str(type(f)) == \"\", \"type of f is not bool. f should be a bool\"\n", + "assert str(f) == \"False\", \"boolean value of f is not correct\"\n", + "\n", + "print('Success!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "cc9122036c0a86a93446786109a3558e", + "grade": true, + "grade_id": "cell-13479eb0e5fff152", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "\n", + "# a function that checks whether a function throws an error\n", + "\"\"\"Check that squares raises an error for invalid inputs\"\"\"\n", + "def test_func_throws(func, ErrorType):\n", + " try:\n", + " func()\n", + " except ErrorType:\n", + " return True\n", + " else:\n", + " print('Did not raise right type of error!')\n", + " return False\n", + "\n", + "# test a custom function\n", + "from hashlib import sha1\n", + "assert str(type(fun(3))) == \"\", \"type of fun(3) is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(fun(3)) == \"3\", \"value of fun(3) is not correct\"\n", + "\n", + "assert str(type(test_func_throws(lambda : fun(-4), ValueError))) == \"\", \"type of test_func_throws(lambda : fun(-4), ValueError) is not bool. test_func_throws(lambda : fun(-4), ValueError) should be a bool\"\n", + "assert str(test_func_throws(lambda : fun(-4), ValueError)) == \"True\", \"boolean value of test_func_throws(lambda : fun(-4), ValueError) is not correct\"\n", + "\n", + "assert str(type(test_func_throws(lambda : fun(0), ValueError))) == \"\", \"type of test_func_throws(lambda : fun(0), ValueError) is not bool. test_func_throws(lambda : fun(0), ValueError) should be a bool\"\n", + "assert str(test_func_throws(lambda : fun(0), ValueError)) == \"False\", \"boolean value of test_func_throws(lambda : fun(0), ValueError) is not correct\"\n", + "\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-multi.ipynb b/nbgrader/tests/apps/files/autotest-multi.ipynb new file mode 100644 index 000000000..7488e64df --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-multi.ipynb @@ -0,0 +1,164 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "### BEGIN SOLUTION\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]\n", + "d = {\"a\" : \"f\", 3 : [1.2, 3]}\n", + "e = (5, -3.3, \"d\")\n", + "f = False\n", + "def fun(x):\n", + " if x < 0:\n", + " raise ValueError\n", + " else:\n", + " return x\n", + "### END SOLUTION" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "test_multi", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "# the basic tests from the simple notebook\n", + "### AUTOTEST a\n", + "### AUTOTEST b\n", + "### AUTOTEST c" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "cell-f2803ba7c42d03ab", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "# multiple expressions per line\n", + "### AUTOTEST type(a); str(a); a == \"5\";\n", + "### AUTOTEST [ch for ch in b]; len(c);\n", + "### AUTOTEST a;\n", + "\n", + "# intervening regular code\n", + "print(\"hello!\")\n", + "\n", + "# differing spacings, numbers of comment characters, trailing whitespace\n", + "### AUTOTEST a \n", + "#AUTOTEST a\n", + "# AUTOTEST a\n", + "## AUTOTEST a\n", + "# AUTOTEST a\n", + "# # # # AUTOTEST a\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "cell-693350420ec62f1b", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "# a few common types\n", + "### AUTOTEST a; b; c; d; e; f;" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "cell-13479eb0e5fff152", + "locked": true, + "points": 1, + "schema_version": 3, + "solution": false, + "task": false + } + }, + "outputs": [], + "source": [ + "\n", + "# a function that checks whether a function throws an error\n", + "\"\"\"Check that squares raises an error for invalid inputs\"\"\"\n", + "def test_func_throws(func, ErrorType):\n", + " try:\n", + " func()\n", + " except ErrorType:\n", + " return True\n", + " else:\n", + " print('Did not raise right type of error!')\n", + " return False\n", + "\n", + "# test a custom function\n", + "### AUTOTEST fun(3)\n", + "### AUTOTEST test_func_throws(lambda : fun(-4), ValueError)\n", + "### AUTOTEST test_func_throws(lambda : fun(0), ValueError)\n" + ] + } + ], + "metadata": { + "celltoolbar": "Create Assignment", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-simple-changed.ipynb b/nbgrader/tests/apps/files/autotest-simple-changed.ipynb new file mode 100644 index 000000000..d918a0df8 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-simple-changed.ipynb @@ -0,0 +1,92 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "f1736680e92dee1b5debd87ec5790f10", + "grade": true, + "grade_id": "test_simple", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of b is not str. b should be an str\"\n", + "assert str(len(b)) == \"5\", \"length of b is not correct\"\n", + "assert str(b.lower()) == \"hello\", \"value of b is not correct\"\n", + "assert str(b) == \"hello\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of c is not list. c should be a list\"\n", + "assert str(len(c)) == \"3\", \"length of c is not correct\"\n", + "assert str(sorted(map(str, c))) == \"['1', '2', 'test']\", \"values of c are not correct\"\n", + "assert str(c) == \"[1, 2, 'test']\", \"order of elements of c is not correct\"\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-simple-unchanged.ipynb b/nbgrader/tests/apps/files/autotest-simple-unchanged.ipynb new file mode 100644 index 000000000..214e5fbbb --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-simple-unchanged.ipynb @@ -0,0 +1,90 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "nbgrader": { + "cell_type": "code", + "checksum": "e0106b7b1e000112c4ce2c8f8fa64ed1", + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "raise NotImplementedError()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": false, + "editable": false, + "max_height": 100, + "nbgrader": { + "cell_type": "code", + "checksum": "f1736680e92dee1b5debd87ec5790f10", + "grade": true, + "grade_id": "test_simple", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "from hashlib import sha1\n", + "assert str(type(a)) == \"\", \"type of a is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()\"\n", + "assert str(a) == \"5\", \"value of a is not correct\"\n", + "\n", + "assert str(type(b)) == \"\", \"type of b is not str. b should be an str\"\n", + "assert str(len(b)) == \"5\", \"length of b is not correct\"\n", + "assert str(b.lower()) == \"hello\", \"value of b is not correct\"\n", + "assert str(b) == \"hello\", \"correct string value of b but incorrect case of letters\"\n", + "\n", + "assert str(type(c)) == \"\", \"type of c is not list. c should be a list\"\n", + "assert str(len(c)) == \"3\", \"length of c is not correct\"\n", + "assert str(sorted(map(str, c))) == \"['1', '2', 'test']\", \"values of c are not correct\"\n", + "assert str(c) == \"[1, 2, 'test']\", \"order of elements of c is not correct\"\n", + "\n", + "print('Success!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/autotest-simple.ipynb b/nbgrader/tests/apps/files/autotest-simple.ipynb new file mode 100644 index 000000000..42c87b071 --- /dev/null +++ b/nbgrader/tests/apps/files/autotest-simple.ipynb @@ -0,0 +1,74 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a variable named `a` with value `5`, a variable `b` with value `\"hello\"`, and a variable `c` with value `[1, 2, \"test\"]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "soln", + "locked": false, + "schema_version": 3, + "solution": true + } + }, + "outputs": [], + "source": [ + "### BEGIN SOLUTION\n", + "a = 5\n", + "b = \"hello\"\n", + "c = [1, 2, \"test\"]\n", + "### END SOLUTION" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "test_simple", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + } + }, + "outputs": [], + "source": [ + "### AUTOTEST a\n", + "### AUTOTEST b\n", + "### AUTOTEST c" + ] + } + ], + "metadata": { + "celltoolbar": "Create Assignment", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/tests/apps/files/test-no-metadata-autotest.ipynb b/nbgrader/tests/apps/files/test-no-metadata-autotest.ipynb new file mode 100644 index 000000000..9139f364e --- /dev/null +++ b/nbgrader/tests/apps/files/test-no-metadata-autotest.ipynb @@ -0,0 +1,225 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For this problem set, we'll be using the Jupyter notebook:\n", + "\n", + "![](jupyter.png)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part A (2 points)\n", + "\n", + "Write a function that returns a list of numbers, such that $x_i=i^2$, for $1\\leq i \\leq n$. Make sure it handles the case where $n<1$ by raising a `ValueError`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def squares(n):\n", + " \"\"\"Compute the squares of numbers from 1 to n, such that the \n", + " ith element of the returned list equals i^2.\n", + " \n", + " \"\"\"\n", + " ### BEGIN SOLUTION\n", + " if n < 1:\n", + " raise ValueError(\"n must be greater than or equal to 1\")\n", + " return [i ** 2 for i in range(1, n + 1)]\n", + " ### END SOLUTION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Your function should print `[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]` for $n=10$. Check that it does:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "\"\"\"Check that squares returns the correct output for several inputs\"\"\"\n", + "### AUTOTEST squares(1)\n", + "### AUTOTEST squares(10)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "\"\"\"Check that squares raises an error for invalid inputs\"\"\"\n", + "try:\n", + " squares(0)\n", + "except ValueError:\n", + " pass\n", + "else:\n", + " raise AssertionError(\"did not raise\")\n", + "\n", + "try:\n", + " squares(-4)\n", + "except ValueError:\n", + " pass\n", + "else:\n", + " raise AssertionError(\"did not raise\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Part B (1 point)\n", + "\n", + "Using your `squares` function, write a function that computes the sum of the squares of the numbers from 1 to $n$. Your function should call the `squares` function -- it should NOT reimplement its functionality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def sum_of_squares(n):\n", + " \"\"\"Compute the sum of the squares of numbers from 1 to n.\"\"\"\n", + " ### BEGIN SOLUTION\n", + " return sum(squares(n))\n", + " ### END SOLUTION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The sum of squares from 1 to 10 should be 385. Verify that this is the answer you get:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "sum_of_squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "\"\"\"Check that sum_of_squares returns the correct answer for various inputs.\"\"\"\n", + "### AUTOTEST sum_of_squares(1)\n", + "### AUTOTEST sum_of_squares(10)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "\"\"\"Check that sum_of_squares relies on squares.\"\"\"\n", + "orig_squares = squares\n", + "del squares\n", + "try:\n", + " sum_of_squares(1)\n", + "except NameError:\n", + " pass\n", + "else:\n", + " raise AssertionError(\"sum_of_squares does not use squares\")\n", + "finally:\n", + " squares = orig_squares" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part C (1 point)\n", + "\n", + "Using LaTeX math notation, write out the equation that is implemented by your `sum_of_squares` function." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "$\\sum_{i=1}^n i^2$" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part D (2 points)\n", + "\n", + "Find a usecase for your `sum_of_squares` function and implement that usecase in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "def pyramidal_number(n):\n", + " \"\"\"Returns the n^th pyramidal number\"\"\"\n", + " return sum_of_squares(n)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python", + "language": "python", + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/nbgrader/tests/apps/files/tests.yml b/nbgrader/tests/apps/files/tests.yml new file mode 100644 index 000000000..cfd000cb5 --- /dev/null +++ b/nbgrader/tests/apps/files/tests.yml @@ -0,0 +1,310 @@ +#kernel_name: +# setup: +# hash: +# dispatch: +# normalize: +# check: +# success: +# +# templates: +# default: +# - test: +# fail: +# +# datatype: +# - test: +# fail: + + +python3: + setup: "from hashlib import sha1" + hash: 'sha1({{snippet}}.encode("utf-8")+b"{{salt}}").hexdigest()' + dispatch: "type({{snippet}})" + normalize: "str({{snippet}})" + check: 'assert {{snippet}} == "{{value}}", "{{message}}"' + success: "print('Success!')" + + templates: + default: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + int: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + float: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not float. Please make sure it is float and not np.float64, etc. You can cast your value into a float using float()" + + - test: "round({{snippet}}, 2)" + fail: "value of {{snippet}} is not correct (rounded to 2 decimal places)" + + set: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not set. {{snippet}} should be a set" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + list: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not list. {{snippet}} should be a list" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + tuple: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not tuple. {{snippet}} should be a tuple" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + str: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not str. {{snippet}} should be an str" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}.lower()" + fail: "value of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "correct string value of {{snippet}} but incorrect case of letters" + + dict: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not dict. {{snippet}} should be a dict" + + - test: "len(list({{snippet}}.keys()))" + fail: "number of keys of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}.keys()))" + fail: "keys of {{snippet}} are not correct" + + - test: "sorted(map(str, {{snippet}}.values()))" + fail: "correct keys, but values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "correct keys and values, but incorrect correspondence in keys and values of {{snippet}}" + + bool: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not bool. {{snippet}} should be a bool" + + - test: "{{snippet}}" + fail: "boolean value of {{snippet}} is not correct" + + type: + - test: "{{snippet}}" + fail: "type of {{snippet}} is not correct" + +# --------------------------------------------- + +python: + setup: "from hashlib import sha1" + hash: 'sha1({{snippet}}.encode("utf-8")+b"{{salt}}").hexdigest()' + dispatch: "type({{snippet}})" + normalize: "str({{snippet}})" + check: 'assert {{snippet}} == "{{value}}", "{{message}}"' + success: "print('Success!')" + + templates: + default: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + int: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + float: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not float. Please make sure it is float and not np.float64, etc. You can cast your value into a float using float()" + + - test: "round({{snippet}}, 2)" + fail: "value of {{snippet}} is not correct (rounded to 2 decimal places)" + + set: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not set. {{snippet}} should be a set" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + list: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not list. {{snippet}} should be a list" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + tuple: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not tuple. {{snippet}} should be a tuple" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}))" + fail: "values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + str: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not str. {{snippet}} should be an str" + + - test: "len({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "{{snippet}}.lower()" + fail: "value of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "correct string value of {{snippet}} but incorrect case of letters" + + dict: + + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not dict. {{snippet}} should be a dict" + + - test: "len(list({{snippet}}.keys()))" + fail: "number of keys of {{snippet}} is not correct" + + - test: "sorted(map(str, {{snippet}}.keys()))" + fail: "keys of {{snippet}} are not correct" + + - test: "sorted(map(str, {{snippet}}.values()))" + fail: "correct keys, but values of {{snippet}} are not correct" + + - test: "{{snippet}}" + fail: "correct keys and values, but incorrect correspondence in keys and values of {{snippet}}" + + bool: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not bool. {{snippet}} should be a bool" + + - test: "{{snippet}}" + fail: "boolean value of {{snippet}} is not correct" + + type: + - test: "{{snippet}}" + fail: "type of {{snippet}} is not correct" + + + +# -------------------------------------------------------------------------------------------------- +ir: + setup: 'library(digest)' + hash: 'digest(paste({{snippet}}, "{{salt}}"))' + dispatch: 'class({{snippet}})' + normalize: 'toString({{snippet}})' + check: 'stopifnot("{{message}}"= setequal({{snippet}}, "{{value}}"))' + success: "print('Success!')" + + templates: + default: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + integer: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not integer" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sort({{snippet}})" + fail: "values of {{snippet}} are not correct" + + numeric: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not double" + + - test: "round({{snippet}}, 2)" + fail: "value of {{snippet}} is not correct (rounded to 2 decimal places)" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sort({{snippet}})" + fail: "values of {{snippet}} are not correct" + + list: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not list" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "sort(c(names({{snippet}})))" + fail: "values of {{snippet}} names are not correct" + + - test: "{{snippet}}" + fail: "order of elements of {{snippet}} is not correct" + + character: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not list" + + - test: "length({{snippet}})" + fail: "length of {{snippet}} is not correct" + + - test: "tolower({{snippet}})" + fail: "value of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "correct string value of {{snippet}} but incorrect case of letters" + + logical: + - test: "class({{snippet}})" + fail: "type of {{snippet}} is not logical" + + - test: "{{snippet}}" + fail: "logical value of {{snippet}} is not correct" diff --git a/nbgrader/tests/apps/test_nbgrader_autograde.py b/nbgrader/tests/apps/test_nbgrader_autograde.py index be4e04838..1f00939f8 100644 --- a/nbgrader/tests/apps/test_nbgrader_autograde.py +++ b/nbgrader/tests/apps/test_nbgrader_autograde.py @@ -94,6 +94,97 @@ def test_grade(self, db, course_dir): assert comment1.comment == None assert comment2.comment == None + + def test_grade_autotest(self, db, course_dir): + """Can files including autotest commands be graded?""" + run_nbgrader(["db", "assignment", "add", "ps1", "--db", db, "--duedate", "2015-02-02 14:58:23.948203 America/Los_Angeles"]) + run_nbgrader(["db", "student", "add", "foo", "--db", db]) + run_nbgrader(["db", "student", "add", "bar", "--db", db]) + + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["generate_assignment", "ps1", "--db", db]) + + self._copy_file(join("files", "autotest-simple-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) + self._copy_file(join("files", "autotest-simple-changed.ipynb"), join(course_dir, "submitted", "bar", "ps1", "p1.ipynb")) + run_nbgrader(["autograde", "ps1", "--db", db]) + + assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "p1.ipynb")) + assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "timestamp.txt")) + assert os.path.isfile(join(course_dir, "autograded", "bar", "ps1", "p1.ipynb")) + assert not os.path.isfile(join(course_dir, "autograded", "bar", "ps1", "timestamp.txt")) + + with Gradebook(db) as gb: + notebook = gb.find_submission_notebook("p1", "ps1", "foo") + assert notebook.score == 0 + assert notebook.max_score == 1 + assert notebook.needs_manual_grade == False + + notebook = gb.find_submission_notebook("p1", "ps1", "bar") + assert notebook.score == 1 + assert notebook.max_score == 1 + assert notebook.needs_manual_grade == False + + def test_grade_hashed_autotest(self, db, course_dir): + """Can files including hashed autotest commands be graded?""" + run_nbgrader(["db", "assignment", "add", "ps1", "--db", db, "--duedate", "2015-02-02 14:58:23.948203 America/Los_Angeles"]) + run_nbgrader(["db", "student", "add", "foo", "--db", db]) + run_nbgrader(["db", "student", "add", "bar", "--db", db]) + + self._copy_file(join("files", "autotest-hashed.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["generate_assignment", "ps1", "--db", db]) + + self._copy_file(join("files", "autotest-hashed-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) + self._copy_file(join("files", "autotest-hashed-changed.ipynb"), join(course_dir, "submitted", "bar", "ps1", "p1.ipynb")) + run_nbgrader(["autograde", "ps1", "--db", db]) + + assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "p1.ipynb")) + assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "timestamp.txt")) + assert os.path.isfile(join(course_dir, "autograded", "bar", "ps1", "p1.ipynb")) + assert not os.path.isfile(join(course_dir, "autograded", "bar", "ps1", "timestamp.txt")) + + with Gradebook(db) as gb: + notebook = gb.find_submission_notebook("p1", "ps1", "foo") + assert notebook.score == 0 + assert notebook.max_score == 1 + assert notebook.needs_manual_grade == False + + notebook = gb.find_submission_notebook("p1", "ps1", "bar") + assert notebook.score == 1 + assert notebook.max_score == 1 + assert notebook.needs_manual_grade == False + + def test_grade_complex_autotest(self, db, course_dir): + """Can files including complicated autotest commands be graded?""" + run_nbgrader(["db", "assignment", "add", "ps1", "--db", db, "--duedate", "2015-02-02 14:58:23.948203 America/Los_Angeles"]) + run_nbgrader(["db", "student", "add", "foo", "--db", db]) + run_nbgrader(["db", "student", "add", "bar", "--db", db]) + + self._copy_file(join("files", "autotest-multi.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["generate_assignment", "ps1", "--db", db]) + + self._copy_file(join("files", "autotest-multi-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) + self._copy_file(join("files", "autotest-multi-changed.ipynb"), join(course_dir, "submitted", "bar", "ps1", "p1.ipynb")) + run_nbgrader(["autograde", "ps1", "--db", db]) + + assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "p1.ipynb")) + assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "timestamp.txt")) + assert os.path.isfile(join(course_dir, "autograded", "bar", "ps1", "p1.ipynb")) + assert not os.path.isfile(join(course_dir, "autograded", "bar", "ps1", "timestamp.txt")) + + with Gradebook(db) as gb: + notebook = gb.find_submission_notebook("p1", "ps1", "foo") + assert notebook.score == 0 + assert notebook.max_score == 4 + assert notebook.needs_manual_grade == False + + notebook = gb.find_submission_notebook("p1", "ps1", "bar") + assert notebook.score == 3 + assert notebook.max_score == 4 + assert notebook.needs_manual_grade == False + def test_showtraceback_exploit(self, db, course_dir): """Can students exploit showtraceback to hide errors from all future cell outputs to receive free points for incorrect cells?""" run_nbgrader(["db", "assignment", "add", "ps1", "--db", db, "--duedate", "2015-02-02 14:58:23.948203 America/Los_Angeles"]) @@ -842,6 +933,95 @@ def test_hidden_tests_single_notebook(self, db, course_dir): nb1 = submission.notebooks[0] assert nb1.score == 1.5 + def test_hidden_tests_autotest(self, db, course_dir): + """Can files with hidden autotests be graded?""" + run_nbgrader(["db", "assignment", "add", "ps1", "--db", db, "--duedate", + "2015-02-02 14:58:23.948203 America/Los_Angeles"]) + run_nbgrader(["db", "student", "add", "foo", "--db", db]) + run_nbgrader(["db", "student", "add", "bar", "--db", db]) + run_nbgrader(["db", "student", "add", "baz", "--db", db]) + with open("nbgrader_config.py", "a") as fh: + fh.write("""c.ClearSolutions.code_stub=dict(python="# YOUR CODE HERE")""") + + self._copy_file(join("files", "autotest-hidden.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["generate_assignment", "ps1", "--db", db]) + + self._copy_file(join("files", "autotest-hidden-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) + self._copy_file(join("files", "autotest-hidden-changed-wrong.ipynb"), join(course_dir, "submitted", "bar", "ps1", "p1.ipynb")) + self._copy_file(join("files", "autotest-hidden-changed-right.ipynb"), join(course_dir, "submitted", "baz", "ps1", "p1.ipynb")) + + # make sure submitted validates for both bar and baz (should only fail on hidden tests), but not foo (missing any input and visible type checks will fail) + output = run_nbgrader([ + "validate", join(course_dir, "submitted", "foo", "ps1", "p1.ipynb"), + ], stdout=True) + assert output.splitlines()[0] == ( + "VALIDATION FAILED ON 1 CELL(S)! If you submit your assignment " + "as it is, you WILL NOT" + ) + output = run_nbgrader([ + "validate", join(course_dir, "submitted", "bar", "ps1", "p1.ipynb") + ], stdout=True) + assert output.strip() == "Success! Your notebook passes all the tests." + + output = run_nbgrader([ + "validate", join(course_dir, "submitted", "baz", "ps1", "p1.ipynb") + ], stdout=True) + assert output.strip() == "Success! Your notebook passes all the tests." + + # autograde + run_nbgrader(["autograde", "ps1", "--db", db]) + assert os.path.exists(join(course_dir, "autograded", "foo", "ps1", "p1.ipynb")) + assert os.path.exists(join(course_dir, "autograded", "bar", "ps1", "p1.ipynb")) + assert os.path.exists(join(course_dir, "autograded", "baz", "ps1", "p1.ipynb")) + + # make sure hidden tests are placed back in autograded + sub_nb = join(course_dir, "autograded", "foo", "ps1", "p1.ipynb") + with io.open(sub_nb, mode='r', encoding='utf-8') as nb: + source = nb.read() + assert "BEGIN HIDDEN TESTS" in source + sub_nb = join(course_dir, "autograded", "bar", "ps1", "p1.ipynb") + with io.open(sub_nb, mode='r', encoding='utf-8') as nb: + source = nb.read() + assert "BEGIN HIDDEN TESTS" in source + sub_nb = join(course_dir, "autograded", "baz", "ps1", "p1.ipynb") + with io.open(sub_nb, mode='r', encoding='utf-8') as nb: + source = nb.read() + assert "BEGIN HIDDEN TESTS" in source + + # make sure autograded for foo does not validate, should fail on visible and hidden tests + output = run_nbgrader([ + "validate", join(course_dir, "autograded", "foo", "ps1", "p1.ipynb"), + ], stdout=True) + assert output.splitlines()[0] == ( + "VALIDATION FAILED ON 1 CELL(S)! If you submit your assignment " + "as it is, you WILL NOT" + ) + # make sure autograded for bar does not, should fail on hidden tests + output = run_nbgrader([ + "validate", join(course_dir, "autograded", "bar", "ps1", "p1.ipynb"), + ], stdout=True) + assert output.splitlines()[0] == ( + "VALIDATION FAILED ON 1 CELL(S)! If you submit your assignment " + "as it is, you WILL NOT" + ) + # make sure autograded for bar validates, should succeed on hidden tests + output = run_nbgrader([ + "validate", join(course_dir, "autograded", "baz", "ps1", "p1.ipynb"), + ], stdout=True) + assert output.strip() == "Success! Your notebook passes all the tests." + + with Gradebook(db) as gb: + submission = gb.find_submission("ps1", "foo") + nb1 = submission.notebooks[0] + assert nb1.score == 0 + submission = gb.find_submission("ps1", "bar") + nb1 = submission.notebooks[0] + assert nb1.score == 0 + submission = gb.find_submission("ps1", "baz") + nb1 = submission.notebooks[0] + assert nb1.score == 1 + def test_handle_failure(self, course_dir): run_nbgrader(["db", "assignment", "add", "ps1", "--duedate", "2015-02-02 14:58:23.948203 America/Los_Angeles"]) diff --git a/nbgrader/tests/apps/test_nbgrader_generate_assignment.py b/nbgrader/tests/apps/test_nbgrader_generate_assignment.py index 34cce609a..1defff6e9 100644 --- a/nbgrader/tests/apps/test_nbgrader_generate_assignment.py +++ b/nbgrader/tests/apps/test_nbgrader_generate_assignment.py @@ -47,6 +47,88 @@ def test_single_file(self, course_dir, temp_cwd): run_nbgrader(["generate_assignment", "ps1"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) + def test_autotests_simple(self, course_dir, temp_cwd): + """Can a notebook with simple autotests be generated with a default yaml location, and is autotest code removed?""" + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["generate_assignment", "ps1"]) + assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) + assert not os.path.isfile(join(course_dir, "release", "ps1", "tests.yml")) + + foo = self._file_contents(join(course_dir, "release", "ps1", "foo.ipynb")) + assert "AUTOTEST" not in foo + + def test_autotests_simple(self, course_dir, temp_cwd): + """Can a notebook with simple autotests be generated with an assignment-specific yaml, and is autotest code removed?""" + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "source", "ps1", "tests.yml")) + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["generate_assignment", "ps1"]) + assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) + assert os.path.isfile(join(course_dir, "release", "ps1", "tests.yml")) + + foo = self._file_contents(join(course_dir, "release", "ps1", "foo.ipynb")) + assert "AUTOTEST" not in foo + + def test_autotests_needs_yaml(self, course_dir, temp_cwd): + """Can a notebook with autotests be generated without a yaml file?""" + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["generate_assignment", "ps1"], retcode=1) + + + def test_autotests_fancy(self, course_dir, temp_cwd): + """Can a more complicated autotests notebook be generated, and is autotest code removed?""" + self._copy_file(join("files", "autotest-multi.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["generate_assignment", "ps1"]) + assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) + + foo = self._file_contents(join(course_dir, "release", "ps1", "foo.ipynb")) + assert "AUTOTEST" not in foo + + def test_autotests_hidden(self, course_dir, temp_cwd): + """Can a notebook with hidden autotest be generated, and is autotest/hidden sections removed?""" + self._copy_file(join("files", "autotest-hidden.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["generate_assignment", "ps1"]) + assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) + + foo = self._file_contents(join(course_dir, "release", "ps1", "foo.ipynb")) + assert "AUTOTEST" not in foo + assert "HIDDEN" not in foo + + def test_autotests_hashed(self, course_dir, temp_cwd): + """Can a notebook with hashed autotests be generated, and is hashed autotest code removed?""" + self._copy_file(join("files", "autotest-hashed.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["generate_assignment", "ps1"]) + assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) + + foo = self._file_contents(join(course_dir, "release", "ps1", "foo.ipynb")) + assert "AUTOTEST" not in foo + assert "HASHED" not in foo + + def test_generate_source_with_tests_flag(self, course_dir, temp_cwd): + """Does setting the flag --generate_source_with_tests also create a notebook with solution and tests in the + source_with_tests directory""" + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "source", "ps1", "tests.yml")) + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["generate_assignment", "ps1", "--generate_source_with_tests"]) + assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) + assert os.path.isfile(join(course_dir, "source_with_tests", "ps1", "foo.ipynb")) + + foo = self._file_contents(join(course_dir, "source_with_tests", "ps1", "foo.ipynb")) + assert "AUTOTEST" not in foo + assert "BEGIN SOLUTION" in foo + assert "END SOLUTION" in foo + assert "raise NotImplementedError" not in foo + def test_deprecation(self, course_dir, temp_cwd): """Can a single file be assigned?""" self._empty_notebook(join(course_dir, 'source', 'ps1', 'foo.ipynb')) diff --git a/nbgrader/tests/apps/test_nbgrader_generate_feedback.py b/nbgrader/tests/apps/test_nbgrader_generate_feedback.py index 6b59bfab9..6ff20a70b 100644 --- a/nbgrader/tests/apps/test_nbgrader_generate_feedback.py +++ b/nbgrader/tests/apps/test_nbgrader_generate_feedback.py @@ -326,6 +326,41 @@ def test_update_newer_single_notebook(self, course_dir): assert p1 != self._file_contents(join(course_dir, "feedback", "foo", "ps1", "p1.html")) assert p2 == self._file_contents(join(course_dir, "feedback", "foo", "ps1", "p2.html")) + def test_autotests(self, course_dir): + """Can feedback be generated for an assignment with autotests?""" + run_nbgrader(["db", "assignment", "add", "ps1"]) + run_nbgrader(["db", "student", "add", "foo"]) + + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "p2.ipynb")) + self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + run_nbgrader(["generate_assignment", "ps1"]) + + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) + self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p2.ipynb")) + self._make_file(join(course_dir, "submitted", "foo", "ps1", "timestamp.txt"), "2015-02-02 15:58:23.948203 America/Los_Angeles") + run_nbgrader(["autograde", "ps1"]) + run_nbgrader(["generate_feedback", "ps1"]) + + assert exists(join(course_dir, "feedback", "foo", "ps1", "p1.html")) + assert exists(join(course_dir, "feedback", "foo", "ps1", "p2.html")) + assert isfile(join(course_dir, "feedback", "foo", "ps1", "timestamp.txt")) + assert self._file_contents(join(course_dir, "feedback", "foo", "ps1", "timestamp.txt")) == "2015-02-02 15:58:23.948203 America/Los_Angeles" + p1 = self._file_contents(join(course_dir, "feedback", "foo", "ps1", "p1.html")) + p2 = self._file_contents(join(course_dir, "feedback", "foo", "ps1", "p2.html")) + + self._empty_notebook(join(course_dir, "autograded", "foo", "ps1", "p1.ipynb")) + self._empty_notebook(join(course_dir, "autograded", "foo", "ps1", "p2.ipynb")) + self._make_file(join(course_dir, "autograded", "foo", "ps1", "timestamp.txt"), "2015-02-02 16:58:23.948203 America/Los_Angeles") + run_nbgrader(["generate_feedback", "ps1", "--notebook", "p1"]) + + assert exists(join(course_dir, "feedback", "foo", "ps1", "p1.html")) + assert exists(join(course_dir, "feedback", "foo", "ps1", "p2.html")) + assert isfile(join(course_dir, "feedback", "foo", "ps1", "timestamp.txt")) + assert self._file_contents(join(course_dir, "feedback", "foo", "ps1", "timestamp.txt")) == "2015-02-02 16:58:23.948203 America/Los_Angeles" + assert p1 != self._file_contents(join(course_dir, "feedback", "foo", "ps1", "p1.html")) + assert p2 == self._file_contents(join(course_dir, "feedback", "foo", "ps1", "p2.html")) + def test_single_user(self, course_dir): run_nbgrader(["db", "assignment", "add", "ps1", "--duedate", "2015-02-02 14:58:23.948203 America/Los_Angeles"]) diff --git a/nbgrader/tests/preprocessors/test_instantiatetests.py b/nbgrader/tests/preprocessors/test_instantiatetests.py new file mode 100644 index 000000000..ac3328557 --- /dev/null +++ b/nbgrader/tests/preprocessors/test_instantiatetests.py @@ -0,0 +1,204 @@ +import pytest +import os +from textwrap import dedent +from ...preprocessors import InstantiateTests +from .base import BaseTestPreprocessor +from .. import create_code_cell, create_text_cell, create_autotest_solution_cell, create_autotest_test_cell +from nbformat.v4 import new_notebook +from nbclient.client import NotebookClient + + +@pytest.fixture +def preprocessor(): + return InstantiateTests() + + +class TestInstantiateTests(BaseTestPreprocessor): + + def test_load_test_template_file(self, preprocessor): + resources = { + 'kernel_name': 'python3', + 'metadata': {'path': 'nbgrader/docs/source/user_guide'} + } + preprocessor._load_test_template_file(resources=resources) + assert preprocessor.test_templates_by_type is not None + assert preprocessor.dispatch_template is not None + assert preprocessor.success_code is not None + assert preprocessor.hash_template is not None + assert preprocessor.check_template is not None + assert preprocessor.normalize_template is not None + assert preprocessor.setup_code is not None + + def test_has_sanitizers(self, preprocessor): + assert 'python' in preprocessor.sanitizers.keys() + assert 'python3' in preprocessor.sanitizers.keys() + assert 'ir' in preprocessor.sanitizers.keys() + + def test_has_comment_strs(self, preprocessor): + assert 'python' in preprocessor.comment_strs.keys() + assert 'python3' in preprocessor.comment_strs.keys() + assert 'ir' in preprocessor.comment_strs.keys() + + def test_replace_autotest_code(self, preprocessor): + sol_cell = create_autotest_solution_cell() + test_cell = create_autotest_test_cell() + test_cell.metadata['nbgrader'] = {'grade': True} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb, resources = preprocessor.preprocess(nb, resources) + assert 'assert' in nb['cells'][1]['source'] + + # test that a warning is thrown when we set enforce_metadata = False and have an AUTOTEST directive in a + # non-grade cell + def test_warning_autotest_nongrade(self, preprocessor, caplog): + preprocessor.enforce_metadata = False + sol_cell = create_autotest_solution_cell() + test_cell = create_autotest_test_cell() + test_cell.metadata['nbgrader'] = {'grade': False} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + + nb, resources = preprocessor.preprocess(nb, resources) + assert "Autotest region detected in a non-grade cell; " in caplog.text + + # test that an error is thrown when we have an AUTOTEST directive in a non-grade cell + def test_error_autotest_nongrade(self, preprocessor, caplog): + sol_cell = create_autotest_solution_cell() + test_cell = create_autotest_test_cell() + test_cell.metadata['nbgrader'] = {'grade': False} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + with pytest.raises(Exception): + nb, resources = preprocessor.preprocess(nb, resources) + + assert "Autotest region detected in a non-grade cell; " in caplog.text + + # test that invalid python statements in AUTOTEST directives cause errors + def test_error_bad_autotest_code(self, preprocessor): + sol_cell = create_autotest_solution_cell() + test_cell = create_autotest_test_cell() + test_cell.source = """ + ### AUTOTEST length(answer) + """ + test_cell.metadata['nbgrader'] = {'grade': True} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + with pytest.raises(Exception): + nb, resources = preprocessor.preprocess(nb, resources) + + # test the code generated for some basic types; ensure correct solution gives success, a few wrong solutions give + # failures + def test_int_autotest(self, preprocessor): + sol_cell = create_autotest_solution_cell() + sol_cell.source = """ + answer = 7 + """ + test_cell = create_autotest_test_cell() + + test_cell.metadata['nbgrader'] = {'grade': True} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb, resources = preprocessor.preprocess(nb, resources) + executed_nb = NotebookClient(nb=nb).execute() + assert executed_nb['cells'][1]['outputs'][0]['text'] == 'Success!\n' + + def test_float_autotest(self, preprocessor): + sol_cell = create_autotest_solution_cell() + sol_cell.source = """ + answer = 7.7 + """ + test_cell = create_autotest_test_cell() + + test_cell.metadata['nbgrader'] = {'grade': True} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb, resources = preprocessor.preprocess(nb, resources) + executed_nb = NotebookClient(nb=nb).execute() + assert executed_nb['cells'][1]['outputs'][0]['text'] == 'Success!\n' + + def test_string_autotest(self, preprocessor): + sol_cell = create_autotest_solution_cell() + sol_cell.source = """ + answer = 'seven' + """ + test_cell = create_autotest_test_cell() + + test_cell.metadata['nbgrader'] = {'grade': True} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb, resources = preprocessor.preprocess(nb, resources) + executed_nb = NotebookClient(nb=nb).execute() + assert executed_nb['cells'][1]['outputs'][0]['text'] == 'Success!\n' + + def test_list_autotest(self, preprocessor): + sol_cell = create_autotest_solution_cell() + sol_cell.source = """ + answer = [1, 2, 3, 4, 5, 6, 7] + """ + test_cell = create_autotest_test_cell() + + test_cell.metadata['nbgrader'] = {'grade': True} + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb, resources = preprocessor.preprocess(nb, resources) + executed_nb = NotebookClient(nb=nb).execute() + assert executed_nb['cells'][1]['outputs'][0]['text'] == 'Success!\n' + + + diff --git a/pyproject.toml b/pyproject.toml index d52bfeeba..46213205a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "rapidfuzz>=1.8", "requests>=2.26", "sqlalchemy>=1.4,<3", + "PyYAML>=6.0" ] version = "0.9.0a1" From 19b089ef62eff30b90032e08cce5846d3de68d27 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 21 Aug 2023 14:06:53 -0700 Subject: [PATCH 02/33] added docs html files for new problems to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 3cecd390f..7da289b32 100644 --- a/.gitignore +++ b/.gitignore @@ -84,14 +84,18 @@ nbgrader/docs/source/user_guide/managing_assignment_files_manually.rst nbgrader/docs/source/user_guide/managing_the_database.rst nbgrader/docs/source/user_guide/autograded/bitdiddle/ps1/problem1.html nbgrader/docs/source/user_guide/autograded/bitdiddle/ps1/problem2.html +nbgrader/docs/source/user_guide/autograded/bitdiddle/ps1/problem3.html nbgrader/docs/source/user_guide/autograded/hacker/ps1/problem1.html nbgrader/docs/source/user_guide/autograded/hacker/ps1/problem2.html +nbgrader/docs/source/user_guide/autograded/hacker/ps1/problem3.html nbgrader/docs/source/user_guide/downloaded/ps1/archive/ps1_hacker_attempt_2016-01-30-20-30-10_problem1.html nbgrader/docs/source/user_guide/release/ps1/problem1.html nbgrader/docs/source/user_guide/release/ps1/problem2.html +nbgrader/docs/source/user_guide/release/ps1/problem3.html nbgrader/docs/source/user_guide/source/header.html nbgrader/docs/source/user_guide/source/ps1/problem1.html nbgrader/docs/source/user_guide/source/ps1/problem2.html +nbgrader/docs/source/user_guide/source/ps1/problem3.html # components stuff node_modules From 67052278a612d57b97ea05fa9c219b83a54ad6cf Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 21 Aug 2023 15:24:47 -0700 Subject: [PATCH 03/33] minor bugfix: shortened --source_with_tests flag in tests --- nbgrader/tests/apps/test_nbgrader_generate_assignment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nbgrader/tests/apps/test_nbgrader_generate_assignment.py b/nbgrader/tests/apps/test_nbgrader_generate_assignment.py index 1defff6e9..444f1f08f 100644 --- a/nbgrader/tests/apps/test_nbgrader_generate_assignment.py +++ b/nbgrader/tests/apps/test_nbgrader_generate_assignment.py @@ -114,12 +114,12 @@ def test_autotests_hashed(self, course_dir, temp_cwd): assert "HASHED" not in foo def test_generate_source_with_tests_flag(self, course_dir, temp_cwd): - """Does setting the flag --generate_source_with_tests also create a notebook with solution and tests in the + """Does setting the flag --source_with_tests also create a notebook with solution and tests in the source_with_tests directory""" self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) self._copy_file(join("files", "tests.yml"), join(course_dir, "source", "ps1", "tests.yml")) run_nbgrader(["db", "assignment", "add", "ps1"]) - run_nbgrader(["generate_assignment", "ps1", "--generate_source_with_tests"]) + run_nbgrader(["generate_assignment", "ps1", "--source_with_tests"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) assert os.path.isfile(join(course_dir, "source_with_tests", "ps1", "foo.ipynb")) From d823f3e224a60a224f21c02ea46e5ef699e4a8d9 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 21 Aug 2023 17:58:10 -0700 Subject: [PATCH 04/33] output for source_with_tests set to the right directory --- nbgrader/converters/generate_source_with_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nbgrader/converters/generate_source_with_tests.py b/nbgrader/converters/generate_source_with_tests.py index 346fc4f2e..2cebdb87b 100644 --- a/nbgrader/converters/generate_source_with_tests.py +++ b/nbgrader/converters/generate_source_with_tests.py @@ -26,7 +26,7 @@ def _input_directory(self) -> str: @property def _output_directory(self) -> str: - return self.coursedir.release_directory + return self.coursedir.source_with_tests_directory preprocessors = List([ InstantiateTests, From 48837d9057a0c133983ea14ef490b52e7f3bbf14 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 22 Aug 2023 18:04:36 -0700 Subject: [PATCH 05/33] simplified kernel execution code --- nbgrader/preprocessors/instantiatetests.py | 515 ++++----------------- 1 file changed, 98 insertions(+), 417 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index c208af9a8..d21e61814 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -5,7 +5,6 @@ from .. import utils from traitlets import Bool, List, Integer, Unicode, Dict, Callable from textwrap import dedent -from . import Execute import secrets import asyncio import inspect @@ -21,83 +20,16 @@ CellTimeoutError, DeadKernelError, ) +from . import NbGraderPreprocessor +from jupyter_client.manager import start_new_kernel try: from time import monotonic # Py 3 except ImportError: from time import time as monotonic # Py 2 - -######################################################################################### -class CellExecutionComplete(Exception): - """ - Used as a control signal for cell execution across run_cell and - process_message function calls. Raised when all execution requests - are completed and no further messages are expected from the kernel - over zeromq channels. - """ - pass - - ######################################################################################### -class CellExecutionError(Exception): - """ - Custom exception to propagate exceptions that are raised during - notebook execution to the caller. This is mostly useful when - using nbconvert as a library, since it allows dealing with - failures gracefully. - """ - - # ------------------------------------------------------------------------------------- - def __init__(self, traceback): - super(CellExecutionError, self).__init__(traceback) - self.traceback = traceback - - # ------------------------------------------------------------------------------------- - def __str__(self): - s = self.__unicode__() - if not isinstance(s, str): - s = s.encode('utf8', 'replace') - return s - - # ------------------------------------------------------------------------------------- - def __unicode__(self): - return self.traceback - - # ------------------------------------------------------------------------------------- - @classmethod - def from_code_and_msg(cls, code, msg): - """Instantiate from a code cell object and a message contents - (message is either execute_reply or error) - """ - tb = '\n'.join(msg.get('traceback', [])) - return cls(exec_err_msg.format(code=code, traceback=tb)) - # ------------------------------------------------------------------------------------- - - -######################################################################################### -class CodeExecutionError(Exception): - """ - Custom exception to propagate exceptions that are raised during - code snippet execution to the caller. This is mostly useful when - using nbconvert as a library, since it allows dealing with - failures gracefully. - """ - - -######################################################################################### - -exec_err_msg = u"""\ -An error occurred while executing the following code: ------------------- -{code} ------------------- -{traceback} -""" - - -######################################################################################### -class InstantiateTests(Execute): +class InstantiateTests(NbGraderPreprocessor): tests = None autotest_filename = Unicode( @@ -167,44 +99,64 @@ class InstantiateTests(Execute): ).tag(config=True) sanitizer = None - global_tests_loaded = False + kernel_name = None + kc = None + execute_result = None def preprocess(self, nb, resources): # avoid starting the kernel at all/processing the notebook if there are no autotest delimiters for index, cell in enumerate(nb.cells): - # ignore non-code cells - if cell.cell_type != 'code': - continue # look for an autotest delimiter in this cell's source; if we find one, process this notebook - if self.autotest_delimiter in cell.source: + # short-circuit ignore non-code cells + if (cell.cell_type == 'code') and (self.autotest_delimiter in cell.source): + # get the kernel name from the notebook + kernel_name = nb.metadata.get("kernelspec", {}).get("name", "") + if kernel_name not in self.comment_strs: + raise ValueError(f"Kernel {kernel_name} has not been specified in InstantiateTests.comment_strs") + if kernel_name not in self.sanitizers: + raise ValueError(f"Kernel {kernel_name} has not been specified in InstantiateTests.sanitizers") + self.log.debug(f"Found kernel {kernel_name}") + self.kernel_name = kernel_name + + # load the template tests file + self.log.debug('Loading template tests file') + self._load_test_template_file(resources) + self.global_tests_loaded = True + + # set up the sanitizer + self.log.debug('Setting sanitizer for kernel {kernel_name}') + self.sanitizer = self.sanitizers[kernel_name] + #start the kernel + self.log.debug('Starting client for kernel {kernel_name}') + km, self.kc = start_new_kernel(kernel_name = kernel_name) + + # run the preprocessor + self.log.debug('Running InstantiateTests preprocessor') nb, resources = super(InstantiateTests, self).preprocess(nb, resources) + + # shut down and cleanup the kernel + self.log.debug('Shutting down / cleaning up kernel') + km.shutdown_kernel() + self.kc = None + self.sanitizer = None + self.execute_result = None + + # return the modified notebook return nb, resources + # if not, just return return nb, resources def preprocess_cell(self, cell, resources, index): - # new_lines will store the replacement code after autotest template instantiation - new_lines = [] - - # first, run the cell normally - # cell, resources = super(InstantiateTests, self).preprocess_cell(cell, resources, index) - - kernel_name = self.nb.metadata.get("kernelspec", {}).get("name", "") - if kernel_name not in self.comment_strs: - raise ValueError( - "kernel '{}' has not been specified in " - "InstantiateTests.comment_strs".format(kernel_name)) - resources["kernel_name"] = kernel_name - - # if it's not a code cell, or it's empty, just return - if cell.cell_type != 'code': + # if it's not a code cell, or if the cell's source is empty, just return + if (cell.cell_type != 'code') or (len(cell.source) == 0): return cell, resources # determine whether the cell is a grade cell is_grade_flag = utils.is_grade(cell) # get the comment string for this language - comment_str = self.comment_strs[resources['kernel_name']] + comment_str = self.comment_strs[self.kernel_name] # split the code lines into separate strings lines = cell.source.split("\n") @@ -213,12 +165,10 @@ def preprocess_cell(self, cell, resources, index): non_autotest_code_lines = [] - if self.sanitizer is None: - self.log.debug('Setting sanitizer for language ' + resources['kernel_name']) - self.sanitizer = self.sanitizers.get(resources['kernel_name'], lambda x: x) + # new_lines will store the replacement code after autotest template instantiation + new_lines = [] for line in lines: - # if the current line doesn't have the autotest_delimiter or is not a comment # then just append the line to the new cell code and go to the next line if self.autotest_delimiter not in line or line.strip()[:len(comment_str)] != comment_str: @@ -227,20 +177,20 @@ def preprocess_cell(self, cell, resources, index): continue # run all code lines prior to the current line containing the autotest_delimiter - asyncio.run(self._async_execute_code_snippet("\n".join(non_autotest_code_lines))) + self._execute_code_snippet("\n".join(non_autotest_code_lines)) non_autotest_code_lines = [] # there are autotests; we should check that it is a grading cell if not is_grade_flag: if not self.enforce_metadata: self.log.warning( - "Autotest region detected in a non-grade cell; " + "AutoTest region detected in a non-grade cell; " "please make sure all autotest regions are within " "'Autograder tests' cells." ) else: self.log.error( - "Autotest region detected in a non-grade cell; " + "AutoTest region detected in a non-grade cell; " "please make sure all autotest regions are within " "'Autograder tests' cells." ) @@ -248,25 +198,17 @@ def preprocess_cell(self, cell, resources, index): self.log.debug('') self.log.debug('') - self.log.debug('Autotest delimiter found on line. Preprocessing...') + self.log.debug('AutoTest delimiter found on line. Preprocessing...') - # the first time we run into an autotest delimiter, obtain the - # tests object from the tests.yml template file for the assignment - # and append any setup code to the cell block we're in - # also figure out what language we're using - - # loading the template tests file - if not self.global_tests_loaded: - self.log.debug('Loading tests template file') - self._load_test_template_file(resources) - self.global_tests_loaded = True + # the first time we run into an autotest delimiter, + # append any setup code to the cell block we're in # if the setup_code is successfully obtained from the template file and # the current cell does not already have the setup code, add the setup_code if (self.setup_code is not None) and (not setup_code_inserted_into_cell): new_lines.append(self.setup_code) setup_code_inserted_into_cell = True - asyncio.run(self._async_execute_code_snippet(self.setup_code)) + self._execute_code_snippet(self.setup_code) # decide whether to use hashing based on whether the self.hashed_delimiter token # appears in the line before the self.autotest_delimiter token @@ -319,7 +261,7 @@ def preprocess_cell(self, cell, resources, index): # run the trailing non-autotest lines, if any remain if len(non_autotest_code_lines) > 0: - asyncio.run(self._async_execute_code_snippet("\n".join(non_autotest_code_lines))) + self._execute_code_snippet("\n".join(non_autotest_code_lines)) # add the final success message if is_grade_flag and self.global_tests_loaded: @@ -341,7 +283,7 @@ def _load_test_template_file(self, resources): or perhaps cannot be loaded, it will attempt to load the default_tests.yaml file with the course_directory """ self.log.debug('loading template tests.yml...') - self.log.debug('kernel_name: ' + resources["kernel_name"]) + self.log.debug(f'kernel_name: {self.kernel_name}') try: with open(os.path.join(resources['metadata']['path'], self.autotest_filename), 'r') as tests_file: tests = yaml.safe_load(tests_file) @@ -371,7 +313,7 @@ def _load_test_template_file(self, resources): raise # get kernel specific data - tests = tests[resources["kernel_name"]] + tests = tests[self.kernel_name] # get the test templates self.test_templates_by_type = tests['templates'] @@ -399,7 +341,7 @@ def _instantiate_tests(self, snippet, salt=None): # get the type of the snippet output (used to dispatch autotest) template = j2.Environment(loader=j2.BaseLoader).from_string(self.dispatch_template) dispatch_code = template.render(snippet=snippet) - dispatch_result = asyncio.run(self._async_execute_code_snippet(dispatch_code)) + dispatch_result = self._execute_code_snippet(dispatch_code) self.log.debug('Dispatch result returned by kernel: ', dispatch_result) # get the test code; if the type isn't in our dict, just default to 'default' # if default isn't in the tests code, this will throw an error @@ -449,310 +391,49 @@ def _instantiate_tests(self, snippet, salt=None): template = j2.Environment(loader=j2.BaseLoader).from_string(templ) instantiated_test = template.render(snippet=snippet) # run the instantiated template code - test_value = asyncio.run(self._async_execute_code_snippet(instantiated_test)) + test_value = self._execute_code_snippet(instantiated_test) instantiated_tests.append(instantiated_test) test_values.append(test_value) return instantiated_tests, test_values, rendered_fail_msgs - # ------------------------------------------------------------------------------------- - - ######################### - # async version of nbgrader interaction with kernel - # the below functions were adapted from the jupyter/nbclient GitHub repo, commit: - # https://github.com/jupyter/nbclient/commit/0c08e27c1ec655cffe9b35cf637da742cdab36e8 - ######################### - - # ------------------------------------------------------------------------------------- - # adapted from nbclient.util.ensure_async - async def _ensure_async(self, obj): - """Convert a non-awaitable object to a coroutine if needed, - and await it if it was not already awaited. - adapted from nbclient.util._ensure_async - """ - if inspect.isawaitable(obj): - try: - result = await obj - except RuntimeError as e: - if str(e) == 'cannot reuse already awaited coroutine': - return obj - raise - return result - return obj - - # ------------------------------------------------------------------------------------- - # adapted from nbclient.client._async_handle_timeout - async def _async_handle_timeout(self, timeout: int) -> None: - - self.log.error("Timeout waiting for execute reply (%is)." % timeout) - if self.interrupt_on_timeout: - self.log.error("Interrupting kernel") - assert self.km is not None - await _ensure_async(self.km.interrupt_kernel()) - else: - raise CellTimeoutError.error_from_timeout_and_cell( - "Cell execution timed out", timeout - ) - - # ------------------------------------------------------------------------------------- - # adapted from nbclient.client._async_check_alive - async def _async_check_alive(self) -> None: - assert self.kc is not None - if not await self._ensure_async(self.kc.is_alive()): - self.log.error("Kernel died while waiting for execute reply.") - raise DeadKernelError("Kernel died") - - # ------------------------------------------------------------------------------------- - # adapted from nbclient.client._async_poll_output_msg - async def _async_poll_output_msg_code( - self, parent_msg_id: str, code - ) -> None: - - assert self.kc is not None - while True: - msg = await self._ensure_async(self.kc.iopub_channel.get_msg(timeout=None)) - if msg['parent_header'].get('msg_id') == parent_msg_id: - try: - msg_type = msg['msg_type'] - self.log.debug("msg_type: %s", msg_type) - content = msg['content'] - self.log.debug("content: %s", content) - - if msg_type in {'execute_result', 'display_data', 'update_display_data'}: - return self.sanitizer(content['data']['text/plain']) - - if msg_type == 'error': - self.log.error("Failed to run code: \n%s", code) - self.log.error("Runtime error from the kernel: \n%s", content['evalue']) - raise CodeExecutionError() - - if msg_type == 'status': - if content['execution_state'] == 'idle': - raise CellExecutionComplete() - - except CellExecutionComplete: - return - - # ------------------------------------------------------------------------------------- - # adapted from nbclient.client.async_wait_for_reply - async def _async_wait_for_reply( - self, msg_id: str, cell: t.Optional[NotebookNode] = None - ) -> t.Optional[t.Dict]: - - assert self.kc is not None - # wait for finish, with timeout - timeout = self._get_timeout(cell) - cummulative_time = 0 - while True: - try: - msg = await _ensure_async( - self.kc.shell_channel.get_msg(timeout=self.shell_timeout_interval) - ) - except Empty: - await self._async_check_alive() - cummulative_time += self.shell_timeout_interval - if timeout and cummulative_time > timeout: - await self._async_async_handle_timeout(timeout, cell) - break - else: - if msg['parent_header'].get('msg_id') == msg_id: - return msg - return None - - # ------------------------------------------------------------------------------------- - # adapted from nbclient.client._async_poll_for_reply - async def _async_poll_for_reply_code( - self, - msg_id: str, - timeout: t.Optional[int], - task_poll_output_msg: asyncio.Future, - task_poll_kernel_alive: asyncio.Future, - ) -> t.Dict: - - assert self.kc is not None - - self.log.debug("Executing _async_poll_for_reply:\n%s", msg_id) - - if timeout is not None: - deadline = monotonic() + timeout - new_timeout = float(timeout) - - while True: - try: - shell_msg = await self._ensure_async(self.kc.shell_channel.get_msg(timeout=new_timeout)) - if shell_msg['parent_header'].get('msg_id') == msg_id: - try: - msg = await asyncio.wait_for(task_poll_output_msg, new_timeout) - except (asyncio.TimeoutError, Empty): - task_poll_kernel_alive.cancel() - raise CellExecutionError("Timeout waiting for IOPub output") - self.log.debug("Get _async_poll_for_reply:\n%s", msg) - - return msg if msg != None else "" - else: - if new_timeout is not None: - new_timeout = max(0, deadline - monotonic()) - except Empty: - self.log.debug("Empty _async_poll_for_reply:\n%s", msg_id) - task_poll_kernel_alive.cancel() - await self._async_check_alive() - await self._async_handle_timeout() - - # ------------------------------------------------------------------------------------- - # adapted from nbclient.client.async_execute_cell - async def _async_execute_code_snippet(self, code): - assert self.kc is not None - - self.log.debug("Executing cell:\n%s", code) - - parent_msg_id = await self._ensure_async(self.kc.execute(code, stop_on_error=not self.allow_errors)) - - task_poll_kernel_alive = asyncio.ensure_future(self._async_check_alive()) - - task_poll_output_msg = asyncio.ensure_future(self._async_poll_output_msg_code(parent_msg_id, code)) - - task_poll_for_reply = asyncio.ensure_future( - self._async_poll_for_reply_code(parent_msg_id, self.timeout, task_poll_output_msg, task_poll_kernel_alive)) - - try: - msg = await task_poll_for_reply - except asyncio.CancelledError: - # can only be cancelled by task_poll_kernel_alive when the kernel is dead - task_poll_output_msg.cancel() - raise DeadKernelError("Kernel died") - except Exception as e: - # Best effort to cancel request if it hasn't been resolved - try: - # Check if the task_poll_output is doing the raising for us - if not isinstance(e, CellControlSignal): - task_poll_output_msg.cancel() - finally: - raise - - return msg - - # ------------------------------------------------------------------------------------- - async def async_execute_cell( - self, - cell: NotebookNode, - cell_index: int, - execution_count: t.Optional[int] = None, - store_history: bool = True, - ) -> NotebookNode: - """ - Executes a single code cell. - - To execute all cells see :meth:`execute`. - - Parameters - ---------- - cell : nbformat.NotebookNode - The cell which is currently being processed. - cell_index : int - The position of the cell within the notebook object. - execution_count : int - The execution count to be assigned to the cell (default: Use kernel response) - store_history : bool - Determines if history should be stored in the kernel (default: False). - Specific to ipython kernels, which can store command histories. - - Returns - ------- - output : dict - The execution output payload (or None for no output). - - Raises - ------ - CellExecutionError - If execution failed and should raise an exception, this will be raised - with defaults about the failure. - - Returns - ------- - cell : NotebookNode - The cell which was just processed. - """ - assert self.kc is not None - - await run_hook(self.on_cell_start, cell=cell, cell_index=cell_index) - - if cell.cell_type != 'code' or not cell.source.strip(): - self.log.debug("Skipping non-executing cell %s", cell_index) - return cell - - if self.skip_cells_with_tag in cell.metadata.get("tags", []): - self.log.debug("Skipping tagged cell %s", cell_index) - return cell - - if self.record_timing: # clear execution metadata prior to execution - cell['metadata']['execution'] = {} - - self.log.debug("Executing cell:\n%s", cell.source) - - cell_allows_errors = (not self.force_raise_errors) and ( - self.allow_errors or "raises-exception" in cell.metadata.get("tags", []) - ) - - await run_hook(self.on_cell_execute, cell=cell, cell_index=cell_index) - parent_msg_id = await _ensure_async( - self.kc.execute( - cell.source, store_history=store_history, stop_on_error=not cell_allows_errors - ) - ) - await run_hook(self.on_cell_complete, cell=cell, cell_index=cell_index) - # We launched a code cell to execute - self.code_cells_executed += 1 - exec_timeout = self._get_timeout(cell) - - cell.outputs = [] - self.clear_before_next_output = False - - task_poll_kernel_alive = asyncio.ensure_future(self._async_poll_kernel_alive()) - task_poll_output_msg = asyncio.ensure_future( - self._async_poll_output_msg_code(parent_msg_id, code) - ) - self.task_poll_for_reply = asyncio.ensure_future( - self._async_poll_for_reply_code( - parent_msg_id, exec_timeout, task_poll_output_msg, task_poll_kernel_alive - ) - ) - try: - exec_reply = await self.task_poll_for_reply - except asyncio.CancelledError: - # can only be cancelled by task_poll_kernel_alive when the kernel is dead - task_poll_output_msg.cancel() - raise DeadKernelError("Kernel died") - except Exception as e: - # Best effort to cancel request if it hasn't been resolved - try: - # Check if the task_poll_output is doing the raising for us - if not isinstance(e, CellControlSignal): - task_poll_output_msg.cancel() - finally: - raise - - if execution_count: - cell['execution_count'] = execution_count - await self._check_raise_for_error(cell, cell_index, exec_reply) - self.nb['cells'][cell_index] = cell - return cell - # ------------------------------------------------------------------------------------- - - -def timestamp(msg: Optional[Dict] = None) -> str: - if msg and 'header' in msg: # The test mocks don't provide a header, so tolerate that - msg_header = msg['header'] - if 'date' in msg_header and isinstance(msg_header['date'], datetime.datetime): - try: - # reformat datetime into expected format - formatted_time = datetime.datetime.strftime( - msg_header['date'], '%Y-%m-%dT%H:%M:%S.%fZ' - ) - if ( - formatted_time - ): # docs indicate strftime may return empty string, so let's catch that too - return formatted_time - except Exception: - pass # fallback to a local time - - return datetime.datetime.utcnow().isoformat() + 'Z' + def _execute_code_snippet(self, code): + self.log.debug("Executing code:\n%s", code) + self.kc.execute_interactive(code, output_hook = self._execute_code_snippet_output_hook) + res = self.execute_result + self.execute_result = None + self.log.debug("Result:\n%s", res) + return res + + def _execute_code_snippet_output_hook(self, msg: t.Dict[str, t.Any]) -> None: + msg_type = msg["header"]["msg_type"] + content = msg["content"] + if msg_type == "stream": + pass + #stream = getattr(sys, content["name"]) + #stream.write(content["text"]) + elif msg_type in ("display_data", "update_display_data", "execute_result"): + self.execute_result = self.sanitizer(content["data"]["text/plain"]) + elif msg_type == "error": + self.log.error("Runtime error from the kernel: \n%s\n%s\n%s", content['ename'], content['evalue'], content['traceback']) + raise CellExecutionError(content['traceback'], content['ename'], content['evalue']) + return + +## TODO: do we need this? commenting out for now; will add back in if it causes errors +#def timestamp(msg: Optional[Dict] = None) -> str: +# if msg and 'header' in msg: # The test mocks don't provide a header, so tolerate that +# msg_header = msg['header'] +# if 'date' in msg_header and isinstance(msg_header['date'], datetime.datetime): +# try: +# # reformat datetime into expected format +# formatted_time = datetime.datetime.strftime( +# msg_header['date'], '%Y-%m-%dT%H:%M:%S.%fZ' +# ) +# if ( +# formatted_time +# ): # docs indicate strftime may return empty string, so let's catch that too +# return formatted_time +# except Exception: +# pass # fallback to a local time +# +# return datetime.datetime.utcnow().isoformat() + 'Z' From f0140fc224d719be146623ee8fc0906d13d8d738 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 22 Aug 2023 18:58:10 -0700 Subject: [PATCH 06/33] store kernel name in resources --- nbgrader/preprocessors/instantiatetests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index d21e61814..ffaea8b7b 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -116,7 +116,7 @@ def preprocess(self, nb, resources): if kernel_name not in self.sanitizers: raise ValueError(f"Kernel {kernel_name} has not been specified in InstantiateTests.sanitizers") self.log.debug(f"Found kernel {kernel_name}") - self.kernel_name = kernel_name + resources["kernel_name"] = kernel_name # load the template tests file self.log.debug('Loading template tests file') @@ -156,7 +156,7 @@ def preprocess_cell(self, cell, resources, index): is_grade_flag = utils.is_grade(cell) # get the comment string for this language - comment_str = self.comment_strs[self.kernel_name] + comment_str = self.comment_strs[resources["kernel_name"]] # split the code lines into separate strings lines = cell.source.split("\n") @@ -283,7 +283,7 @@ def _load_test_template_file(self, resources): or perhaps cannot be loaded, it will attempt to load the default_tests.yaml file with the course_directory """ self.log.debug('loading template tests.yml...') - self.log.debug(f'kernel_name: {self.kernel_name}') + self.log.debug(f'kernel_name: {resources["kernel_name"]}') try: with open(os.path.join(resources['metadata']['path'], self.autotest_filename), 'r') as tests_file: tests = yaml.safe_load(tests_file) @@ -313,7 +313,7 @@ def _load_test_template_file(self, resources): raise # get kernel specific data - tests = tests[self.kernel_name] + tests = tests[resources["kernel_name"]] # get the test templates self.test_templates_by_type = tests['templates'] From 924a26ddaf133ac99bc188beb0ef56456a5e346b Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 22 Aug 2023 19:16:33 -0700 Subject: [PATCH 07/33] minor ed --- nbgrader/tests/preprocessors/test_instantiatetests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nbgrader/tests/preprocessors/test_instantiatetests.py b/nbgrader/tests/preprocessors/test_instantiatetests.py index ac3328557..bed94506a 100644 --- a/nbgrader/tests/preprocessors/test_instantiatetests.py +++ b/nbgrader/tests/preprocessors/test_instantiatetests.py @@ -73,7 +73,7 @@ def test_warning_autotest_nongrade(self, preprocessor, caplog): } nb, resources = preprocessor.preprocess(nb, resources) - assert "Autotest region detected in a non-grade cell; " in caplog.text + assert "AutoTest region detected in a non-grade cell; " in caplog.text # test that an error is thrown when we have an AUTOTEST directive in a non-grade cell def test_error_autotest_nongrade(self, preprocessor, caplog): @@ -92,7 +92,7 @@ def test_error_autotest_nongrade(self, preprocessor, caplog): with pytest.raises(Exception): nb, resources = preprocessor.preprocess(nb, resources) - assert "Autotest region detected in a non-grade cell; " in caplog.text + assert "AutoTest region detected in a non-grade cell; " in caplog.text # test that invalid python statements in AUTOTEST directives cause errors def test_error_bad_autotest_code(self, preprocessor): From 9ea6577b476baaa5bc54d7f2433d521aea22afc5 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Wed, 23 Aug 2023 13:44:00 -0700 Subject: [PATCH 08/33] remove problem3.ipynb from the ui-tests --- nbgrader/tests/ui-tests/assignment_list.spec.ts | 5 +++++ nbgrader/tests/ui-tests/formgrader.spec.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/nbgrader/tests/ui-tests/assignment_list.spec.ts b/nbgrader/tests/ui-tests/assignment_list.spec.ts index 87ae7d702..4a689d929 100644 --- a/nbgrader/tests/ui-tests/assignment_list.spec.ts +++ b/nbgrader/tests/ui-tests/assignment_list.spec.ts @@ -124,6 +124,11 @@ const addCourses = async (request: APIRequestContext, tmpPath: string) => { `${tmpPath}/source/Problem Set 1/problem2.ipynb`, `${tmpPath}/source/Problem Set 1/Problem 2.ipynb` ); + // don't run autotest in the ui tests + await contents.deleteFile( + `${tmpPath}/source/Problem Set 1/problem3.ipynb` + ); + await contents.createDirectory(`${tmpPath}/source/ps.01`); await contents.uploadFile( path.resolve(__dirname, "files", "empty.ipynb"), diff --git a/nbgrader/tests/ui-tests/formgrader.spec.ts b/nbgrader/tests/ui-tests/formgrader.spec.ts index 6f6b211a8..222a57da7 100644 --- a/nbgrader/tests/ui-tests/formgrader.spec.ts +++ b/nbgrader/tests/ui-tests/formgrader.spec.ts @@ -140,6 +140,10 @@ const addCourses = async (request: APIRequestContext, tmpPath: string) => { `${tmpPath}/source/Problem Set 1/problem2.ipynb`, `${tmpPath}/source/Problem Set 1/Problem 2.ipynb` ); + // don't run autotest in the ui tests + await contents.deleteFile( + `${tmpPath}/source/Problem Set 1/problem3.ipynb` + ); await contents.renameDirectory( `${tmpPath}/submitted/bitdiddle`, `${tmpPath}/submitted/Bitdiddle` From 5a521ac65693200f2b59a6efa492374399ddeb69 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Thu, 24 Aug 2023 20:50:09 -0700 Subject: [PATCH 09/33] removed commented out timestamp code from earlier snippet execution --- nbgrader/preprocessors/instantiatetests.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index ffaea8b7b..2089c5d48 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -418,22 +418,3 @@ def _execute_code_snippet_output_hook(self, msg: t.Dict[str, t.Any]) -> None: self.log.error("Runtime error from the kernel: \n%s\n%s\n%s", content['ename'], content['evalue'], content['traceback']) raise CellExecutionError(content['traceback'], content['ename'], content['evalue']) return - -## TODO: do we need this? commenting out for now; will add back in if it causes errors -#def timestamp(msg: Optional[Dict] = None) -> str: -# if msg and 'header' in msg: # The test mocks don't provide a header, so tolerate that -# msg_header = msg['header'] -# if 'date' in msg_header and isinstance(msg_header['date'], datetime.datetime): -# try: -# # reformat datetime into expected format -# formatted_time = datetime.datetime.strftime( -# msg_header['date'], '%Y-%m-%dT%H:%M:%S.%fZ' -# ) -# if ( -# formatted_time -# ): # docs indicate strftime may return empty string, so let's catch that too -# return formatted_time -# except Exception: -# pass # fallback to a local time -# -# return datetime.datetime.utcnow().isoformat() + 'Z' From a8ab39f36b60b977e6e4e881dc3bd30c48170087 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 28 Aug 2023 12:08:14 -0700 Subject: [PATCH 10/33] tests.yml -> autotests.yml --- nbgrader/apps/quickstartapp.py | 6 +++--- .../user_guide/{tests.yml => autotests.yml} | 0 nbgrader/preprocessors/instantiatetests.py | 18 +++++++++--------- .../apps/files/{tests.yml => autotests.yml} | 0 nbgrader/tests/apps/test_nbgrader_autograde.py | 8 ++++---- .../apps/test_nbgrader_generate_assignment.py | 16 ++++++++-------- .../apps/test_nbgrader_generate_feedback.py | 2 +- 7 files changed, 25 insertions(+), 25 deletions(-) rename nbgrader/docs/source/user_guide/{tests.yml => autotests.yml} (100%) rename nbgrader/tests/apps/files/{tests.yml => autotests.yml} (100%) diff --git a/nbgrader/apps/quickstartapp.py b/nbgrader/apps/quickstartapp.py index 0af1460e4..36f8b528f 100644 --- a/nbgrader/apps/quickstartapp.py +++ b/nbgrader/apps/quickstartapp.py @@ -122,10 +122,10 @@ def start(self): ignore_html = shutil.ignore_patterns("*.html") shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignore_html) - # copying the tests.yml file to the course directory + # copying the autotests.yml file to the course directory tests_file_path = os.path.abspath(os.path.join( - os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'tests.yml')) - shutil.copyfile(tests_file_path, os.path.join(course_path, 'tests.yml')) + os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'autotests.yml')) + shutil.copyfile(tests_file_path, os.path.join(course_path, 'autotests.yml')) # create the config file self.log.info("Generating example config file...") diff --git a/nbgrader/docs/source/user_guide/tests.yml b/nbgrader/docs/source/user_guide/autotests.yml similarity index 100% rename from nbgrader/docs/source/user_guide/tests.yml rename to nbgrader/docs/source/user_guide/autotests.yml diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 2089c5d48..22857e598 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -33,7 +33,7 @@ class InstantiateTests(NbGraderPreprocessor): tests = None autotest_filename = Unicode( - "tests.yml", + "autotests.yml", help="The filename where automatic testing code is stored" ).tag(config=True) @@ -279,10 +279,10 @@ def preprocess_cell(self, cell, resources, index): # ------------------------------------------------------------------------------------- def _load_test_template_file(self, resources): """ - attempts to load the tests.yml file within the assignment directory. In case such file is not found + attempts to load the autotests.yml file within the assignment directory. In case such file is not found or perhaps cannot be loaded, it will attempt to load the default_tests.yaml file with the course_directory """ - self.log.debug('loading template tests.yml...') + self.log.debug('loading template autotests.yml...') self.log.debug(f'kernel_name: {resources["kernel_name"]}') try: with open(os.path.join(resources['metadata']['path'], self.autotest_filename), 'r') as tests_file: @@ -292,7 +292,7 @@ def _load_test_template_file(self, resources): except FileNotFoundError: # if there is no tests file, just load a default tests dict self.log.warning( - 'No tests.yml file found in the assignment directory. Loading the default tests.yml file in the course root directory') + 'No autotests.yml file found in the assignment directory. Loading the default autotests.yml file in the course root directory') # tests = {} try: with open(os.path.join(self.autotest_filename), 'r') as tests_file: @@ -300,15 +300,15 @@ def _load_test_template_file(self, resources): except FileNotFoundError: # if there is no tests file, just create a default empty tests dict self.log.warning( - 'No tests.yml file found. If AUTOTESTS appears in testing cells, an error will be thrown.') + 'No autotests.yml file found. If AUTOTESTS appears in testing cells, an error will be thrown.') tests = {} except yaml.parser.ParserError as e: - self.log.error('tests.yml contains invalid YAML code.') + self.log.error('autotests.yml contains invalid YAML code.') self.log.error(e.msg) raise except yaml.parser.ParserError as e: - self.log.error('tests.yml contains invalid YAML code.') + self.log.error('autotests.yml contains invalid YAML code.') self.log.error(e.msg) raise @@ -348,13 +348,13 @@ def _instantiate_tests(self, snippet, salt=None): try: tests = self.test_templates_by_type.get(dispatch_result, self.test_templates_by_type['default']) except KeyError: - self.log.error('tests.yml must contain a top-level "default" key with corresponding test code') + self.log.error('autotests.yml must contain a top-level "default" key with corresponding test code') raise try: test_templs = [t['test'] for t in tests] fail_msgs = [t['fail'] for t in tests] except KeyError: - self.log.error('each type in tests.yml must have a list of dictionaries with a "test" and "fail" key') + self.log.error('each type in autotests.yml must have a list of dictionaries with a "test" and "fail" key') self.log.error('the "test" item should store the test template code, ' 'and the "fail" item should store a failure message') raise diff --git a/nbgrader/tests/apps/files/tests.yml b/nbgrader/tests/apps/files/autotests.yml similarity index 100% rename from nbgrader/tests/apps/files/tests.yml rename to nbgrader/tests/apps/files/autotests.yml diff --git a/nbgrader/tests/apps/test_nbgrader_autograde.py b/nbgrader/tests/apps/test_nbgrader_autograde.py index 1f00939f8..7bd3eacdc 100644 --- a/nbgrader/tests/apps/test_nbgrader_autograde.py +++ b/nbgrader/tests/apps/test_nbgrader_autograde.py @@ -102,7 +102,7 @@ def test_grade_autotest(self, db, course_dir): run_nbgrader(["db", "student", "add", "bar", "--db", db]) self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["generate_assignment", "ps1", "--db", db]) self._copy_file(join("files", "autotest-simple-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) @@ -132,7 +132,7 @@ def test_grade_hashed_autotest(self, db, course_dir): run_nbgrader(["db", "student", "add", "bar", "--db", db]) self._copy_file(join("files", "autotest-hashed.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["generate_assignment", "ps1", "--db", db]) self._copy_file(join("files", "autotest-hashed-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) @@ -162,7 +162,7 @@ def test_grade_complex_autotest(self, db, course_dir): run_nbgrader(["db", "student", "add", "bar", "--db", db]) self._copy_file(join("files", "autotest-multi.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["generate_assignment", "ps1", "--db", db]) self._copy_file(join("files", "autotest-multi-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) @@ -944,7 +944,7 @@ def test_hidden_tests_autotest(self, db, course_dir): fh.write("""c.ClearSolutions.code_stub=dict(python="# YOUR CODE HERE")""") self._copy_file(join("files", "autotest-hidden.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["generate_assignment", "ps1", "--db", db]) self._copy_file(join("files", "autotest-hidden-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) diff --git a/nbgrader/tests/apps/test_nbgrader_generate_assignment.py b/nbgrader/tests/apps/test_nbgrader_generate_assignment.py index 444f1f08f..cb93d0f30 100644 --- a/nbgrader/tests/apps/test_nbgrader_generate_assignment.py +++ b/nbgrader/tests/apps/test_nbgrader_generate_assignment.py @@ -50,11 +50,11 @@ def test_single_file(self, course_dir, temp_cwd): def test_autotests_simple(self, course_dir, temp_cwd): """Can a notebook with simple autotests be generated with a default yaml location, and is autotest code removed?""" self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["db", "assignment", "add", "ps1"]) run_nbgrader(["generate_assignment", "ps1"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) - assert not os.path.isfile(join(course_dir, "release", "ps1", "tests.yml")) + assert not os.path.isfile(join(course_dir, "release", "ps1", "autotests.yml")) foo = self._file_contents(join(course_dir, "release", "ps1", "foo.ipynb")) assert "AUTOTEST" not in foo @@ -62,11 +62,11 @@ def test_autotests_simple(self, course_dir, temp_cwd): def test_autotests_simple(self, course_dir, temp_cwd): """Can a notebook with simple autotests be generated with an assignment-specific yaml, and is autotest code removed?""" self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "source", "ps1", "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "source", "ps1", "autotests.yml")) run_nbgrader(["db", "assignment", "add", "ps1"]) run_nbgrader(["generate_assignment", "ps1"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) - assert os.path.isfile(join(course_dir, "release", "ps1", "tests.yml")) + assert os.path.isfile(join(course_dir, "release", "ps1", "autotests.yml")) foo = self._file_contents(join(course_dir, "release", "ps1", "foo.ipynb")) assert "AUTOTEST" not in foo @@ -81,7 +81,7 @@ def test_autotests_needs_yaml(self, course_dir, temp_cwd): def test_autotests_fancy(self, course_dir, temp_cwd): """Can a more complicated autotests notebook be generated, and is autotest code removed?""" self._copy_file(join("files", "autotest-multi.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["db", "assignment", "add", "ps1"]) run_nbgrader(["generate_assignment", "ps1"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) @@ -92,7 +92,7 @@ def test_autotests_fancy(self, course_dir, temp_cwd): def test_autotests_hidden(self, course_dir, temp_cwd): """Can a notebook with hidden autotest be generated, and is autotest/hidden sections removed?""" self._copy_file(join("files", "autotest-hidden.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["db", "assignment", "add", "ps1"]) run_nbgrader(["generate_assignment", "ps1"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) @@ -104,7 +104,7 @@ def test_autotests_hidden(self, course_dir, temp_cwd): def test_autotests_hashed(self, course_dir, temp_cwd): """Can a notebook with hashed autotests be generated, and is hashed autotest code removed?""" self._copy_file(join("files", "autotest-hashed.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["db", "assignment", "add", "ps1"]) run_nbgrader(["generate_assignment", "ps1"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) @@ -117,7 +117,7 @@ def test_generate_source_with_tests_flag(self, course_dir, temp_cwd): """Does setting the flag --source_with_tests also create a notebook with solution and tests in the source_with_tests directory""" self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "foo.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "source", "ps1", "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "source", "ps1", "autotests.yml")) run_nbgrader(["db", "assignment", "add", "ps1"]) run_nbgrader(["generate_assignment", "ps1", "--source_with_tests"]) assert os.path.isfile(join(course_dir, "release", "ps1", "foo.ipynb")) diff --git a/nbgrader/tests/apps/test_nbgrader_generate_feedback.py b/nbgrader/tests/apps/test_nbgrader_generate_feedback.py index 6ff20a70b..cb76d3e13 100644 --- a/nbgrader/tests/apps/test_nbgrader_generate_feedback.py +++ b/nbgrader/tests/apps/test_nbgrader_generate_feedback.py @@ -333,7 +333,7 @@ def test_autotests(self, course_dir): self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb")) self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "source", "ps1", "p2.ipynb")) - self._copy_file(join("files", "tests.yml"), join(course_dir, "tests.yml")) + self._copy_file(join("files", "autotests.yml"), join(course_dir, "autotests.yml")) run_nbgrader(["generate_assignment", "ps1"]) self._copy_file(join("files", "autotest-simple.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb")) From 50e40c672c7292fb771647fdd710708ec7a7fb13 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 28 Aug 2023 12:16:39 -0700 Subject: [PATCH 11/33] logging string interpolation --- nbgrader/preprocessors/instantiatetests.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 22857e598..53e133823 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -115,7 +115,7 @@ def preprocess(self, nb, resources): raise ValueError(f"Kernel {kernel_name} has not been specified in InstantiateTests.comment_strs") if kernel_name not in self.sanitizers: raise ValueError(f"Kernel {kernel_name} has not been specified in InstantiateTests.sanitizers") - self.log.debug(f"Found kernel {kernel_name}") + self.log.debug("Found kernel %s", kernel_name) resources["kernel_name"] = kernel_name # load the template tests file @@ -124,10 +124,10 @@ def preprocess(self, nb, resources): self.global_tests_loaded = True # set up the sanitizer - self.log.debug('Setting sanitizer for kernel {kernel_name}') + self.log.debug('Setting sanitizer for kernel %s', kernel_name) self.sanitizer = self.sanitizers[kernel_name] #start the kernel - self.log.debug('Starting client for kernel {kernel_name}') + self.log.debug('Starting client for kernel %s', kernel_name) km, self.kc = start_new_kernel(kernel_name = kernel_name) # run the preprocessor @@ -214,7 +214,7 @@ def preprocess_cell(self, cell, resources, index): # appears in the line before the self.autotest_delimiter token use_hash = (self.hashed_delimiter in line[:line.find(self.autotest_delimiter)]) if use_hash: - self.log.debug('Hashing delimiter found, using template: ' + self.hash_template) + self.log.debug('Hashing delimiter found, using template: %s', self.hash_template) else: self.log.debug('Hashing delimiter not found') @@ -233,18 +233,18 @@ def preprocess_cell(self, cell, resources, index): # generate the test for each snippet for snippet in snippets: - self.log.debug('Running autotest generation for snippet ' + snippet) + self.log.debug('Running autotest generation for snippet %s', snippet) # create a random salt for this test if use_hash: salt = secrets.token_hex(8) - self.log.debug('Using salt: ' + salt) + self.log.debug('Using salt: %s', salt) else: salt = None # get the normalized(/hashed) template tests for this code snippet self.log.debug( - 'Instantiating normalized' + ('/hashed ' if use_hash else ' ') + 'test templates based on type') + 'Instantiating normalized%s test templates based on type', ' & hashed' if use_hash else '') instantiated_tests, test_values, fail_messages = self._instantiate_tests(snippet, salt) # add all the lines to the cell @@ -253,7 +253,7 @@ def preprocess_cell(self, cell, resources, index): for i in range(len(instantiated_tests)): check_code = template.render(snippet=instantiated_tests[i], value=test_values[i], message=fail_messages[i]) - self.log.debug('Test: ' + check_code) + self.log.debug('Test: %s', check_code) new_lines.append(check_code) # add an empty line after this block of test code @@ -283,7 +283,7 @@ def _load_test_template_file(self, resources): or perhaps cannot be loaded, it will attempt to load the default_tests.yaml file with the course_directory """ self.log.debug('loading template autotests.yml...') - self.log.debug(f'kernel_name: {resources["kernel_name"]}') + self.log.debug('kernel_name: %s', resources["kernel_name"]) try: with open(os.path.join(resources['metadata']['path'], self.autotest_filename), 'r') as tests_file: tests = yaml.safe_load(tests_file) @@ -342,7 +342,7 @@ def _instantiate_tests(self, snippet, salt=None): template = j2.Environment(loader=j2.BaseLoader).from_string(self.dispatch_template) dispatch_code = template.render(snippet=snippet) dispatch_result = self._execute_code_snippet(dispatch_code) - self.log.debug('Dispatch result returned by kernel: ', dispatch_result) + self.log.debug('Dispatch result returned by kernel: %s', dispatch_result) # get the test code; if the type isn't in our dict, just default to 'default' # if default isn't in the tests code, this will throw an error try: From be7f998d432ba960371086eb51bc1614f62c6d77 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 28 Aug 2023 12:26:49 -0700 Subject: [PATCH 12/33] optional success code (and fix execute success code bug) --- nbgrader/preprocessors/instantiatetests.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 53e133823..c13894501 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -259,15 +259,15 @@ def preprocess_cell(self, cell, resources, index): # add an empty line after this block of test code new_lines.append('') + # add the final success code and execute it + if is_grade_flag and self.global_tests_loaded and (self.autotest_delimiter in cell.source) and (self.success_code is not None): + new_lines.append(self.success_code) + non_autotest_code_lines.append(self.success_code) + # run the trailing non-autotest lines, if any remain if len(non_autotest_code_lines) > 0: self._execute_code_snippet("\n".join(non_autotest_code_lines)) - # add the final success message - if is_grade_flag and self.global_tests_loaded: - if self.autotest_delimiter in cell.source: - new_lines.append(self.success_code) - # replace the cell source cell.source = "\n".join(new_lines) @@ -322,7 +322,7 @@ def _load_test_template_file(self, resources): self.dispatch_template = tests['dispatch'] # get the success message template - self.success_code = tests['success'] + self.success_code = tests.get('success', None) # get the hash code template self.hash_template = tests['hash'] From ec6354585c98d11358b79acb4cc2d3044a80475d Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 28 Aug 2023 12:31:42 -0700 Subject: [PATCH 13/33] reraise filenotfound error when find autotest directive but no autotests.yml file --- nbgrader/preprocessors/instantiatetests.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index c13894501..3f6d619ab 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -293,15 +293,13 @@ def _load_test_template_file(self, resources): # if there is no tests file, just load a default tests dict self.log.warning( 'No autotests.yml file found in the assignment directory. Loading the default autotests.yml file in the course root directory') - # tests = {} try: with open(os.path.join(self.autotest_filename), 'r') as tests_file: tests = yaml.safe_load(tests_file) except FileNotFoundError: # if there is no tests file, just create a default empty tests dict - self.log.warning( - 'No autotests.yml file found. If AUTOTESTS appears in testing cells, an error will be thrown.') - tests = {} + self.log.error('No autotests.yml file found, but there were autotest directives found in the notebook. ') + raise except yaml.parser.ParserError as e: self.log.error('autotests.yml contains invalid YAML code.') self.log.error(e.msg) From e8c3d7ee05dc211eb8108aa5349e4c53117a257d Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 28 Aug 2023 12:37:57 -0700 Subject: [PATCH 14/33] hash optional; raise error if not set --- nbgrader/preprocessors/instantiatetests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 3f6d619ab..fc02f8422 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -214,6 +214,8 @@ def preprocess_cell(self, cell, resources, index): # appears in the line before the self.autotest_delimiter token use_hash = (self.hashed_delimiter in line[:line.find(self.autotest_delimiter)]) if use_hash: + if self.hash_template is None: + raise ValueError('Found a hashing delimiter, but the hash property has not been set in autotests.yml') self.log.debug('Hashing delimiter found, using template: %s', self.hash_template) else: self.log.debug('Hashing delimiter not found') @@ -323,7 +325,7 @@ def _load_test_template_file(self, resources): self.success_code = tests.get('success', None) # get the hash code template - self.hash_template = tests['hash'] + self.hash_template = tests.get('hash', None) # get the hash code template self.check_template = tests['check'] From 2ceab614183fe12c2fcf8dbbd0120f2b5860d85c Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Mon, 28 Aug 2023 13:25:06 -0700 Subject: [PATCH 15/33] minor comment rephrasing --- nbgrader/preprocessors/instantiatetests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index fc02f8422..7cb9a4d05 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -292,14 +292,14 @@ def _load_test_template_file(self, resources): self.log.debug(tests) except FileNotFoundError: - # if there is no tests file, just load a default tests dict + # if there is no tests file, try to load default tests dict self.log.warning( 'No autotests.yml file found in the assignment directory. Loading the default autotests.yml file in the course root directory') try: with open(os.path.join(self.autotest_filename), 'r') as tests_file: tests = yaml.safe_load(tests_file) except FileNotFoundError: - # if there is no tests file, just create a default empty tests dict + # if there is not even a default tests file, re-raise the FileNotFound error self.log.error('No autotests.yml file found, but there were autotest directives found in the notebook. ') raise except yaml.parser.ParserError as e: From c53a31909f21743086d1b5e3199e1e45fce92ec4 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 09:03:59 -0700 Subject: [PATCH 16/33] better if statement formatting Co-authored-by: Nicolas Brichet <32258950+brichet@users.noreply.github.com> --- nbgrader/preprocessors/instantiatetests.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 7cb9a4d05..1957d551d 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -262,7 +262,12 @@ def preprocess_cell(self, cell, resources, index): new_lines.append('') # add the final success code and execute it - if is_grade_flag and self.global_tests_loaded and (self.autotest_delimiter in cell.source) and (self.success_code is not None): + if ( + is_grade_flag + and self.global_tests_loaded + and (self.autotest_delimiter in cell.source) + and (self.success_code is not None) + ): new_lines.append(self.success_code) non_autotest_code_lines.append(self.success_code) From 52ae394b9b49d83dcbce4fb066db8f419082603b Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 09:04:20 -0700 Subject: [PATCH 17/33] remove unnecessary spaces in start new kernel arg Co-authored-by: Nicolas Brichet <32258950+brichet@users.noreply.github.com> --- nbgrader/preprocessors/instantiatetests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 1957d551d..0aa0c98a9 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -128,7 +128,7 @@ def preprocess(self, nb, resources): self.sanitizer = self.sanitizers[kernel_name] #start the kernel self.log.debug('Starting client for kernel %s', kernel_name) - km, self.kc = start_new_kernel(kernel_name = kernel_name) + km, self.kc = start_new_kernel(kernel_name=kernel_name) # run the preprocessor self.log.debug('Running InstantiateTests preprocessor') From 577c733f451e64d3fbac055c13b4413a205b0fea Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 09:08:42 -0700 Subject: [PATCH 18/33] clear outputs from test notebook --- .../apps/files/autotest-multi-unchanged.ipynb | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb b/nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb index 212fee372..6090389a7 100644 --- a/nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb +++ b/nbgrader/tests/apps/files/autotest-multi-unchanged.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": { "deletable": false, "nbgrader": { @@ -23,7 +23,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": { "deletable": false, "editable": false, @@ -39,15 +39,7 @@ "solution": false } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Success!\n" - ] - } - ], + "outputs": [], "source": [ "# the basic tests from the simple notebook\n", "from hashlib import sha1\n", @@ -69,7 +61,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": { "deletable": false, "editable": false, @@ -86,19 +78,7 @@ "task": false } }, - "outputs": [ - { - "ename": "AssertionError", - "evalue": "value of str(a) is not correct", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mAssertionError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m/tmp/ipykernel_645570/554255095.py\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtype\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"type of str(a) is not str. str(a) should be an str\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 6\u001b[0m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"1\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"length of str(a) is not correct\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 7\u001b[0;31m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mlower\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"5\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"value of str(a) is not correct\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 8\u001b[0m \u001b[0;32massert\u001b[0m \u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"5\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"correct string value of str(a) but incorrect case of letters\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 9\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mAssertionError\u001b[0m: value of str(a) is not correct" - ] - } - ], + "outputs": [], "source": [ "# multiple expressions per line\n", "from hashlib import sha1\n", @@ -269,9 +249,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.10" + "version": "3.10.12" } }, "nbformat": 4, - "nbformat_minor": 1 + "nbformat_minor": 4 } From 702d10da13a5d5082e44006cf76b562ea9db0c5a Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 09:19:31 -0700 Subject: [PATCH 19/33] added jinja to pyproject --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 46213205a..5d90abf06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,8 @@ dependencies = [ "rapidfuzz>=1.8", "requests>=2.26", "sqlalchemy>=1.4,<3", - "PyYAML>=6.0" + "PyYAML>=6.0", + "Jinja2>=3.0" ] version = "0.9.0a1" From da8c972bb83fa13a67b95493b4b21a1f77b2fd99 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 11:21:10 -0700 Subject: [PATCH 20/33] initial commit of advanced topics docs, minor polish on creating assignments docs --- nbgrader/docs/source/user_guide/advanced.rst | 20 +++++++++++++++++++ .../creating_and_grading_assignments.ipynb | 19 ++++++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/nbgrader/docs/source/user_guide/advanced.rst b/nbgrader/docs/source/user_guide/advanced.rst index 502c0d393..b2b686865 100644 --- a/nbgrader/docs/source/user_guide/advanced.rst +++ b/nbgrader/docs/source/user_guide/advanced.rst @@ -194,3 +194,23 @@ containerization system. For details on using ``envkernel`` with singularity, see the `README `_ of ``envkernel``. + +.. _customizing-autotests: + +Automatic test code generation +--------------------------------------- + +.. versionadded:: 0.9.0 + +.. seealso:: + + :ref:`autograder-tests-cell-automatic-test-code` + General introduction to automatic test code generation. + + +nbgrader now supports generating test code automatically +using ``### AUTOTEST`` and ``### HASHED AUTOTEST`` statements. + +TODO +- autotest.yml syntax +- using ``--source-with-tests`` diff --git a/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb b/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb index 7c473c592..336a07cbb 100644 --- a/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb +++ b/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb @@ -426,7 +426,7 @@ " (see :ref:`autograde-assignments`)." ] }, - { + { "cell_type": "raw", "metadata": {}, "source": [ @@ -451,16 +451,15 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Tests in \"Autograder tests\" cells can be automatically and dynamically generated through the use of a special syntax such as ``### AUTOTEST`` and ``### HASHED AUTOTEST``, for example:\n", - "\n", - "![autograder tests autotest tests](images/autograder_tests_autotest_jlab.png)\n" + "Tests in \"Autograder tests\" cells can be automatically and dynamically generated through the use of the special syntax ``### AUTOTEST`` and ``### HASHED AUTOTEST``. This syntax allows you to specify only the objects you want to test, rather than having to write the test code yourself manually; `nbgrader` will generate the test code for you. For example,\n", + "![autograder tests autotest syntax](images/autograder_tests_autotest_jlab.png)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "In the release version, the above autotest statements get converted to the following test code that the students see:\n", + "In this example, the instructor wants to test that the returned value of `squares(1)`, `squares(2)`, and `squares(3)` lines up with the value from the source copy. In the release copy, the above autotest statements will be converted to the following test code that the students see:\n", "![autograder tests autotest tests](images/autograder_tests_autogenerated_tests_jlab.png)" ] }, @@ -468,15 +467,19 @@ "cell_type": "raw", "metadata": {}, "source": [ - "When creating the release version (see :ref:`assign-and-release-an-assignment`), the autotest lines (lines starting with the special syntax) will transform into automatically generated test cases (i.e., assert statements). The value of the expression(s) following the special syntax will be evaluated in the solution version to generate test cases that are checked in the student version. If this special syntax is not used, then the contents of the cell will remain as is.\n", + "When creating the release version (see :ref:`assign-and-release-an-assignment`), the autotest lines (lines starting with the special syntax) will transform into automatically generated test cases (i.e., assert statements). The value of the expression(s) following the special syntax will be evaluated in the solution version to generate test cases that are checked in the student version. If this special syntax is not used, then the contents of the cell will remain as-is.\n", "\n", ".. note::\n", "\n", - " Lines starting with ### AUTOTEST will generate test code where the answer is visible to students. To generate test code where the answers are *hashed* (not viewable by students), begin the line with the syntax ### HASHED AUTOTEST instead.\n", + " Lines starting with ``### AUTOTEST`` will generate test code where the answer is visible to students. In the example above, the tests for ``squares(1)`` and ``squares(2)`` can be examined by students to see the answer. To generate test code that students can run, but where the answers are not viewable by students (they are *hashed*), begin the line with the syntax ``### HASHED AUTOTEST`` instead. You can also make `### AUTOTEST` and `### HASHED AUTOTEST` statements hidden and not runnable by students by wrapping them in ``### BEGIN HIDDEN TESTS`` and ``### END HIDDEN TESTS`` as in :ref:`autograder-tests-cell-hidden-tests`\n", " \n", ".. note:: \n", "\n", - " You can put multiple expressions to be tested on a single ### AUTOTEST line (or ### HASHED AUTOTEST line), separated by semicolons." + " You can put multiple expressions to be tested on a single ``### AUTOTEST`` line (or ``### HASHED AUTOTEST`` line), separated by semicolons.\n", + "\n", + ".. note::\n", + "\n", + " You can follow the ``### AUTOTEST`` or ``### HASHED AUTOTEST`` syntax with any valid Python expression. Test code will be automatically generated based on the return type of that expression. See :ref:`customizing-autotests` for more technical details about how ``### AUTOTEST`` and ``### HASHED AUTOTEST`` statements are converted into test code, and how to customize this process." ] }, { From db9b4102063ada12cf53c51c35465d15c1971dec Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 14:33:28 -0700 Subject: [PATCH 21/33] done advanced docs section on autotest --- nbgrader/docs/source/user_guide/advanced.rst | 143 ++++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/nbgrader/docs/source/user_guide/advanced.rst b/nbgrader/docs/source/user_guide/advanced.rst index b2b686865..715cab537 100644 --- a/nbgrader/docs/source/user_guide/advanced.rst +++ b/nbgrader/docs/source/user_guide/advanced.rst @@ -210,7 +210,144 @@ Automatic test code generation nbgrader now supports generating test code automatically using ``### AUTOTEST`` and ``### HASHED AUTOTEST`` statements. +In this section, you can find more detail on how this works and +how to customize the test generation process. +Suppose you ask students to create a ``foo`` function that adds 5 to +an integer. In the source copy of the notebook, you might write something like + +.. code:: python + + ### BEGIN SOLUTION + def foo(x): + return x + 5 + ### END SOLUTION + +In a test cell, you would normally then write test code manually to probe various aspects of the solution. +For example, you might check that the function increments 3 to 8 properly, and that the type +of the output is an integer. + +.. code:: python + + assert isinstance(foo(3), int), "incrementing an int by 5 should return an int" + assert foo(3) == 8, "3+5 should be 8" + +nbgrader now provides functionality to automate this process. Instead of writing tests explicitly, +you can instead specify *what you want to test*, and let nbgrader decide *how to test it* automatically. + +.. code:: python + + ### AUTOTEST foo(3) + +This directive indicates that you want to check ``foo(3)`` in the student's notebook, and make sure it +aligns with the value of ``foo(3)`` in the current source copy. You can write any valid expression (in the +language of your notebook) after the ``### AUTOTEST`` directive. For example, you could write + +.. code:: python + + ### AUTOTEST (foo(3) - 5 == 3) + +to generate test code for the expression ``foo(3)-5==3`` (i.e., a boolean value), and make sure that evaluating +the student's copy of this expression has a result that aligns with the source version (i.e., ``True``). You can write multiple +``### AUTOTEST`` directives in one cell. You can also separate multiple expressions on one line with semicolons: + +.. code:: python + + ### AUTOTEST foo(3); foo(4); foo(5) != 8 + +These directives will insert code into student notebooks where the solution is available in plaintext. If you want to +obfuscate the answers in the student copy, you should instead use a ``### HASHED AUTOTEST``, which will produce +a student notebook where the answers are hashed and not viewable by students. + +When you generate an assignment containing ``### AUTOTEST`` (or ``### HASHED AUTOTEST``) statements, nbgrader looks for a file +named ``autotests.yml`` that contains instructions on how to generate test code. It first looks +in the assignment directory itself (in case you want to specify special tests for just that assignment), and if it is +not found there, nbgrader searches in the course root directory. +The ``autotests.yml`` file is a `YAML `__ file that looks something like this: + +.. code:: yaml + + python3: + setup: "from hashlib import sha1" + hash: 'sha1({{snippet}}.encode("utf-8")+b"{{salt}}").hexdigest()' + dispatch: "type({{snippet}})" + normalize: "str({{snippet}})" + check: 'assert {{snippet}} == """{{value}}""", """{{message}}"""' + success: "print('Success!')" + + templates: + default: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not correct" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + + int: + - test: "type({{snippet}})" + fail: "type of {{snippet}} is not int. Please make sure it is int and not np.int64, etc. You can cast your value into an int using int()" + + - test: "{{snippet}}" + fail: "value of {{snippet}} is not correct" + +The outermost level in the YAML file (the example shows an entry for ``python3``) specifies which kernel the configuration applies to. ``autotests.yml`` can +have separate sections for multiple kernels / languages. The ``autotests.yml`` file uses `Jinja templates `__ to +specify snippets of code that will be executed/inserted into Jupyter notebooks in the process of generating the assignment. You should familiarize yourself +with the basics of Jinja templates before proceeding. For each kernel, there are a few configuration settings possible: + +- **dispatch:** When you write ``### AUTOTEST foo(3)``, nbgrader needs to know how to test ``foo(3)``. It does so by executing ``foo(3)``, then checking its *type*, + and then running tests corresponding to that type in the ``autotests.yml`` file. Specifically, when generating an assignment, nbgrader substitutes the ``{{snippet}}`` template + variable with the expression ``foo(3)``, and then evaluates the dispatch code based on that. In this case, nbgrader runs ``type(foo(3))``, which will + return ``int``, so nbgrader will know to test ``foo(3)`` using tests for integer variables. +- **templates:** Once nbgrader determines the type of the expression ``foo(3)``, it will look for that type in the list of templates for the kernel. In this case, + it will find the ``int`` type in the list (it will use the **default** if the type is not found). Each type will have associated with it a + list of **test**/**fail** template pairs, which tell nbgrader what tests to run + and what messages to print in the event of a failure. Once again, ``{{snippet}}`` will be replaced by the ``foo(3)`` expression. In ``autotests.yml`` above, the + ``int`` type has two tests: one that checks type of the expression, and one that checks its value. In this case, the student notebook will have + two tests: one that checks the value of ``type(foo(3))``, and one that checks the value of ``foo(3)``. +- **normalize:** For each test code expression (for example, ``type(foo(3))`` as mentioned previously), nbgrader will execute code using the corresponding + Jupyter kernel, which will respond with a result in the form of a *string*. So nbgrader now knows that if it runs ``type(foo(3))`` at this + point in the notebook, and converts the output to a string (i.e., *normalizes it*), it should obtain ``"int"``. However, nbgrader does not know how to convert output to a string; that + depends on the kernel! So the normalize code template tells nbgrader how to convert an expression to a string. In the ``autotests.yml`` example above, the + normalize template suggests that nbgrader should try to compare ``str(type(foo(3)))`` to ``"int"``. +- **check:** This is the code template that will be inserted into the student notebook to run each test. The template has three variables. ``{{snippet}}`` is the normalized + test code. The ``{{value}}`` is the evaluated version of that test code, based on the source notebook. The ``{{message}}`` is + text that will be printed in the event of a test failure. In the example above, the check code template tells nbgrader to insert an ``assert`` statement to run the test. +- **hash (optional):** This is a code template that is responsible for hashing (i.e., obfuscating) the answers in the student notebok. The template has two variables. + ``{{snippet}}`` represents the expression that will be hashed, and ``{{salt}}`` is used for nbgrader to insert a `salt `__ + prior to hashing. The salt helps avoid students being able to identify hashes from common question types. For example, a true/false question has only two possible answers; + without a salt, students would be able to recognize the hashes of ``True`` and ``False`` in their notebooks. By adding a salt, nbgrader makes the hashed version of the answer + different for each question, preventing identifying answers based on their hashes. +- **setup (optional):** This is a code template that will be run at the beginning of all test cells containing ``### AUTOTEST`` or ``### HASHED AUTOTEST`` directives. It is often used to import + special packages that only the test code requires. In the example above, the setup code is used to import the ``sha1`` function from ``hashlib``, which is necessary + for hashed test generation. +- **success (optional):** This is a code template that will be added to the end of all test cells containing ``### AUTOTEST`` or ``### HASHED AUTOTEST`` directives. In the + generated student version of the notebook, + this code will run if all the tests pass. In the example ``autotests.yml`` file above, the success code is used to run ``print('Success!')``, i.e., simply print a message to + indicate that all tests in the cell passed. + +.. note:: + + For assignments with ``### AUTOTEST`` and ``### HASHED AUTOTEST`` directives, it is often handy + to have an editable copy of the assignment with solutions *and* test code inserted. You can + use ``nbgrader generate_assignment --source_with_tests`` to generate this version of an assignment, + which will appear in the ``source_with_tests/`` folder in the course repository. + +.. warning:: + + The default ``autotests.yml`` test templates file included with the repository has tests for many + common data types (``int``, ``dict``, ``list``, ``float``, etc). It also has a ``default`` test template + that it will try to apply to any types that do not have specified tests. If you want to automatically + generate your own tests for custom types, you will need to implement those test templates in ``autotests.yml``. That being said, custom + object types often have standard Python types as class attributes. Sometimes an easier option is to use nbgrader to test these + attributes automatically instead. For example, if ``obj`` is a complicated type with no specific test template available, + but ``obj`` has an ``int`` attribute ``x``, you could consider testing that attribute directly, e.g., ``### AUTOTEST obj.x``. + +.. warning:: + + The InstantiateTests preprocessor in nbgrader is responsible for generating test code from ``### AUTOTEST`` + directives and the ``autotests.yml`` file. It has some configuration parameters not yet mentioned here. + The most important of these is the ``InstantiateTests.sanitizers`` dictionary, which tells nbgrader how to + clean up the string output from each kind of Jupyter kernel before using it in the process of generating tests. We have + implemented sanitizers for popular kernels in nbgrader already, but you might need to add your own. + -TODO -- autotest.yml syntax -- using ``--source-with-tests`` From a881d2ba4dae736940eb7eecb595ef13eea02718 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 15:59:43 -0700 Subject: [PATCH 22/33] moved problem3 to it's own problem set ps1_autotest --- .gitignore | 4 --- nbgrader/apps/quickstartapp.py | 26 ++++++++++++++----- .../managing_assignment_files.ipynb | 12 ++++----- .../tests/ui-tests/assignment_list.spec.ts | 4 --- nbgrader/tests/ui-tests/formgrader.spec.ts | 4 --- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 7da289b32..3cecd390f 100644 --- a/.gitignore +++ b/.gitignore @@ -84,18 +84,14 @@ nbgrader/docs/source/user_guide/managing_assignment_files_manually.rst nbgrader/docs/source/user_guide/managing_the_database.rst nbgrader/docs/source/user_guide/autograded/bitdiddle/ps1/problem1.html nbgrader/docs/source/user_guide/autograded/bitdiddle/ps1/problem2.html -nbgrader/docs/source/user_guide/autograded/bitdiddle/ps1/problem3.html nbgrader/docs/source/user_guide/autograded/hacker/ps1/problem1.html nbgrader/docs/source/user_guide/autograded/hacker/ps1/problem2.html -nbgrader/docs/source/user_guide/autograded/hacker/ps1/problem3.html nbgrader/docs/source/user_guide/downloaded/ps1/archive/ps1_hacker_attempt_2016-01-30-20-30-10_problem1.html nbgrader/docs/source/user_guide/release/ps1/problem1.html nbgrader/docs/source/user_guide/release/ps1/problem2.html -nbgrader/docs/source/user_guide/release/ps1/problem3.html nbgrader/docs/source/user_guide/source/header.html nbgrader/docs/source/user_guide/source/ps1/problem1.html nbgrader/docs/source/user_guide/source/ps1/problem2.html -nbgrader/docs/source/user_guide/source/ps1/problem3.html # components stuff node_modules diff --git a/nbgrader/apps/quickstartapp.py b/nbgrader/apps/quickstartapp.py index 36f8b528f..06b6824ff 100644 --- a/nbgrader/apps/quickstartapp.py +++ b/nbgrader/apps/quickstartapp.py @@ -40,6 +40,15 @@ """ ) ), + 'autotest': ( + {'QuickStartApp': {'autotest': True}}, + dedent( + """ + Create notebook assignments that have examples of automatic test generation via + ### AUTOTEST and ### HASHED AUTOTEST statements. + """ + ) + ), } class QuickStartApp(NbGrader): @@ -73,6 +82,8 @@ class QuickStartApp(NbGrader): force = Bool(False, help="Whether to overwrite existing files").tag(config=True) + autotest = Bool(False, help="Whether to use automatic test generation in example files").tag(config=True) + @default("classes") def _classes_default(self): classes = super(QuickStartApp, self)._classes_default() @@ -119,13 +130,14 @@ def start(self): self.log.info("Copying example from the user guide...") example = os.path.abspath(os.path.join( os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'source')) - ignore_html = shutil.ignore_patterns("*.html") - shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignore_html) - - # copying the autotests.yml file to the course directory - tests_file_path = os.path.abspath(os.path.join( - os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'autotests.yml')) - shutil.copyfile(tests_file_path, os.path.join(course_path, 'autotests.yml')) + if self.autotest: + tests_file_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'autotests.yml')) + shutil.copyfile(tests_file_path, os.path.join(course_path, 'autotests.yml')) + ignored_files = shutil.ignore_patterns("*.html", "ps2", "ps1") + else: + ignored_files = shutil.ignore_patterns("*.html", "ps2", "autotests.yml", "ps1_autotest") + shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignored_files) # create the config file self.log.info("Generating example config file...") diff --git a/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb b/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb index 498a6190a..8c75210be 100644 --- a/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb +++ b/nbgrader/docs/source/user_guide/managing_assignment_files.ipynb @@ -465,8 +465,8 @@ "total ##\n", "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] jupyter.png\n", "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem1.ipynb\n", - "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem2.ipynb\n", - "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem3.ipynb\n" + "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem1_autotest.ipynb\n", + "-rw-r--r-- 1 nb_user nb_group [size] [date] [time] problem2.ipynb\n" ] } ], @@ -817,12 +817,12 @@ "[SubmitApp | WARNING] Possible missing notebooks and/or extra notebooks submitted for assignment ps1:\n", " Expected:\n", " \tproblem1.ipynb: MISSING\n", + " \tproblem1_autotest.ipynb: FOUND\n", " \tproblem2.ipynb: FOUND\n", - " \tproblem3.ipynb: FOUND\n", " Submitted:\n", " \tmyproblem1.ipynb: EXTRA\n", + " \tproblem1_autotest.ipynb: OK\n", " \tproblem2.ipynb: OK\n", - " \tproblem3.ipynb: OK\n", "[SubmitApp | INFO] Submitted as: example_course ps1 [timestamp] UTC\n" ] } @@ -898,12 +898,12 @@ "[SubmitApp | CRITICAL] Assignment ps1 not submitted. There are missing notebooks for the submission:\n", " Expected:\n", " \tproblem1.ipynb: MISSING\n", + " \tproblem1_autotest.ipynb: FOUND\n", " \tproblem2.ipynb: FOUND\n", - " \tproblem3.ipynb: FOUND\n", " Submitted:\n", " \tmyproblem1.ipynb: EXTRA\n", + " \tproblem1_autotest.ipynb: OK\n", " \tproblem2.ipynb: OK\n", - " \tproblem3.ipynb: OK\n", "[SubmitApp | ERROR] nbgrader submit failed\n" ] } diff --git a/nbgrader/tests/ui-tests/assignment_list.spec.ts b/nbgrader/tests/ui-tests/assignment_list.spec.ts index 4a689d929..b54d4ca83 100644 --- a/nbgrader/tests/ui-tests/assignment_list.spec.ts +++ b/nbgrader/tests/ui-tests/assignment_list.spec.ts @@ -124,10 +124,6 @@ const addCourses = async (request: APIRequestContext, tmpPath: string) => { `${tmpPath}/source/Problem Set 1/problem2.ipynb`, `${tmpPath}/source/Problem Set 1/Problem 2.ipynb` ); - // don't run autotest in the ui tests - await contents.deleteFile( - `${tmpPath}/source/Problem Set 1/problem3.ipynb` - ); await contents.createDirectory(`${tmpPath}/source/ps.01`); await contents.uploadFile( diff --git a/nbgrader/tests/ui-tests/formgrader.spec.ts b/nbgrader/tests/ui-tests/formgrader.spec.ts index 222a57da7..6f6b211a8 100644 --- a/nbgrader/tests/ui-tests/formgrader.spec.ts +++ b/nbgrader/tests/ui-tests/formgrader.spec.ts @@ -140,10 +140,6 @@ const addCourses = async (request: APIRequestContext, tmpPath: string) => { `${tmpPath}/source/Problem Set 1/problem2.ipynb`, `${tmpPath}/source/Problem Set 1/Problem 2.ipynb` ); - // don't run autotest in the ui tests - await contents.deleteFile( - `${tmpPath}/source/Problem Set 1/problem3.ipynb` - ); await contents.renameDirectory( `${tmpPath}/submitted/bitdiddle`, `${tmpPath}/submitted/Bitdiddle` From 1af6da29255625e1783337e91348131a7ef1ed34 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 16:00:02 -0700 Subject: [PATCH 23/33] moved problem3 to its own ps1_autotest assignment --- .../source/ps1_autotest/jupyter.png | Bin 0 -> 5733 bytes .../source/ps1_autotest/problem1.ipynb | 399 ++++++++++++++++++ .../source/ps1_autotest/problem2.ipynb | 79 ++++ 3 files changed, 478 insertions(+) create mode 100644 nbgrader/docs/source/user_guide/source/ps1_autotest/jupyter.png create mode 100644 nbgrader/docs/source/user_guide/source/ps1_autotest/problem1.ipynb create mode 100644 nbgrader/docs/source/user_guide/source/ps1_autotest/problem2.ipynb diff --git a/nbgrader/docs/source/user_guide/source/ps1_autotest/jupyter.png b/nbgrader/docs/source/user_guide/source/ps1_autotest/jupyter.png new file mode 100644 index 0000000000000000000000000000000000000000..201fc09ce423a6e83c74829d25b711f65ba47f1a GIT binary patch literal 5733 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 SF7+@s1{7peq$?y%g8u^szt&R# literal 0 HcmV?d00001 diff --git a/nbgrader/docs/source/user_guide/source/ps1_autotest/problem1.ipynb b/nbgrader/docs/source/user_guide/source/ps1_autotest/problem1.ipynb new file mode 100644 index 000000000..16d713402 --- /dev/null +++ b/nbgrader/docs/source/user_guide/source/ps1_autotest/problem1.ipynb @@ -0,0 +1,399 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "jupyter", + "locked": true, + "schema_version": 3, + "solution": false + } + }, + "source": [ + "For this problem set, we'll be using the Jupyter notebook:\n", + "\n", + "![](jupyter.png)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part A (2 points)\n", + "\n", + "Write a function that returns a list of numbers, such that $x_i=i^2$, for $1\\leq i \\leq n$. Make sure it handles the case where $n<1$ by raising a `ValueError`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "squares", + "locked": false, + "schema_version": 3, + "solution": true + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "def squares(n):\n", + " \"\"\"Compute the squares of numbers from 1 to n, such that the \n", + " ith element of the returned list equals i^2.\n", + " \n", + " \"\"\"\n", + " ### BEGIN SOLUTION\n", + " if n < 1:\n", + " raise ValueError(\"n must be greater than or equal to 1\")\n", + " return [i ** 2 for i in range(1, n + 1)]\n", + " ### END SOLUTION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Your function should print `[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]` for $n=10$. Check that it does:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "correct_squares", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that squares returns the correct output for several inputs\"\"\"\n", + "### AUTOTEST squares(1); squares(2)\n", + "### HASHED AUTOTEST squares(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "squares_invalid_input", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that squares raises an error for invalid inputs\"\"\"\n", + "def test_func_throws(func, ErrorType):\n", + " try:\n", + " func()\n", + " except ErrorType:\n", + " return True\n", + " else:\n", + " print('Did not raise right type of error!')\n", + " return False\n", + " \n", + "### AUTOTEST test_func_throws(lambda : squares(0), ValueError)\n", + "### AUTOTEST test_func_throws(lambda : squares(-4), ValueError);\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Part B (1 point)\n", + "\n", + "Using your `squares` function, write a function that computes the sum of the squares of the numbers from 1 to $n$. Your function should call the `squares` function -- it should NOT reimplement its functionality." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "sum_of_squares", + "locked": false, + "schema_version": 3, + "solution": true + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "def sum_of_squares(n):\n", + " \"\"\"Compute the sum of the squares of numbers from 1 to n.\"\"\"\n", + " ### BEGIN SOLUTION\n", + " return sum(squares(n))\n", + " ### END SOLUTION" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The sum of squares from 1 to 10 should be 385. Verify that this is the answer you get:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "sum_of_squares(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "correct_sum_of_squares", + "locked": false, + "points": 0.5, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that sum_of_squares returns the correct answer for various inputs.\"\"\"\n", + "### AUTOTEST sum_of_squares(1)\n", + "### AUTOTEST sum_of_squares(2); sum_of_squares(10) \n", + "### AUTOTEST sum_of_squares(11) \n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_uses_squares", + "locked": false, + "points": 0.5, + "schema_version": 3, + "solution": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "\"\"\"Check that sum_of_squares relies on squares.\"\"\"\n", + "\n", + "orig_squares = squares\n", + "del squares\n", + "\n", + "### AUTOTEST test_func_throws(lambda : sum_of_squares(1), NameError)\n", + "\n", + "squares = orig_squares\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part C (1 point)\n", + "\n", + "Using LaTeX math notation, write out the equation that is implemented by your `sum_of_squares` function." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_equation", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": true + } + }, + "source": [ + "$\\sum_{i=1}^n i^2$" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part D (2 points)\n", + "\n", + "Find a usecase for your `sum_of_squares` function and implement that usecase in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "sum_of_squares_application", + "locked": false, + "points": 2, + "schema_version": 3, + "solution": true + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "def pyramidal_number(n):\n", + " \"\"\"Returns the n^th pyramidal number\"\"\"\n", + " return sum_of_squares(n)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "cell-938593c4a215c6cc", + "locked": true, + "points": 4, + "schema_version": 3, + "solution": false, + "task": true + } + }, + "source": [ + "---\n", + "## Part E (4 points)\n", + "\n", + "State the formulae for an arithmetic and geometric sum and verify them numerically for an example of your choice." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part F (1 points)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": false, + "grade_id": "cell-d3df8cd59fd0eb74", + "locked": false, + "schema_version": 3, + "solution": true, + "task": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "my_dictionary = {\n", + " 'one' : 1,\n", + " 'two' : 2,\n", + " 'three' : 3\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "cell-6e9ff83aa5dfaf17", + "locked": true, + "points": 0, + "schema_version": 3, + "solution": false, + "task": false + }, + "vscode": { + "languageId": "python" + } + }, + "outputs": [], + "source": [ + "### AUTOTEST my_dictionary\n", + "### AUTOTEST my_dictionary[\"one\"]" + ] + } + ], + "metadata": { + "celltoolbar": "Create Assignment", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/nbgrader/docs/source/user_guide/source/ps1_autotest/problem2.ipynb b/nbgrader/docs/source/user_guide/source/ps1_autotest/problem2.ipynb new file mode 100644 index 000000000..a8c653699 --- /dev/null +++ b/nbgrader/docs/source/user_guide/source/ps1_autotest/problem2.ipynb @@ -0,0 +1,79 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Consider the following piece of code:\n", + "\n", + "```python\n", + "def f(x):\n", + " if x == 0 or x == 1:\n", + " return x\n", + " return f(x - 1) + f(x - 2)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part A (1 point)\n", + "\n", + "Describe, in words, what this code does, and how it does it." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "part-a", + "locked": false, + "points": 1, + "schema_version": 3, + "solution": true + } + }, + "source": [ + "This function computes the fibonnaci sequence using recursion. The base cases are $x=0$ and $x=1$, in which case the function will return 0 or 1, respectively. In all other cases, the function will call itself to find the $x-1$ and $x-2$ fibonnaci numbers, and then add them together." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## Part B (2 points)\n", + "\n", + "For what inputs will this function not behave as expected? What will happen?" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "nbgrader": { + "grade": true, + "grade_id": "part-b", + "locked": false, + "points": 2, + "schema_version": 3, + "solution": true + } + }, + "source": [ + "The function will not work correctly for inputs less than zero. Such inputs will result in an infinite recursion, as the function will keep subtracting one but never reach a base case that stops it." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python", + "language": "python", + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} From 0ca7287b916f21081e18d0433cd60b8173432e72 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 16:46:32 -0700 Subject: [PATCH 24/33] remove problem3 from ps1 --- .../user_guide/source/ps1/problem3.ipynb | 399 ------------------ 1 file changed, 399 deletions(-) delete mode 100644 nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb diff --git a/nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb b/nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb deleted file mode 100644 index 16d713402..000000000 --- a/nbgrader/docs/source/user_guide/source/ps1/problem3.ipynb +++ /dev/null @@ -1,399 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "jupyter", - "locked": true, - "schema_version": 3, - "solution": false - } - }, - "source": [ - "For this problem set, we'll be using the Jupyter notebook:\n", - "\n", - "![](jupyter.png)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part A (2 points)\n", - "\n", - "Write a function that returns a list of numbers, such that $x_i=i^2$, for $1\\leq i \\leq n$. Make sure it handles the case where $n<1$ by raising a `ValueError`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "squares", - "locked": false, - "schema_version": 3, - "solution": true - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "def squares(n):\n", - " \"\"\"Compute the squares of numbers from 1 to n, such that the \n", - " ith element of the returned list equals i^2.\n", - " \n", - " \"\"\"\n", - " ### BEGIN SOLUTION\n", - " if n < 1:\n", - " raise ValueError(\"n must be greater than or equal to 1\")\n", - " return [i ** 2 for i in range(1, n + 1)]\n", - " ### END SOLUTION" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Your function should print `[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]` for $n=10$. Check that it does:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "squares(10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "correct_squares", - "locked": false, - "points": 1, - "schema_version": 3, - "solution": false - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "\"\"\"Check that squares returns the correct output for several inputs\"\"\"\n", - "### AUTOTEST squares(1); squares(2)\n", - "### HASHED AUTOTEST squares(3)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "squares_invalid_input", - "locked": false, - "points": 1, - "schema_version": 3, - "solution": false - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "\"\"\"Check that squares raises an error for invalid inputs\"\"\"\n", - "def test_func_throws(func, ErrorType):\n", - " try:\n", - " func()\n", - " except ErrorType:\n", - " return True\n", - " else:\n", - " print('Did not raise right type of error!')\n", - " return False\n", - " \n", - "### AUTOTEST test_func_throws(lambda : squares(0), ValueError)\n", - "### AUTOTEST test_func_throws(lambda : squares(-4), ValueError);\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Part B (1 point)\n", - "\n", - "Using your `squares` function, write a function that computes the sum of the squares of the numbers from 1 to $n$. Your function should call the `squares` function -- it should NOT reimplement its functionality." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "sum_of_squares", - "locked": false, - "schema_version": 3, - "solution": true - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "def sum_of_squares(n):\n", - " \"\"\"Compute the sum of the squares of numbers from 1 to n.\"\"\"\n", - " ### BEGIN SOLUTION\n", - " return sum(squares(n))\n", - " ### END SOLUTION" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The sum of squares from 1 to 10 should be 385. Verify that this is the answer you get:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "sum_of_squares(10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "correct_sum_of_squares", - "locked": false, - "points": 0.5, - "schema_version": 3, - "solution": false - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "\"\"\"Check that sum_of_squares returns the correct answer for various inputs.\"\"\"\n", - "### AUTOTEST sum_of_squares(1)\n", - "### AUTOTEST sum_of_squares(2); sum_of_squares(10) \n", - "### AUTOTEST sum_of_squares(11) \n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "sum_of_squares_uses_squares", - "locked": false, - "points": 0.5, - "schema_version": 3, - "solution": false - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "\"\"\"Check that sum_of_squares relies on squares.\"\"\"\n", - "\n", - "orig_squares = squares\n", - "del squares\n", - "\n", - "### AUTOTEST test_func_throws(lambda : sum_of_squares(1), NameError)\n", - "\n", - "squares = orig_squares\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part C (1 point)\n", - "\n", - "Using LaTeX math notation, write out the equation that is implemented by your `sum_of_squares` function." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "sum_of_squares_equation", - "locked": false, - "points": 1, - "schema_version": 3, - "solution": true - } - }, - "source": [ - "$\\sum_{i=1}^n i^2$" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part D (2 points)\n", - "\n", - "Find a usecase for your `sum_of_squares` function and implement that usecase in the cell below." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "sum_of_squares_application", - "locked": false, - "points": 2, - "schema_version": 3, - "solution": true - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "def pyramidal_number(n):\n", - " \"\"\"Returns the n^th pyramidal number\"\"\"\n", - " return sum_of_squares(n)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "cell-938593c4a215c6cc", - "locked": true, - "points": 4, - "schema_version": 3, - "solution": false, - "task": true - } - }, - "source": [ - "---\n", - "## Part E (4 points)\n", - "\n", - "State the formulae for an arithmetic and geometric sum and verify them numerically for an example of your choice." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part F (1 points)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "cell-d3df8cd59fd0eb74", - "locked": false, - "schema_version": 3, - "solution": true, - "task": false - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "my_dictionary = {\n", - " 'one' : 1,\n", - " 'two' : 2,\n", - " 'three' : 3\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "cell-6e9ff83aa5dfaf17", - "locked": true, - "points": 0, - "schema_version": 3, - "solution": false, - "task": false - }, - "vscode": { - "languageId": "python" - } - }, - "outputs": [], - "source": [ - "### AUTOTEST my_dictionary\n", - "### AUTOTEST my_dictionary[\"one\"]" - ] - } - ], - "metadata": { - "celltoolbar": "Create Assignment", - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 1 -} From cf1d60557948da68ed4bd8f8dabf6cccfe4cbd88 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 17:11:47 -0700 Subject: [PATCH 25/33] ignore autotest assignment files; make quickstart rename ps1_autotest -> ps1; add quickstart test for autotest --- .gitignore | 3 +++ nbgrader/apps/quickstartapp.py | 6 +++-- .../tests/apps/test_nbgrader_quickstart.py | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3cecd390f..4943e0362 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,9 @@ nbgrader/docs/source/user_guide/release/ps1/problem2.html nbgrader/docs/source/user_guide/source/header.html nbgrader/docs/source/user_guide/source/ps1/problem1.html nbgrader/docs/source/user_guide/source/ps1/problem2.html +nbgrader/docs/source/user_guide/source/ps1_autotest/problem1.html +nbgrader/docs/source/user_guide/source/ps1_autotest/problem2.html +nbgrader/docs/source/user_guide/source/ps2/problem.html # components stuff node_modules diff --git a/nbgrader/apps/quickstartapp.py b/nbgrader/apps/quickstartapp.py index 06b6824ff..0bca02f8a 100644 --- a/nbgrader/apps/quickstartapp.py +++ b/nbgrader/apps/quickstartapp.py @@ -126,7 +126,7 @@ def start(self): if not os.path.isdir(course_path): os.mkdir(course_path) - # populating it with an example + # populate it with an example self.log.info("Copying example from the user guide...") example = os.path.abspath(os.path.join( os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'source')) @@ -135,9 +135,11 @@ def start(self): os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'autotests.yml')) shutil.copyfile(tests_file_path, os.path.join(course_path, 'autotests.yml')) ignored_files = shutil.ignore_patterns("*.html", "ps2", "ps1") + shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignored_files) + os.rename(os.path.join(course_path, "source", "ps1_autotest"), os.path.join(course_path, "source", "ps1")) else: ignored_files = shutil.ignore_patterns("*.html", "ps2", "autotests.yml", "ps1_autotest") - shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignored_files) + shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignored_files) # create the config file self.log.info("Generating example config file...") diff --git a/nbgrader/tests/apps/test_nbgrader_quickstart.py b/nbgrader/tests/apps/test_nbgrader_quickstart.py index 57c67934b..6ab100310 100644 --- a/nbgrader/tests/apps/test_nbgrader_quickstart.py +++ b/nbgrader/tests/apps/test_nbgrader_quickstart.py @@ -117,3 +117,28 @@ def test_quickstart_f(self): # nbgrader generate_assignment should work run_nbgrader(["generate_assignment", "ps1"]) + + def test_quickstart_autotest(self): + """Is the quickstart example with autotests properly generated?""" + + run_nbgrader(["quickstart", "example", "--autotest"]) + + # it should fail if it already exists + run_nbgrader(["quickstart", "example", "--autotest"], retcode=1) + + # it should succeed if --force is given + os.remove(os.path.join("example", "nbgrader_config.py")) + run_nbgrader(["quickstart", "example", "--force", "--autotest"]) + assert os.path.exists(os.path.join("example", "nbgrader_config.py")) + assert os.path.exists(os.path.join("example", "autotests.yml")) + + # nbgrader validate should work + os.chdir("example") + for nb in os.listdir(os.path.join("source", "ps1")): + if not nb.endswith(".ipynb"): + continue + output = run_nbgrader(["validate", os.path.join("source", "ps1", nb)], stdout=True) + assert output.strip() == "Success! Your notebook passes all the tests." + + # nbgrader generate_assignment should work + run_nbgrader(["generate_assignment", "ps1"]) From 4b76b89da8b82eb4dc937c298a95a6d5784d501a Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 18:08:26 -0700 Subject: [PATCH 26/33] remove ps2 R assignment; check for autotest directive in quickstart tests --- .gitignore | 1 - nbgrader/apps/quickstartapp.py | 4 +- .../source/user_guide/source/ps2/jupyter.png | Bin 5733 -> 0 bytes .../user_guide/source/ps2/problem.ipynb | 241 ------------------ .../tests/apps/test_nbgrader_quickstart.py | 18 ++ 5 files changed, 20 insertions(+), 244 deletions(-) delete mode 100644 nbgrader/docs/source/user_guide/source/ps2/jupyter.png delete mode 100644 nbgrader/docs/source/user_guide/source/ps2/problem.ipynb diff --git a/.gitignore b/.gitignore index 4943e0362..c04f56b69 100644 --- a/.gitignore +++ b/.gitignore @@ -94,7 +94,6 @@ nbgrader/docs/source/user_guide/source/ps1/problem1.html nbgrader/docs/source/user_guide/source/ps1/problem2.html nbgrader/docs/source/user_guide/source/ps1_autotest/problem1.html nbgrader/docs/source/user_guide/source/ps1_autotest/problem2.html -nbgrader/docs/source/user_guide/source/ps2/problem.html # components stuff node_modules diff --git a/nbgrader/apps/quickstartapp.py b/nbgrader/apps/quickstartapp.py index 0bca02f8a..c477f3091 100644 --- a/nbgrader/apps/quickstartapp.py +++ b/nbgrader/apps/quickstartapp.py @@ -134,11 +134,11 @@ def start(self): tests_file_path = os.path.abspath(os.path.join( os.path.dirname(__file__), '..', 'docs', 'source', 'user_guide', 'autotests.yml')) shutil.copyfile(tests_file_path, os.path.join(course_path, 'autotests.yml')) - ignored_files = shutil.ignore_patterns("*.html", "ps2", "ps1") + ignored_files = shutil.ignore_patterns("*.html", "ps1") shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignored_files) os.rename(os.path.join(course_path, "source", "ps1_autotest"), os.path.join(course_path, "source", "ps1")) else: - ignored_files = shutil.ignore_patterns("*.html", "ps2", "autotests.yml", "ps1_autotest") + ignored_files = shutil.ignore_patterns("*.html", "autotests.yml", "ps1_autotest") shutil.copytree(example, os.path.join(course_path, "source"), ignore=ignored_files) # create the config file diff --git a/nbgrader/docs/source/user_guide/source/ps2/jupyter.png b/nbgrader/docs/source/user_guide/source/ps2/jupyter.png deleted file mode 100644 index 201fc09ce423a6e83c74829d25b711f65ba47f1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5733 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 SF7+@s1{7peq$?y%g8u^szt&R# diff --git a/nbgrader/docs/source/user_guide/source/ps2/problem.ipynb b/nbgrader/docs/source/user_guide/source/ps2/problem.ipynb deleted file mode 100644 index 2286cd1eb..000000000 --- a/nbgrader/docs/source/user_guide/source/ps2/problem.ipynb +++ /dev/null @@ -1,241 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "jupyter", - "locked": true, - "schema_version": 3, - "solution": false - } - }, - "source": [ - "For this problem set, we'll be using the Jupyter notebook:\n", - "\n", - "![](jupyter.png)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part A (2 points)\n", - "\n", - "Write a function that returns a vector of numbers, such that $x_i=i^2$, for $1\\leq i \\leq n$. Make sure it handles the case where $n<1$ by using `stop(\"n must be greater than or equal to 1\")`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "squares", - "locked": false, - "schema_version": 3, - "solution": true - } - }, - "outputs": [], - "source": [ - "squares <- function(n){\n", - " ### BEGIN SOLUTION\n", - " if (n < 1){\n", - " stop(\"n must be greater than or equal to 1\")\n", - " }\n", - " ret <- c()\n", - " for (i in 1:n){\n", - " ret <- append(ret, i**2)\n", - " }\n", - " return(ret)\n", - " ### END SOLUTION\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Your function should print `[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]` for $n=10$. Check that it does:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "squares(10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "correct_squares", - "locked": false, - "points": 2, - "schema_version": 3, - "solution": false - } - }, - "outputs": [], - "source": [ - "### AUTOTEST squares(1); squares(2); squares(15)\n", - "### AUTOTEST squares(10)\n", - "### AUTOTEST squares(11)\n", - "### AUTOTEST 3\n", - "### AUTOTEST squares(3)\n", - "### HASHED AUTOTEST squares(3)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Part B (1 point)\n", - "\n", - "Using your `squares` function, write a function that computes the sum of the squares of the numbers from 1 to $n$. Your function should call the `squares` function -- it should NOT reimplement its functionality." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": false, - "grade_id": "sum_of_squares", - "locked": false, - "schema_version": 3, - "solution": true - } - }, - "outputs": [], - "source": [ - "sum_of_squares <- function(n) {\n", - " ### BEGIN SOLUTION\n", - " return(sum(squares(n)))\n", - " ### END SOLUTION\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The sum of squares from 1 to 10 should be 385. Verify that this is the answer you get:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sum_of_squares(10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "correct_sum_of_squares", - "locked": false, - "points": 1, - "schema_version": 3, - "solution": false - } - }, - "outputs": [], - "source": [ - "### AUTOTEST sum_of_squares(1)\n", - "### AUTOTEST sum_of_squares(2); sum_of_squares(10) \n", - "### AUTOTEST sum_of_squares(11) \n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part C (1 point)\n", - "\n", - "Using LaTeX math notation, write out the equation that is implemented by your `sum_of_squares` function." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "sum_of_squares_equation", - "locked": false, - "points": 1, - "schema_version": 3, - "solution": true - } - }, - "source": [ - "$\\sum_{i=1}^n i^2$" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part D (2 points)\n", - "\n", - "Find a usecase for your `sum_of_squares` function and implement that usecase in the cell below." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "nbgrader": { - "grade": true, - "grade_id": "sum_of_squares_application", - "locked": false, - "points": 2, - "schema_version": 3, - "solution": true - } - }, - "outputs": [], - "source": [ - "pyramidal_number <- function(n){\n", - " return(sum_of_squares(n))\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Part E (4 points)\n", - "\n", - "State the formulae for an arithmetic and geometric sum and verify them numerically for an example of your choice." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "R", - "language": "R", - "name": "ir" - } - }, - "nbformat": 4, - "nbformat_minor": 1 -} diff --git a/nbgrader/tests/apps/test_nbgrader_quickstart.py b/nbgrader/tests/apps/test_nbgrader_quickstart.py index 6ab100310..577e096b1 100644 --- a/nbgrader/tests/apps/test_nbgrader_quickstart.py +++ b/nbgrader/tests/apps/test_nbgrader_quickstart.py @@ -39,6 +39,13 @@ def test_quickstart(self, fake_home_dir): # nbgrader generate_assignment should work run_nbgrader(["generate_assignment", "ps1"]) + # there should be no autotests in any notebook in ps1 + for nb in os.listdir(os.path.join("source", "ps1")): + if not nb.endswith(".ipynb"): + continue + with open(os.path.join("source", "ps1", nb), 'r') as f: + assert "AUTOTEST" not in f.read() + def test_quickstart_overwrite_course_folder_if_structure_not_present(self): """Is the quickstart example properly generated?""" @@ -129,6 +136,8 @@ def test_quickstart_autotest(self): # it should succeed if --force is given os.remove(os.path.join("example", "nbgrader_config.py")) run_nbgrader(["quickstart", "example", "--force", "--autotest"]) + + # ensure both autotests.yml and nbgrader_config.py are in the course root dir assert os.path.exists(os.path.join("example", "nbgrader_config.py")) assert os.path.exists(os.path.join("example", "autotests.yml")) @@ -142,3 +151,12 @@ def test_quickstart_autotest(self): # nbgrader generate_assignment should work run_nbgrader(["generate_assignment", "ps1"]) + + # there should be autotests in at least one notebook in ps1 + found_autotest = False + for nb in os.listdir(os.path.join("source", "ps1")): + if not nb.endswith(".ipynb"): + continue + with open(os.path.join("source", "ps1", nb), 'r') as f: + found_autotest = found_autotest or ("AUTOTEST" in f.read()) + assert found_autotest From 2702e2c47a22dd42874bf64e73e49dbd4a4e93d9 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Tue, 29 Aug 2023 18:24:05 -0700 Subject: [PATCH 27/33] minor code tags syntax fix in docs --- .../source/user_guide/creating_and_grading_assignments.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb b/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb index 336a07cbb..8e3256b67 100644 --- a/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb +++ b/nbgrader/docs/source/user_guide/creating_and_grading_assignments.ipynb @@ -471,7 +471,7 @@ "\n", ".. note::\n", "\n", - " Lines starting with ``### AUTOTEST`` will generate test code where the answer is visible to students. In the example above, the tests for ``squares(1)`` and ``squares(2)`` can be examined by students to see the answer. To generate test code that students can run, but where the answers are not viewable by students (they are *hashed*), begin the line with the syntax ``### HASHED AUTOTEST`` instead. You can also make `### AUTOTEST` and `### HASHED AUTOTEST` statements hidden and not runnable by students by wrapping them in ``### BEGIN HIDDEN TESTS`` and ``### END HIDDEN TESTS`` as in :ref:`autograder-tests-cell-hidden-tests`\n", + " Lines starting with ``### AUTOTEST`` will generate test code where the answer is visible to students. In the example above, the tests for ``squares(1)`` and ``squares(2)`` can be examined by students to see the answer. To generate test code that students can run, but where the answers are not viewable by students (they are *hashed*), begin the line with the syntax ``### HASHED AUTOTEST`` instead. You can also make ``### AUTOTEST`` and ``### HASHED AUTOTEST`` statements hidden and not runnable by students by wrapping them in ``### BEGIN HIDDEN TESTS`` and ``### END HIDDEN TESTS`` as in :ref:`autograder-tests-cell-hidden-tests`\n", " \n", ".. note:: \n", "\n", From 696406547c38ffd50fcbd2ac1a6b443474aad24f Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Wed, 30 Aug 2023 18:26:56 -0700 Subject: [PATCH 28/33] set kernel working dir to notebook resources path --- nbgrader/preprocessors/instantiatetests.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 0aa0c98a9..b975a2f54 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -118,6 +118,9 @@ def preprocess(self, nb, resources): self.log.debug("Found kernel %s", kernel_name) resources["kernel_name"] = kernel_name + # get the resources path from the notebook + resources_path = resources.get('metadata', {}).get('path', None) + # load the template tests file self.log.debug('Loading template tests file') self._load_test_template_file(resources) @@ -126,9 +129,9 @@ def preprocess(self, nb, resources): # set up the sanitizer self.log.debug('Setting sanitizer for kernel %s', kernel_name) self.sanitizer = self.sanitizers[kernel_name] - #start the kernel - self.log.debug('Starting client for kernel %s', kernel_name) - km, self.kc = start_new_kernel(kernel_name=kernel_name) + #start the kernel with the specified kernel and in the local path of the notebook + self.log.debug('Starting client for kernel %s at path %s', kernel_name, resources_path if resources_path is not None else '') + km, self.kc = start_new_kernel(kernel_name=kernel_name, cwd=resources_path) # run the preprocessor self.log.debug('Running InstantiateTests preprocessor') @@ -263,9 +266,9 @@ def preprocess_cell(self, cell, resources, index): # add the final success code and execute it if ( - is_grade_flag - and self.global_tests_loaded - and (self.autotest_delimiter in cell.source) + is_grade_flag + and self.global_tests_loaded + and (self.autotest_delimiter in cell.source) and (self.success_code is not None) ): new_lines.append(self.success_code) From d271f9cd36b6990f21b7bcae6ea34d46c59b980d Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Wed, 30 Aug 2023 23:58:13 -0700 Subject: [PATCH 29/33] Convert to deterministic salt computed using cell source/index --- nbgrader/preprocessors/instantiatetests.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index b975a2f54..3a3cfa960 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -5,9 +5,9 @@ from .. import utils from traitlets import Bool, List, Integer, Unicode, Dict, Callable from textwrap import dedent -import secrets import asyncio import inspect +import hashlib import typing as t from nbformat import NotebookNode from queue import Empty @@ -161,6 +161,11 @@ def preprocess_cell(self, cell, resources, index): # get the comment string for this language comment_str = self.comment_strs[resources["kernel_name"]] + # seed the salt generator for this cell + # avoid actual random seeds so that release versions are consistent across + # calls to nbgrader generate_assignment + salt_int = int(hashlib.sha256((cell.source+str(index)).encode('utf-8')).hexdigest(), 16) % 10**6 + # split the code lines into separate strings lines = cell.source.split("\n") @@ -240,9 +245,10 @@ def preprocess_cell(self, cell, resources, index): for snippet in snippets: self.log.debug('Running autotest generation for snippet %s', snippet) - # create a random salt for this test + # create a salt for this test if use_hash: - salt = secrets.token_hex(8) + salt_int += 1 + salt = hex(salt_int) self.log.debug('Using salt: %s', salt) else: salt = None From c58646d752dfcffb5085487f409a8af3939de71f Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Wed, 30 Aug 2023 23:59:39 -0700 Subject: [PATCH 30/33] minor ed --- nbgrader/preprocessors/instantiatetests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nbgrader/preprocessors/instantiatetests.py b/nbgrader/preprocessors/instantiatetests.py index 3a3cfa960..6e85e1fdd 100644 --- a/nbgrader/preprocessors/instantiatetests.py +++ b/nbgrader/preprocessors/instantiatetests.py @@ -248,7 +248,7 @@ def preprocess_cell(self, cell, resources, index): # create a salt for this test if use_hash: salt_int += 1 - salt = hex(salt_int) + salt = hex(salt_int)[2:] self.log.debug('Using salt: %s', salt) else: salt = None From cc5acbfade7870c51a60a00a1379c40ac7425d30 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Thu, 31 Aug 2023 10:19:54 -0700 Subject: [PATCH 31/33] remove extra jinja from pyproject Co-authored-by: Nicolas Brichet <32258950+brichet@users.noreply.github.com> --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5d90abf06..a5c1c9156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ dependencies = [ "requests>=2.26", "sqlalchemy>=1.4,<3", "PyYAML>=6.0", - "Jinja2>=3.0" ] version = "0.9.0a1" From 05570a019e4911390f33f6843f1587a1cefa8c5b Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Thu, 31 Aug 2023 14:33:22 -0700 Subject: [PATCH 32/33] add tests for kernel working dir and release nb consistency --- nbgrader/tests/__init__.py | 8 ++ .../preprocessors/test_instantiatetests.py | 76 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/nbgrader/tests/__init__.py b/nbgrader/tests/__init__.py index 82992b1ba..5239d4cb6 100644 --- a/nbgrader/tests/__init__.py +++ b/nbgrader/tests/__init__.py @@ -266,3 +266,11 @@ def create_autotest_test_cell(): """ cell = new_code_cell(source=source) return cell + +def create_file_loader_cell(filename): + source = f""" + with open('{filename}', 'r') as f: + tmp = f.read() + """ + cell = create_regular_cell(source, 'code') + return cell diff --git a/nbgrader/tests/preprocessors/test_instantiatetests.py b/nbgrader/tests/preprocessors/test_instantiatetests.py index bed94506a..d3a39f637 100644 --- a/nbgrader/tests/preprocessors/test_instantiatetests.py +++ b/nbgrader/tests/preprocessors/test_instantiatetests.py @@ -1,9 +1,10 @@ import pytest +import shutil import os from textwrap import dedent from ...preprocessors import InstantiateTests from .base import BaseTestPreprocessor -from .. import create_code_cell, create_text_cell, create_autotest_solution_cell, create_autotest_test_cell +from .. import create_code_cell, create_text_cell, create_autotest_solution_cell, create_autotest_test_cell, create_file_loader_cell from nbformat.v4 import new_notebook from nbclient.client import NotebookClient @@ -39,6 +40,7 @@ def test_has_comment_strs(self, preprocessor): assert 'python3' in preprocessor.comment_strs.keys() assert 'ir' in preprocessor.comment_strs.keys() + # test that autotest generates assert statements def test_replace_autotest_code(self, preprocessor): sol_cell = create_autotest_solution_cell() test_cell = create_autotest_test_cell() @@ -55,6 +57,78 @@ def test_replace_autotest_code(self, preprocessor): nb, resources = preprocessor.preprocess(nb, resources) assert 'assert' in nb['cells'][1]['source'] + # test that autotest generates consistent output given the same input + def test_consistent_release_version(self, preprocessor): + sol_cell = create_autotest_solution_cell() + test_cell = create_autotest_test_cell() + test_cell.metadata['nbgrader'] = {'grade': True} + + # create and process first notebook + nb1 = new_notebook() + nb1.metadata['kernelspec'] = { + "name": "python3" + } + nb1.cells.append(sol_cell) + nb1.cells.append(test_cell) + resources1 = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb1, resources1 = preprocessor.preprocess(nb1, resources1) + + # create and process second notebook + nb2 = new_notebook() + nb2.metadata['kernelspec'] = { + "name": "python3" + } + nb2.cells.append(sol_cell) + nb2.cells.append(test_cell) + resources2 = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb2, resources2 = preprocessor.preprocess(nb2, resources2) + assert nb1['cells'][1]['source'] == nb2['cells'][1]['source'] + + # test that autotest starts a kernel that uses the `path` metadata as working directory + def test_kernel_workingdir(self, preprocessor, caplog): + sol_cell = create_autotest_solution_cell() + test_cell = create_autotest_test_cell() + load_cell = create_file_loader_cell('grades.csv') + test_cell.metadata['nbgrader'] = {'grade': True} + + # with the right path, the kernel should load the file + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + nb.cells.append(load_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/'} + } + nb, resources = preprocessor.preprocess(nb, resources) + + # without the right path, the kernel should report an error + nb = new_notebook() + nb.metadata['kernelspec'] = { + "name": "python3" + } + nb.cells.append(sol_cell) + nb.cells.append(test_cell) + nb.cells.append(load_cell) + resources = { + 'metadata': {'path': 'nbgrader/docs/source/user_guide/source/'} + } + # make sure autotest doesn't fail prior to running the + # preprocessor because it can't find autotests.yml + # we want it to fail because it can't find a resource file + shutil.copyfile('nbgrader/docs/source/user_guide/autotests.yml', 'nbgrader/docs/source/user_guide/source/autotests.yml') + with pytest.raises(Exception): + nb, resources = preprocessor.preprocess(nb, resources) + os.remove('nbgrader/docs/source/user_guide/source/autotests.yml') + + assert "FileNotFoundError" in caplog.text + # test that a warning is thrown when we set enforce_metadata = False and have an AUTOTEST directive in a # non-grade cell def test_warning_autotest_nongrade(self, preprocessor, caplog): From 865f418a6f3ab380c62b93907bbb4d5177b6eb00 Mon Sep 17 00:00:00 2001 From: Trevor Campbell Date: Thu, 31 Aug 2023 15:00:20 -0700 Subject: [PATCH 33/33] minor bugfix in workingdir tests --- .../tests/preprocessors/test_instantiatetests.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/nbgrader/tests/preprocessors/test_instantiatetests.py b/nbgrader/tests/preprocessors/test_instantiatetests.py index d3a39f637..8dc78a75a 100644 --- a/nbgrader/tests/preprocessors/test_instantiatetests.py +++ b/nbgrader/tests/preprocessors/test_instantiatetests.py @@ -89,13 +89,13 @@ def test_consistent_release_version(self, preprocessor): assert nb1['cells'][1]['source'] == nb2['cells'][1]['source'] # test that autotest starts a kernel that uses the `path` metadata as working directory - def test_kernel_workingdir(self, preprocessor, caplog): + # with the right path, the kernel should load the file + def test_kernel_right_workingdir(self, preprocessor, caplog): sol_cell = create_autotest_solution_cell() test_cell = create_autotest_test_cell() load_cell = create_file_loader_cell('grades.csv') test_cell.metadata['nbgrader'] = {'grade': True} - # with the right path, the kernel should load the file nb = new_notebook() nb.metadata['kernelspec'] = { "name": "python3" @@ -108,7 +108,14 @@ def test_kernel_workingdir(self, preprocessor, caplog): } nb, resources = preprocessor.preprocess(nb, resources) - # without the right path, the kernel should report an error + # test that autotest starts a kernel that uses the `path` metadata as working directory + # without the right path, the kernel should report an error + def test_kernel_wrong_workingdir(self, preprocessor, caplog): + sol_cell = create_autotest_solution_cell() + test_cell = create_autotest_test_cell() + load_cell = create_file_loader_cell('grades.csv') + test_cell.metadata['nbgrader'] = {'grade': True} + nb = new_notebook() nb.metadata['kernelspec'] = { "name": "python3" @@ -125,6 +132,7 @@ def test_kernel_workingdir(self, preprocessor, caplog): shutil.copyfile('nbgrader/docs/source/user_guide/autotests.yml', 'nbgrader/docs/source/user_guide/source/autotests.yml') with pytest.raises(Exception): nb, resources = preprocessor.preprocess(nb, resources) + # remove the temporary resource os.remove('nbgrader/docs/source/user_guide/source/autotests.yml') assert "FileNotFoundError" in caplog.text