diff --git a/.gitignore b/.gitignore index c65f5612d12e1e54466b6173c01d30abba6e380b..abc52eaa38d029223acedaf0b2c9720ecb564bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # editors configurations travo.code-workspace .idea +code/artifacts.zip +forkedRepositories/froidevaux.pierre_thomas/activity.json .vscode/settings.json *~ @@ -14,6 +16,9 @@ forkedRepositories/* .gitlab-ci-local* code/artifacts.zip +# local git tests +.gitlab-ci-local* + # local rendered documentation docs/build .spyproject diff --git a/travo/jupyter_course.py b/travo/jupyter_course.py index 68ce96affd9a947d1a8b7a39c9978b03b87431da..2a459b57afdf22f2f24b931dcd52ffd6c296ce41 100644 --- a/travo/jupyter_course.py +++ b/travo/jupyter_course.py @@ -22,6 +22,11 @@ from .assignment import SubmissionStatus from .course import Course from travo.i18n import _ from .utils import run +from .nbgrader_utils import ( + remove_submission_gradebook, + Gradebook, + merge_assignment_gradebook, +) if TYPE_CHECKING: from . import dashboards @@ -190,7 +195,7 @@ class JupyterCourse(Course): import jupytext # type: ignore for mdname in glob.glob(os.path.join(path, "*.md")): - with io.open(mdname) as fd: + with io.open(mdname, encoding="utf-8") as fd: if "nbgrader" not in fd.read(): self.log.debug( "Skip markdown file/notebook with no nbgrader metadata:" @@ -198,10 +203,15 @@ class JupyterCourse(Course): ) continue ipynbname = mdname[:-3] + ".ipynb" - self.log.info(f"Converting {mdname} to {ipynbname}") - notebook = jupytext.read(mdname) - jupytext.write(notebook, ipynbname) - # run(["jupytext", mdname, "--to ipynb"]) + if not os.path.exists(ipynbname): + self.log.info(f"Converting {mdname} to {ipynbname}") + notebook = jupytext.read(mdname) + jupytext.write(notebook, ipynbname) + else: + self.log.info(f"{ipynbname} already exists") + # ensure user writing permission + os.chmod(ipynbname, 0o644) + os.chmod(mdname, 0o644) self.log.info("Updating cross-links to other notebooks (.md->.ipynb)") with open(ipynbname, "r") as file: filedata = file.read() @@ -216,9 +226,15 @@ class JupyterCourse(Course): for ipynbname in glob.glob(os.path.join(path, "*.ipynb")): mdname = ipynbname[:-6] + ".md" - self.log.info(f"Converting {ipynbname} to {mdname}") - notebook = jupytext.read(ipynbname) - jupytext.write(notebook, mdname) + if not os.path.exists(mdname): + self.log.info(f"Converting {ipynbname} to {mdname}") + notebook = jupytext.read(ipynbname) + jupytext.write(notebook, mdname) + else: + self.log.info(f"{mdname} already exists") + # ensure user writing permission + os.chmod(ipynbname, 0o644) + os.chmod(mdname, 0o644) self.log.info("Updating cross-links to other notebooks (.ipynb->.md)") with open(mdname, "r") as file: filedata = file.read() @@ -247,13 +263,26 @@ class JupyterCourse(Course): release_path = assignment.release_path() self.convert_from_md_to_ipynb(path=source_path) with tempfile.TemporaryDirectory() as tmpdirname: - gitdir = os.path.join(release_path, ".git") - tmpgitdir = os.path.join(tmpdirname, ".git") db = os.path.join(release_path, ".gradebook.db") - self.log.info("Sauvegarde de l'historique git") - shutil.move(gitdir, tmpgitdir) + gitdir = os.path.join(release_path, ".git") + is_git = os.path.exists(gitdir) + if is_git: + tmpgitdir = os.path.join(tmpdirname, ".git") + self.log.info("Sauvegarde de l'historique git") + shutil.move(gitdir, tmpgitdir) assignment_basename = os.path.basename(assignment_name) try: + run( + [ + "nbgrader", + "generate_assignment", + "--force", + f"--CourseDirectory.source_directory={self.source_directory}", + f"--CourseDirectory.release_directory={self.release_directory}", + assignment_basename, + "--db='sqlite:///.gradebook.db'", + ] + ) run( [ "nbgrader", @@ -265,14 +294,20 @@ class JupyterCourse(Course): f"--db='sqlite:///{db}'", ] ) + self.log.info(f"Importation de {db} dans .gradebook.db") + # merge_assignment_gradebook( + # source=Gradebook(f"sqlite:///{db}"), + # target=Gradebook("sqlite:///.gradebook.db"), + # ) self.convert_from_ipynb_to_md(path=release_path) except (): pass finally: - self.log.info("Restauration de l'historique git") - # In case the target_path has been destroyed and not recreated - os.makedirs(release_path, exist_ok=True) - shutil.move(tmpgitdir, gitdir) + if is_git: + self.log.info("Restauration de l'historique git") + # In case the target_path has been destroyed and not recreated + os.makedirs(release_path, exist_ok=True) + shutil.move(tmpgitdir, gitdir) if add_gitlab_ci and self.gitlab_ci_yml is not None: io.open(os.path.join(release_path, ".gitlab-ci.yml"), "w").write( self.gitlab_ci_yml.format(assignment=assignment_basename) @@ -328,10 +363,24 @@ class JupyterCourse(Course): nbgrader_config.append("--CourseDirectory.submitted_directory=submitted") return nbgrader_config - def autograde(self, assignment_name: str, tag: str) -> None: + def nbgrader_autograde(self, assignment_name: str, tag: str = "*") -> None: """ - Autograde the assignment for the given student. The student name can be given - with wildcard. + Run nbgrader autograde for the assignment of the given student. + Student submissions must follow nbgrader convention and be in `submitted` directory. + Student notebooks can be either md or ipynb files. + + The student name can be given with wildcard. + + Examples: + + Autograde all students submissions in the submitted directory:: + + course.nbgrader_autograde("Assignment1") + + Autograde submissions for a given student: + + course.nbgrader_autograde("Assignment1", + tag="firstname.lastname") """ run(["nbgrader", "--version"]) assignment = os.path.basename(assignment_name) @@ -354,6 +403,156 @@ class JupyterCourse(Course): ] ) + def local_autograde(self, assignment_name: str, tag: str = "*") -> None: + """ + Autograde the assignment for the given student locally in the autograded folder. + Student submissions must follow nbgrader convention and be in `submitted` directory. + Gradebooks .gradebook.db files are produced in the `autograded` folder. + + The student name can be given with wildcard. + + Examples: + + Autograde all students submissions in the submitted directory:: + + course.local_autograde("Assignment1") + + Autograde submissions for a given student: + + course.local_autograde("Assignment1", + tag="firstname.lastname") + """ + run(["nbgrader", "--version"]) + assignment = os.path.basename(assignment_name) + self.nbgrader_update_student_list(tag=tag) + for student_id in os.listdir("submitted"): + if tag.replace("*", "") not in student_id: + continue + self.convert_from_md_to_ipynb( + os.path.join("submitted", student_id, assignment) + ) + self.convert_from_md_to_ipynb(assignment) + cwd = os.getcwd() + os.makedirs("autograded", exist_ok=True) + for submitted_dir in sorted( + glob.glob(f"submitted/{tag}/{os.path.basename(assignment_name)}") + ): + student = submitted_dir.split("/")[1] + autograded_dir = os.path.join( + "autograded", student, os.path.basename(assignment_name) + ) + # make a full copy of submitted dir, including .git repo + shutil.rmtree(autograded_dir, ignore_errors=True) + os.makedirs(autograded_dir, exist_ok=True) + shutil.copytree(submitted_dir, autograded_dir, dirs_exist_ok=True) + # make student_autograde as in CI + os.chdir(submitted_dir) + self.student_autograde(assignment_name=assignment_name, student=student) + # get autograded files result + shutil.copytree( + autograded_dir, os.path.join(cwd, autograded_dir), dirs_exist_ok=True + ) + # clean the submitted repo from nbgrader artifacts and leave the submitted repo + for subdir in ["submitted", "autograded", "feedback", "feedback_generated"]: + shutil.rmtree(subdir) + os.chdir(cwd) + self.collect_autograded_post( + assignment_name=os.path.basename(assignment_name), + tag=tag, + autograded=True, + on_inconsistency="ERROR", + new_score_policy="only_empty", + ) + + def forge_autograde( + self, + assignment_name: str, + tag: str = "*", + new_score_policy: str = "only_empty", + ) -> None: + """ + Autograde the student's assignments directly on the forge + + Submit the corrected assignment from submitted, wait for the student_autograde + and collect the student gradebook. + + Examples: + + Autograde all students submissions in the current directory:: + + course.forge_autograde("Assignment1") + + Autograde all students submissions for a given student: + + course.forge_autograde("Assignment1", + tag="firstname.lastname") + """ + failed = [] + self.forge.login() + for assignment_dir in glob.glob( + f"submitted/{tag}/{os.path.basename(assignment_name)}" + ): + student = assignment_dir.split("/")[1] + # get student group + project = self.assignment(assignment_name, username=student) + submission = project.submission() + assert ( + submission.repo.forked_from_project is not None + ), "a student assignment should be a fork" + student_group = submission.repo.forked_from_project.namespace.name + self.log.info(f"Student: {student} (group {student_group})") + remove_submission_gradebook( + Gradebook("sqlite:///.gradebook.db"), + os.path.basename(assignment_name), + student, + ) + # re-submit the submitted + self.log.info("- Enregistrement des changements:") + self.forge.ensure_local_git_configuration(dir=os.getcwd()) + if ( + self.forge.git( + [ + "commit", + "--all", + "-m", + f"Correction par {self.forge.get_current_user().username}", + ], + check=False, + cwd=assignment_dir, + ).returncode + != 0 + ): + self.log.info(" Pas de changement à enregistrer") + + self.log.info("- Envoi des changements:") + branch = submission.repo.default_branch + url = str(submission.repo.web_url) + self.forge.git(["push", url, branch], cwd=assignment_dir) + # Force an update of origin/master (or whichever the origin default branch) + # self.forge.git(["update-ref", f"refs/remotes/origin/{branch}", branch]) + self.log.info( + f"- Nouvelle soumission effectuée. " + f"Vous pouvez consulter le dépôt: {url}" + ) + # autograde + try: + job = submission.ensure_autograded(force_autograde=True) + except RuntimeError as e: + self.log.warning(e) + failed.append(assignment_dir) + continue + # collect gradebooks + self.log.info(f"fetch autograded for {assignment_dir}") + submission.repo.fetch_artifacts(job, path=".", prefix="") + self.merge_autograded_db( + assignment_name=os.path.basename(assignment_name), + tag=tag, + on_inconsistency="WARNING", + new_score_policy=new_score_policy, + ) + if failed: + self.log.warning(f"Failed autograde: {' '.join(failed)}") + def generate_feedback( self, assignment_name: str, tag: str = "*", new_score_policy: str = "only_empty" ) -> None: @@ -587,6 +786,26 @@ class JupyterCourse(Course): if failed: self.log.warning(f"Failed force autograde: {' '.join(failed)}") + def collect_in_submitted( + self, assignment_name: str, student_group: Optional[str] = None + ) -> None: + """ + Collect the student's submissions following nbgrader's standard organization + and convert markdown into ipynb notebooks. + + This wrapper for `collect`: + - forces a login; + - reports more information to the user (at a cost); + - stores the output in the subdirectory `submitted/`, + following nbgrader's standard organization. + + This is used by the course dashboard. + """ + super().collect_in_submitted(assignment_name, student_group) + self.convert_from_md_to_ipynb( + path=f"submitted/*/{os.path.basename(assignment_name)}" + ) + def collect_status( self, assignment_name: str, student_group: Optional[str] = None ) -> List[SubmissionStatus]: @@ -742,7 +961,6 @@ class JupyterCourse(Course): from nbgrader.api import Gradebook, MissingEntry from .nbgrader_utils import ( merge_submission_gradebook, - merge_assignment_gradebook, ) target = Gradebook("sqlite:///.gradebook.db") diff --git a/travo/nbgrader_utils.py b/travo/nbgrader_utils.py index a47e34ed55edb6e2971760c667079a08a3f5cbee..24c52bd3789b77d07b357af1b941d71de7f6716a 100644 --- a/travo/nbgrader_utils.py +++ b/travo/nbgrader_utils.py @@ -27,6 +27,9 @@ def merge_assignment_gradebook(source: Gradebook, target: Gradebook) -> None: for notebook in assignment.notebooks: args, kwargs = to_args(notebook, ["name"]) + # kernelspec must be specified for nbgrader autograde + if notebook.kernelspec is not None: + kwargs["kernelspec"] = notebook.kernelspec target.update_or_create_notebook(*args, assignment.name, **kwargs) for cell in notebook.grade_cells: args, kwargs = to_args(cell, ["name", "notebook", "assignment"])