From 1d8edf972fab95f8c8d2ad873c10d7a7f333f3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Thu, 22 Aug 2024 10:19:36 +0200 Subject: [PATCH 01/24] Draft: Student dashboard: form dialogs, new actions and more natural UI for student groups --- conftest.py | 29 ++- travo/assignment.py | 230 +++++++++++++-------- travo/course.py | 87 ++++++-- travo/dashboards.py | 341 ++++++++++++++++++++++++++------ travo/gitlab.py | 90 +++++---- travo/jupyter_course.py | 12 +- travo/locale/travo.fr.yml | 23 ++- travo/nbgrader_utils.py | 6 +- travo/tests/test_assignement.py | 23 ++- travo/tests/test_utils.py | 2 + 10 files changed, 623 insertions(+), 220 deletions(-) diff --git a/conftest.py b/conftest.py index 6c080411..5e3a3463 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,7 @@ import datetime import os.path import pytest # type: ignore import random +import shutil import string from typing import Iterator @@ -138,11 +139,31 @@ def standalone_assignment_dir(tmp_path: str, test_run_id: str) -> str: @pytest.fixture def standalone_assignment_submission( - standalone_assignment: Assignment, + standalone_assignment: Assignment, tmp_path: str ) -> Iterator[Project]: - project = standalone_assignment.ensure_submission_repo() - yield project - standalone_assignment.forge.remove_project(project.path_with_namespace, force=True) + assignment_dir = os.path.join(tmp_path, "tmpclone") + submission_repo = standalone_assignment.ensure_submission_repo() + # ensure_submission_repo does not copy the repository content + # so we still need to do that manually + forge = standalone_assignment.forge + branch = standalone_assignment.repo().default_branch + forge.git( + [ + "clone", + standalone_assignment.repo().http_url_with_base_to_repo(), + assignment_dir, + ], + cwd=tmp_path, + ) + forge.git( + ["push", submission_repo.http_url_with_base_to_repo(), branch], + cwd=assignment_dir, + ) + shutil.rmtree(assignment_dir) + yield standalone_assignment.submission() + standalone_assignment.forge.remove_project( + submission_repo.path_with_namespace, force=True + ) @pytest.fixture diff --git a/travo/assignment.py b/travo/assignment.py index 9446106c..5b3edc56 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -5,12 +5,13 @@ import os import socket import subprocess import time -from typing import Any, Dict, List, Optional, Tuple, Union, cast +from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast from .gitlab import ( anonymous_user, Forge, Job, Project, + Resource, ResourceRef, ResourceNotFoundError, User, @@ -165,8 +166,8 @@ class Assignment: >>> assignment = getfixture("standalone_assignment") >>> assignment.get_submission_username(assignment.repo()) - >>> project = getfixture("standalone_assignment_submission") - >>> assignment.get_submission_username(project) + >>> submission = getfixture("standalone_assignment_submission") + >>> assignment.get_submission_username(submission.repo) 'travo-test-etu' """ if project.path_with_namespace == self.repo_path: @@ -176,10 +177,10 @@ class Assignment: ########################################################################## - def submission_repo(self) -> Project: - return self.forge.get_project(self.submission_path()) + def submission_repo(self, username: Optional[str] = None) -> Project: + return self.forge.get_project(self.submission_path(username=username)) - def has_submission(self) -> bool: + def has_submission(self, username: Optional[str] = None) -> bool: """ Return whether the user already has a submission for this assignment @@ -196,7 +197,7 @@ class Assignment: False """ try: - self.submission_repo() + self.submission_repo(username=username) return True except ResourceNotFoundError: return False @@ -207,6 +208,9 @@ class Assignment: Creating it and configuring it if needed. + Caveat: this does not initialize the repository content from + the assignment upon creation. + Example:: >>> assignment = getfixture("standalone_assignment") @@ -433,7 +437,7 @@ class Assignment: {self.script} fetch {assignment_dir} """ ) - raise RuntimeError("missing assignment directory") + raise RuntimeError(_("missing assignment directory")) if not os.path.isdir(assignment_dir): self.log.error( f"""Un fichier ou répertoire {assignment_dir} existe @@ -443,84 +447,108 @@ class Assignment: ) raise RuntimeError("corrupted assignment directory") - def fetch_branch( + def merge_from( self, + source: Project, assignment_dir: str, - branch: Optional[str], - content: str, - on_failure: str = "error", - ) -> None: + branch: Optional[str] = None, + content: str = "", + on_failure: Literal["warning", "error"] = "error", + ) -> bool: """ - Fetch from a branch of the assignment repository + Try to merge content in the assignment directory + + Parameters + ---------- + assignment_dir : str, optional + Path to directory. The default is None. - If branch_name is None, the default branch is used. + source: Project, optional + The repository from which to merge content. The default is + the assignment repository. - This will try to merge the branch `branch` of the assignment - repository (if it exists) into the local clone. In case of - merge conflict, the local clone is rolled back to its original - state, and a warning or error is emmitted depending on the - value of `on_failure`. + branch: str, optional + The branch of the source repository from which to merge content. + The default is the default branch of the source repository. + If the branch does not exist, report and return False. - Assumption: `assignment_dir` is a clone of the assignment + A commit is done before attempting the merge. In case of merge + conflict, the assignment directory is rolled back to its + original state, and a warning or error is emmitted depending + on the value of `on_failure`. + + Return whether the merge was successful (or no merge was needed). + + Assumptions: `assignment_dir` is a clone of the assignment repository, with a local git configuration (see :ref:`gitlab.ensure_local_git_configuration`). - """ - self.log.info(f"Intégration des {content} depuis le dépot d'origine") + msg = _("merging", content=content) + self.log.info(msg) - source = self.repo() if branch is None: branch = source.default_branch if not any(b["name"] == branch for b in source.get_branches()): - self.log.info(f"Pas de {content} pour ce devoir") - return + self.log.info(_("no content", content=content)) + return True # self.check_assignment("$assignment") # Share this with fetch def git(args: List[str], **kwargs: Any) -> subprocess.CompletedProcess: return self.forge.git(args, cwd=assignment_dir, **kwargs) - self.log.info("- Sauvegarde préalable:") + save_msg = _("save before", what=msg) + self.log.info("- " + save_msg) git( - ["commit", "--all", "-m", f"Sauvegarde avant intégration des {content}"], + ["commit", "--all", "-m", save_msg], check=False, ) - self.log.info("- Téléchargement:") + self.log.info("- " + _("download")) git(["fetch", source.http_url_with_base_to_repo(), branch]) + # TODO: nice error message in case of failure + # - Échec au téléchargement des mises à jour" # travo_raise_error f"Échec au téléchargement des {content}" - self.log.info("- Tentative d'intégration:") - if ( - git( - ["merge", "-m", f"Intégration des {content}", "FETCH_HEAD"], check=False - ).returncode - != 0 - ): + self.log.info("- " + _("try to merge")) + if git(["merge", "-m", msg, "FETCH_HEAD"], check=False).returncode != 0: git(["merge", "--abort"]) - message = _("abandon failing merge", content=content) + message = _("abort conflicting merge", content=content) if on_failure == "warning": self.log.warning(message) + return False else: assert on_failure == "error" raise RuntimeError(message) + return True - def fetch(self, assignment_dir: Optional[str] = None, force: bool = False) -> None: + def fetch( + self, + assignment_dir: Optional[str] = None, + username: Optional[str] = None, + force: bool = False, + ) -> None: """ - Fetch the given assignment + Fetch (i.e. download or update) the assignment The user must have an account on the forge: - to check whether the user has a submission for this assignment - to setup the local git configuration - If logged in as `anonymous`, fetch will ignore any personnal - repository the user may have, will skip the git configuration - and won't fetch updates or erratas. We could try harder on - this last point if there is a use case for it. + If logged in as `anonymous`, `fetch` ignores any personnal + repository the user may have, skips the git configuration and + won't fetch updates or erratas. We could try harder on this + last point if there is a use case for it. Parameters ---------- assignment_dir : str, optional - Path to directory. The default is None. + Path to directory. By default, use the assignment + directory defined in this assignment. + + username : str, optional + If set, the assignment will be fetched from + the submission of `username` instead + force : bool, optional If True, create a backup of existing local repo and forces download of remote repo. The default is False. @@ -528,7 +556,6 @@ class Assignment: Returns ------- None - """ self.forge.login(anonymous_ok=True) user = self.forge.get_current_user() @@ -543,26 +570,17 @@ class Assignment: if not os.path.exists(assignment_dir): self.assignment_clone(assignment_dir) - elif user is not anonymous_user and self.has_submission(): - submission_repo = self.forge.get_project(self.submission_path()) + elif user is not anonymous_user and self.has_submission(username=username): + submission_repo = self.submission_repo(username=username) self.ensure_clone_configuration(assignment_dir) - self.log.info("Intégration des mises à jour depuis votre dépot personnel") - self.log.info("- Sauvegarde préalable:") - git(["commit", "--all", "-m", "Sauvegarde avant mise à jour"], check=False), - self.log.info("- Téléchargement:") - # TODO: nice error message in case of failure - # - Échec au téléchargement des mises à jour" - git(["fetch", submission_repo.http_url_to_repo]) - self.log.info("- Tentative d'intégration:") - - if ( - git( - ["merge", "-m", "Intégration des mises à jour", "FETCH_HEAD"], - check=False, - ).returncode - != 0 - ): - git(["merge", "--abort"]) + success = self.merge_from( + source=submission_repo, + assignment_dir=assignment_dir, + content=_("updates from submission"), + on_failure="warning", + ) + + if not success: if force: now = datetime.datetime.now().isoformat(sep="_", timespec="minutes") # Something special needs to be done for "." @@ -589,12 +607,18 @@ class Assignment: ) raise RuntimeError(_("fetch failed conflict")) if user is not anonymous_user: + # Currently anonymous users won't get updates, as + # merge_from may require a commit which requires git to be + # configured. We could try harder if there is a use case + # for it. self.forge.ensure_local_git_configuration(assignment_dir) - # fetch_branch may require a commit which require git to be configured - # we could try harder if there is a use case for it - self.fetch_branch(assignment_dir, branch=None, content=_("updates")) - self.fetch_branch( - assignment_dir, + repo = self.repo() + self.merge_from( + source=repo, assignment_dir=assignment_dir, content=_("updates") + ) + self.merge_from( + source=repo, + assignment_dir=assignment_dir, branch="errata", content=_("erratas"), on_failure="warning", @@ -783,6 +807,49 @@ class Assignment: for url in bad_projects: self.log.warning(url) + def share_with( + self, + username: str, + access_level: Union[ + int, Resource.AccessLevels + ] = Resource.AccessLevels.DEVELOPER, + ) -> None: + """ + Grant the given user access to the submission repository + """ + try: + repo = self.submission_repo() + except ResourceNotFoundError: + raise RuntimeError( + _("no submission; please submit", assignment_name=self.name) + ) + user = self.forge.get_user(username) + repo.share_with(user, access=access_level) + + def ensure_main_submission(self, leader_name: str) -> None: + try: + repo = self.submission_repo() + except ResourceNotFoundError: + raise RuntimeError( + _("no submission; please submit", assignment_name=self.name) + ) + if leader_name == self.get_username(): + raise RuntimeError( + _("cannot set self as leader", assignment_name=self.name) + ) + + try: + main_repo = self.submission_repo(username=leader_name) + except ResourceNotFoundError: + raise RuntimeError( + _( + "no main submission", + assignment_name=self.name, + leader_name=leader_name, + ) + ) + repo.ensure_is_fork_of(main_repo) + # Only consider running or finished jobs # this excludes: 'canceled', 'skipped', 'manual', @@ -981,23 +1048,22 @@ class Submission: second entry is a dictionary mapping usernames of team mates to their submissions. """ - repo = self.repo + # Fetch the repo and user name of the leader forked_from_project = self.repo.forked_from_project - if forked_from_project is None: - raise ValueError( - "Team information not available " - "for a project with no forked_from information" - ) - leader_name = self.assignment.get_submission_username(forked_from_project) - if leader_name is not None: - repo = forked_from_project + if forked_from_project is not None: + leader_repo = forked_from_project + leader_name = self.assignment.get_submission_username(leader_repo) else: - leader_name = self.assignment.get_submission_username(repo) + leader_name = None + if leader_name is None: + leader_repo = self.repo + leader_name = self.assignment.get_submission_username(leader_repo) assert leader_name is not None - team = {leader_name: repo} - if repo.forks_count > 0: - for fork in repo.get_forks(simple=True): + # Fetch the team information + team = {leader_name: leader_repo} + if leader_repo.forks_count > 0: + for fork in leader_repo.get_forks(simple=True): username = self.assignment.get_submission_username(fork) assert username is not None team[username] = fork diff --git a/travo/course.py b/travo/course.py index 9077528a..87aae90a 100755 --- a/travo/course.py +++ b/travo/course.py @@ -15,7 +15,7 @@ from datetime import datetime from dataclasses import dataclass, field import re import subprocess -from typing import List, Optional, Tuple, Union, Any, TYPE_CHECKING +from typing import List, Any, Dict, Optional, Tuple, Union, TYPE_CHECKING from .utils import getLogger, run from .gitlab import ( @@ -58,6 +58,18 @@ forbidden_chars_in_name = re.compile(r"[^-+_. \w0-9]") forbidden_chars_in_path = re.compile("[^-A-Za-z0-9]") +class MissingInformationError(RuntimeError): + """ + An operation could not be executed due to missing information. + + The missing information can be recovered from the `missing` attribute. + """ + + def __init__(self, message: str, missing: Dict[str, Any]) -> None: + super().__init__(message) + self.missing = missing + + @dataclass class CourseAssignment(Assignment): # Until Python 3.10 and keyword only fields, a subdataclass @@ -205,23 +217,55 @@ class CourseAssignment(Assignment): else: return " - ".join(components[1:]) + def submit( + self, + assignment_dir: Optional[str] = None, + leader_name: Optional[str] = None, + student_group: Optional[str] = None, + ) -> None: + # Temporarily set the student group for the time of the submission. + # In the long run the logic for handling student groups should be + # reworked to not be stateful anymore, and instead passed down as + # argument to the repository creation + old_student_group = self.student_group + self.student_group = student_group + try: + super().submit(assignment_dir=assignment_dir, leader_name=leader_name) + finally: + self.student_group = old_student_group + def ensure_submission_repo(self, leader_name: Optional[str] = None) -> Project: """ Return the submission repository for this assignment Creating it and configuring it if needed. - """ if self.course.group_submissions: - # Ensure the existence of the nested GitLab groups holding the submission - path_components = self.submission_path_components() - name_components = self.submission_name_components() - current_path = "" - for path, name in list(zip(path_components, name_components))[:-1]: - if current_path: - current_path += "/" - current_path += path - self.forge.ensure_group(current_path, name=name, visibility="private") + + def ensure_group_recursive( + path_components: Tuple[str, ...], name_components: Tuple[str, ...] + ) -> None: + """ + Ensure the existence of the nested GitLab groups holding the submission + + It is assumed that if the group or its super groups exist, + they have the correct names and visibility + """ + assert len(path_components) == len(name_components) + if not path_components: + return + path = "/".join(path_components) + try: + self.course.forge.get_group(path) + except ResourceNotFoundError: + ensure_group_recursive(path_components[:-1], name_components[:-1]) + name = name_components[-1] + self.forge.ensure_group(path, name=name, visibility="private") + + ensure_group_recursive( + self.submission_path_components()[:-1], + self.submission_name_components()[:-1], + ) return super().ensure_submission_repo(leader_name=leader_name) def submissions_forked_from_path(self) -> Union[str, Unknown]: @@ -605,8 +649,10 @@ class Course: _("specify group", student_groups=", ".join(self.student_groups)) + "\n" ) # message += _('help', script=self.script) + missing = {"student_group": tuple(self.student_groups)} - raise RuntimeError(message) + exception = MissingInformationError(message, missing=missing) + raise exception def check_subcourse(self, subcourse: Optional[str], none_ok: bool = False) -> None: """ @@ -756,7 +802,11 @@ class Course: assignment_name, student_group=student_group, leader_name=leader_name ) assignment_dir = self.work_dir(assignment_name) - the_assignment.submit(assignment_dir=assignment_dir, leader_name=leader_name) + the_assignment.submit( + assignment_dir=assignment_dir, + student_group=student_group, + leader_name=leader_name, + ) def share_with( self, @@ -766,14 +816,9 @@ class Course: int, Resource.AccessLevels ] = Resource.AccessLevels.DEVELOPER, ) -> None: - try: - repo = self.assignment(assignment_name).submission_repo() - except ResourceNotFoundError: - raise RuntimeError( - _("no submission; please submit", assignment_name=assignment_name) - ) - user = self.forge.get_user(username) - repo.share_with(user, access=access_level) + return self.assignment(assignment_name).share_with( + username=username, access_level=access_level + ) def release( self, assignment_name: str, visibility: str = "public", path: str = "." diff --git a/travo/dashboards.py b/travo/dashboards.py index ef231004..599d6fe7 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -21,7 +21,7 @@ import os import subprocess import requests from threading import Thread -from typing import Any, Callable, Dict, Optional, List, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple try: from ipywidgets import ( # type: ignore @@ -51,11 +51,11 @@ from IPython.display import display, Javascript # type: ignore from .assignment import Assignment -from .gitlab import GitLab, AuthenticationError, ResourceNotFoundError, unknown +from .gitlab import GitLab, AuthenticationError, ResourceNotFoundError from .utils import run from travo.i18n import _ -from .course import Course, CourseAssignment +from .course import Course, CourseAssignment, MissingInformationError from .jupyter_course import JupyterCourse # TODO: should use the current foreground color rather than black @@ -173,12 +173,104 @@ class AuthenticationWidget(VBox): raise AuthenticationError(message) +class FormDialog(ipywidgets.HBox): + def __init__(self) -> None: + super().__init__([]) + self.on_cancel: Optional[Callable] = None + self.validate_button = ipywidgets.Button( + description=_("validate"), button_style="success" + ) + self.cancel_button = ipywidgets.Button( + description=_("cancel"), button_style="danger" + ) + self.validate_button.on_click(self.validate) + self.cancel_button.on_click(self.cancel) + self.cancel() + + def open_dialog( + self, + input_widgets: List, + on_validate: Callable, + on_cancel: Optional[Callable] = None, + ) -> None: + self.cancel() # in case a dialog is currently open, cancel it + self.children = [*input_widgets, self.cancel_button, self.validate_button] + self.layout.display = "" + self.on_cancel = on_cancel + self.on_validate = on_validate + + def request_information( + self, + input: Dict[str, Any], + on_validate: Callable, + on_cancel: Optional[Callable] = None, + confirm: bool = False, + ) -> None: + def make_widget(T: type | tuple) -> ipywidgets.Widget: + if T is bool: + return ipywidgets.Checkbox() + if T is str: + return ipywidgets.Text() + if isinstance(T, tuple): + return ipywidgets.Dropdown(options=T) + raise ValueError(f"Don't know how to produce a widget for type {T}") + + widgets = {key: make_widget(value) for key, value in input.items()} + input_widgets = [] + if confirm: + self.validate_button.disabled = True + confirmUI = ipywidgets.Checkbox() + + def on_confirmed(_: Any) -> None: + self.validate_button.disabled = not confirmUI.value + + confirmUI.observe(on_confirmed, names="value") + input_widgets.extend([ipywidgets.Label(_("confirm")), confirmUI]) + + for key, widget in widgets.items(): + input_widgets.extend([ipywidgets.Label(_(key)), widget]) + + def c() -> None: + on_validate(**{key: widget.value for key, widget in widgets.items()}) + + self.open_dialog( + input_widgets, + on_validate=c, + on_cancel=on_cancel, + ) + + def validate(self, button: Any = None) -> None: + self.validate_button.disabled = True + try: + self.on_validate() + except Exception: + pass + else: + self.close_dialog() + finally: + self.validate_button.disabled = False + + def cancel(self, button: Any = None) -> None: + if self.on_cancel is not None: + self.on_cancel() + self.on_cancel = None + self.close_dialog() + + def close_dialog(self) -> None: + self.children = [] + self.layout.display = "none" + + class StatusBar(VBox): def __init__( - self, authentication_widget: AuthenticationWidget, log: logging.Logger + self, + authentication_widget: AuthenticationWidget, + log: logging.Logger, + form_dialog: Optional[FormDialog] = None, ) -> None: minimize_layout = {"flex": "0 0 content"} self.authentication_widget = authentication_widget + self.form_dialog = form_dialog self.statusUI = Label(_("ready"), layout={"flex": "1 0 content"}) self.log_show_UI = ToggleButton( description=_("show log details"), @@ -231,26 +323,62 @@ class StatusBar(VBox): self, action: str, command: Callable, - kwargs: dict = {}, + kwargs: Dict[str, Any] = {}, + input: Dict[str, Any] = {}, + confirm: bool = False, on_finished: Optional[Callable] = None, on_success: Optional[Callable] = None, ) -> None: + if self.form_dialog is not None: + self.form_dialog.cancel() self.statusUI.value = action + ": " + _("ongoing") - exit_status = _("failed") # in case of unexpected exception - success = False + + def finish(exit_status: str) -> None: + if on_finished is not None: + on_finished() + self.statusUI.value = action + ": " + exit_status + + def request_information(input: Dict[str, Any], confirm: bool = False) -> None: + assert self.form_dialog is not None + self.statusUI.value = action + ": " + _("waiting for information") + + def on_validate(**information: Any) -> None: + self.run( + action=action, + command=command, + kwargs={**kwargs, **information}, + on_finished=on_finished, + on_success=on_success, + ) + + def on_cancel() -> None: + finish(exit_status=_("canceled")) + + self.form_dialog.request_information( + input=input, + confirm=confirm, + on_validate=on_validate, + on_cancel=on_cancel, + ) + + if input or confirm: + request_information(input=input, confirm=confirm) + return + with self.logUI: try: command(**kwargs) - success = True - exit_status = _("finished") + except MissingInformationError as e: + request_information(e.missing) except (RuntimeError, subprocess.CalledProcessError) as e: - exit_status = _("failed with error", error=str(e)) - finally: - if on_finished is not None: - on_finished() - self.statusUI.value = action + ": " + exit_status - if success and on_success is not None: - on_success() + finish(exit_status=_("failed with error", error=str(e))) + except Exception as e: # in case of unexpected exception + finish(exit_status=f"{_('failed')}: {e}") + raise + else: + if on_success is not None: + on_success() + finish(exit_status=_("finished")) def run_in_subthread( self, @@ -312,6 +440,7 @@ class AssignmentStudentDashboard(HBox): disabled=True, ) self.submitUI.on_click(lambda event: self.submit()) + self.work_dir_UI = Button( description=_("open"), button_style="primary", @@ -322,9 +451,31 @@ class AssignmentStudentDashboard(HBox): ) self.work_dir_UI.on_click(self.open_work_dir_callback) self.scoreUI = HTML("", tooltip=_("browse feedback")) + self.updateUI = Button( + description="", + # button_style="primary", + icon="rotate-right", + tooltip=_("update view"), + layout=layout, + disabled=False, + ) + self.updateUI.on_click(lambda event: self.update()) self.submissionUI = HTML(layout=Layout(align_self="center")) + self.other_actionsUI = Dropdown( + description="", + options=[ + (_("other actions"), ""), + (_("fetch from"), "fetch from"), + (_("set student group"), "set student group"), + (_("share with"), "share with"), + (_("set main submission"), "set main submission"), + (_("remove submission"), "remove submission"), + ], + ) + self.other_actionsUI.observe(lambda change: self.other_action(), names="value") + HBox.__init__( self, [ @@ -334,6 +485,8 @@ class AssignmentStudentDashboard(HBox): self.submitUI, self.submissionUI, self.scoreUI, + self.updateUI, + self.other_actionsUI, ], ) Thread(target=self.update).start() @@ -390,16 +543,7 @@ class AssignmentStudentDashboard(HBox): self.assignment.assignment_dir ): self.work_dir_UI.disabled = False - if ( - self.assignment.submissions_forked_from_path() is unknown - and not status.is_submitted() - ): - # To be generalized: the current tooltip makes the - # assumption of a course assignments with student - # groups - self.submitUI.tooltip += " (" + _("needs student group") + ")" - else: - self.submitUI.disabled = False + self.submitUI.disabled = False if status.autograde_status == "success": # these two s/could be provided by autograde_status @@ -490,6 +634,82 @@ class AssignmentStudentDashboard(HBox): ) ) + def other_action(self) -> None: + action = self.other_actionsUI.value + if not action: + return + input: Dict[str, Any] + + self.other_actionsUI.disabled = True + + def on_finished() -> None: + self.other_actionsUI.index = 0 + + self.other_actionsUI.disabled = False + + assert action in [ + "set student group", + "share with", + "set main submission", + "fetch from", + "remove submission", + ] + + confirm = False + command: Callable + + if action == "remove submission": + confirm = True + input = {} + + def command() -> None: + self.assignment.remove_submission(force=True) + + elif action == "fetch from": + origins = {"assignment": self.assignment.repo()} + status = self.assignment.status() + assert status.team is not None + for username in status.team.keys(): + origins[username] = status.team[username] + command = self.assignment.merge_from + input = { + "origin": str, + "on_conflict": ( + "abort", + "backup and fetch", + "merge anyway", + ), + } + + elif action == "share with": + command = self.assignment.share_with + input = {"username": str} + + elif action == "set main submission": + input = {"username": str} + + def command(username: str) -> None: + # TODO: simplify and generalize: + # - once leader_name can be passed down to submit without being ignored + # - once there are dedicated method to set the leader or student group + # without submitting + self.assignment.ensure_main_submission(leader_name=username) + + elif action == "set student group": + command = self.assignment.submit + assert isinstance(self.assignment, CourseAssignment) + assert self.assignment.course.student_groups is not None + input = {"student_group": tuple(self.assignment.course.student_groups)} + + self.status_bar.run( + action=_(action), + command=command, + confirm=confirm, + input=input, + on_success=self.update, + on_finished=on_finished, + ) + class CourseStudentDashboard(VBox): """ @@ -498,7 +718,6 @@ class CourseStudentDashboard(VBox): This class currently assumes that the user is logged in. """ - student_group_UI: Dropdown assignments: Tuple[str, ...] = () assignment_dashboards: Dict[str, AssignmentStudentDashboard] @@ -537,24 +756,12 @@ class CourseStudentDashboard(VBox): else: self.subcourse_UI = Dropdown() - if self.course.student_groups is not None: - self.student_group_UI = Dropdown( - description=_("student group"), - value=student_group, - options=course.student_groups, - ) - self.header.children += (self.student_group_UI,) - self.student_group_UI.observe( - lambda change: self.update_student_group(), names="value" - ) - else: - self.student_group_UI = Dropdown() - self.authentication_widget = AuthenticationWidget(forge=self.course.forge) + self.form_dialog = FormDialog() self.grid = GridBox( layout=Layout( - grid_template_columns="repeat(6, max-content)", + grid_template_columns="repeat(8, max-content)", grid_gap="5px 5px", border=border_layout["border"], ) @@ -562,11 +769,29 @@ class CourseStudentDashboard(VBox): self.assignment_dashboards = {} self.status_bar = StatusBar( - authentication_widget=self.authentication_widget, log=self.course.log + authentication_widget=self.authentication_widget, + form_dialog=self.form_dialog, + log=self.course.log, + ) + + self.updateUI = Button( + description="", + # button_style="primary", + icon="rotate-right", + tooltip=_("update view"), + layout=Layout(width="initial"), + disabled=False, ) + self.updateUI.on_click(lambda event: self.update(update_assignment_list=True)) super().__init__( - [self.authentication_widget, self.header, self.grid, self.status_bar], + [ + self.authentication_widget, + self.header, + self.grid, + self.form_dialog, + self.status_bar, + ], layout={"width": "fit-content"}, ) self.update(update_assignment_list=True) @@ -582,17 +807,7 @@ class CourseStudentDashboard(VBox): ) self.update(update_assignment_list=True) - def update_student_group(self) -> None: - student_group = self.student_group_UI.value - for assignment_name in self.assignments: - assignment_dashboard = self.assignment_dashboards[assignment_name] - assignment = assignment_dashboard.assignment - assert isinstance(assignment, CourseAssignment) - assignment.student_group = student_group - assignment_dashboard.update() - def update(self, update_assignment_list: bool = False) -> None: - student_group = self.student_group_UI.value subcourse = self.subcourse_UI.value # if student_group is None: # self.center = None @@ -609,22 +824,21 @@ class CourseStudentDashboard(VBox): for assignment in self.assignments: if assignment not in self.assignment_dashboards: self.assignment_dashboards[assignment] = AssignmentStudentDashboard( - self.course.assignment(assignment, student_group=student_group), + self.course.assignment(assignment), status_bar=self.status_bar, authentication_widget=self.authentication_widget, ) if update_assignment_list: self.grid.children = [ - Label(label) - for label in [ - _("assignment"), - "", - _("work directory"), - "", - _("submission"), - _("score"), - ] + Label(_("assignment")), + Label(""), + Label(_("work directory")), + Label(""), + Label(_("submission")), + Label(_("score")), + self.updateUI, + Label(""), ] + [ widget for assignment in self.assignments @@ -1399,6 +1613,7 @@ class CourseGradeDashboard(VBox): job, artifact_path=path ).text scores = pd.read_csv(io.StringIO(scores_txt)) + # TODO: add type check for scores to help mypy total_score = np.sum(scores["total_score"].values) max_manual_score = np.sum( scores["max_manual_score"].values diff --git a/travo/gitlab.py b/travo/gitlab.py index 9f90c2dc..7b1498cc 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -1299,6 +1299,56 @@ class Project(Resource): time.sleep(1) return forge.get_project(id) + def ensure_is_fork_of(self, forked_from: Union["Project", str]) -> "Project": + """ + Ensure that this project is a fork of the given project + + Return this project, or an updated copy of it if needed + """ + forked_from_path = ( + forked_from.path_with_namespace + if isinstance(forked_from, Project) + else forked_from + ) + + # Check the fork relationship and update it if needed + if ( + self.forked_from_project is not None + and self.forked_from_project.path_with_namespace == forked_from_path + ): + return self + # The fork relationship needs to be set or updated + self.gitlab.log.info( + "Setting fork relation " + f"from {self.path_with_namespace} " + f"to {forked_from_path}" + ) + + if not isinstance(forked_from, Project): + # This is both to check the existence of the requested + # forked from project and recover its id + forked_from = self.gitlab.get_project(path=forked_from_path) + + # In some cases, fork.forked_from_project may be None even if + # the project actually has a fork relation set. This happens for + # exemple with GitLab 15.3.3 in the following scenario + # - C is a fork of B which is a fork of A + # - B gets deleted + # - C still appears as fork of A in the user interface; trying + # to set its fork relation to something else fails; C does not + # appear in the list of forks of A + # We therefore systematically try to delete the fork relation; it + # fails silently if there is none. + self.gitlab.delete(f"/projects/{self.id}/fork") + + json = self.gitlab.post(f"/projects/{self.id}/fork/{forked_from.id}").json() + if "message" in json: + raise RuntimeError(f"failed: {json['message']}") + self = Project(gitlab=self.gitlab, **json) + assert self.forked_from_project is not None + assert self.forked_from_project.path_with_namespace == forked_from_path + return self + def ensure_fork( self, path: str, @@ -1410,45 +1460,7 @@ class Project(Resource): forked_from_path = self.path_with_namespace assert isinstance(forked_from_path, str) - # Check the fork relationship and update it if needed - if ( - fork.forked_from_project is not None - and fork.forked_from_project.path_with_namespace == forked_from_path - ): - return fork - # The fork relationship needs to be set or updated - self.gitlab.log.info( - "Setting fork relation " - f"from {fork.path_with_namespace} " - f"to {forked_from_path}" - ) - - if forked_from_path != self.path_with_namespace: - # This is both to check the existence of the requested - # forked from project and recover its id - forked_from = self.gitlab.get_project(path=forked_from_path) - else: - forked_from = self - - # In some cases, fork.forked_from_project may be None even if - # the project actually has a fork relation set. This happens for - # exemple with GitLab 15.3.3 in the following scenario - # - C is a fork of B which is a fork of A - # - B gets deleted - # - C still appears as fork of A in the user interface; trying - # to set its fork relation to something else fails; C does not - # appear in the list of forks of A - # We therefore systematically try to delete the fork relation; it - # fails silently if there is none. - self.gitlab.delete(f"/projects/{fork.id}/fork") - - json = self.gitlab.post(f"/projects/{fork.id}/fork/{forked_from.id}").json() - if "message" in json: - raise RuntimeError(f"failed: {json['message']}") - fork = Project(gitlab=self.gitlab, **json) - assert fork.forked_from_project is not None - assert fork.forked_from_project.path_with_namespace == forked_from_path - return fork + return fork.ensure_is_fork_of(forked_from_path) def share_with( self, diff --git a/travo/jupyter_course.py b/travo/jupyter_course.py index 1690417e..91f2e1fc 100644 --- a/travo/jupyter_course.py +++ b/travo/jupyter_course.py @@ -75,7 +75,7 @@ quality_report = """ jupyterhub_host = "https://jupyterhub.ijclab.in2p3.fr" -def jupyter_notebook_in_hub( +def jupyter_lab_in_hub( path: str, debug: bool = False, background: bool = False ) -> Optional[str]: """ @@ -106,12 +106,12 @@ def jupyter_notebook_in_hub( command = [ "jupyter", - "notebook", + "lab", "--no-browser", - f"--NotebookApp.base_url={prefix}proxy/absolute/{port}/", + f"--LabApp.base_url={prefix}proxy/absolute/{port}/", f"--port={port}", f"--log-level={log_level}", - "--NotebookApp.allow_remote_access=True", + "--LabApp.allow_remote_access=True", ] if background: subprocess.Popen(command) @@ -128,7 +128,7 @@ def jupyter_notebook(path: str) -> None: This works both in the command line or within a JupyterHub """ if "JUPYTERHUB_SERVICE_PREFIX" in os.environ: - jupyter_notebook_in_hub(path) + jupyter_lab_in_hub(path) else: subprocess.run( [ @@ -897,7 +897,7 @@ class JupyterCourse(Course): if "JUPYTERHUB_SERVICE_PREFIX" in os.environ: print("Launching formgrader in the background") - jurl = jupyter_notebook_in_hub(path=url, background=True) + jurl = jupyter_lab_in_hub(path=url, background=True) assert ( jurl is not None ) # TODO check if jurl can be None and what it means diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index 2107b783..100267d0 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -24,6 +24,7 @@ fr: open assignment: "Ouvrir le devoir %{assignment_name}" browse feedback: "Consulter les détails du score et de la correction" no submission; please submit: "Dépôt personnel inexistant sur GitLab\nMerci de déposer `%{assignment_name}" + no main submission: "%{leader_name} n'a pas déposé %{assignment_name} sur GitLab, ou ne l'a pas partagé avec vous" No need to launch Jupyter on JupyterHub: "Sur le service JupyterHub, il est inutile de lancer Jupyter" "Can't open work dir: assignment_dir is not set": "Ne peux pas ouvrir le répertoire de travail: assignment_dir n'est pas défini" validation failed: "Échec de la validation avec %{errors} erreur(s) and %{failures} échec(s)" @@ -48,11 +49,12 @@ fr: show command log: "Afficher la trace d'exécution des opérations" command log level: "Configurer le niveau de détail de la trace d'exécution des opérations" student group: "Groupe" + student_group: "Groupe" invalid credentials: "Échec de l'authentification. Identifiant ou mot de passe incorrect?" invalid token environment variable: "Variable d'environnement TRAVO_TOKEN invalide ou expirée." sign in: "Authentification" authentication required: "Authentification requise" - abandon conflicting merge: "L'intégration des %{content} induirait un conflit; abandon" + abort conflicting merge: "L'intégration des %{content} induirait un conflit; abandon" updates: mises à jour erratas: erratas submission failed: "échec du dépôt: essayer d'abord de télécharger?" @@ -104,3 +106,22 @@ fr: Number of submissions: "#copies: %{number_of_submissions}" last release: "Dernière publication : %{visibility} %{date}" Needs manual grading: "Besoin d'une correction manuelle: %{counts} \nScores: %{mean}+/-%{dev}" + remove submission: "Suppression du dépôt" + share with: "Partager avec (travail en équipe)" + set main submission: "Définir le dépôt principal (travail en équipe)" + set student group: "Définir le groupe" + cancel: "Annuler" + validate: "Valider" + confirm: "Êtes vous sûr?" + other actions: "Autres actions" + waiting for information: "En attente d'information" + canceled: "Annulé" + cannot set self as leader: "Ce sont vos équipiers qui doivent définir votre dépôt comme dépôt principal" + missing assignment directory: "Répertoire de travail manquant; téléchargez votre devoir?" + update view: "Mettre à jour" + fetch from: "Télécharger ou mettre à jour depuis" + downloading: "Téléchargement" + "save before": "Sauvegarde avant %{what}" + "merging": "intégration de %{content}" + "try to merge": "Tentative d'intégration" + "no content": "Pas de %{content}" diff --git a/travo/nbgrader_utils.py b/travo/nbgrader_utils.py index cfc80ab7..8a072581 100644 --- a/travo/nbgrader_utils.py +++ b/travo/nbgrader_utils.py @@ -67,7 +67,11 @@ def merge_submission_gradebook( args, kwargs = to_args(submission, ["student"]) del kwargs["first_name"] del kwargs["last_name"] - target.update_or_create_submission(assignment.name, *args, **kwargs) + # Don't pass in kwargs, because only the student name is + # relevant to be able to create a submission, while + # updating an attribute like the id in the database may + # wreak havoc + target.update_or_create_submission(assignment.name, *args) # , **kwargs) for notebook in submission.notebooks: for grade in notebook.grades: diff --git a/travo/tests/test_assignement.py b/travo/tests/test_assignement.py index ddb3e01b..52138680 100644 --- a/travo/tests/test_assignement.py +++ b/travo/tests/test_assignement.py @@ -4,17 +4,34 @@ from travo.gitlab import Project from travo.assignment import Assignment +def test_merge_from( + standalone_assignment: Assignment, + standalone_assignment_submission: Project, + standalone_assignment_dir: str, +) -> None: + assignment = standalone_assignment + assignment_dir = standalone_assignment_dir + assignment.fetch(assignment_dir=assignment_dir) + assert os.path.isfile(os.path.join(assignment_dir, "README.md")) + assert not os.path.isfile(os.path.join(assignment_dir, "newfile")) + submission = standalone_assignment_submission + submission.repo.ensure_file("newfile", branch="master") + assignment.merge_from(submission.repo, assignment_dir=assignment_dir) + assert os.path.isfile(os.path.join(assignment_dir, "newfile")) + + def test_collect_assignment( standalone_assignment: Assignment, standalone_assignment_submission: Project ) -> None: assignment = standalone_assignment - student = standalone_assignment_submission.owner + student = standalone_assignment_submission.student assignment.collect() - assert os.path.isdir(f"{student.username}") + assert os.path.isdir(student) + assert os.path.isfile(os.path.join(student, "README.md")) assignment.collect(template="foo/bar-{path}-{username}") - assert os.path.isdir(f"foo/bar-{assignment.name}-{student.username}") + assert os.path.isdir(f"foo/bar-{assignment.name}-{student}") def test_fetch_from_empty_submission_repo( diff --git a/travo/tests/test_utils.py b/travo/tests/test_utils.py index a2d9ff8b..97433828 100644 --- a/travo/tests/test_utils.py +++ b/travo/tests/test_utils.py @@ -9,11 +9,13 @@ def test_get_origin_error(tmp_path: str) -> None: os.chdir(tmp_path) with pytest.raises(RuntimeError, match="fatal: not a git repository"): os.environ["LANG"] = "en_US.UTF-8" + os.environ["LANGUAGE"] = "C" # The directory is not a git repository git_get_origin() subprocess.run(["git", "init", "--quiet"], cwd=tmp_path) with pytest.raises(RuntimeError, match="error: No such remote 'origin'"): os.environ["LANG"] = "en_US.UTF-8" + os.environ["LANGUAGE"] = "C" # The remote origin is not defined git_get_origin() -- GitLab From 4acf4cc2ef9dd852b6f0de7ddfdb1c23a725ed23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Thu, 29 Aug 2024 16:43:01 +0200 Subject: [PATCH 02/24] Better testing - New fixture: rich_course_deployed - New fixture utility: to_be_teared_down - New context manager: travo.util.working_directory - Support for tests involving multiple gitlab users by mean of a context (Gitlab.logged_as) - Default collection of users with standardized names (including travo-test-etu->student1 and blondin_al->student2) - Multiuser tests for Assignment.merge_from, Course.collect, test_course_generate_and_release - Fixed test_collect for courses and assignments to run in a temporary directory - Misc minor fixes and doc improvements - Systematically create GitLabTest instances through the gitlab fixture to guarantee uniqueness (and document why) - GitLab.get_branch: raise ResourceNotFound if the branch is missing (backward incompatible) - New argument initialized for Project.ensure_fork and Assignment.ensure_submission_repo to ensure that the project should be initialized upon creation with the content of the origin repository --- build_tools/create_basic_gitlab.py | 51 +++--- conftest.py | 113 +++++++++----- travo/assignment.py | 63 +++++++- travo/course.py | 22 +-- travo/gitlab.py | 242 ++++++++++++++++++++++++----- travo/locale/travo.en.yml | 2 + travo/locale/travo.fr.yml | 10 +- travo/tests/test_assignement.py | 33 ++-- travo/tests/test_course.py | 148 +++++++++--------- travo/utils.py | 33 +++- 10 files changed, 502 insertions(+), 215 deletions(-) diff --git a/build_tools/create_basic_gitlab.py b/build_tools/create_basic_gitlab.py index f1ff18e1..8da18e53 100644 --- a/build_tools/create_basic_gitlab.py +++ b/build_tools/create_basic_gitlab.py @@ -53,24 +53,37 @@ gitlab_oauth_token = resp_data["access_token"] gl = gitlab.Gitlab(gitlab_url, oauth_token=gitlab_oauth_token) # create users -user_data = { - "username": "travo-test-etu", - "email": "travo@gmail.com", - "name": "Étudiant de test pour travo", - "password": "aqwzsx(t1", - "can_create_group": "True", -} - -user = create_user(user_data) - -other_user_data = { - "username": "blondin_al", - "email": "blondin_al@blondin_al.fr", - "name": "Utilisateur de test pour travo", - "password": "aqwzsx(t2", -} +users_data = [ + { + "username": "student1", + "email": "travo@gmail.com", + "name": "Étudiant de test pour travo", + "password": "aqwzsx(t1", + "can_create_group": "True", + }, + { + "username": "student2", + "email": "student2@foo.bar", + "name": "Student 2", + "password": "aqwzsx(t2", + }, + { + "username": "instructor1", + "email": "instructor1@foo.bar", + "name": "Instructor 1", + "password": "aqwzsx(t3", + }, + { + "username": "instructor2", + "email": "instructor2@foo.bar", + "name": "Instructor 2", + "password": "aqwzsx(t4", + }, +] + +users = {user_data["username"]: create_user(user_data) for user_data in users_data} +user = users["student1"] -other_user = create_user(other_user_data) # create user projects and groups project_data = {"name": "nom-valide", "visibility": "private"} @@ -78,7 +91,7 @@ project_data = {"name": "nom-valide", "visibility": "private"} create_user_project(user, project_data) project_data = { - "name": "Fork-de-travo-test-etu-du-projet-Exemple-projet-CICD", + "name": "Fork-de-student1-du-projet-Exemple-projet-CICD", "visibility": "private", } @@ -119,7 +132,7 @@ project = create_user_project(admin_user, project_data) # create commits # See https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions # noqa: E501 # for actions detail -data = { +commits_data = { "branch": "master", "commit_message": "blah blah blah", "author_name": user.name, diff --git a/conftest.py b/conftest.py index 5e3a3463..52860af5 100644 --- a/conftest.py +++ b/conftest.py @@ -2,13 +2,12 @@ import datetime import os.path import pytest # type: ignore import random -import shutil import string -from typing import Iterator +from typing import Callable, Iterator, List -from travo.gitlab import GitLab, GitLabTest, Group, Project, User +from travo.gitlab import GitLab, GitLabTest, Group, Project, User, Resource from travo.course import Course -from travo.assignment import Assignment +from travo.assignment import Assignment, Submission @pytest.fixture @@ -42,7 +41,7 @@ def test_run_id() -> str: @pytest.fixture def project_path(test_run_id: str) -> str: - return f"travo-test-etu/temporary-test-projet-{test_run_id}" + return f"student1/temporary-test-projet-{test_run_id}" @pytest.fixture @@ -77,7 +76,7 @@ def group(gitlab: GitLab, group_path: str, group_name: str) -> Iterator[Group]: @pytest.fixture def user_name() -> str: - return "travo-test-etu" + return "student1" @pytest.fixture @@ -87,7 +86,7 @@ def user(gitlab: GitLab, user_name: str) -> User: @pytest.fixture def other_user(gitlab: GitLab) -> User: - return gitlab.get_user("blondin_al") + return gitlab.get_user("student2") @pytest.fixture @@ -118,7 +117,9 @@ def standalone_assignment( test_run_id: str, ) -> Iterator[Assignment]: repo = gitlab.ensure_project( - f"TestGroup/TestAssignment-{test_run_id}", f"Test assignment - {test_run_id}" + path=f"TestGroup/TestAssignment-{test_run_id}", + name=f"Test assignment - {test_run_id}", + visibility="public", ) repo.ensure_file("README.md", branch="master") yield Assignment( @@ -127,7 +128,6 @@ def standalone_assignment( repo_path=repo.path_with_namespace, name=repo.path, instructors_path="TestGroup", - username=user_name, ) gitlab.remove_project(repo.path_with_namespace, force=True) @@ -137,40 +137,56 @@ def standalone_assignment_dir(tmp_path: str, test_run_id: str) -> str: return os.path.join(tmp_path, f"Assignment-{test_run_id}") +@pytest.fixture +def to_be_teared_down() -> Iterator[Callable[[Resource], None]]: + """ + A factory fixture for planning the removal of resources upon tear down + + Currently projects and groups are supported. + + Example: + + >>> forge = get_fixture("gitlab") + >>> to_be_teared_down = get_fixture("to_be_teared_down") + >>> group = forge.ensure_group("MyTemporaryGroup") + >>> to_be_teared_down(group) + + Reference: https://docs.pytest.org/en/6.2.x/fixture.html#factories-as-fixtures + """ + resources: List[Resource] = [] + + def _to_be_teared_down(resource: Resource) -> None: + resources.append(resource) + + yield _to_be_teared_down + + # Tear down resources + + for resource in reversed(resources): + forge = resource.gitlab + assert isinstance(forge, GitLabTest) + with forge.logged_as("root"): + if isinstance(resource, Group): + forge.remove_group(resource.id) + else: + assert isinstance(resource, Project) + forge.remove_project(resource.id) + + @pytest.fixture def standalone_assignment_submission( - standalone_assignment: Assignment, tmp_path: str -) -> Iterator[Project]: - assignment_dir = os.path.join(tmp_path, "tmpclone") - submission_repo = standalone_assignment.ensure_submission_repo() - # ensure_submission_repo does not copy the repository content - # so we still need to do that manually - forge = standalone_assignment.forge - branch = standalone_assignment.repo().default_branch - forge.git( - [ - "clone", - standalone_assignment.repo().http_url_with_base_to_repo(), - assignment_dir, - ], - cwd=tmp_path, - ) - forge.git( - ["push", submission_repo.http_url_with_base_to_repo(), branch], - cwd=assignment_dir, - ) - shutil.rmtree(assignment_dir) + standalone_assignment: Assignment, +) -> Iterator[Submission]: + standalone_assignment.ensure_submission_repo(initialized=True) yield standalone_assignment.submission() - standalone_assignment.forge.remove_project( - submission_repo.path_with_namespace, force=True - ) + standalone_assignment.forge.remove_project(standalone_assignment.submission_path()) @pytest.fixture -def course() -> Course: +def course(gitlab: GitLabTest) -> Course: return Course( name="Test course", - forge=GitLabTest(), + forge=gitlab, path="TestCourse", student_dir="~/TestCourse", assignments_group_path="TestCourse/2020-2021", @@ -180,10 +196,11 @@ def course() -> Course: @pytest.fixture -def rich_course() -> Course: +def rich_course(gitlab: GitLabTest) -> Course: + # The course path and name could be randomized to enable parallel tests return Course( name="Test course", - forge=GitLabTest(), + forge=gitlab, path="TestCourse", session_path="2020-2021", assignments=["Assignment1", "Assignment2"], @@ -193,6 +210,28 @@ def rich_course() -> Course: ) +@pytest.fixture +def rich_course_deployed(gitlab: GitLabTest, rich_course: Course) -> Iterator[Course]: + with gitlab.logged_as("instructor1"): + rich_course.forge.ensure_group( + path=rich_course.path, + name=rich_course.name, + visibility="public", + ) + + assert rich_course.session_name is not None + rich_course.forge.ensure_group( + path=rich_course.assignments_group_path, + name=rich_course.session_name, + visibility="public", + ) + + yield rich_course + + with gitlab.logged_as("instructor1"): + rich_course.forge.remove_group(rich_course.path) + + @pytest.fixture def course_assignment_group(course: Course) -> Group: course.forge.ensure_group(path=course.path, name=course.name, visibility="public") diff --git a/travo/assignment.py b/travo/assignment.py index 5b3edc56..16be7c52 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -78,7 +78,6 @@ class Assignment: repo_path=repo.path_with_namespace, name=name, instructors_path=instructors_path, - username=username, _repo_cache=repo, ) @@ -114,7 +113,7 @@ class Assignment: >>> assignment = getfixture("standalone_assignment") >>> assignment.submission_path() - 'travo-test-etu/TestAssignment-2...' + 'student1/TestAssignment-2...' """ username = self.get_username(username) return username + "/" + os.path.basename(self.repo_path) @@ -168,7 +167,7 @@ class Assignment: >>> assignment.get_submission_username(assignment.repo()) >>> submission = getfixture("standalone_assignment_submission") >>> assignment.get_submission_username(submission.repo) - 'travo-test-etu' + 'student1' """ if project.path_with_namespace == self.repo_path: return None @@ -202,7 +201,9 @@ class Assignment: except ResourceNotFoundError: return False - def ensure_submission_repo(self, leader_name: Optional[str] = None) -> Project: + def ensure_submission_repo( + self, leader_name: Optional[str] = None, initialized: bool = False + ) -> Project: """ Return the submission repository for this assignment @@ -245,6 +246,7 @@ class Assignment: emails_disabled=True, default_branch=repo.default_branch, jobs_enabled=self.jobs_enabled_for_students, + initialized=initialized, ) if my_repo.default_branch == "null": web_url = my_repo.web_url @@ -289,7 +291,7 @@ class Assignment: my_repo.ensure_badge(name="Scores", link_url=link_url, image_url=image_url) - self.log.info("- Votre dépôt personnel:") + self.log.info(f"- {_('your submission')}:") self.log.info(f" {my_repo.web_url}") return my_repo @@ -336,7 +338,7 @@ class Assignment: >>> path = assignment.submission_path() >>> project = forge.get_project(path) >>> project.path_with_namespace - 'travo-test-etu/TestAssignment-20...' + 'student1/TestAssignment-20...' >>> print(forge.git(["config", "--local", "user.name"], ... capture_output=True, ... cwd=assignment_dir).stdout.decode(), end='') @@ -352,8 +354,8 @@ class Assignment: ... capture_output=True, ... cwd=assignment_dir, ... ).stdout.decode(), end='') - origin http://.../travo-test-etu/TestAssig...git (fetch) - origin http://.../travo-test-etu/TestAssig...git (push) + origin http://.../student1/TestAssig...git (fetch) + origin http://.../student1/TestAssig...git (push) This is an idempotent operation: @@ -483,6 +485,51 @@ class Assignment: Assumptions: `assignment_dir` is a clone of the assignment repository, with a local git configuration (see :ref:`gitlab.ensure_local_git_configuration`). + + Examples: + + We fetch an assignment which already has a submission: + + >>> gitlab = getfixture("gitlab") + >>> to_be_teared_down = getfixture("to_be_teared_down") + >>> assignment = getfixture("standalone_assignment") + >>> submission_repo = assignment.ensure_submission_repo(initialized=True) + >>> to_be_teared_down(submission_repo) + >>> assignment_dir = getfixture("standalone_assignment_dir") + + >>> assignment.fetch(assignment_dir=assignment_dir) + + >>> assert os.path.isfile(os.path.join(assignment_dir, "README.md")) + >>> assert not os.path.isfile(os.path.join(assignment_dir, "newfile")) + + we add a new file in the submission: + + >>> submission_repo.ensure_file("newfile") + + and check that, after merging from the modified submission, + the new file is present locally: + + >>> assignment.merge_from(submission_repo, assignment_dir=assignment_dir) + True + + >>> assert os.path.isfile(os.path.join(assignment_dir, "newfile")) + + Now let's create a submission for student2, add there a + different file, and share it with the current student: + + >>> with gitlab.logged_as("student2"): + ... submission2_repo = assignment.ensure_submission_repo( + ... initialized=True) + ... to_be_teared_down(submission2_repo) + ... submission2_repo.ensure_file("newfile-student2") + ... assignment.share_with("student1") + + After merging from student2's submission, the new file is present locally: + + >>> assignment.merge_from(submission2_repo, assignment_dir=assignment_dir) + True + + >>> assert os.path.isfile(os.path.join(assignment_dir, "newfile-student2")) """ msg = _("merging", content=content) self.log.info(msg) diff --git a/travo/course.py b/travo/course.py index 87aae90a..2bc91da8 100755 --- a/travo/course.py +++ b/travo/course.py @@ -92,7 +92,7 @@ class CourseAssignment(Assignment): >>> assignment = course.assignment("SubCourse/Assignment1") >>> assignment.submission_path_components() - ('travo-test-etu', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') + ('student1', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') >>> assignment.submission_path_components(username="john.doo") ('john.doo', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') @@ -103,7 +103,7 @@ class CourseAssignment(Assignment): >>> course.path='TestModule/TestCourse' >>> assignment.submission_path_components() - ('travo-test-etu-travo', 'TestModule', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') + ('student1-travo', 'TestModule', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') """ root = self.get_username(username) if self.course.group_submissions: @@ -122,21 +122,21 @@ class CourseAssignment(Assignment): >>> course = getfixture("course") >>> course.assignment("SubCourse/Assignment1").submission_path() - 'travo-test-etu/TestCourse-SubCourse-Assignment1' + 'student1/TestCourse-SubCourse-Assignment1' >>> course = getfixture("rich_course") >>> course.assignment("SubCourse/Assignment1").submission_path() - 'travo-test-etu/TestCourse-2020-2021-SubCourse-Assignment1' + 'student1/TestCourse-2020-2021-SubCourse-Assignment1' >>> course.assignment("SubCourse/Assignment1", ... student_group="Group1").submission_path() - 'travo-test-etu/TestCourse-2020-2021-SubCourse-Assignment1' + 'student1/TestCourse-2020-2021-SubCourse-Assignment1' More examples with grouped submissions: >>> course.group_submissions = True >>> assignment = course.assignment("SubCourse/Assignment1") >>> assignment.submission_path() - 'travo-test-etu-travo/TestCourse/2020-2021/SubCourse/Assignment1' + 'student1-travo/TestCourse/2020-2021/SubCourse/Assignment1' >>> assignment.submission_path(username="john.doo") 'john-doo-travo/TestCourse/2020-2021/SubCourse/Assignment1' """ @@ -234,7 +234,9 @@ class CourseAssignment(Assignment): finally: self.student_group = old_student_group - def ensure_submission_repo(self, leader_name: Optional[str] = None) -> Project: + def ensure_submission_repo( + self, leader_name: Optional[str] = None, initialized: bool = False + ) -> Project: """ Return the submission repository for this assignment @@ -266,7 +268,9 @@ class CourseAssignment(Assignment): self.submission_path_components()[:-1], self.submission_name_components()[:-1], ) - return super().ensure_submission_repo(leader_name=leader_name) + return super().ensure_submission_repo( + leader_name=leader_name, initialized=initialized + ) def submissions_forked_from_path(self) -> Union[str, Unknown]: """Return the path of the repository that submissions should be a fork of. @@ -321,7 +325,7 @@ class CourseAssignment(Assignment): >>> assignment.get_submission_username(assignment.repo()) >>> assignment.get_submission_username(submission_repo) - 'travo-test-etu' + 'student1' TODO: test with a rich course and the assignment fork for a student group """ diff --git a/travo/gitlab.py b/travo/gitlab.py index 7b1498cc..b560dffc 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -1,4 +1,5 @@ import base64 +import contextlib from dataclasses import dataclass, field, InitVar, fields import enum import fnmatch @@ -17,6 +18,7 @@ import pathlib import typing import typing_utils # type: ignore from typing import ( + Iterator, Optional, List, Sequence, @@ -208,7 +210,7 @@ class GitLab: Login. Here the credentials are passed as parameters; they typically are instead entered interactively by the user: - >>> gitlab.login(username="travo-test-etu", + >>> gitlab.login(username="student1", ... password="aqwzsx(t1") Now we may access non-public information like the user status: @@ -389,20 +391,24 @@ class GitLab: Recall that, in GitLab's terminology, a namespace is either a group or a user's home. - Caveat: with GitLab 11 this method only support the users's - own home in addition to groups + Linitation: the current implementation requires the user to be + logged in and to be at least maintainer of the namespace. This + is ok since this method is currently only used for creating + projects or subgroups in the namespace). However the error + message may be misleading if a user tries to create a project + or group in a namepace they are not maintainer of. Examples: >>> forge = getfixture("gitlab") >>> forge.login() >>> forge.namespace_id("") - >>> forge.namespace_id("travo-test-etu") + >>> forge.namespace_id("student1") 2 >>> forge.namespace_id("group1") - 6 + 8 >>> forge.namespace_id("group1/subgroup") - 7 + 9 >>> forge.namespace_id("not_a_group") Traceback (most recent call last): @@ -471,7 +477,7 @@ class GitLab: >>> forge = getfixture('gitlab') >>> path = getfixture('project_path'); path - 'travo-test-etu/temporary-test-projet-20...' + 'student1/temporary-test-projet-20...' >>> name = getfixture('project_name'); name 'temporary test projet created at 20...' @@ -562,13 +568,13 @@ class GitLab: >>> project.http_url_to_repo 'http://.../groupe-public-test/projet-public.git' - >>> project = gitlab.get_project("travo-test-etu/nom-valide") + >>> project = gitlab.get_project("student1/nom-valide") Traceback (most recent call last): ... travo.gitlab.ResourceNotFoundError: ...ide not found: 404 Project Not Found >>> gitlab.login() - >>> project = gitlab.get_project("travo-test-etu/nom-valide") + >>> project = gitlab.get_project("student1/nom-valide") >>> project.owner User(gitlab=...) """ @@ -593,7 +599,7 @@ class GitLab: 'http://.../groupe-public-test/projet-public.git' >>> path = getfixture('project_path'); path - 'travo-test-etu/temporary-test-projet-20...' + 'student1/temporary-test-projet-20...' >>> name = getfixture('project_name'); name 'temporary test projet created at 20...' @@ -696,7 +702,7 @@ class GitLab: **attributes, ) - def remove_group(self, id_or_path: str, force: bool = False) -> None: + def remove_group(self, id_or_path: Union[str, int], force: bool = False) -> None: """ Remove group (DANGEROUS!) @@ -714,7 +720,7 @@ class GitLab: >>> gitlab.login() >>> user = gitlab.get_current_user() >>> user.username - 'travo-test-etu' + 'student1' >>> gitlab.logout() @@ -740,11 +746,11 @@ class GitLab: >>> gitlab = getfixture("gitlab") - >>> user = gitlab.get_user("travo-test-etu") + >>> user = gitlab.get_user("student1") >>> user.username - 'travo-test-etu' + 'student1' >>> user.web_url - 'http://.../travo-test-etu' + 'http://.../student1' >>> user.public_email >>> assert gitlab.get_user(user) is user @@ -787,8 +793,8 @@ class GitLab: >>> capfd = getfixture('capfd') >>> gitlab_url = getfixture('gitlab_url') - >>> url = (f"{gitlab_url}/travo-test-etu/" - ... "Fork-de-travo-test-etu-du-projet-Exemple-projet-CICD.git") + >>> url = (f"{gitlab_url}/student1/" + ... "Fork-de-student1-du-projet-Exemple-projet-CICD.git") >>> gitlab = getfixture('gitlab') >>> gitlab.login() >>> gitlab.git(["clone", url, "repository"], cwd=gitlab.home_dir) @@ -1112,7 +1118,7 @@ class Resource(metaclass=ClassCallMetaclass): >>> gitlab = getfixture('gitlab') >>> gitlab.login() - >>> project = gitlab.get_project("travo-test-etu/nom-valide") + >>> project = gitlab.get_project("student1/nom-valide") >>> import datetime >>> description = f"Description: {datetime.datetime.now()}" >>> import logging @@ -1122,7 +1128,7 @@ class Resource(metaclass=ClassCallMetaclass): 'Description: 20...' >>> assert project.description == description - >>> project = gitlab.get_project("travo-test-etu/nom-valide") + >>> project = gitlab.get_project("student1/nom-valide") >>> assert project.description == description """ self.setattributes(**{key: value}) @@ -1349,15 +1355,19 @@ class Project(Resource): assert self.forked_from_project.path_with_namespace == forked_from_path return self + # Note: the types are set to Any for the optional arguments for + # compatibility with passing **attributes which are of type Any def ensure_fork( self, path: str, name: str, forked_from_path: Any = None, # Optional[Union[str, Unknown]] forked_from_missing: Any = None, # Callable[] + initialized: Any = False, # bool **attributes: Any, ) -> "Project": - """Ensure that `path` is a fork of `self` with given name and attributes + """ + Ensure that `path` is a fork of `self` with given name and attributes Creating the fork and configuring it if needed. @@ -1403,11 +1413,15 @@ class Project(Resource): support choosing the target path and namespace (nor setting attributes at once?). As a workaround, the current implementation creates the target project independently, and - then set the fork relationship. + then sets the fork relationship. Caveats: - The operation is not atomic, therefore more fragile - - The repository data is *not* transferred + + - By default, the repository of the fork is *not* + initialized. If `initialized` is set to True, then upon + creation the default branch of the repository is initialized + with that of the origin. Bonus: - Unlike the forking operation in the API, this is a @@ -1430,6 +1444,8 @@ class Project(Resource): # namespace=os.path.dirname(path), # name=name, # **attributes) + if forked_from_path is None: + forked_from_path = self.path_with_namespace if forked_from_path is unknown: # Won't be able to create the fork or to set the fork relationship # Complain if this is required @@ -1439,7 +1455,43 @@ class Project(Resource): forked_from_missing() if fork.forked_from_project is None: forked_from_missing() + fork = self.gitlab.ensure_project(path=path, name=name, **attributes) + + # If the repository is empty (the default branch does not + # exist) then initialize the repository with the content + # of the default branch of the original repository + def is_initialized(repo: Project) -> bool: + try: + fork.get_branch(fork.default_branch) + return True + except ResourceNotFoundError: + return False + + if initialized and not is_initialized(fork): + if forked_from_path is unknown: + forked_from_missing() + self.gitlab.log.info(_("initializing submission")) + forked_from = self.gitlab.get_project(forked_from_path) + with tempfile.TemporaryDirectory() as tmpdirname: + assignment_dir = os.path.join(tmpdirname, self.name) + branch = forked_from.default_branch + fork.gitlab.git( + [ + "clone", + forked_from.http_url_with_base_to_repo(), + assignment_dir, + ] + ) + assert os.path.isdir(assignment_dir) + fork.gitlab.git( + ["push", fork.http_url_with_base_to_repo(), branch], + cwd=assignment_dir, + ) + # Reload project, since the default branch (and other + # properties?) may have changed + fork = fork.gitlab.get_project(fork.id) + if forked_from_path is unknown: # Just check that the fork of fork relationship is consistent f = fork @@ -1456,8 +1508,6 @@ class Project(Resource): f"is not a fork of fork of {self.path_with_namespace}" ) - if forked_from_path is None: - forked_from_path = self.path_with_namespace assert isinstance(forked_from_path, str) return fork.ensure_is_fork_of(forked_from_path) @@ -1728,10 +1778,11 @@ class Project(Resource): """ if branch_name is None: branch_name = self.default_branch - # Could raise a ResourceNotFoundError on missing branches - return self.gitlab.get_json( - f"/projects/{self.id}/repository/branches/{branch_name}" - ) + res = self.gitlab.get(f"/projects/{self.id}/repository/branches/{branch_name}") + json = res.json() + if json.get("message") == "404 Branch Not Found": + raise ResourceNotFoundError(f"Branch {branch_name} not found") + return json def ensure_branch( self, branch_name: Optional[str] = None, ref: Optional[str] = None @@ -1745,9 +1796,7 @@ class Project(Resource): ref = self.default_branch try: return self.get_branch(branch_name) - except requests.HTTPError: - # Could raise again if failure had an other cause than a - # missing branch + except ResourceNotFoundError: pass return self.gitlab.post_json( f"/projects/{self.id}/repository/branches", @@ -1798,7 +1847,7 @@ class Project(Resource): Traceback (most recent call last): ... RuntimeError: get file myfile of ref mybranch - of project travo-test-etu/temporary-test-projet-20... + of project student1/temporary-test-projet-20... failed: 404 Commit Not Found >>> project.ensure_file(filename, branch) @@ -1827,14 +1876,14 @@ class Project(Resource): Traceback (most recent call last): ... RuntimeError: get file foobar of ref mybranch - of project travo-test-etu/temporary-test-projet-20... + of project student1/temporary-test-projet-20... failed: 404 Commit Not Found >>> project.get_file(filename, "foobar") Traceback (most recent call last): ... RuntimeError: get file foobar of ref mybranch - of project travo-test-etu/temporary-test-projet-20... + of project student1/temporary-test-projet-20... failed: 404 Commit Not Found """ file = urlencode(file) @@ -2050,7 +2099,7 @@ class Project(Resource): >>> project = getfixture('project') >>> project.get_creator() - User(gitlab=...username='travo-test-etu'...) + User(gitlab=...username='student1'...) """ if self._get_creator_cache is None: creator_id = self.creator_id @@ -2667,11 +2716,20 @@ class GitLabTest(GitLab): >>> gitlab = getfixture('gitlab') >>> gitlab.login() + + Caveat: due to the token management (stored on file in the home + directory), there currently should not be two instances of the + class `GitLabTest` (more generally of `GitLab` for a given forge) + logged in with different GitLab users. It is therefore recommended + to construct instances of `GitLabTest` as above via the `gitlab` + test fixture which guarantees that it is unique (Singleton). + + To enable test scénarios involving several GitLab users, a context + manager `gitlab.logged_as` is provided to temporarily switch the + currently logged in user. """ base_url: str = "http://gitlab/" - username: str = "travo-test-etu" - password: str = "aqwzsx(t1" def __init__(self, base_url: str = base_url) -> None: if "GITLAB_HOST" in os.environ and "GITLAB_80_TCP_PORT" in os.environ: @@ -2684,10 +2742,116 @@ class GitLabTest(GitLab): def confirm(self, message: str) -> bool: return True + # The following data should be shared with build_tools/create_basic_gitlab.py + users = [ + { + "username": "root", + "password": "dr0w554p!&ew=]gdS", + }, + { + "username": "student1", + "password": "aqwzsx(t1", + }, + { + "username": "student2", + "password": "aqwzsx(t2", + }, + { + "username": "instructor1", + "password": "aqwzsx(t3", + }, + { + "username": "instructor2", + "password": "aqwzsx(t4", + }, + ] + passwords = {user["username"]: user["password"] for user in users} + def login( self, - username: Optional[str] = username, - password: Optional[str] = password, + username: Optional[str] = None, + password: Optional[str] = None, anonymous_ok: bool = False, ) -> None: + """ + Ensure that this GitLabTest session is authenticated + + This behaves as GitLab.login, with two additional features: + + - If a username is given from the list of predefined users in + `self.users`, then the password is filled by default + automatically. + + - If no username is provided, and not logged in, then the + session is authenticated as "student1" (mimicking the usual + behavior of the current user typing the credentials + interactively). + """ + if username is None and self.token is None: + username = "student1" + if username is not None: + password = self.passwords.get(username) super().login(username=username, password=password, anonymous_ok=anonymous_ok) + self.log.info(f"LOGIN: {self.get_current_user().username} {username}") + assert ( + username is None + or anonymous_ok + or self.get_current_user().username == username + ) + + @contextlib.contextmanager + def logged_as( + self, + username: Optional[str] = None, + password: Optional[str] = None, + anonymous_ok: bool = False, + ) -> Iterator: + """ + Defines a context for this gitlab session with temporary + authentication as another user + + Example: + + >>> gitlab = getfixture('gitlab') + >>> gitlab.login('student1') + >>> gitlab.get_current_user().username + 'student1' + >>> with gitlab.logged_as('student2'): + ... gitlab.get_current_user().username + 'student2' + >>> gitlab.get_current_user().username + 'student1' + + >>> gitlab.logout() + >>> gitlab.get_current_user() + Traceback (most recent call last): + ... + requests.exceptions.HTTPError:... + >>> with gitlab.logged_as('student2'): + ... gitlab.get_current_user().username + 'student2' + >>> gitlab.get_current_user() + Traceback (most recent call last): + ... + requests.exceptions.HTTPError:... + + >>> gitlab.login('anonymous', anonymous_ok=True) + >>> gitlab.get_current_user().username + 'anonymous' + >>> with gitlab.logged_as('student2'): + ... gitlab.get_current_user().username + 'student2' + >>> gitlab.get_current_user().username # doctest: +SKIP + 'student1' + """ + save_token = self.token + try: + self.logout() + assert self._current_user is None + self.login(username=username, password=password, anonymous_ok=anonymous_ok) + yield self + finally: + self.logout() + if save_token is not None: + self.set_token(save_token) + # Maybe something needs to be done to restore anonymous login? diff --git a/travo/locale/travo.en.yml b/travo/locale/travo.en.yml index 366c9e65..7c1c6dfd 100644 --- a/travo/locale/travo.en.yml +++ b/travo/locale/travo.en.yml @@ -103,3 +103,5 @@ en: Number of submissions: "#submission: %{number_of_submissions}" last release: "Last release: %{visibility} %{date}" Needs manual grading: "Needs manual grading: %{counts} \nGrades: %{mean}+/-%{dev}" + initializing submission: "Initializing the submission from the assignment" + your submission: "Your submission" diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index 100267d0..f6fc65fb 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -121,7 +121,9 @@ fr: update view: "Mettre à jour" fetch from: "Télécharger ou mettre à jour depuis" downloading: "Téléchargement" - "save before": "Sauvegarde avant %{what}" - "merging": "intégration de %{content}" - "try to merge": "Tentative d'intégration" - "no content": "Pas de %{content}" + save before: "Sauvegarde avant %{what}" + merging: "intégration de %{content}" + try to merge: "Tentative d'intégration" + no content": "Pas de %{content}" + initializing submission: "Initialisation de la soumission à partir du devoir" + your submission: "Votre submission" diff --git a/travo/tests/test_assignement.py b/travo/tests/test_assignement.py index 52138680..dad8cc8d 100644 --- a/travo/tests/test_assignement.py +++ b/travo/tests/test_assignement.py @@ -1,34 +1,21 @@ import os -from travo.gitlab import Project -from travo.assignment import Assignment +from travo.assignment import Assignment, Submission +from travo.utils import working_directory -def test_merge_from( +def test_collect( standalone_assignment: Assignment, - standalone_assignment_submission: Project, - standalone_assignment_dir: str, -) -> None: - assignment = standalone_assignment - assignment_dir = standalone_assignment_dir - assignment.fetch(assignment_dir=assignment_dir) - assert os.path.isfile(os.path.join(assignment_dir, "README.md")) - assert not os.path.isfile(os.path.join(assignment_dir, "newfile")) - submission = standalone_assignment_submission - submission.repo.ensure_file("newfile", branch="master") - assignment.merge_from(submission.repo, assignment_dir=assignment_dir) - assert os.path.isfile(os.path.join(assignment_dir, "newfile")) - - -def test_collect_assignment( - standalone_assignment: Assignment, standalone_assignment_submission: Project + standalone_assignment_submission: Submission, + tmp_path: str, ) -> None: assignment = standalone_assignment student = standalone_assignment_submission.student - assignment.collect() - assert os.path.isdir(student) - assert os.path.isfile(os.path.join(student, "README.md")) + with working_directory(tmp_path): + assignment.collect() + assert os.path.isdir(os.path.join(tmp_path, student)) + assert os.path.isfile(os.path.join(tmp_path, student, "README.md")) assignment.collect(template="foo/bar-{path}-{username}") assert os.path.isdir(f"foo/bar-{assignment.name}-{student}") @@ -59,6 +46,8 @@ def test_fetch_from_empty_submission_repo( # The submission repository should now have a single branch named # master, and be a fork of the assignment repository my_repo = forge.get_project(path=assignment.submission_path()) + # There may be a race condition here; on at least one occasion + # the branch was not yet available when running the tests locally (branch,) = my_repo.get_branches() assert branch["name"] == "master" assert my_repo.forked_from_project is not None diff --git a/travo/tests/test_course.py b/travo/tests/test_course.py index 0eea5b7a..a7d67d93 100644 --- a/travo/tests/test_course.py +++ b/travo/tests/test_course.py @@ -3,7 +3,7 @@ import pytest import os from travo.console_scripts import Travo from travo.course import Course -from travo.gitlab import GitLabTest +from travo.utils import working_directory @pytest.mark.parametrize("embed_option", [False, True]) @@ -47,10 +47,10 @@ def test_group_submission_true(rich_course): ) -def test_check_course_parameters(): +def test_check_course_parameters(gitlab): i18n.set("locale", "en") course = Course( - forge=GitLabTest(), + forge=gitlab, path="Info111", name="Info 111 Programmation Impérative", student_dir="~/ProgImperative", @@ -77,62 +77,70 @@ def test_course_share_with(course): i18n.set("locale", "en") with pytest.raises(RuntimeError, match="No submission on GitLab"): - course.share_with(username="travo-test-etu", assignment_name="Assignment1") + course.share_with(username="student1", assignment_name="Assignment1") -def test_course_collect(rich_course, user_name, tmp_path): +def test_course_collect( + gitlab, rich_course_deployed, to_be_teared_down, user_name, tmp_path +): + rich_course = rich_course_deployed assignments = rich_course.assignments student_groups = rich_course.student_groups - - for group in student_groups: - group_path = rich_course.assignments_group_path + "/" + group - group_name = group - rich_course.forge.ensure_group( - path=group_path, name=group_name, visibility="public" - ) - for assignment in assignments: - path = rich_course.assignments_group_path + "/" + assignment - project = rich_course.forge.ensure_project( - path, assignment, visibility="public" + # Deploy the course assignments ("Assignment1" and "Assignment2") + with gitlab.logged_as("instructor1"): + for group in student_groups: + group_path = rich_course.assignments_group_path + "/" + group + group_name = group + rich_course.forge.ensure_group( + path=group_path, name=group_name, visibility="public" ) - project.ensure_file("README.md", branch="master") - path = group_path + "/" + assignment - project.ensure_fork(path, assignment, visibility="public") - - os.chdir(tmp_path) - rich_course.fetch("Assignment1") - rich_course.submit("Assignment1", student_group="Group1") - - rich_course.collect("Assignment1") - assert os.path.isdir(user_name) - - rich_course.collect( - "Assignment1", - student_group="Group1", - template="collect-{path}-Group1/{username}", - ) - assert os.path.isdir(f"collect-Assignment1-Group1/{user_name}") - - rich_course.collect( - "Assignment1", - student_group="Group2", - template="collect-{path}-Group2/{username}", - ) - assert not os.path.isdir(f"collect-Assignment1-Group2/{user_name}") + for assignment in assignments: + path = rich_course.assignments_group_path + "/" + assignment + project = rich_course.forge.ensure_project( + path=path, name=assignment, visibility="public" + ) + project.ensure_file("README.md", branch="master") + path = group_path + "/" + assignment + project.ensure_fork( + path, assignment, visibility="public", initialized=True + ) + + assignment = rich_course.assignment("Assignment1", student_group="Group1") + for student_name in ["student1", "student2"]: + with gitlab.logged_as(student_name): + to_be_teared_down(assignment.ensure_submission_repo(initialized=True)) + + with gitlab.logged_as("instructor1"): + with working_directory(tmp_path): + rich_course.collect("Assignment1") + assert os.path.isdir(os.path.join(tmp_path, "student1")) + assert os.path.isdir(os.path.join(tmp_path, "student2")) + + rich_course.collect( + "Assignment1", + student_group="Group1", + template="collect-{path}-Group1/{username}", + ) + assert os.path.isdir("collect-Assignment1-Group1/student1") + assert os.path.isdir("collect-Assignment1-Group1/student2") - rich_course.collect_in_submitted( - "Assignment1", - student_group="Group1", - ) - assert os.path.isdir(f"submitted/{user_name}") + rich_course.collect( + "Assignment1", + student_group="Group2", + template="collect-{path}-Group2/{username}", + ) + assert not os.path.isdir("collect-Assignment1-Group2/student1") - # Clean after test - # Commented out for now for the next test should pass - # This is bad as tests should be independent! - # rich_course.forge.remove_group(rich_course.assignments_group_path, force=True) + rich_course.collect_in_submitted( + "Assignment1", + student_group="Group1", + ) + assert os.path.isdir("submitted/student1") + assert os.path.isdir("submitted/student2") -def test_course_generate_and_release(rich_course): +def test_course_generate_and_release(gitlab, rich_course_deployed): + rich_course = rich_course_deployed rich_course.group_submissions = True source_dir = "./source" release_dir = "./release" @@ -149,31 +157,19 @@ def test_course_generate_and_release(rich_course): rich_course.source_dir = source_dir rich_course.gitlab_ci_yml = "my gitlab-ci.yml file" - # generate the assignment before release - rich_course.generate_assignment(assignment_name) - - assert os.path.isdir(release_dir) - assert os.path.isdir(os.path.join(release_dir, assignment_name)) - assert os.path.isdir(os.path.join(release_dir, assignment_name, ".git")) - assert os.path.isfile(os.path.join(release_dir, assignment_name, ".gitlab-ci.yml")) - assert os.path.isfile(os.path.join(release_dir, assignment_name, ".gitignore")) - - rich_course.forge.ensure_group( - path=rich_course.path, - name=rich_course.name, - visibility="public", - ) - - rich_course.forge.ensure_group( - path=rich_course.assignments_group_path, - name=rich_course.session_name, - visibility="public", - ) + with gitlab.logged_as("instructor1"): + # generate the assignment before release + rich_course.generate_assignment(assignment_name) - rich_course.release( - assignment_name, - path=os.path.join(release_dir, assignment_name), - ) + assert os.path.isdir(release_dir) + assert os.path.isdir(os.path.join(release_dir, assignment_name)) + assert os.path.isdir(os.path.join(release_dir, assignment_name, ".git")) + assert os.path.isfile( + os.path.join(release_dir, assignment_name, ".gitlab-ci.yml") + ) + assert os.path.isfile(os.path.join(release_dir, assignment_name, ".gitignore")) - # Clean after test - rich_course.forge.remove_group(rich_course.assignments_group_path, force=True) + rich_course.release( + assignment_name, + path=os.path.join(release_dir, assignment_name), + ) diff --git a/travo/utils.py b/travo/utils.py index 84b46d69..3ae6e07e 100755 --- a/travo/utils.py +++ b/travo/utils.py @@ -1,9 +1,12 @@ import subprocess import urllib import urllib.parse -from typing import cast, Any, Sequence, Optional +from typing import cast, Any, Iterator, Sequence, Optional import logging import colorlog # type: ignore +import os +from contextlib import contextmanager + _logger: Optional[logging.Logger] = None @@ -103,3 +106,31 @@ def git_get_origin(cwd: str = ".") -> str: lines = result.stdout.decode().splitlines() assert lines return cast(str, lines[0]) + + +@contextmanager +def working_directory(path: str) -> Iterator: + """ + A context manager which changes the working directory to the given + path, and then changes it back to its previous value on exit. + + Example: + + >>> tmp_path = getfixture("tmp_path") + >>> dirname = "this-is-a-long-directory-name" + + This creates a directory in `tmp_path`, instead of in the current + working directory: + + >>> with working_directory(tmp_path): + ... os.mkdir(dirname) + + >>> assert os.path.exists(os.path.join(tmp_path, dirname)) + >>> assert not os.path.exists(dirname) + """ + prev_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(prev_cwd) -- GitLab From 0bdde36384909d70da25eb36e810027488aa84a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Sun, 1 Sep 2024 21:57:55 +0200 Subject: [PATCH 03/24] Typo --- build_tools/create_basic_gitlab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build_tools/create_basic_gitlab.py b/build_tools/create_basic_gitlab.py index 8da18e53..ba18ca5c 100644 --- a/build_tools/create_basic_gitlab.py +++ b/build_tools/create_basic_gitlab.py @@ -147,7 +147,7 @@ commits_data = { } try: - project.commits.create(data) + project.commits.create(commits_data) except gitlab.exceptions.GitlabCreateError as e: print(f"file already committed: {e.error_message}") -- GitLab From 0503d5d9cf9de1ffe8856625d44efc0c12ebf219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Mon, 2 Sep 2024 18:54:54 +0200 Subject: [PATCH 04/24] Improve test robustness with test course paths and names parametrised by test run id --- conftest.py | 16 ++++++++-------- travo/course.py | 22 +++++++++++----------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/conftest.py b/conftest.py index 52860af5..fe7021db 100644 --- a/conftest.py +++ b/conftest.py @@ -33,7 +33,7 @@ def test_run_id() -> str: """ A (statistically) unique id for this test run """ - now = datetime.datetime.now().isoformat().replace(":", "-") + now = datetime.datetime.now().isoformat().replace(":", "_").replace("-", "_") return ( now + "-" + "".join(random.choices(string.ascii_uppercase + string.digits, k=5)) ) @@ -118,7 +118,7 @@ def standalone_assignment( ) -> Iterator[Assignment]: repo = gitlab.ensure_project( path=f"TestGroup/TestAssignment-{test_run_id}", - name=f"Test assignment - {test_run_id}", + name=f"Test assignment {test_run_id}", visibility="public", ) repo.ensure_file("README.md", branch="master") @@ -185,27 +185,27 @@ def standalone_assignment_submission( @pytest.fixture def course(gitlab: GitLabTest) -> Course: return Course( - name="Test course", forge=gitlab, path="TestCourse", - student_dir="~/TestCourse", + name="Test course", assignments_group_path="TestCourse/2020-2021", + student_dir="~/TestCourse", group_submissions=False, ) # course.log.setLevel("DEBUG") @pytest.fixture -def rich_course(gitlab: GitLabTest) -> Course: +def rich_course(gitlab: GitLabTest, test_run_id: str) -> Course: # The course path and name could be randomized to enable parallel tests return Course( - name="Test course", forge=gitlab, - path="TestCourse", + path=f"TestRichCourse-{test_run_id}", + name=f"Test rich course {test_run_id}", session_path="2020-2021", assignments=["Assignment1", "Assignment2"], student_groups=["Group1", "Group2"], - student_dir="~/TestCourse", + student_dir="~/RichCourse", group_submissions=False, ) diff --git a/travo/course.py b/travo/course.py index 2bc91da8..761530fa 100755 --- a/travo/course.py +++ b/travo/course.py @@ -92,14 +92,14 @@ class CourseAssignment(Assignment): >>> assignment = course.assignment("SubCourse/Assignment1") >>> assignment.submission_path_components() - ('student1', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') + ('student1', 'TestRichCourse-...', '2020-2021', 'SubCourse', 'Assignment1') >>> assignment.submission_path_components(username="john.doo") - ('john.doo', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') + ('john.doo', 'TestRichCourse-...', '2020-2021', 'SubCourse', 'Assignment1') >>> course.group_submissions = True >>> assignment.submission_path_components(username="john.doo") - ('john-doo-travo', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') + ('john-doo-travo', 'TestRichCourse-...', '2020-2021', 'SubCourse', 'Assignment1') >>> course.path='TestModule/TestCourse' >>> assignment.submission_path_components() @@ -126,19 +126,19 @@ class CourseAssignment(Assignment): >>> course = getfixture("rich_course") >>> course.assignment("SubCourse/Assignment1").submission_path() - 'student1/TestCourse-2020-2021-SubCourse-Assignment1' + 'student1/TestRichCourse-...-2020-2021-SubCourse-Assignment1' >>> course.assignment("SubCourse/Assignment1", ... student_group="Group1").submission_path() - 'student1/TestCourse-2020-2021-SubCourse-Assignment1' + 'student1/TestRichCourse-...-2020-2021-SubCourse-Assignment1' More examples with grouped submissions: >>> course.group_submissions = True >>> assignment = course.assignment("SubCourse/Assignment1") >>> assignment.submission_path() - 'student1-travo/TestCourse/2020-2021/SubCourse/Assignment1' + 'student1-travo/TestRichCourse-.../2020-2021/SubCourse/Assignment1' >>> assignment.submission_path(username="john.doo") - 'john-doo-travo/TestCourse/2020-2021/SubCourse/Assignment1' + 'john-doo-travo/TestRichCourse-.../2020-2021/SubCourse/Assignment1' """ components = self.submission_path_components(username) if self.course.group_submissions: @@ -160,14 +160,14 @@ class CourseAssignment(Assignment): >>> assignment.submission_name_components() ('Étudiant de test pour travo', - 'Test course', '2020-2021', 'SubCourse', 'Assignment1') + 'Test rich course ...', '2020-2021', 'SubCourse', 'Assignment1') >>> from travo.i18n import _ >>> import i18n >>> i18n.set('locale', 'fr') >>> course.group_submissions = True >>> assignment.submission_name_components() (...Étudiant de test pour travo..., - 'Test course', '2020-2021', 'SubCourse', 'Assignment1') + 'Test rich course ...', '2020-2021', 'SubCourse', 'Assignment1') Test: @@ -209,7 +209,7 @@ class CourseAssignment(Assignment): >>> course = getfixture("rich_course") >>> course.forge.login() >>> course.assignment("SubCourse/Assignment1").submission_name() - 'Test course - 2020-2021 - SubCourse - Assignment1' + 'Test rich course ... - 2020-2021 - SubCourse - Assignment1' """ components = self.submission_name_components() if self.course.group_submissions: @@ -287,7 +287,7 @@ class CourseAssignment(Assignment): unknown >>> course.assignment("SubCourse/Assignment1", ... student_group="Group1").submissions_forked_from_path() - 'TestCourse...2020-2021/SubCourse/Group1/Assignment1' + 'TestRichCourse-.../2020-2021/SubCourse/Group1/Assignment1' """ if self.leader_name is not None: return super().submissions_forked_from_path() -- GitLab From 0fcba0ac722cf1f9eddcd5dc7340129e031d194f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Mon, 2 Sep 2024 20:59:59 +0200 Subject: [PATCH 05/24] Update test outputs w.r.t. latest changes --- travo/assignment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/travo/assignment.py b/travo/assignment.py index 16be7c52..9d681081 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -128,7 +128,7 @@ class Assignment: >>> assignment = getfixture("standalone_assignment") >>> assignment.submission_name() - 'Test assignment - 2...' + 'Test assignment 2...' """ return self.repo().name @@ -222,7 +222,7 @@ class Assignment: >>> my_repo.path 'TestAssignment-20...' >>> my_repo.name - 'Test assignment - 20...' + 'Test assignment 20...' >>> assert my_repo.forked_from_project.id == repo.id >>> my_repo = assignment.ensure_submission_repo() -- GitLab From 7c1e056e87bc26e80f82baa02a9d1df6ed7f8ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Tue, 3 Sep 2024 23:25:11 +0200 Subject: [PATCH 06/24] Disable action "fetch_from" which is not ready yet --- travo/dashboards.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/travo/dashboards.py b/travo/dashboards.py index 599d6fe7..d1c9684a 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -211,7 +211,7 @@ class FormDialog(ipywidgets.HBox): return ipywidgets.Checkbox() if T is str: return ipywidgets.Text() - if isinstance(T, tuple): + if isinstance(T, (tuple, dict)): return ipywidgets.Dropdown(options=T) raise ValueError(f"Don't know how to produce a widget for type {T}") @@ -467,7 +467,7 @@ class AssignmentStudentDashboard(HBox): description="", options=[ (_("other actions"), ""), - (_("fetch from"), "fetch from"), + # (_("fetch from"), "fetch from"), # not ready yet (_("set student group"), "set student group"), (_("share with"), "share with"), (_("set main submission"), "set main submission"), @@ -666,14 +666,15 @@ class AssignmentStudentDashboard(HBox): self.assignment.remove_submission(force=True) elif action == "fetch from": - origins = {"assignment": self.assignment.repo()} + # TODO this is not quite yet ready + sources = {"assignment": self.assignment.repo()} status = self.assignment.status() assert status.team is not None for username in status.team.keys(): - origins[username] = status.team[username] + sources[username] = status.team[username] command = self.assignment.merge_from input = { - "origin": str, + "source": sources, "on_conflict": ( "abort", "backup and fetch", -- GitLab From cd8a5bf27d6608bed5dadd5a7f64470cd299e57f Mon Sep 17 00:00:00 2001 From: Chiara Marmo Date: Wed, 4 Sep 2024 19:17:04 +0200 Subject: [PATCH 07/24] Fix buggy merge. --- travo/course.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/travo/course.py b/travo/course.py index a8ad7709..6c23ff8f 100755 --- a/travo/course.py +++ b/travo/course.py @@ -92,7 +92,7 @@ class CourseAssignment(Assignment): >>> assignment = course.assignment("SubCourse/Assignment1") >>> assignment.submission_path_components() - ('student1', 'TestCourse', '2020-2021', 'SubCourse', 'Assignment1') + ('student1', 'TestRichCourse-...', '2020-2021', 'SubCourse', 'Assignment1') >>> assignment.submission_path_components(username="john.doo") ('john.doo', 'TestRichCourse-...', '2020-2021', 'SubCourse', 'Assignment1') -- GitLab From fa5de5d1306b5ea47adc5e2204f38d1ee9033611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Wed, 2 Oct 2024 21:29:41 +0200 Subject: [PATCH 08/24] Assignment.merge_from: support merging updates and errata as anonymous --- travo/assignment.py | 40 ++++++++++++++++++++++------------------ travo/gitlab.py | 9 +++++++-- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/travo/assignment.py b/travo/assignment.py index 9d681081..6e8f2f67 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -456,6 +456,7 @@ class Assignment: branch: Optional[str] = None, content: str = "", on_failure: Literal["warning", "error"] = "error", + anonymous_ok: bool = False, ) -> bool: """ Try to merge content in the assignment directory @@ -543,7 +544,9 @@ class Assignment: # self.check_assignment("$assignment") # Share this with fetch def git(args: List[str], **kwargs: Any) -> subprocess.CompletedProcess: - return self.forge.git(args, cwd=assignment_dir, **kwargs) + return self.forge.git( + args, cwd=assignment_dir, anonymous_ok=anonymous_ok, **kwargs + ) save_msg = _("save before", what=msg) self.log.info("- " + save_msg) @@ -653,23 +656,24 @@ class Assignment: """ ) raise RuntimeError(_("fetch failed conflict")) - if user is not anonymous_user: - # Currently anonymous users won't get updates, as - # merge_from may require a commit which requires git to be - # configured. We could try harder if there is a use case - # for it. - self.forge.ensure_local_git_configuration(assignment_dir) - repo = self.repo() - self.merge_from( - source=repo, assignment_dir=assignment_dir, content=_("updates") - ) - self.merge_from( - source=repo, - assignment_dir=assignment_dir, - branch="errata", - content=_("erratas"), - on_failure="warning", - ) + + # Fetch updates and errata + self.forge.ensure_local_git_configuration(assignment_dir) + repo = self.repo() + self.merge_from( + source=repo, + assignment_dir=assignment_dir, + content=_("updates"), + anonymous_ok=True, + ) + self.merge_from( + source=repo, + assignment_dir=assignment_dir, + branch="errata", + content=_("erratas"), + on_failure="warning", + anonymous_ok=True, + ) def submit( self, assignment_dir: Optional[str] = None, leader_name: Optional[str] = None diff --git a/travo/gitlab.py b/travo/gitlab.py index b560dffc..a0a12252 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -832,11 +832,14 @@ class GitLab: cwd=dir, capture_output=True, check=False, + anonymous_ok=True, ) - if res.returncode != 0: + if res.returncode != 0 or res.stdout.decode() == "anonymous\n": user = self.get_current_user() self.git( - ["config", "--local", f"user.{item}", getattr(user, item)], cwd=dir + ["config", "--local", f"user.{item}", getattr(user, item)], + cwd=dir, + anonymous_ok=True, ) def collect_forks( @@ -2695,6 +2698,8 @@ class User(Resource): @dataclass class AnonymousUser: username: str = "anonymous" + name: str = "Anonymous" + email: str = "anonymous mail" anonymous_user = AnonymousUser() -- GitLab From bdc0846c56639d045438d29ab3b974bc8f2c08ef Mon Sep 17 00:00:00 2001 From: Chiara Marmo Date: Mon, 14 Oct 2024 16:05:35 +0200 Subject: [PATCH 09/24] Fix bad merge --- travo/gitlab.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/travo/gitlab.py b/travo/gitlab.py index a0a12252..ef1701c2 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -2736,17 +2736,6 @@ class GitLabTest(GitLab): base_url: str = "http://gitlab/" - def __init__(self, base_url: str = base_url) -> None: - if "GITLAB_HOST" in os.environ and "GITLAB_80_TCP_PORT" in os.environ: - base_url = ( - f"http://{os.environ['GITLAB_HOST']}:{os.environ['GITLAB_80_TCP_PORT']}" - ) - self.tempdir: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory() - super().__init__(base_url=base_url, home_dir=self.tempdir.name) - - def confirm(self, message: str) -> bool: - return True - # The following data should be shared with build_tools/create_basic_gitlab.py users = [ { @@ -2755,23 +2744,43 @@ class GitLabTest(GitLab): }, { "username": "student1", + "email": "travo@gmail.com", + "name": "Étudiant de test pour travo", "password": "aqwzsx(t1", + "can_create_group": "True", }, { "username": "student2", + "email": "student2@foo.bar", + "name": "Student 2", "password": "aqwzsx(t2", }, { "username": "instructor1", + "email": "instructor1@foo.bar", + "name": "Instructor 1", "password": "aqwzsx(t3", }, { "username": "instructor2", + "email": "instructor2@foo.bar", + "name": "Instructor 2", "password": "aqwzsx(t4", }, ] passwords = {user["username"]: user["password"] for user in users} + def __init__(self, base_url: str = base_url) -> None: + if "GITLAB_HOST" in os.environ and "GITLAB_80_TCP_PORT" in os.environ: + base_url = ( + f"http://{os.environ['GITLAB_HOST']}:{os.environ['GITLAB_80_TCP_PORT']}" + ) + self.tempdir: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory() + super().__init__(base_url=base_url, home_dir=self.tempdir.name) + + def confirm(self, message: str) -> bool: + return True + def login( self, username: Optional[str] = None, -- GitLab From 388516deeabce570502c7501ec1498833134ec2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Mon, 14 Oct 2024 19:27:24 +0000 Subject: [PATCH 10/24] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Chiara Marmo <9886115-cmarmo@users.noreply.gitlab.com> --- travo/gitlab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/travo/gitlab.py b/travo/gitlab.py index ef1701c2..0ae04fa8 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -391,7 +391,7 @@ class GitLab: Recall that, in GitLab's terminology, a namespace is either a group or a user's home. - Linitation: the current implementation requires the user to be + Limitation: the current implementation requires the user to be logged in and to be at least maintainer of the namespace. This is ok since this method is currently only used for creating projects or subgroups in the namespace). However the error -- GitLab From 506e93f150660c9d25945cc5890e059a17f8b8d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Wed, 20 Nov 2024 23:45:56 +0100 Subject: [PATCH 11/24] Assignment.ensure_main_submission: add documentation and (tentative) test --- travo/assignment.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/travo/assignment.py b/travo/assignment.py index 273c74ad..93c006c4 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -878,6 +878,44 @@ class Assignment: repo.share_with(user, access=access_level) def ensure_main_submission(self, leader_name: str) -> None: + """ + Ensure that the main submission of the team of the current + user for this submission is leader_name's + + That is that this user's submission is configured as a fork of + `leader_name`'s submission. + + Example: + + Let's create an assignment with submissions of student1 and student2: + + >>> gitlab = getfixture("gitlab") + >>> to_be_teared_down = getfixture("to_be_teared_down") + >>> assignment = getfixture("standalone_assignment") + >>> for student_name in ["student1", "student2"]: + ... with gitlab.logged_as(student_name): + ... to_be_teared_down(\ + ... assignment.ensure_submission_repo(initialized=True)) + + student2 grants student1 access to their submission: + + >>> with gitlab.logged_as("student2"): + ... assignment.share_with("student1") + + student1 sets student2's submission as main submission for their team: + + >>> assignment.ensure_main_submission("student2") + + We test that student1's submission is a for of student2's + submission, which itself is a fork of the assignment: + + >>> submission_repo = assignment.submission_repo() + >>> leader_repo = assignment.submission_repo("student2") + >>> assert submission_repo.forked_from_project.path_with_namespace == \ + ... leader_repo.path_with_namespace + >>> assert leader_repo.forked_from_project.path_with_namespace == \ + ... assignment.repo_path + """ try: repo = self.submission_repo() except ResourceNotFoundError: -- GitLab From 2fc2e425a69cb42c53a8db76faf615a7af9609db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Thu, 21 Nov 2024 22:50:46 +0100 Subject: [PATCH 12/24] Assignment.ensure_main_submission: finalize documentation and test --- travo/assignment.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/travo/assignment.py b/travo/assignment.py index 93c006c4..47fde583 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -879,11 +879,14 @@ class Assignment: def ensure_main_submission(self, leader_name: str) -> None: """ - Ensure that the main submission of the team of the current - user for this submission is leader_name's + Ensure that the main submission is set for this submission. - That is that this user's submission is configured as a fork of - `leader_name`'s submission. + This is meant for team work on a submission. The principle is + as follow: among all the submissions for the members of team, + one is chosen as main submission; its owner is called leader + of the team. Following a typical collaboration pattern with + forges, all the others submissions are configured to be forks + of the main submission. Example: @@ -894,27 +897,36 @@ class Assignment: >>> assignment = getfixture("standalone_assignment") >>> for student_name in ["student1", "student2"]: ... with gitlab.logged_as(student_name): - ... to_be_teared_down(\ + ... to_be_teared_down( ... assignment.ensure_submission_repo(initialized=True)) + student1 and student2 decide together that student2's + submission will be the main submission (so student2 is called + the team leader). + student2 grants student1 access to their submission: >>> with gitlab.logged_as("student2"): ... assignment.share_with("student1") - student1 sets student2's submission as main submission for their team: + student1 can now set student2's submission as main submission: >>> assignment.ensure_main_submission("student2") - We test that student1's submission is a for of student2's + If there are more students in the team, the same needs to be + done for each of them. They also probably want to grant access + to their submissions to the other members of the team, but + that's optional. + + We test that student1's submission is a fork of student2's submission, which itself is a fork of the assignment: >>> submission_repo = assignment.submission_repo() >>> leader_repo = assignment.submission_repo("student2") - >>> assert submission_repo.forked_from_project.path_with_namespace == \ - ... leader_repo.path_with_namespace - >>> assert leader_repo.forked_from_project.path_with_namespace == \ - ... assignment.repo_path + >>> assert (submission_repo.forked_from_project.path_with_namespace == + ... leader_repo.path_with_namespace) + >>> assert (leader_repo.forked_from_project.path_with_namespace == + ... assignment.repo_path) """ try: repo = self.submission_repo() -- GitLab From 618243f94999e899e509266a2f9f6fb7dc680326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Thu, 21 Nov 2024 23:14:04 +0100 Subject: [PATCH 13/24] tests/test_utils: use LC_ALL to force messages to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the recommended practice of gettext's documentation: "LC_ALL is an environment variable that overrides all of these. It is typically used in scripts that run particular programs. For example, configure scripts generated by GNU autoconf use LC_ALL to make sure that the configuration tests don’t operate in locale dependent ways." https://www.gnu.org/software/gettext/manual/gettext.html#Locale-Environment-Variables --- travo/tests/test_utils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/travo/tests/test_utils.py b/travo/tests/test_utils.py index 97433828..f54f3717 100644 --- a/travo/tests/test_utils.py +++ b/travo/tests/test_utils.py @@ -6,16 +6,14 @@ from travo.utils import git_get_origin def test_get_origin_error(tmp_path: str) -> None: + os.environ["LC_ALL"] = "C" + os.chdir(tmp_path) with pytest.raises(RuntimeError, match="fatal: not a git repository"): - os.environ["LANG"] = "en_US.UTF-8" - os.environ["LANGUAGE"] = "C" # The directory is not a git repository git_get_origin() subprocess.run(["git", "init", "--quiet"], cwd=tmp_path) with pytest.raises(RuntimeError, match="error: No such remote 'origin'"): - os.environ["LANG"] = "en_US.UTF-8" - os.environ["LANGUAGE"] = "C" # The remote origin is not defined git_get_origin() -- GitLab From a00a7f1ca598e971dfaebc9067ec84fe00ef60da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Tue, 26 Nov 2024 21:58:22 +0100 Subject: [PATCH 14/24] Add link to the doc of locale environment variables for future ref --- travo/tests/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/travo/tests/test_utils.py b/travo/tests/test_utils.py index f54f3717..34629123 100644 --- a/travo/tests/test_utils.py +++ b/travo/tests/test_utils.py @@ -6,6 +6,8 @@ from travo.utils import git_get_origin def test_get_origin_error(tmp_path: str) -> None: + # Forces english locale for testing. See: + # https://www.gnu.org/software/gettext/manual/html_node/Locale-Environment-Variables.html os.environ["LC_ALL"] = "C" os.chdir(tmp_path) -- GitLab From 763450c6c4be915faf620cac57a42fa7379212a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Tue, 26 Nov 2024 21:59:28 +0100 Subject: [PATCH 15/24] Locale file fix --- travo/locale/travo.fr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index f6fc65fb..d6ac75bb 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -124,6 +124,6 @@ fr: save before: "Sauvegarde avant %{what}" merging: "intégration de %{content}" try to merge: "Tentative d'intégration" - no content": "Pas de %{content}" + no content: "Pas de %{content}" initializing submission: "Initialisation de la soumission à partir du devoir" your submission: "Votre submission" -- GitLab From acedb54be8ebc121d0aff3ca4c09001c8ba23f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Tue, 26 Nov 2024 22:44:27 +0100 Subject: [PATCH 16/24] Assignment.get_leader_and_team: add doctest --- travo/assignment.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/travo/assignment.py b/travo/assignment.py index 47fde583..723473b3 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -913,6 +913,9 @@ class Assignment: >>> assignment.ensure_main_submission("student2") + >>> assignment.get_leader_and_team() + ("student2", {"student2": ..., "student"1: ...}) + If there are more students in the team, the same needs to be done for each of them. They also probably want to grant access to their submissions to the other members of the team, but -- GitLab From 684e07c922e79f0511092ac1d18ae3b18e18b360 Mon Sep 17 00:00:00 2001 From: Chiara Marmo Date: Wed, 27 Nov 2024 10:25:45 +0100 Subject: [PATCH 17/24] Fix docstring test --- travo/assignment.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/travo/assignment.py b/travo/assignment.py index 723473b3..a99e9fd3 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -912,9 +912,8 @@ class Assignment: student1 can now set student2's submission as main submission: >>> assignment.ensure_main_submission("student2") - - >>> assignment.get_leader_and_team() - ("student2", {"student2": ..., "student"1: ...}) + >>> assignment.submission().get_leader_and_team() + ('student2', {'student2': ..., 'student1': ...}) If there are more students in the team, the same needs to be done for each of them. They also probably want to grant access -- GitLab From 591784d008d60841d7e70a0790ce8b6ecd2995ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Thu, 28 Nov 2024 23:54:49 +0100 Subject: [PATCH 18/24] Locale: fixed missing or unused messages. Now the same localized messages are available in French and English --- travo/locale/travo.en.yml | 24 +++++++++++++++++++++++- travo/locale/travo.fr.yml | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/travo/locale/travo.en.yml b/travo/locale/travo.en.yml index 7c1c6dfd..705ea151 100644 --- a/travo/locale/travo.en.yml +++ b/travo/locale/travo.en.yml @@ -51,7 +51,7 @@ en: invalid token environment variable: "Invalid or expired TRAVO_TOKEN environment variable." sign in: "Sign in" authentication required: "Authentification required" - abandon conflicting merge: "Integrating %{content} would cause a conflit; abandon" + abort conflicting merge: "Integrating %{content} would cause a conflit; abandon" updates: "updates" erratas: erratas submission failed: "submission failed: try to fetch first?" @@ -105,3 +105,25 @@ en: Needs manual grading: "Needs manual grading: %{counts} \nGrades: %{mean}+/-%{dev}" initializing submission: "Initializing the submission from the assignment" your submission: "Your submission" + share with: "Share with (team work)" + no main submission: "%{leader_name} has not submitted %{assignment_name} on GitLab or did not share it with you" + set main submission: "Set main submission (team work)" + No need to launch Jupyter on JupyterHub: "No need to launch Jupyter on JupyterHub" + "Can't open work dir: assignment_dir is not set": "Can't open work dir: assignment_dir is not set" + remove submission: "Remove submission" + set student group: "Set student group" + cancel: "Cancel" + validate: "Validate" + confirm: "Are you sure?" + other actions: "Other actions" + waiting for information: "Waiting for information" + canceled: "Canceled" + cannot set self as leader: "Cannot set self as leader" + missing assignment directory: "Missing assignment directory; fetch your assignment?" + update view: "Update view" + fetch from: "Fetch from" + downloading: "Downloading" + save before: "Saving before %{what}" + merging: "Merging %{content}" + try to merge: "Trying to merge" + no content: "No %{content}" diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index d6ac75bb..5ec387a5 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -9,6 +9,7 @@ fr: assignment: "Devoir" assignment repository: "Sujet" work directory: "Répertoire de travail" + repository: "Dépôt git" submission: "Dépôt" score: "Score" success: "Succès" @@ -49,7 +50,6 @@ fr: show command log: "Afficher la trace d'exécution des opérations" command log level: "Configurer le niveau de détail de la trace d'exécution des opérations" student group: "Groupe" - student_group: "Groupe" invalid credentials: "Échec de l'authentification. Identifiant ou mot de passe incorrect?" invalid token environment variable: "Variable d'environnement TRAVO_TOKEN invalide ou expirée." sign in: "Authentification" -- GitLab From 1d0d2d02ddf65d6ffbf2710cdb588be7a45c5a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Fri, 29 Nov 2024 00:06:07 +0100 Subject: [PATCH 19/24] Assignment.check_assignment_dir: add doctest + i18n --- travo/assignment.py | 25 ++++++++++++++++++++++++- travo/locale/travo.en.yml | 1 + travo/locale/travo.fr.yml | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/travo/assignment.py b/travo/assignment.py index a99e9fd3..4e3b7e80 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -429,6 +429,29 @@ class Assignment: Check the assignment directory Raise an error if it does not exist or is dubious + + Tests: + + >>> import io, i18n + >>> i18n.set('locale', 'en') + >>> tmp_path = getfixture("tmp_path") + >>> os.chdir(tmp_path) + >>> assignment = getfixture("course").assignment("Assignment") + + >>> assignment.check_assignment_dir("Assignment") + Traceback (most recent call last): + ... + RuntimeError: Missing assignment directory; fetch your assignment? + + >>> io.open("Assignment", 'w').close() + >>> assignment.check_assignment_dir("Assignment") + Traceback (most recent call last): + ... + RuntimeError: Corrupted assignment directory + >>> os.remove("Assignment") + + >>> os.mkdir("Assignment") + >>> assignment.check_assignment_dir("Assignment") """ self.log.info(f"- Vérification de la présence du devoir {assignment_dir}") if not os.path.exists(assignment_dir): @@ -447,7 +470,7 @@ class Assignment: Déplacez ou supprimez le """ ) - raise RuntimeError("corrupted assignment directory") + raise RuntimeError(_("corrupted assignment directory")) def merge_from( self, diff --git a/travo/locale/travo.en.yml b/travo/locale/travo.en.yml index 705ea151..e702f062 100644 --- a/travo/locale/travo.en.yml +++ b/travo/locale/travo.en.yml @@ -127,3 +127,4 @@ en: merging: "Merging %{content}" try to merge: "Trying to merge" no content: "No %{content}" + corrupted assignment directory: "Corrupted assignment directory" diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index 5ec387a5..04a09f72 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -127,3 +127,4 @@ fr: no content: "Pas de %{content}" initializing submission: "Initialisation de la soumission à partir du devoir" your submission: "Votre submission" + corrupted assignment directory: "Corrupted assignment directory" -- GitLab From dd8e0049394417d1e40a22bfe9bf26825d9e56ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Fri, 29 Nov 2024 01:05:05 +0100 Subject: [PATCH 20/24] dashboards.FormDialog: add doctests --- travo/dashboards.py | 110 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/travo/dashboards.py b/travo/dashboards.py index d1c9684a..fb8044f0 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -174,6 +174,116 @@ class AuthenticationWidget(VBox): class FormDialog(ipywidgets.HBox): + """ + A dialog to request information through a form + + Example: + + Let's set english as language: + + >>> import io, i18n + >>> i18n.set('locale', 'en') + + We create a dialog: + + >>> dialog = FormDialog() + + Originally it's empty: + + >>> dialog.children + () + + We now want to request information from the user. To this end, we + describe the name of the requested items together with their types + or list of values: + + >>> input = {"clear scores": bool, + ... "student group": str, + ... "release mode": ("public", "private")} + + We also define a callback function to handle the information upon + validation by the user; here, we just print the values: + + >>> def on_validate(**kwargs): + ... print(kwargs) + + Optionaly, we define a callback function to be called if the user cancels: + + >>> def on_cancel(): + ... print("Cancelling") + + We request the information from the user: + + >>> dialog.request_information(input=input, + ... on_validate=on_validate, + ... on_cancel=on_cancel) + + This populates the dialog with localized labels and widgets to + input the requested entries according to their types, and two + buttons to cancel or validate: + + >>> dialog.children + (Label(value='clear scores'), + Checkbox(value=False), + Label(value='Student group'), + Text(value=''), + Label(value='Release mode'), + Dropdown(options=('public', 'private'), value='public'), + Button(button_style='danger', description='Cancel', style=ButtonStyle()), + Button(button_style='success', description='Validate', style=ButtonStyle())) + + Let's emulate the user clicking on the Cancel button: + + >>> dialog.cancel() + Cancelling + >>> dialog.children + () + + This time, let's rerequest the information, and emulate the user + setting some of the values and clicking the Validate: + + >>> dialog.request_information(input=input, on_validate=on_validate) + >>> dialog.children[1].value = True + >>> dialog.children[3].value = "mygroup" + >>> dialog.children[5].value = "private" + >>> dialog.validate() + {'clear scores': True, 'student group': 'mygroup', 'release mode': 'private'} + + The same, with a confirmation checkbox: + + >>> dialog.request_information(input=input, + ... on_validate=on_validate, + ... confirm=True) + >>> dialog.children + (Label(value='Are you sure?'), + Checkbox(value=False), + Label(value='clear scores'), + Checkbox(value=False), + Label(value='Student group'), + Text(value=''), + Label(value='Release mode'), + Dropdown(options=('public', 'private'), value='public'), + Button(button_style='danger', description='Cancel', style=ButtonStyle()), + Button(button_style='success', description='Validate', disabled=True, + style=ButtonStyle())) + + >>> dialog.children[3].value = True + >>> dialog.children[5].value = "mygroup" + >>> dialog.children[7].value = "private" + + Note that the the validate button is disabled. Let's check the + Confirmation checkbox to enable the validate button: + + >>> dialog.children[1].value = True + >>> dialog.children[9] + Button(button_style='success', description='Validate', style=ButtonStyle()) + + Now we can validate: + + >>> dialog.validate() + {'clear scores': True, 'student group': 'mygroup', 'release mode': 'private'} + """ + def __init__(self) -> None: super().__init__([]) self.on_cancel: Optional[Callable] = None -- GitLab From 9e85f93521c11ad0b813821e0c6b4491994a775d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Fri, 29 Nov 2024 01:05:42 +0100 Subject: [PATCH 21/24] fixture: stricter type checking --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index fe7021db..067e03d4 100644 --- a/conftest.py +++ b/conftest.py @@ -111,7 +111,7 @@ def standalone_assignment_namespace(gitlab: GitLab) -> Group: @pytest.fixture def standalone_assignment( - gitlab: GitLab, + gitlab: GitLabTest, user_name: str, standalone_assignment_namespace: Group, test_run_id: str, -- GitLab From 72bc79146af7fdd67c1761f5dfe32d608e3dcf96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Fri, 29 Nov 2024 14:08:32 +0100 Subject: [PATCH 22/24] Use typing.Union rather than | for compatibility with Python 3.10 --- travo/dashboards.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/travo/dashboards.py b/travo/dashboards.py index fb8044f0..15409de2 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -21,7 +21,7 @@ import os import subprocess import requests from threading import Thread -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Union try: from ipywidgets import ( # type: ignore @@ -316,7 +316,7 @@ class FormDialog(ipywidgets.HBox): on_cancel: Optional[Callable] = None, confirm: bool = False, ) -> None: - def make_widget(T: type | tuple) -> ipywidgets.Widget: + def make_widget(T: Union[type, tuple]) -> ipywidgets.Widget: if T is bool: return ipywidgets.Checkbox() if T is str: -- GitLab From 340c0be53bc645813ac391051f5ace7a44ba7a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Fri, 29 Nov 2024 14:21:23 +0100 Subject: [PATCH 23/24] Update changelog --- docs/sources/changelog.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/sources/changelog.md b/docs/sources/changelog.md index a4ea8716..b53f3b5a 100644 --- a/docs/sources/changelog.md +++ b/docs/sources/changelog.md @@ -8,6 +8,16 @@ - `GitLab.get_branch` now raises `ResourceNotFound` if the branch is missing. - Manage jupyter courses with jupyter lab options rather than jupyter notebook. +### Student dashboard + +- New student actions available through a dropdown menu + - share with (team work) + - set main submission (team work) + - remove submission + - merge from another submission +- Smoother User Experience through form dialogs that appear only when + information is required from the user (e.g. to choose the student group) + ### Test infrastructure - Improved test robustness with test (rich) course paths and names parametrised by test run id. @@ -15,6 +25,10 @@ - New context managers: `travo.util.working_directory`, `Gitlab.logged_as`. - Refactor user creation in basic gitlab infrastructure. +### Misc + +- Refactored Assignment.merge + ## Version 1.0 The 1.0 release has focused on: -- GitLab From 9eda637411a7d1c487d2a0e33a33e061ee703fcf Mon Sep 17 00:00:00 2001 From: Chiara Marmo <9886115-cmarmo@users.noreply.gitlab.com> Date: Fri, 29 Nov 2024 14:36:24 +0000 Subject: [PATCH 24/24] Apply 1 suggestion(s) to 1 file(s) --- docs/sources/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/changelog.md b/docs/sources/changelog.md index b53f3b5a..10b1f1bb 100644 --- a/docs/sources/changelog.md +++ b/docs/sources/changelog.md @@ -27,7 +27,7 @@ ### Misc -- Refactored Assignment.merge +- Refactored `Assignment.merge` ## Version 1.0 -- GitLab