Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Needed for local running. #4

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
otter Python API
"""

import platform

from . import api
from .check import logs
from .check.notebook import Notebook
from .version import __version__

# whether Otter is running on Window
_WINDOWS = platform.system() == "Windows"
6 changes: 6 additions & 0 deletions __main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Python otter module"""

from .cli import cli

if __name__ == "__main__":
cli()
Binary file added __pycache__/__init__.cpython-39.pyc
Binary file not shown.
Binary file added __pycache__/__main__.cpython-39.pyc
Binary file not shown.
Binary file added __pycache__/api.cpython-39.pyc
Binary file not shown.
Binary file added __pycache__/argparser.cpython-39.pyc
Binary file not shown.
Binary file added __pycache__/cli.cpython-39.pyc
Binary file not shown.
Binary file added __pycache__/runner.cpython-39.pyc
Binary file not shown.
Binary file added __pycache__/utils.cpython-39.pyc
Binary file not shown.
Binary file added __pycache__/version.cpython-39.pyc
Binary file not shown.
64 changes: 64 additions & 0 deletions api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""A programmatic API for using Otter-Grader"""

__all__ = ["export_notebook", "grade_submission"]

import os
import sys
import shutil
import tempfile

from contextlib import redirect_stdout

try:
from contextlib import nullcontext
except ImportError:
from .utils import nullcontext # nullcontext is new in Python 3.7

from .export import export_notebook
from .run import main as run_grader


def grade_submission(submission_path, ag_path="autograder.zip", quiet=False, debug=False):
"""
Runs non-containerized grading on a single submission at ``submission_path`` using the autograder
configuration file at ``ag_path``.

Creates a temporary grading directory using the ``tempfile`` library and grades the submission
by replicating the autograder tree structure in that folder and running the autograder there. Does
not run environment setup files (e.g. ``setup.sh``) or install requirements, so any requirements
should be available in the environment being used for grading.

Print statements executed during grading can be suppressed with ``quiet``.

Args:
submission_path (``str``): path to submission file
ag_path (``str``): path to autograder zip file
quiet (``bool``, optional): whether to suppress print statements during grading; default
``False``
debug (``bool``, optional): whether to run the submission in debug mode (without ignoring
errors)

Returns:
``otter.test_files.GradingResults``: the results object produced during the grading of the
submission.
"""

dp = tempfile.mkdtemp()

if quiet:
f = open(os.devnull, "w")
cm = redirect_stdout(f)
else:
cm = nullcontext()

# TODO: is the output_dir argument of run_grader necessary here?
with cm:
results = run_grader(
submission_path, autograder=ag_path, output_dir=dp, no_logo=True, debug=debug)

if quiet:
f.close()

shutil.rmtree(dp)

return results
128 changes: 128 additions & 0 deletions argparser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
Argument parser for Otter command-line tools
"""

import sys
import argparse

from textwrap import dedent

INVOKED_FROM_PYTHON = "__main__.py" in sys.argv[0]
PROG = ("otter", "python3 -m otter")[INVOKED_FROM_PYTHON]

def get_parser():
"""
Creates and returns the argument parser for Otter

Returns:
``argparse.ArgumentParser``: the argument parser for Otter command-line tools
"""

parser = argparse.ArgumentParser(prog=PROG, description=dedent("""\
Command-line utility for Otter-Grader, a Python-based autograder for Jupyter Notebooks, RMarkdown
files, and Python and R scripts that runs locally on the instructors machine. For more information,
see https://otter-grader.readthedocs.io/
"""))
parser.add_argument("--version", default=False, action="store_true", help="Show version information and exit")
subparsers = parser.add_subparsers()


##### PARSER FOR otter assign #####
assign_parser = subparsers.add_parser("assign", description="Create distribution versions of otter-assign-formatted notebook")
assign_parser.add_argument("master", help="Notebook with solutions and tests.")
assign_parser.add_argument("result", help="Directory containing the result.")
assign_parser.add_argument("--no-run-tests", help="Don't run tests.", default=False, action="store_true")
assign_parser.add_argument("--no-pdfs", help="Don't generate PDFs; overrides assignment config", default=False, action="store_true")
assign_parser.add_argument("--username", default=None, help="Gradescope username for generating a token")
assign_parser.add_argument("--password", default=None, help="Gradescope password for generating a token")
assign_parser.add_argument("--debug", default=False, action="store_true", help="Do not ignore errors in running tests for debugging")

assign_parser.set_defaults(func_str="assign.main")


##### PARSER FOR otter check #####
check_parser = subparsers.add_parser("check", description="Checks Python file against tests")
check_parser.add_argument("file", help="Python file to grade")
check_parser.add_argument("-q", "--question", help="Grade a specific test")
check_parser.add_argument("-t", "--tests-path", default="tests", help="Path to test files")
check_parser.add_argument("--seed", type=int, default=None, help="A random seed to be executed before each cell")

check_parser.set_defaults(func_str="check.main")


##### PARSER FOR otter export #####
export_parser = subparsers.add_parser("export", description="Exports a Jupyter Notebook to PDF with optional filtering")
export_parser.add_argument("source", help="Notebook to export")
export_parser.add_argument("dest", nargs='?', default=None, help="Path to write PDF")
export_parser.add_argument("--filtering", default=False, action="store_true", help="Whether the PDF should be filtered")
export_parser.add_argument("--pagebreaks", default=False, action="store_true", help="Whether the PDF should have pagebreaks between questions")
export_parser.add_argument("-s", "--save", default=False, action="store_true", help="Save intermediate file(s) as well")
export_parser.add_argument("-e", "--exporter", default=None, choices=["latex", "html"], nargs="?", help="Type of PDF exporter to use")
export_parser.add_argument("--debug", default=False, action="store_true", help="Export in debug mode")

export_parser.set_defaults(func_str="export.main")


##### PARSER FOR otter generate #####
generate_parser = subparsers.add_parser("generate", description="Generates zipfile to configure Gradescope autograder")
generate_parser.add_argument("-t", "--tests-path", nargs='?', type=str, default="./tests/", help="Path to test files")
generate_parser.add_argument("-o", "--output-path", nargs='?', type=str, default="./", help="Path to which to write zipfile")
generate_parser.add_argument("-c", "--config", nargs='?', default=None, help="Path to otter configuration file; ./otter_config.json automatically checked")
generate_parser.add_argument("-r", "--requirements", nargs='?', default=None, help="Path to requirements.txt file; ./requirements.txt automatically checked")
generate_parser.add_argument("--overwrite-requirements", default=False, action="store_true", help="Overwrite (rather than append to) default requirements for Gradescope; ignored if no REQUIREMENTS argument")
generate_parser.add_argument("-e", "--environment", nargs='?', default=None, help="Path to environment.yml file; ./environment.yml automatically checked (overwrite)")
generate_parser.add_argument("-l", "--lang", default="python", choices=["python", "r"], type=str, help="Assignment programming language; defaults to Python")
generate_parser.add_argument("--autograder-dir", nargs="?", default="/autograder", help="Root autograding directory inside grading container")
generate_parser.add_argument("--username", default=None, help="Gradescope username for generating a token")
generate_parser.add_argument("--password", default=None, help="Gradescope password for generating a token")
generate_parser.add_argument("files", nargs='*', help="Other support files needed for grading (e.g. .py files, data files)")

generate_parser.set_defaults(func_str="generate.main")


##### PARSER FOR otter grade #####
grade_parser = subparsers.add_parser("grade", description="Grade assignments locally using Docker containers")

# necessary path arguments
grade_parser.add_argument("-p", "--path", type=str, default="./", help="Path to directory of submissions")
grade_parser.add_argument("-a", "--autograder", type=str, default="./autograder.zip", help="Path to autograder zip file")
grade_parser.add_argument("-o", "--output-dir", type=str, default="./", help="Directory to which to write output")

# metadata parser arguments
grade_parser.add_argument("-g", "--gradescope", action="store_true", default=False, help="Flag for Gradescope export")
grade_parser.add_argument("-c", "--canvas", action="store_true", default=False, help="Flag for Canvas export")
grade_parser.add_argument("-j", "--json", default=False, help="Flag for path to JSON metadata")
grade_parser.add_argument("-y", "--yaml", default=False, help="Flag for path to YAML metadata")

# submission format arguments
grade_parser.add_argument("-s", "--scripts", action="store_true", default=False, help="Flag to incidicate grading Python scripts")
grade_parser.add_argument("-z", "--zips", action="store_true", default=False, help="Whether submissions are zip files from Notebook.export")

# PDF export options
grade_parser.add_argument("--pdfs", default=False, action="store_true", help="Whether to copy notebook PDFs out of containers")

# other settings and optional arguments
grade_parser.add_argument("-v", "--verbose", action="store_true", help="Flag for verbose output")
grade_parser.add_argument("--containers", type=int, help="Specify number of containers to run in parallel")
grade_parser.add_argument("--image", default="ucbdsinfra/otter-grader", help="Custom docker image to run on")
grade_parser.add_argument("--no-kill", action="store_true", default=False, help="Do not kill containers after grading")
grade_parser.add_argument("--debug", action="store_true", default=False, help="Print stdout/stderr from grading for debugging")

grade_parser.add_argument("--prune", action="store_true", default=False, help="Prune all of Otter's grading images")
grade_parser.add_argument("-f", "--force", action="store_true", default=False, help="Force action (don't ask for confirmation)")

grade_parser.set_defaults(func_str="grade.main")


###### PARSER FOR otter run #####
run_parser = subparsers.add_parser("run", description="Run non-containerized Otter on a single submission") # TODO
run_parser.add_argument("submission", help="Path to submission to be graded")
run_parser.add_argument("-a", "--autograder", default="./autograder.zip", help="Path to autograder zip file")
run_parser.add_argument("-o", "--output-dir", default="./", help="Directory to which to write output")
run_parser.add_argument("--no-logo", action="store_true", default=False, help="Suppress Otter logo in stdout")
run_parser.add_argument("--debug", default=False, action="store_true", help="Do not ignore errors when running submission")

run_parser.set_defaults(func_str="run.main")


return parser
175 changes: 175 additions & 0 deletions assign/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Assignment creation tool for Otter-Grader"""

import json
import os
import pathlib
import warnings

from .assignment import Assignment
from .utils import run_tests, write_otter_config_file, run_generate_autograder

from ..export import export_notebook
from ..export.exporters import WkhtmltopdfNotFoundError
from ..plugins import PluginCollection
from ..utils import block_print, get_relpath, knit_rmd_file


def main(master, result, *, no_pdfs=False, no_run_tests=False, username=None, password=None,
debug=False, v1=False):
"""
Runs Otter Assign on a master notebook.

Args:
master (``str``): path to master notebook
result (``str``): path to result directory
no_pdfs (``bool``): whether to ignore any configurations indicating PDF generation for this run
no_run_tests (``bool``): prevents Otter tests from being automatically run on the solutions
notebook
username (``str``): a username for Gradescope for generating a token
password (``str``): a password for Gradescope for generating a token
debug (``bool``): whether to run in debug mode (without ignoring errors during testing)
v1 (``bool``): whether to use Otter Assign Format v1 instead of v0
"""
if not v1:
warnings.warn(
"Otter Assign format v0 will be deprecated in Otter v4 and removed in a later release.",
FutureWarning)

from .v0 import main as v0_main
return v0_main(master, result, no_pdfs=no_pdfs, no_run_tests=no_run_tests, username=username,
password=password, debug=debug)

master, result = pathlib.Path(os.path.abspath(master)), pathlib.Path(os.path.abspath(result))
print("Generating views...")

assignment = Assignment()

result = get_relpath(master.parent, result)
orig_dir = os.getcwd()
os.chdir(master.parent)

assignment.master, assignment.result = master, result

if assignment.is_rmd:
from .rmarkdown_adapter.output import write_output_directories
else:
from .output import write_output_directories

try:
output_nb_path = write_output_directories(master, result, assignment)

# update seed variables
if isinstance(assignment.seed, dict):
if assignment.generate:
if assignment.generate is True:
assignment.generate = {}
assignment.generate["seed"] = assignment.seed["autograder_value"]
assignment.generate["seed_variable"] = assignment.seed["variable"]

# check that we have a seed if needed
if assignment.seed_required:
generate_args = assignment.generate
if generate_args is True:
generate_args = {'seed': None}
assert not generate_args or generate_args.get('seed', None) is not None or \
not assignment.is_python, "Seeding cell found but no seed provided"

plugins = assignment.plugins
if plugins:
pc = PluginCollection(plugins, "", {})
pc.run("during_assign", assignment)
if assignment.generate is True:
assignment.generate = {"plugins": []}
if assignment.generate is not False:
if not assignment.generate.get("plugins"):
assignment.generate["plugins"] = []
assignment.generate["plugins"].extend(plugins)
else:
pc = None

# generate Gradescope autograder zipfile
if assignment.generate:
print("Generating autograder zipfile...")
run_generate_autograder(result, assignment, username, password, plugin_collection=pc)

# generate PDF of solutions
if assignment.solutions_pdf and not no_pdfs:
print("Generating solutions PDF...")
filtering = assignment.solutions_pdf == 'filtered'

src = os.path.abspath(str(result / 'autograder' / master.name))
dst = os.path.abspath(str(result / 'autograder' / (master.stem + '-sol.pdf')))

if not assignment.is_rmd:
try:
export_notebook(
src,
dest=dst,
filtering=filtering,
pagebreaks=filtering,
exporter_type="html",
)
except WkhtmltopdfNotFoundError:
export_notebook(
src,
dest=dst,
filtering=filtering,
pagebreaks=filtering,
)

else:
if filtering:
raise ValueError("Filtering is not supported with RMarkdown assignments")

knit_rmd_file(src, dst)

# generate a tempalte PDF for Gradescope
if assignment.template_pdf and not no_pdfs:
print("Generating template PDF...")

src = os.path.abspath(str(result / 'autograder' / master.name))
dst = os.path.abspath(str(result / 'autograder' / (master.stem + '-template.pdf')))

if not assignment.is_rmd:
export_notebook(
src,
dest=dst,
filtering=True,
pagebreaks=True,
exporter_type="latex",
)

else:
raise ValueError(f"Filtering is not supported with RMarkdown assignments; use " + \
"solutions_pdf to generate a Gradescope template instead.")

# generate the .otter file if needed
if not assignment.is_rmd and assignment.save_environment:
if assignment.is_r:
warnings.warn(
"Otter Service and serialized environments are unsupported with R, "
"configurations ignored"
)
else:
write_otter_config_file(master, result, assignment)

# run tests on autograder notebook
if assignment.run_tests and not no_run_tests and assignment.is_python:
print("Running tests...")
# with block_print():
if isinstance(assignment.generate, bool):
seed = None
else:
seed = assignment.generate.get('seed', None)

if assignment._otter_config is not None:
test_pc = PluginCollection(assignment._otter_config.get("plugins", []), output_nb_path, {})

else:
test_pc = pc

run_tests(result / 'autograder' / master.name, debug=debug, seed=seed, plugin_collection=test_pc)
print("All tests passed!")

finally:
os.chdir(orig_dir)
Binary file added assign/__pycache__/__init__.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/assignment.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/blocks.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/cell_generators.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/constants.cpython-39.pyc
Binary file not shown.
Binary file not shown.
Binary file added assign/__pycache__/output.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/plugins.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/questions.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/solutions.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/tests.cpython-39.pyc
Binary file not shown.
Binary file added assign/__pycache__/utils.cpython-39.pyc
Binary file not shown.
Loading