示例#1
0
    def _check_for_updates():
        # grab the server version
        web_api = WebAPI()
        version = web_api.get_version()

        print('done')

        create_tuple = lambda v: tuple(map(int, v.split('.')))
        server_version = create_tuple(version)
        local_version = create_tuple(VERSION)

        if server_version > local_version or force_update:
            print('Updating MyPyTutor...', end='', flush=True)

            # grab our new zip file
            mpt_zip_path = web_api.get_mpt_zipfile()

            # extract over the script path
            # do NOT delete things; the user could have other stuff here
            script_dir = get_script_dir()

            safely_extract_zipfile(mpt_zip_path, script_dir)

            print('done')

            # re-exec with the new version
            # remove update arg, or we get an infinite loop
            argv = sys.argv
            if '--force-update-mpt' in argv:
                argv.remove('--force-update-mpt')

            execl(sys.executable, sys.executable, *argv)
示例#2
0
    def _check_for_updates():
        # grab the server version
        web_api = WebAPI()
        version = web_api.get_version()

        print('done')

        create_tuple = lambda v: tuple(map(int, v.split('.')))
        server_version = create_tuple(version)
        local_version = create_tuple(VERSION)

        if server_version > local_version or force_update:
            print('Updating MyPyTutor...', end='', flush=True)

            # grab our new zip file
            mpt_zip_path = web_api.get_mpt_zipfile()

            # extract over the script path
            # do NOT delete things; the user could have other stuff here
            script_dir = get_script_dir()

            safely_extract_zipfile(mpt_zip_path, script_dir)

            print('done')

            # re-exec with the new version
            # remove update arg, or we get an infinite loop
            argv = sys.argv
            if '--force-update-mpt' in argv:
                argv.remove('--force-update-mpt')

            execl(sys.executable, sys.executable, *argv)
示例#3
0
def get_session():
    api = WebAPI()
    username, password = get_login_details()

    if not api.login(username, password):
        return None

    return api
示例#4
0
def try_login(username, password):
    """
    Try to log in with the given username and password.

    Args:
      username (str): The username to log in with.
      password (str): The password to use.

    Returns:
      If successful, the logged-in WebAPI instance.
      If unsuccessful, None.

    """
    from tutorlib.interface.web_api import WebAPI, WebAPIError
    web_api = WebAPI()

    print('Attempting to login as {}...'.format(username), end='', flush=True)

    def reset_credentials():
        import keyring
        from tutorlib.config.configuration import load_config, save_config
        cfg = load_config()

        try:
            keyring.delete_password(MPT_SERVICE, cfg.online.username)
        except Exception:  # PasswordDeleteError isn't defined anywhere!
            pass

        cfg.online.username = ''

        save_config(cfg)

    try:
        success = web_api.login(username, password)
    except WebAPIError:
        success = False

    # it's safer to clear the credentials even if the login threw
    # this will cause users to have to log in again if they try to run MPT
    # without an internet connection, but oh well
    if not success:
        reset_credentials()

    print('done' if success else 'failed')
    return web_api if success else None
示例#5
0
def try_login(username, password):
    """
    Try to log in with the given username and password.

    Args:
      username (str): The username to log in with.
      password (str): The password to use.

    Returns:
      If successful, the logged-in WebAPI instance.
      If unsuccessful, None.

    """
    from tutorlib.interface.web_api import WebAPI, WebAPIError
    web_api = WebAPI()

    print('Attempting to login as {}...'.format(username), end='', flush=True)

    def reset_credentials():
        import keyring
        from tutorlib.config.configuration import load_config, save_config
        cfg = load_config()

        try:
            keyring.delete_password(MPT_SERVICE, cfg.online.username)
        except Exception:  # PasswordDeleteError isn't defined anywhere!
            pass

        cfg.online.username = ''

        save_config(cfg)

    try:
        success = web_api.login(username, password)
    except WebAPIError:
        success = False

    # it's safer to clear the credentials even if the login threw
    # this will cause users to have to log in again if they try to run MPT
    # without an internet connection, but oh well
    if not success:
        reset_credentials()

    print('done' if success else 'failed')
    return web_api if success else None
示例#6
0
def update_default_tutorial_package(force_update=False):
    """
    Update the default tutorial package if necessary.

    Args:
      force_update (bool, optional): If True, update regardless of whether a
        newer version of the tutorial package is available on the server.

    """
    from tutorlib.config.configuration import load_config
    from tutorlib.gui.app.support \
            import remove_directory_contents, safely_extract_zipfile
    from tutorlib.interface.problems \
            import TutorialPackage, TutorialPackageError
    from tutorlib.interface.web_api import WebAPI, WebAPIError

    print('Checking for tutorial package updates...', end='', flush=True)

    # grab our config file
    cfg = load_config()
    package_name = cfg.tutorials.default
    package_options = getattr(cfg, package_name)

    # try to open the tutorial package
    try:
        tutorial_package = TutorialPackage(package_name, package_options)
    except TutorialPackageError:
        print('failed')
        return

    # check if we need to do an update at all
    web_api = WebAPI()

    try:
        timestamp = web_api.get_tutorials_timestamp()
    except WebAPIError:
        print('failed')
        return

    # we need to be comparing as ints
    create_tuple = lambda t: tuple(map(int, t.split('.')))
    server_timestamp = create_tuple(timestamp)
    local_timestamp = create_tuple(tutorial_package.timestamp)

    print('done')

    # we only want to update if the server's version is more recent
    # a more recent local version should only arise in development, anyway
    if server_timestamp <= local_timestamp and not force_update:
        return

    print('Updating tutorial package...', end='', flush=True)

    # grab the zipfile
    try:
        zip_path = web_api.get_tutorials_zipfile()
    except WebAPIError:
        print('failed')
        return

    # extract the zipfile into our empty tutorial directory
    remove_directory_contents(tutorial_package.options.tut_dir)
    safely_extract_zipfile(zip_path, tutorial_package.options.tut_dir)

    print('done')
示例#7
0
def bootstrap_tutorials():
    """
    If the default tutorial path does not exist, download and extract the
    tutorials zipfile.

    If no default tutorial package is specified, CSSE1001Tutorials will be
    assumed (this is the default package name used by this script).

    """
    from tutorlib.config.configuration import load_config, save_config
    from tutorlib.gui.app.support import safely_extract_zipfile
    from tutorlib.interface.problems \
            import TutorialPackage, TutorialPackageError
    from tutorlib.interface.web_api import WebAPI, WebAPIError

    # grab our config file
    cfg = load_config()
    options = getattr(cfg, cfg.tutorials.default or 'CSSE1001Tutorials')

    def tutorials_are_installed():
        if not options.tut_dir:  # no entry in config at all (default)
            return False
        if not os.path.exists(options.tut_dir):  # no package directory at all
            return False
        if not os.path.exists(options.ans_dir):  # no answers dir
            return False

        try:
            _ = TutorialPackage(cfg.tutorials.default, options)
        except TutorialPackageError:
            return False

        return True

    if not tutorials_are_installed():
        print('Downloading default tutorial package...', end='', flush=True)

        web_api = WebAPI()
        try:
            filename = web_api.get_tutorials_zipfile()
        except WebAPIError:
            print('failed')
            sys.exit(1)

        print('done')

        # set the default tutorial directory
        # our default tutorial directory is in the same directory as the script
        # note that this assumes we used the default config, which created the
        # CSSE1001Tutorials key
        print('Installing default tutorial package...', end='', flush=True)

        script_dir = get_script_dir()
        options.tut_dir = os.path.join(script_dir, 'CSSE1001Tutorials')
        options.ans_dir = os.path.join(script_dir, 'CSSE1001Answers')

        safely_extract_zipfile(filename, options.tut_dir)

        if not os.path.exists(options.ans_dir):
            os.mkdir(options.ans_dir)

        save_config(cfg)

        print('done')
示例#8
0
    def __init__(self, master, web_api=None):
        assert web_api is None or web_api.is_logged_in, \
                'If a WebAPI instance is provided, it must be logged in'

        #### Set up the window
        master.title('MyPyTutor')
        master.protocol("WM_DELETE_WINDOW", self.close)

        #### Set up our menu
        self.menu = TutorialMenu(master, delegate=self)
        master.config(menu=self.menu)

        #### Set up local variables
        ## Optionals / property bases
        self.current_tutorial = None
        self._editor = None
        self._tutorial_package = None
        self._submissions = {}

        ## Important top-level vars
        self.master = master
        self.cfg = load_config()

        ## Vars with side effects
        self.tutorial_package = self.cfg.tutorials.default
        self.menu.set_tutorial_packages(self.cfg.tutorials.names)

        ## Objects
        self.attempts = TutorialAttempts()
        self.interpreter = Interpreter()

        if web_api is None:
            self.web_api = WebAPI(self._login_status_change)
            self.master.after(0, self.login)
        else:
            self.web_api = web_api
            self.web_api.listener = self._login_status_change

            # immediately perform the callback, assuming we've synced already
            self._login_status_change(logged_in=True, do_sync=False)

        self.sync_client = SyncClient(self.web_api)

        ## Purely private vars
        self._is_closing = False

        ## Finalise GUI Setup

        width = min(master.winfo_screenwidth(), self.cfg.resolution.width)
        height = min(master.winfo_screenheight(), self.cfg.resolution.height)
        master.geometry('{}x{}'.format(width, height))

        #### Create GUI Widgets
        ## Top Frame
        top_frame = ttk.Frame(master)
        top_frame.pack(fill=tk.BOTH, expand=tk.TRUE)

        ## Tutorial (html display of tutorial problem)
        self.tutorial_frame = TutorialFrame(
            top_frame,
        )
        self.tutorial_frame.pack(fill=tk.BOTH, expand=tk.TRUE)
        self.tutorial_frame.splash(version=VERSION)

        ## Short Problem Description
        self.short_description = ttk.Label(top_frame)  # TODO: sort out style
        self.short_description.pack()

        ## Toolbar (hints, login status etc)
        toolbar = ttk.Frame(top_frame)  # TODO: sort out style
        toolbar.pack(side=tk.TOP, fill=tk.X)

        self.hint_button = ttk.Button(
            toolbar, text='Next Hint', command=self._next_hint
        )

        self.online_status = ttk.Label(
            toolbar, relief=tk.SUNKEN
        )
        self.online_status.pack(
            side=tk.RIGHT, pady=3, ipady=2, padx=2, ipadx=2
        )
        self._set_online_status(logged_in_user=None)

        self.problem_status = ttk.Label(
            toolbar, relief=tk.SUNKEN
        )
        self.problem_status.pack(
            side=tk.RIGHT, pady=3, ipady=2, padx=2, ipadx=2
        )

        ## Test Output
        self.test_output = TestOutput(
            top_frame,
            self,
        )
        self.test_output.pack(fill=tk.X, expand=tk.FALSE)

        ## Analysis Output
        self.analysis_output = AnalysisOutput(
            top_frame,
        )
        self.analysis_output.pack(fill=tk.X, expand=tk.FALSE)
示例#9
0
class TutorialApp(TutorialMenuDelegate, TutorEditorDelegate,
        TestOutputDelegate):
    """
    The main MyPyTutor application.

    Attributes:
      cfg (Namespace): The MyPyTutor configuration options.
      current_tutorial (Tutorial): The currently selected tutorial problem.
      interpreter (Interpreter): The interpreter used by MyPyTutor.
      tutorial_package (TutorialPackage): The selected tutorial package.
      web_api (WebAPI): The web API for the app.

    GUI Attributes:
      analysis_output (AnalysisOutput): The frame displaying the current static
          analysis results.
      editor (TutorEditor): The edit window (where students write their code).
      hint_button (Button): The button which, when clicked, causes the next
          hint to be displayed.
      master (tk.Wm): The base tk window that the app is displayed in.
      menu (TutorialMenu): The menubar.
      online_status (Label): The label showing whether the student is currently
          logged in (ie, authenticated).
      short_description (Label): The label containing the short description
          of the current tutorial problem.
      sync_client (SyncClient): The tutorial synchronisation client.
      test_output (TestOutput): The frame displaying the current test results.
      tutorial_attempts (TutorialAttempts): The number of attempts that the
        student has made at a tutorial problem.
      tutorial_frame (TutorialFrame): The frame which displays the tutorial
          problem and associated data, such as hints.

    """
    def __init__(self, master, web_api=None):
        assert web_api is None or web_api.is_logged_in, \
                'If a WebAPI instance is provided, it must be logged in'

        #### Set up the window
        master.title('MyPyTutor')
        master.protocol("WM_DELETE_WINDOW", self.close)

        #### Set up our menu
        self.menu = TutorialMenu(master, delegate=self)
        master.config(menu=self.menu)

        #### Set up local variables
        ## Optionals / property bases
        self.current_tutorial = None
        self._editor = None
        self._tutorial_package = None
        self._submissions = {}

        ## Important top-level vars
        self.master = master
        self.cfg = load_config()

        ## Vars with side effects
        self.tutorial_package = self.cfg.tutorials.default
        self.menu.set_tutorial_packages(self.cfg.tutorials.names)

        ## Objects
        self.attempts = TutorialAttempts()
        self.interpreter = Interpreter()

        if web_api is None:
            self.web_api = WebAPI(self._login_status_change)
            self.master.after(0, self.login)
        else:
            self.web_api = web_api
            self.web_api.listener = self._login_status_change

            # immediately perform the callback, assuming we've synced already
            self._login_status_change(logged_in=True, do_sync=False)

        self.sync_client = SyncClient(self.web_api)

        ## Purely private vars
        self._is_closing = False

        ## Finalise GUI Setup

        width = min(master.winfo_screenwidth(), self.cfg.resolution.width)
        height = min(master.winfo_screenheight(), self.cfg.resolution.height)
        master.geometry('{}x{}'.format(width, height))

        #### Create GUI Widgets
        ## Top Frame
        top_frame = ttk.Frame(master)
        top_frame.pack(fill=tk.BOTH, expand=tk.TRUE)

        ## Tutorial (html display of tutorial problem)
        self.tutorial_frame = TutorialFrame(
            top_frame,
        )
        self.tutorial_frame.pack(fill=tk.BOTH, expand=tk.TRUE)
        self.tutorial_frame.splash(version=VERSION)

        ## Short Problem Description
        self.short_description = ttk.Label(top_frame)  # TODO: sort out style
        self.short_description.pack()

        ## Toolbar (hints, login status etc)
        toolbar = ttk.Frame(top_frame)  # TODO: sort out style
        toolbar.pack(side=tk.TOP, fill=tk.X)

        self.hint_button = ttk.Button(
            toolbar, text='Next Hint', command=self._next_hint
        )

        self.online_status = ttk.Label(
            toolbar, relief=tk.SUNKEN
        )
        self.online_status.pack(
            side=tk.RIGHT, pady=3, ipady=2, padx=2, ipadx=2
        )
        self._set_online_status(logged_in_user=None)

        self.problem_status = ttk.Label(
            toolbar, relief=tk.SUNKEN
        )
        self.problem_status.pack(
            side=tk.RIGHT, pady=3, ipady=2, padx=2, ipadx=2
        )

        ## Test Output
        self.test_output = TestOutput(
            top_frame,
            self,
        )
        self.test_output.pack(fill=tk.X, expand=tk.FALSE)

        ## Analysis Output
        self.analysis_output = AnalysisOutput(
            top_frame,
        )
        self.analysis_output.pack(fill=tk.X, expand=tk.FALSE)

    ## Properties
    @property
    def editor(self):
        """
        Return a reference to the editor window.

        If the editor is not currently visible, accessing it via this property
        will create it and set it to visible.

        This behaviour is intended to permit lazy construction of the editor.

        Returns:
          The editor window (an instance of TutorEditor).
        """
        if self._editor is None:
            self._editor = TutorEditor(
                menu_delegate=self,
                editor_delegate=self,
                root=self.master,
            )
        return self._editor

    ## Private methods
    @property
    def tutorial_package(self):
        return self._tutorial_package

    @tutorial_package.setter
    def tutorial_package(self, package_name):
        """
        Select the tutorial package with the given name.

        If no name is provided, prompt the user to add a new tutorial.

        If a name is provided but the package cannot be loaded, display an
        error to the user but otherwise do nothing.
        This may leave the application in an inconsistent state.

        Warning: if no name is provided, prompting the user to add a new
        tutorial is performed *asynchronously*.  Because of this, if a value
        given to this setter could be null, the calling code *cannot* rely on
        tutorial_package having a valid value immediately after this function
        returns.  For example, the following code is *not safe*:

            self.tutorial_package = 'CSSE1001Tutorials' if rand() < 0.5 else ''
            print(self.tutorial_package.name)  # UNSAFE

        If the assigned value is '' in the above example, self.tutorial_package
        will not have a value until, at the earliest, *after* the next run of
        the GUI event loop (as that is when the user will be prompted to add
        a tutorial).

        In normal use, this should not pose a problem; the only time the
        assigned value should even potentially be the empty string is the very
        first time that MPT is run.

        Args:
          package_name (str): The name of the package to select.

        """
        if not package_name:
            # if no package is given, we want to try to add a new one
            # this will be the case the very first time MyPyTutor is launched
            # in that case, the Tk object (root) will not yet have entered
            # the main loop
            # attempting to display any tk widget before entering the main
            # loop will cause the application to suffer display errors on OS X
            # once it actually appears (see issue #49)
            # by deferring the add action, we can avoid this issue
            self.master.after(0, self.add_tutorial_package)
            return

        assert hasattr(self.cfg, package_name), \
                'Attempt to select unknown package: {}'.format(package_name)
        options = getattr(self.cfg, package_name)

        try:
            self._tutorial_package = TutorialPackage(package_name, options)
        except TutorialPackageError as e:
            tkmessagebox.showerror(
                'Invalid Tutorial Package',
                'Failed to load {}: {}'.format(package_name, e),
            )
            return

        # update menu
        self.menu.set_selected_tutorial_package(self.tutorial_package)

    def _next_hint(self):
        """
        Display the next hint.

        If there are no more hints to display, do nothing.

        """
        if not self.tutorial_frame.show_next_hint():
            self.hint_button.pack_forget()

    def _set_problem_status(self):
        status = self._submissions.get(
            self.current_tutorial,
        )
        strings = {
            WebAPI.OK: 'Yes',
            WebAPI.LATE: 'Late',
            WebAPI.MISSING: 'No',
            None: 'Unknown',
        }
        text = 'Submitted: {}'.format(strings[status])
        self.problem_status.config(text=text)

    def _set_online_status(self, logged_in_user=None):
        """
        Update the online status label.

        Args:
          logged_in_user (str, optional): The username of the student who is
              logged in.  Defaults to None, which indicates that the student
              has logged out.

        """
        if logged_in_user is None:
            self.online_status.config(text='Status: Not Logged In')
        else:
            self.online_status.config(
                text='Status: Logged in as {}'.format(logged_in_user)
            )

    def _ask_for_directory(self, initial_dir=None, prompt='Choose Directory'):
        """
        Display a tk filedialog which prompts the user to select a directory.

        Args:
          initial_dir (str, optional): The initial directory to display in the
              filedialog.  If initial_dir is not provided or does not exist,
              defaults to the user's home directory.
          prompt (str, optional): The prompt to display in the filedialog.
              Defaults to 'Choose Directory'.

        Returns:
          The result of tkfiledialog.askdirectory

        """
        if initial_dir is None or not os.path.exists(initial_dir):
            initial_dir = os.path.expanduser('~')

        return tkfiledialog.askdirectory(title=prompt, initialdir=initial_dir)

    def _display_web_api_error(self, ex):
        """
        Display the given WebAPIError to the user.

        Args:
          ex (WebAPIError): The error to display.

        """
        message = '{}\n\nDetailed error message:\n{}'.format(
            ex.message, ex.details
        )

        tkmessagebox.showerror(
            'Online Services Error',
            message
        )

    ## General callbacks
    def close(self, evt=None):
        """
        Close event for the TutorialApp.

        The app will only be closed if the .editor indicates that it is safe
        to do so (this will prompt the student to save their code).

        """
        # don't try to close more than once
        # this fixes errors when the user keeps hammering the close button
        if self._is_closing:
            return

        if self.editor.close() == tkmessagebox.YES:
            self._is_closing = True

            self.interpreter.kill()

            self.attempts.save()

            self.cfg.resolution.width = self.master.winfo_width()
            self.cfg.resolution.height = self.master.winfo_height()

            save_config(self.cfg)

            self.master.destroy()

    ## Public-ish methods
    @skip_if_attr_none('current_tutorial')
    def run_tests(self, try_to_submit=True, record_attempt=False):
        """
        Test and analyse the student code.

        If there is a compilation error in the student code, highlight the line
        of that error in the editor and return.

        Otherwise, update `.test_output` and `.analysis_output` with the
        results of the testing and analysis respectively.

        This method will also reload the interpreter with the tested code, so
        that students can play with it themselves.

        Args:
          try_to_submit (bool, optional): If True, attempt to submit the
            tutorial if the student's answer is correct.
        record_attempt (bool, optional): If True, record that the student has
            made an attempt at the icurrent problem.

        Returns:
          Whether the code passes the tests and the analysis.

          The code will pass iff it passes every test case *and* causes no
          analysis errors.
          The second requirement is important, as it helps to avoid 'cheat'
          solutions (such as using the `sum` function when instructed not to).

        """
        code_text = self.editor.get_text()

        # run the tests
        # if the student code cannot be parsed, highlight the problem line
        tester, analyser, error_line = run_tests(
            self.current_tutorial, code_text
        )

        if error_line is not None:
            self.highlight_error(error_line)

        # show the results on the UI
        self.test_output.set_test_results(tester.results)
        self.analysis_output.set_analyser(analyser)

        # return whether the code passed
        success = tester.passed and not analyser.errors

        previous_submission_status = self._submissions.get(
            self.current_tutorial, WebAPI.MISSING
        )

        # record an attempt if the user has not previously been successful
        # if the user is not logged in, assume that they have not been
        # successful; NB that this could cause inflation in some circumstances
        if record_attempt and success and (not self.web_api.is_logged_in \
                or previous_submission_status == WebAPI.MISSING):
            self.attempts.record_attempt(
                self.current_tutorial, self.tutorial_package
            )

        # try to submit if successful
        if success and try_to_submit:
            if self.web_api.is_logged_in:
                try:
                    response = self.web_api.submit_answer(
                        self.current_tutorial,
                        self.editor.get_text(),
                        self.attempts.num_attempts_for(
                            self.current_tutorial, self.tutorial_package
                        )
                    )
                    if response is not None:
                        self.master.after(0, self.update_submissions)
                except WebAPIError:
                    pass  # ignore: silently trying to submit
            else:
                if previous_submission_status == WebAPI.MISSING:
                    def _show_info_box():
                        tkmessagebox.showinfo(
                            'Correct!',
                            'Remember that you must log in and submit your ' \
                            'answer in order to receive any marks.'
                        )
                    self.master.after(0, _show_info_box)

        # reload the interpreter with the tested code
        if self.interpreter.is_alive:
            self.interpreter.reload(self.editor.get_text())

        return success

    ## TutorialMenuDelegate
    # problems
    @skip_if_attr_none('tutorial_package')
    def change_problem(self, increment=None, problem=None):
        """
        Change the tutorial problem.

        If the student currently has a problem open with unsaved changes, they
        will be prompted to save those changes.  If they choose to cancel,
        this function will return.

        Either `increment` or `problem` must be provided.  If `increment` is
        provided, it will be interpreted as an instruction to go to the problem
        `increment` after (or before) the current problem.  If `problem` is
        provided, that problem will be switched to directly.

        After the current problem is selected, the `.tutorial_frame`,
        `.short_description`,  and `.editor` are updated as appropriate.

        Tests are automatically run on the new tutorial.  As a side effect,
        both `.test_output` and `.analysis_output` will be updated.

        Args:
          increment (int, optional): The number of problems to skip, relative
              to the current problem.  An increment of 1 will select the next
              problem; -1 will select the previous problem.  Defaults to None.
          problem (Tutorial, optional): The problem to change to.  Defaults to
              None.  If `increment` is not provided, `problem` must be.

        """
        if self.editor.maybesave() == tkmessagebox.CANCEL:
            return

        if increment is not None:
            if increment < 0:
                f = self.tutorial_package.previous
                increment = -increment
            else:
                f = self.tutorial_package.next

            problem = self.current_tutorial

            for _ in range(increment):
                problem = f(self.current_tutorial)

        # set the current tutorial
        assert problem is not None
        self.current_tutorial = problem

        # show the problem text and description
        self.tutorial_frame.tutorial = self.current_tutorial
        self.short_description.config(
            text=self.current_tutorial.short_description
        )

        # set up the editor
        self.editor.reset(self.current_tutorial)
        self.editor.undo.reset_undo()

        # set up the hints toolbar
        if self.current_tutorial.hints:
            self.hint_button.pack(side=tk.LEFT)
        else:
            self.hint_button.pack_forget()

        # update the ui
        self._set_problem_status()

        # run the tests
        # this will fill out the results and static analysis sections
        self.run_tests()

    def _login_status_change(self, logged_in, do_sync=True):
        if do_sync:  # on login or logout
            callback = partial(self.synchronise, no_login=not logged_in)
            callback.__name__ = 'callback'
            self.master.after(0, callback)

        if logged_in:
            self.master.after(0, self.update_submissions)

    # online
    def login(self):
        """
        Attempt to log the student in to the MyPyTutor system.

        If the login fails, show the student an error message.

        If the login succeeds, set the online status appropriately.

        Returns:
          Whether the login attempt succeeded.

        """
        try:
            logged_in = self.web_api.login()
        except WebAPIError:
            tkmessagebox.showerror(
                'Login failed',
                'Please check your credentials and try again.  ' \
                'If the problem persists, check your internet connection.  ' \
                'Some functionality (such as submitting answers) will be ' \
                'unavailable until you log in successfully.'
            )
            logged_in = False

        self._set_online_status(
            logged_in_user=self.web_api.user if logged_in else None
        )
        return logged_in

    def logout(self):
        """
        Log the student out of the MyPyTutor system, and set the online status
        to reflect this.

        """
        self.web_api.logout()

        self._set_online_status(logged_in_user=None)

    @skip_if_attr_none('current_tutorial')
    def submit(self):
        """
        Submit the current tutorial problem.

        The student may only submit if logged on, and if their answer is
        correct (ie, if it passes all tests and raises no analysis errors).

        Attempting to submit will prompt the student to log on (if they are not
        already logged in), and will re-run the tests.

        """
        if not self.login():
            return

        if self.run_tests(try_to_submit=False):
            try:
                response = self.web_api.submit_answer(
                    self.current_tutorial,
                    self.editor.get_text(),
                    self.attempts.num_attempts_for(
                        self.current_tutorial, self.tutorial_package
                    ),
                )
            except WebAPIError as e:
                self._display_web_api_error(e)
                return

            if response is None:
                tkmessagebox.showinfo(
                    'Code Not Submitted',
                    'This tutorial problem has already been submitted.  ' \
                    'There is no need to submit more than once.'
                )
                return

            messages = {self.web_api.OK: 'Code submitted on time',
                        self.web_api.LATE: 'Code submitted late',
                        self.web_api.LATE_OK: 'Code submitted. You will not ' \
                            'receive a penalty for late submission.'}

            tkmessagebox.showinfo(
                'Submission Successful!',
                messages[response],
            )

            self.master.after(0, self.update_submissions)

    def update_submissions(self):
        """
        Get the latest submissions information for the user.

        The student must be logged on to show their submissions.  This method
        will prompt the student to login if necessary.

        If successful, this will update the menu with the submissions
        information.

        Returns:
          A dictionary mapping tutorials to the submission status if
          successful; None otherwise.

        """
        if not self.login():
            return None

        try:
            submissions = self.web_api.get_submissions(self.tutorial_package)
        except WebAPIError as e:
            self._display_web_api_error(e)
            return None

        # update the menu with the new submissions as well
        self.menu.set_submissions(submissions)

        self._submissions = submissions

        # update the problem status
        self._set_problem_status()

        return submissions

    @skip_if_attr_none('tutorial_package')
    def show_submissions(self):
        """
        Show the student their submission history.

        This will indicate which problems have been completed, and which
        still need to be done.

        """
        submissions = self.update_submissions()
        if submissions is None:
            return

        SubmissionsDialog(self.master, submissions, self.tutorial_package)

    @skip_if_attr_none('tutorial_package')
    def synchronise(self, suppress_popups=False, no_login=None):
        """
        Synchronise the tutorial answers.

        A tutorial will be downloaded from the server iff:
          * there is no local answer, but there is one on the server; or
          * the local and remote answers differ, but the remote one was
            modified after the local one.

        A tutorial will be uploaded to the server iff:
          * there is no answer on the server, but there is a local one; or
          * the local and remote answers differ, but the local one was modified
            at the same time as or before the one on the server.

        Args:
          suppress_popups (bool, optional): If True, do not show any popup
              messages.  This is intended to be used when the synchronisation
              is taking place as part of the close handler.  Defaults to False.
          no_login (bool, optional): If True, do not attempt to login; if the
              user is not logged in, exit immediately and silently.  Defaults
              to the same value as suppress_popups.

        """
        if no_login is None:
            no_login = suppress_popups

        # if we're not logged in, and we are told not to, or can't, then quit
        if not self.web_api.is_logged_in:
            if no_login or not self.login():
                return

        # start showing the progress popup
        popup = ProgressPopup()

        def _show_failure_message():
            tkmessagebox.showerror(
                'Could Not Synchronise Answer Code',
                'Please check that you are correctly logged in, ' \
                'and that your internet connection is active.'
            )

        def _show_success_message():
            tkmessagebox.showinfo(
                'Synchronisation Complete!',
                'Your answers have been successfully synchronised with the ' \
                'server.',
            )

        def _background_task():
            # certain methods used in the synchronisation process might throw
            # WebAPIError, so we want to wrap everything in an exception
            # handler
            # note that as this is on a background thread, we must not make
            # any UI calls
            try:
                success = self.sync_client.synchronise(self.tutorial_package)

                if not suppress_popups:
                    if success:
                        self.master.after(0, _show_success_message)
                    else:
                        self.master.after(0, _show_failure_message)
            except WebAPIError as e:
                if not suppress_popups:
                    self.master.after(0, self._display_web_api_error, e)
            finally:
                self.master.after(0, popup.destroy)

        # do this on a background thread
        exec_sync(_background_task)

    # tools
    @skip_if_attr_none('editor')
    def show_visualiser(self):
        """
        Open a web browser with the student's current code pre-loaded into the
        online visualiser tool.

        No login is necessary; the tool is publicly available.

        """
        self.web_api.visualise(self.editor.get_text())

    @skip_if_attr_none('editor')
    def show_interpreter(self):
        """
        Open the interpreter.

        """
        self.interpreter.reload(self.editor.get_text())

    #@skip_if_attr_none('editor')
    def backup(self):
        """
        Backup the user's data.

        """
        filename = self._backup()

    def _backup(self, filename=None, now=None, showMessage=True):
        """
        Backup the user's data.

        Args:
            filename:       The filename to backup to. If None, prompts users for one.
            now:            The time to use for generation of timestamp to suffix to the filename.
                            Defaults to time.time()
                            Not relevant if filename is not None.
            showMessage:    Determines whether to show a GUI success message or not once backup has been completed.

        """

        if now is None:
            now = time.time()

        if filename is None:
            timestamp = datetime.datetime.fromtimestamp(now).strftime('%Y-%m-%d %H-%M-%S')

            # Request filename from user
            filename = tkfiledialog.asksaveasfilename(
                defaultextension='.zip',
                filetypes=[('Zip Archives', '.zip'), ('All files', '.*')],
                initialfile="mpt-backup {timestamp}".format(timestamp=timestamp)
                )

        if not filename:
            return

        if filename.endswith('.zip'):
            filename = filename[:-4]

        # Create zipfile
        filename = shutil.make_archive(filename, 'zip', self.tutorial_package.options.ans_dir)

        if showMessage:
            tkmessagebox.showinfo("Backup complete!", "Your answers have been backed up successfully to {}".format(filename))

        return filename

    def process_submission_reset(self, problems, now=None):
        """
        Processes a user initiated submission reset.
        1. Clears local files
        2. Clears submissions on server
        3. Synchronizes attempts with server

        Args:
            problems: A list of tutorial problems to reset.
        """

        window = tk.Toplevel(self.master)
        tk.Label(window, text="Working").pack(anchor=tk.CENTER, padx=50, pady=50)
        window.grab_set()

        # 1. Clear local files
        for problem, problem_set in problems:
            if not problem.has_answer:
                # Skip problems that have no attempt recorded
                continue

            problem.clear_local_attempt()

        # 2. Clear submissions on server
        self.web_api.reset_answers([problem for problem, _ in problems])

        # 3. Synchronize attempts with server
        self.synchronise()

        window.grab_release()
        window.destroy()

        tkmessagebox.showinfo("Successfully Reset", "The submissions that you selected have been successfully reset.")

    def reset_submissions(self):
        """
        Allows a user to select some of their submissions to reset.
        """
        if self.current_tutorial and self.editor.maybesave() == tkmessagebox.CANCEL:
            return

        now = time.time()

        def done():
            self.master.after(0, self.update_submissions)

            if self.current_tutorial:
                self.editor.reset(self.current_tutorial)
                self.editor.undo.reset_undo()

        def process(output):
            self.process_submission_reset(output, now)

            done()

        while True:
            # Check for backup first
            do_backup = tkmessagebox.askyesno(
                "Backup first?",
                "It is HIGHLY recommended to backup your answers before attempting to reset any of your submissions."
                "\n\n"
                "Would you like to backup now?"
            )

            if do_backup:
                if self._backup(now=now):
                    break
            else:
                break


        # Prompt user with list of submissions to reset
        dialog = SubmissionsSelectDialog(self.master, None, self.tutorial_package, text="Reset", command=process)

    # preferences
    @skip_if_attr_none('tutorial_package')
    def change_tutorial_directory(self):
        """
        Prompt to change the current tutorial directory.

        This is the directory that the tutorial problems are read from.

        If the tutorial directory is changed, reload the current tutorial
        package.  This may cause an error popup if the new directory is
        not valid.

        """
        directory = self._ask_for_directory(
            prompt='Choose Tutorial Folder: {}'.format(
                    self.tutorial_package.name
                ),
            initial_dir=self.tutorial_package.options.tut_dir,
        )

        if directory:
            # .current_tutorial.options is bound to cfg, so will change it
            self.tutorial_package.options.tut_dir = directory
            # force a reload of the tutorial package so that all tutorials
            # with a reference to the old directory are replaced
            self.tutorial_package = self.tutorial_package.name

    @skip_if_attr_none('tutorial_package')
    def change_answers_directory(self):
        """
        Prompt to change the current answers directory.

        This is the directory that the tutorial answers are written to.

        If the answers directory is changed, reload the current tutorial
        package.  This will update the `.answer_path` property on each tutorial
        object.  The `.current_tutorial` will then be updated to match this
        (so that it does not contain a reference to the old object).  Finally,
        the save path for the editor must also be updated.

        """
        directory = self._ask_for_directory(
            prompt='Choose Answers Folder: {}'.format(
                    self.tutorial_package.name
                ),
            initial_dir=self.tutorial_package.options.ans_dir,
        )

        if directory:
            # .current_tutorial.options is bound to cfg, so will change it
            self.tutorial_package.options.ans_dir = directory
            # force a reload of the tutorial package so that all tutorials
            # with a reference to the old directory are replaced
            self.tutorial_package = self.tutorial_package.name

            # need to update reference to Tutorial in new (reloaded) package
            # (but only if we actually have a tutorial open)
            if self.current_tutorial is None:
                return

            self.current_tutorial = self.tutorial_package.tutorial_with_name(
                self.current_tutorial.name
            )
            self.editor.set_filename(self.current_tutorial.answer_path)
            # TODO: relocate answers?

    @skip_if_attr_none('tutorial_package')
    def set_as_default_package(self):
        """
        Set the current tutorial package as the default tutorial package.

        """
        self.cfg.tutorials.default = self.tutorial_package.name

    def add_tutorial_package(self):
        """
        Prompt the user to add a tutorial package.

        If there is no default tutorial package, then this tutorial package
        will be added as the default.  Otherwise, it will be added as an
        ordinary package.

        If the package was added as the default package, it will be selected
        automatically.

        """
        # if we don't have a default tutorial, we should add this one as the
        # default and then switch to it
        as_default = not self.cfg.tutorials.default
        msg = add_tutorial(self.cfg, as_default=as_default, window=self.master)

        if msg is not None:
            tkmessagebox.showerror(
                'Failed to Add Tutorial',
                'Could not add tutorial: {}'.format(msg),
            )
            return

        if as_default:
            self.tutorial_package = self.cfg.tutorials.default

        self.menu.set_tutorial_packages(self.cfg.tutorials.names)

    @skip_if_attr_none('tutorial_package')
    def remove_current_tutorial_package(self):
        """
        Remove the current tutorial package.

        It is not possible to remove the default tutorial package, or the only
        tutorial package which is available.

        The user will be prompted to confirm the package's removal.  If they
        choose to proceed, the default tutorial package will be selected.

        This process does not delete any problem or answer files.

        """
        if self.cfg.tutorials.default == self.tutorial_package.name:
            tkmessagebox.showerror(
                'Remove Current Tutorial Error',
                'You cannot remove the default tutorial.  ' \
                'Try setting a new default first.'
            )
            return

        if len(self.cfg.tutorials.names) == 1:
            tkmessagebox.showerror(
                'Remove Current Tutorial Error'
                'You cannot remove the last tutorial.  ' \
                'Try adding a new tutorial first.'
            )
            return

        should_remove = tkmessagebox.askquestion(
            'Remove Tutorial?',
            'Do you really want to remove {}?'.format(
                self.tutorial_package.name
            )
        )

        if str(should_remove) == tkmessagebox.YES:
            del self.cfg[self.tutorial_package.name]
            self.cfg.tutorials.names.remove(self.tutorial_package.name)

            self.tutorial_package = self.cfg.tutorials.default

            self.menu.set_tutorial_packages(self.cfg.tutorials.names)

    def change_tutorial_package(self, package_name):
        """
        Change the selected tutorial package.

        Args:
          package_name (str): The name of the tutorial package to change to.

        """
        self.tutorial_package = package_name

    # feedback
    def feedback(self, problem_feedback=False):
        """
        Display a window which enables the user to provide feedback.

        Args:
          problem_feedback (bool, optional): Whether the feedback is about the
              current problem.  If True, then information about the problem
              will be included along with the feedback message.  Defaults to
              False.

        """
        def callback(subject, feedback):
            self.web_api.provide_feedback(
                subject,
                feedback,
                code=self.editor.get_text() if problem_feedback else ''
            )

        if problem_feedback and self.current_tutorial is not None:
            FeedbackDialog(
                self.master,
                'Problem Feedback: {}'.format(self.current_tutorial.name),
                self.current_tutorial.name,
                callback=callback,
            )
        else:
            FeedbackDialog(
                self.master,
                'General Feedback',
                callback=callback,
            )

    # help
    def show_help_dialog(self):
        """
        Show the help dialog.

        """
        self.web_api.display_help()

    def show_about_dialog(self):
        """
        Show the about dialog.

        """
        TutAboutDialog(self.master, 'About MyPyTutor')

    ## TutorEditorDelegate
    check_solution = lambda self: self.run_tests(record_attempt=True)
    quit_editor = close

    ## TestOutputDelegate
    def highlight_error(self, line_number):
        """
        Highlight an error on the given line.

        Args:
          line_number (int): The line number to highlight.

        """
        self.editor.error_line(line_number)
示例#10
0
文件: app.py 项目: dartmoth/MyPyTutor
    def __init__(self, master):
        #### Set up the window
        master.title('MyPyTutor')
        master.protocol("WM_DELETE_WINDOW", self.close)

        #### Set up our menu
        self.menu = TutorialMenu(master, delegate=self)
        master.config(menu=self.menu)

        #### Set up local variables
        ## Optionals / property bases
        self.current_tutorial = None
        self._editor = None
        self._tutorial_package = None

        ## Important top-level vars
        self.master = master
        self.cfg = load_config()

        ## Vars with side effects
        self.tutorial_package = self.cfg.tutorials.default
        self.menu.set_tutorial_packages(self.cfg.tutorials.names)

        ## Objects
        self.web_api = WebAPI()
        master.after(0, self.synchronise)  # post event immediately after init

        #### Create GUI Widgets
        ## Top Frame
        top_frame = ttk.Frame(master)
        top_frame.pack(fill=tk.BOTH, expand=tk.TRUE)

        ## Tutorial (html display of tutorial problem)
        self.tutorial_frame = TutorialFrame(
            top_frame, (self.cfg.font.name, self.cfg.font.size),
            self.cfg.window_sizes.problem)
        self.tutorial_frame.pack(fill=tk.BOTH, expand=tk.TRUE)
        self.tutorial_frame.splash(version=VERSION)

        ## Short Problem Description
        self.short_description = ttk.Label(top_frame)  # TODO: sort out style
        self.short_description.pack()

        ## Toolbar (hints, login status etc)
        toolbar = ttk.Frame(top_frame)  # TODO: sort out style
        toolbar.pack(side=tk.TOP, fill=tk.X)

        self.hint_button = ttk.Button(toolbar,
                                      text='Next Hint',
                                      command=self._next_hint)

        self.online_status = ttk.Label(toolbar, relief=tk.SUNKEN)
        self.online_status.pack(side=tk.RIGHT,
                                pady=3,
                                ipady=2,
                                padx=2,
                                ipadx=2)
        self._set_online_status(logged_in_user=None)

        ## Test Output
        self.test_output = TestOutput(
            top_frame,
            self.cfg.font.size,
            self.cfg.window_sizes.output,
        )
        self.test_output.pack(fill=tk.BOTH, expand=0)

        ## Analysis Output
        self.analysis_output = AnalysisOutput(
            top_frame,
            self.cfg.font.size,
            self.cfg.window_sizes.analysis,
        )
        self.analysis_output.pack(fill=tk.BOTH, expand=0)
示例#11
0
文件: app.py 项目: dartmoth/MyPyTutor
class TutorialApp(TutorialMenuDelegate, TutorEditorDelegate):
    """
    The main MyPyTutor application.

    Attributes:
      cfg (Namespace): The MyPyTutor configuration options.
      current_tutorial (Tutorial): The currently selected tutorial problem.
      tutorial_package (TutorialPackage): The selected tutorial package.
      web_api (WebAPI): The web API for the app.

    GUI Attributes:
      analysis_output (AnalysisOutput): The frame displaying the current static
          analysis results.
      editor (TutorEditor): The edit window (where students write their code).
      hint_button (Button): The button which, when clicked, causes the next
          hint to be displayed.
      master (tk.Wm): The base tk window that the app is displayed in.
      menu (TutorialMenu): The menubar.
      online_status (Label): The label showing whether the student is currently
          logged in (ie, authenticated).
      short_description (Label): The label containing the short description
          of the current tutorial problem.
      test_output (TestOutput): The frame displaying the current test results.
      tutorial_frame (TutorialFrame): The frame which displays the tutorial
          problem and associated data, such as hints.

    """
    def __init__(self, master):
        #### Set up the window
        master.title('MyPyTutor')
        master.protocol("WM_DELETE_WINDOW", self.close)

        #### Set up our menu
        self.menu = TutorialMenu(master, delegate=self)
        master.config(menu=self.menu)

        #### Set up local variables
        ## Optionals / property bases
        self.current_tutorial = None
        self._editor = None
        self._tutorial_package = None

        ## Important top-level vars
        self.master = master
        self.cfg = load_config()

        ## Vars with side effects
        self.tutorial_package = self.cfg.tutorials.default
        self.menu.set_tutorial_packages(self.cfg.tutorials.names)

        ## Objects
        self.web_api = WebAPI()
        master.after(0, self.synchronise)  # post event immediately after init

        #### Create GUI Widgets
        ## Top Frame
        top_frame = ttk.Frame(master)
        top_frame.pack(fill=tk.BOTH, expand=tk.TRUE)

        ## Tutorial (html display of tutorial problem)
        self.tutorial_frame = TutorialFrame(
            top_frame, (self.cfg.font.name, self.cfg.font.size),
            self.cfg.window_sizes.problem)
        self.tutorial_frame.pack(fill=tk.BOTH, expand=tk.TRUE)
        self.tutorial_frame.splash(version=VERSION)

        ## Short Problem Description
        self.short_description = ttk.Label(top_frame)  # TODO: sort out style
        self.short_description.pack()

        ## Toolbar (hints, login status etc)
        toolbar = ttk.Frame(top_frame)  # TODO: sort out style
        toolbar.pack(side=tk.TOP, fill=tk.X)

        self.hint_button = ttk.Button(toolbar,
                                      text='Next Hint',
                                      command=self._next_hint)

        self.online_status = ttk.Label(toolbar, relief=tk.SUNKEN)
        self.online_status.pack(side=tk.RIGHT,
                                pady=3,
                                ipady=2,
                                padx=2,
                                ipadx=2)
        self._set_online_status(logged_in_user=None)

        ## Test Output
        self.test_output = TestOutput(
            top_frame,
            self.cfg.font.size,
            self.cfg.window_sizes.output,
        )
        self.test_output.pack(fill=tk.BOTH, expand=0)

        ## Analysis Output
        self.analysis_output = AnalysisOutput(
            top_frame,
            self.cfg.font.size,
            self.cfg.window_sizes.analysis,
        )
        self.analysis_output.pack(fill=tk.BOTH, expand=0)

    ## Properties
    @property
    def editor(self):
        """
        Return a reference to the editor window.

        If the editor is not currently visible, accessing it via this property
        will create it and set it to visible.

        This behaviour is intended to permit lazy construction of the editor.

        Returns:
          The editor window (an instance of TutorEditor).
        """
        if self._editor is None:
            self._editor = TutorEditor(
                menu_delegate=self,
                editor_delegate=self,
                root=self.master,
            )
        return self._editor

    ## Private methods
    @property
    def tutorial_package(self):
        return self._tutorial_package

    @tutorial_package.setter
    def tutorial_package(self, package_name):
        """
        Select the tutorial package with the given name.

        If no name is provided, prompt the user to add a new tutorial.

        If a name is provided but the package cannot be loaded, display an
        error to the user but otherwise do nothing.
        This may leave the application in an inconsistent state.

        Warning: if no name is provided, prompting the user to add a new
        tutorial is performed *asynchronously*.  Because of this, if a value
        given to this setter could be null, the calling code *cannot* rely on
        tutorial_package having a valid value immediately after this function
        returns.  For example, the following code is *not safe*:

            self.tutorial_package = 'CSSE1001Tutorials' if rand() < 0.5 else ''
            print(self.tutorial_package.name)  # UNSAFE

        If the assigned value is '' in the above example, self.tutorial_package
        will not have a value until, at the earliest, *after* the next run of
        the GUI event loop (as that is when the user will be prompted to add
        a tutorial).

        In normal use, this should not pose a problem; the only time the
        assigned value should even potentially be the empty string is the very
        first time that MPT is run.

        Args:
          package_name (str): The name of the package to select.

        """
        if not package_name:
            # if no package is given, we want to try to add a new one
            # this will be the case the very first time MyPyTutor is launched
            # in that case, the Tk object (root) will not yet have entered
            # the main loop
            # attempting to display any tk widget before entering the main
            # loop will cause the application to suffer display errors on OS X
            # once it actually appears (see issue #49)
            # by deferring the add action, we can avoid this issue
            self.master.after(0, self.add_tutorial_package)
            return

        assert hasattr(self.cfg, package_name), \
                'Attempt to select unknown package: {}'.format(package_name)
        options = getattr(self.cfg, package_name)

        try:
            self._tutorial_package = TutorialPackage(package_name, options)
        except TutorialPackageError as e:
            tkmessagebox.showerror(
                'Invalid Tutorial Package',
                'Failed to load {}: {}'.format(package_name, e),
            )
            return

        # update menu
        self.menu.set_selected_tutorial_package(self.tutorial_package)

    def _next_hint(self):
        """
        Display the next hint.

        If there are no more hints to display, do nothing.

        """
        if not self.tutorial_frame.show_next_hint():
            self.hint_button.pack_forget()

    def _set_online_status(self, logged_in_user=None):
        """
        Update the online status label.

        Args:
          logged_in_user (str, optional): The username of the student who is
              logged in.  Defaults to None, which indicates that the student
              has logged out.

        """
        if logged_in_user is None:
            self.online_status.config(text='Status: Not Logged In')
        else:
            self.online_status.config(
                text='Status: Logged in as {}'.format(logged_in_user))

    def _ask_for_directory(self, initial_dir=None, prompt='Choose Directory'):
        """
        Display a tk filedialog which prompts the user to select a directory.

        Args:
          initial_dir (str, optional): The initial directory to display in the
              filedialog.  If initial_dir is not provided or does not exist,
              defaults to the user's home directory.
          prompt (str, optional): The prompt to display in the filedialog.
              Defaults to 'Choose Directory'.

        Returns:
          The result of tkfiledialog.askdirectory

        """
        if initial_dir is None or not os.path.exists(initial_dir):
            initial_dir = os.path.expanduser('~')

        return tkfiledialog.askdirectory(title=prompt, initialdir=initial_dir)

    def _upload_answer(self, tutorial):
        """
        Upload the answer for the given tutorial to the server.

        The tutorial must be part of the current tutorial package.

        Args:
          tutorial (Tutorial): The tutorial to upload the answer for.

        Returns:
          Whether the upload was successful.

        """
        if not os.path.exists(tutorial.answer_path):
            return False

        with open(tutorial.answer_path) as f:
            code = f.read()

        problem_set = self.tutorial_package.problem_set_containing(tutorial)
        assert problem_set is not None,\
                'Tutorial {} not found in current package'.format(tutorial)

        return self.web_api.upload_answer(tutorial, problem_set,
                                          self.tutorial_package, code)

    def _download_answer(self, tutorial):
        """
        Download the answer for the given tutorial from the server.

        The tutorial must be part of the current tutorial package.

        Args:
          tutorial (Tutorial): The tutorial to download the answer for.

        Returns:
          Whether the download was successful.

        """
        problem_set = self.tutorial_package.problem_set_containing(tutorial)
        assert problem_set is not None, \
                'Tutorial {} not found in current package'.format(tutorial)

        response = self.web_api.download_answer(tutorial, problem_set,
                                                self.tutorial_package)
        if response is None:
            return False  # no tutorial to download, or download error

        # write it to disk
        with open(tutorial.answer_path, 'w') as f:
            f.write(response)

        return True

    def _get_answer_info(self, tutorial):
        """
        Get the hash and modification time of the student's answer to the
        given tutorial on the server.

        This information can be compared to local data in order to determine
        whether the latest version of the tutorial is on the server or is
        available locally.

        The tutorial must be part of the current tutorial package.

        Args:
          tutorial (Tutorial): The tutorial to query the server about.

        Returns:
          A tuple of the answer information.
          The first element of the tuple is the hash of the answer file.
          The second element of the tuple is the file's modification time.

        """
        problem_set = self.tutorial_package.problem_set_containing(tutorial)
        assert problem_set is not None, \
            'Tutorial {} not found in current package'.format(tutorial)

        return self.web_api.answer_info(tutorial, problem_set,
                                        self.tutorial_package)

    def _display_web_api_error(self, ex):
        """
        Display the given WebAPIError to the user.

        Args:
          ex (WebAPIError): The error to display.

        """
        message = '{}\n\nDetailed error message:\n{}'.format(
            ex.message, ex.details)

        tkmessagebox.showerror('Online Services Error', message)

    ## General callbacks
    def close(self, evt=None):
        """
        Close event for the TutorialApp.

        The app will only be closed if the .editor indicates that it is safe
        to do so (this will prompt the student to save their code).

        """
        if self.editor.close() == tkmessagebox.YES:
            self.synchronise(suppress_popups=True)
            self.logout()

            save_config(self.cfg)

            self.master.destroy()

    ## Public-ish methods
    @skip_if_attr_none('current_tutorial')
    def run_tests(self):
        """
        Test and analyse the student code.

        If there is a compilation error in the student code, highlight the line
        of that error in the editor and return.

        Otherwise, update `.test_output` and `.analysis_output` with the
        results of the testing and analysis respectively.

        Returns:
          Whether the code passes the tests and the analysis.

          The code will pass iff it passes every test case *and* causes no
          analysis errors.
          The second requirement is important, as it helps to avoid 'cheat'
          solutions (such as using the `sum` function when instructed not to).

        """
        code_text = self.editor.get_text()

        # run the tests
        # if the student code cannot be parsed, highlight the problem line
        try:
            tester, analyser = run_tests(self.current_tutorial, code_text)
        except StudentCodeError as e:
            self.editor.error_line(e.linenum)
            return False

        # show the results on the UI
        self.test_output.set_test_results(tester.results)
        self.analysis_output.set_analyser(analyser)

        # return whether the code passed
        return tester.passed and not analyser.errors

    ## TutorialMenuDelegate
    # problems
    @skip_if_attr_none('tutorial_package')
    def change_problem(self, increment=None, problem=None):
        """
        Change the tutorial problem.

        If the student currently has a problem open with unsaved changes, they
        will be prompted to save those changes.  If they choose to cancel,
        this function will return.

        Either `increment` or `problem` must be provided.  If `increment` is
        provided, it will be interpreted as an instruction to go to the problem
        `increment` after (or before) the current problem.  If `problem` is
        provided, that problem will be switched to directly.

        After the current problem is selected, the `.tutorial_frame`,
        `.short_description`,  and `.editor` are updated as appropriate.

        Tests are automatically run on the new tutorial.  As a side effect,
        both `.test_output` and `.analysis_output` will be updated.

        Args:
          increment (int, optional): The number of problems to skip, relative
              to the current problem.  An increment of 1 will select the next
              problem; -1 will select the previous problem.  Defaults to None.
          problem (Tutorial, optional): The problem to change to.  Defaults to
              None.  If `increment` is not provided, `problem` must be.

        """
        if self.editor.maybesave() == tkmessagebox.CANCEL:
            return

        if increment is not None:
            if increment < 0:
                f = self.tutorial_package.previous
                increment = -increment
            else:
                f = self.tutorial_package.next

            problem = self.current_tutorial

            for _ in range(increment):
                problem = f(self.current_tutorial)

        # set the current tutorial
        assert problem is not None
        self.current_tutorial = problem

        # show the problem text and description
        self.tutorial_frame.tutorial = self.current_tutorial
        self.short_description.config(
            text=self.current_tutorial.short_description)

        # set up the editor
        self.editor.reset(self.current_tutorial)
        self.editor.undo.reset_undo()

        # set up the hints toolbar
        if self.current_tutorial.hints:
            self.hint_button.pack(side=tk.LEFT)
        else:
            self.hint_button.pack_forget()

        # run the tests
        # this will fill out the results and static analysis sections
        self.run_tests()

    # online
    def login(self):
        """
        Attempt to log the student in to the MyPyTutor system.

        If the login fails, show the student an error message.

        If the login succeeds, set the online status appropriately.

        Returns:
          Whether the login attempt succeeded.

        """
        if not self.web_api.login():
            tkmessagebox.showerror(
                'Login failed',
                'Please check your credentials and try again.  ' \
                'If the problem persists, check your internet connection.  ' \
                'Some functionality (such as submitting answers) will be ' \
                'unavailable until you log in successfully.'
            )
            return False

        self._set_online_status(logged_in_user=self.web_api.user)
        return True

    def logout(self):
        """
        Log the student out of the MyPyTutor system, and set the online status
        to reflect this.

        """
        self.web_api.logout()

        self._set_online_status(logged_in_user=None)

    @skip_if_attr_none('current_tutorial')
    def submit(self):
        """
        Submit the current tutorial problem.

        The student may only submit if logged on, and if their answer is
        correct (ie, if it passes all tests and raises no analysis errors).

        Attempting to submit will prompt the student to log on (if they are not
        already logged in), and will re-run the tests.

        """
        if not self.login():
            return

        if self.run_tests():
            try:
                response = self.web_api.submit_answer(self.current_tutorial,
                                                      self.editor.get_text())
            except WebAPIError as e:
                self._display_web_api_error(e)
                return

            if response is None:
                tkmessagebox.showinfo(
                    'Code Not Submitted',
                    'This tutorial problem has already been submitted.  ' \
                    'There is no need to submit more than once.'
                )
                return

            tkmessagebox.showinfo(
                'Submission Successful!',
                'Code submitted {}'.format('on time' if response else 'late'),
            )

    @skip_if_attr_none('tutorial_package')
    def show_submissions(self):
        """
        Show the student their submission history.

        This will indicate which problems have been completed, and which
        still need to be done.

        The student must be logged on to show their submissions.  This method
        will prompt the student to login if necessary.

        """
        if not self.login():
            return

        try:
            submissions = self.web_api.get_submissions(self.tutorial_package)
        except WebAPIError as e:
            self._display_web_api_error(e)
            return

        SubmissionsDialog(self.master, submissions, self.tutorial_package)

    def _synchronise(self, suppress_popups=False):
        """
        Synchronise the tutorial answers.

        A tutorial will be downloaded from the server iff:
          * there is no local answer, but there is one on the server; or
          * the local and remote answers differ, but the remote one was
            modified after the local one.

        A tutorial will be uploaded to the server iff:
          * there is no answer on the server, but there is a local one; or
          * the local and remote answers differ, but the local one was modified
            at the same time as or before the one on the server.

        This method performs the actual synchronisation.  It does not handle
        any exceptions which may be thrown by the underlying code (ie, it may
        raise WebAPIError).

        Args:
          suppress_popups (bool, optional): If True, do not show any popup
              messages.  This is intended to be used when the synchronisation
              is taking place as part of the close handler.  Defaults to False.

        """
        for problem_set in self.tutorial_package.problem_sets:
            for tutorial in problem_set:
                remote_hash, remote_mtime = self._get_answer_info(tutorial)

                if not tutorial.has_answer:
                    if remote_hash is not None:  # there exists a remote copy
                        self._download_answer(tutorial)
                    continue

                local_hash, local_mtime = tutorial.answer_info

                if local_hash == remote_hash:  # no changes
                    continue

                if remote_hash is None or local_mtime >= remote_mtime:
                    success = self._upload_answer(tutorial)
                else:
                    success = self._download_answer(tutorial)

                if not success:
                    return False
        return True

    @skip_if_attr_none('tutorial_package')
    def synchronise(self, suppress_popups=False, no_login=None):
        """
        Synchronise the tutorial answers.

        A tutorial will be downloaded from the server iff:
          * there is no local answer, but there is one on the server; or
          * the local and remote answers differ, but the remote one was
            modified after the local one.

        A tutorial will be uploaded to the server iff:
          * there is no answer on the server, but there is a local one; or
          * the local and remote answers differ, but the local one was modified
            at the same time as or before the one on the server.

        Args:
          suppress_popups (bool, optional): If True, do not show any popup
              messages.  This is intended to be used when the synchronisation
              is taking place as part of the close handler.  Defaults to False.
          no_login (bool, optional): If True, do not attempt to login; if the
              user is not logged in, exit immediately and silently.  Defaults
              to the same value as suppress_popups.

        """
        if no_login is None:
            no_login = suppress_popups

        # if we're not logged in, and we are told not to, or can't, then quit
        if not self.web_api.is_logged_in:
            if no_login or not self.login():
                return

        # start showing the progress popup
        popup = ProgressPopup()

        def _show_failure_message():
            tkmessagebox.showerror(
                'Could Not Synchronise Answer Code',
                'Please check that you are correctly logged in, ' \
                'and that your internet connection is active.'
            )

        def _show_success_message():
            tkmessagebox.showinfo(
                'Synchronisation Complete!',
                'Your answers have been successfully synchronised with the ' \
                'server.',
            )

        def _background_task():
            # certain methods used in the synchronisation process might throw
            # WebAPIError, so we want to wrap everything in an exception
            # handler
            # note that as this is on a background thread, we must not make
            # any UI calls
            try:
                success = self._synchronise(suppress_popups=suppress_popups)

                if not suppress_popups:
                    if success:
                        self.master.after(0, _show_success_message)
                    else:
                        self.master.after(0, _show_failure_message)
            except WebAPIError as e:
                if not suppress_popups:
                    self.master.after(0, self._display_web_api_error, e)
            finally:
                popup.destroy()

        # do this on a background thread
        exec_sync(_background_task)

    # tools
    def show_visualiser(self):
        """
        Open a web browser with the student's current code pre-loaded into the
        online visualiser tool.

        No login is necessary; the tool is publicly available.

        """
        self.web_api.visualise(self.editor.get_text())

    def reload_interpreter(self):
        """
        Reload the interpeter window with the latest version of the
        student's code.

        If the interpreter is not visible, it should be opened.

        """
        raise NotImplementedError('Interpeter not yet implemented')

    # preferences
    def configure_fonts(self):
        font_chooser = FontChooser(self.master, self,
                                   (self.cfg.font.name, self.cfg.font.size))
        if font_chooser.result is None:
            return

        self.cfg.font.name, self.cfg.font.size = font_chooser.result

        self.update_fonts()

    @skip_if_attr_none('tutorial_package')
    def change_tutorial_directory(self):
        """
        Prompt to change the current tutorial directory.

        This is the directory that the tutorial problems are read from.

        If the tutorial directory is changed, reload the current tutorial
        package.  This may cause an error popup if the new directory is
        not valid.

        """
        directory = self._ask_for_directory(
            prompt='Choose Tutorial Folder: {}'.format(
                self.tutorial_package.name),
            initial_dir=self.tutorial_package.options.tut_dir,
        )

        if directory:
            # .current_tutorial.options is bound to cfg, so will change it
            self.tutorial_package.options.tut_dir = directory
            # force a reload of the tutorial package so that all tutorials
            # with a reference to the old directory are replaced
            self.tutorial_package = self.tutorial_package.name

    @skip_if_attr_none('tutorial_package')
    def change_answers_directory(self):
        """
        Prompt to change the current answers directory.

        This is the directory that the tutorial answers are written to.

        If the answers directory is changed, reload the current tutorial
        package.  This will update the `.answer_path` property on each tutorial
        object.  The `.current_tutorial` will then be updated to match this
        (so that it does not contain a reference to the old object).  Finally,
        the save path for the editor must also be updated.

        """
        directory = self._ask_for_directory(
            prompt='Choose Answers Folder: {}'.format(
                self.tutorial_package.name),
            initial_dir=self.tutorial_package.options.ans_dir,
        )

        if directory:
            # .current_tutorial.options is bound to cfg, so will change it
            self.tutorial_package.options.ans_dir = directory
            # force a reload of the tutorial package so that all tutorials
            # with a reference to the old directory are replaced
            self.tutorial_package = self.tutorial_package.name

            # need to update reference to Tutorial in new (reloaded) package
            # (but only if we actually have a tutorial open)
            if self.current_tutorial is None:
                return

            self.current_tutorial = self.tutorial_package.tutorial_with_name(
                self.current_tutorial.name)
            self.editor.set_filename(self.current_tutorial.answer_path)
            # TODO: relocate answers?

    @skip_if_attr_none('tutorial_package')
    def set_as_default_package(self):
        """
        Set the current tutorial package as the default tutorial package.

        """
        self.cfg.tutorials.default = self.tutorial_package.name

    def add_tutorial_package(self):
        """
        Prompt the user to add a tutorial package.

        If there is no default tutorial package, then this tutorial package
        will be added as the default.  Otherwise, it will be added as an
        ordinary package.

        If the package was added as the default package, it will be selected
        automatically.

        """
        # if we don't have a default tutorial, we should add this one as the
        # default and then switch to it
        as_default = not self.cfg.tutorials.default
        msg = add_tutorial(self.cfg, as_default=as_default, window=self.master)

        if msg is not None:
            tkmessagebox.showerror(
                'Failed to Add Tutorial',
                'Could not add tutorial: {}'.format(msg),
            )
            return

        if as_default:
            self.tutorial_package = self.cfg.tutorials.default

        self.menu.set_tutorial_packages(self.cfg.tutorials.names)

    @skip_if_attr_none('tutorial_package')
    def remove_current_tutorial_package(self):
        """
        Remove the current tutorial package.

        It is not possible to remove the default tutorial package, or the only
        tutorial package which is available.

        The user will be prompted to confirm the package's removal.  If they
        choose to proceed, the default tutorial package will be selected.

        This process does not delete any problem or answer files.

        """
        if self.cfg.tutorials.default == self.tutorial_package.name:
            tkmessagebox.showerror(
                'Remove Current Tutorial Error',
                'You cannot remove the default tutorial.  ' \
                'Try setting a new default first.'
            )
            return

        if len(self.cfg.tutorials.names) == 1:
            tkmessagebox.showerror(
                'Remove Current Tutorial Error'
                'You cannot remove the last tutorial.  ' \
                'Try adding a new tutorial first.'
            )
            return

        should_remove = tkmessagebox.askquestion(
            'Remove Tutorial?', 'Do you really want to remove {}?'.format(
                self.tutorial_package.name))

        if str(should_remove) == tkmessagebox.YES:
            del self.cfg[self.tutorial_package.name]
            self.cfg.tutorials.names.remove(self.tutorial_package.name)

            self.tutorial_package = self.cfg.tutorials.default

            self.menu.set_tutorial_packages(self.cfg.tutorials.names)

    def change_tutorial_package(self, package_name):
        """
        Change the selected tutorial package.

        Args:
          package_name (str): The name of the tutorial package to change to.

        """
        self.tutorial_package = package_name

    # feedback
    def feedback(self, problem_feedback=False):
        """
        Display a window which enables the user to provide feedback.

        Args:
          problem_feedback (bool, optional): Whether the feedback is about the
              current problem.  If True, then information about the problem
              will be included along with the feedback message.  Defaults to
              False.

        """
        if problem_feedback and self.current_tutorial is not None:
            FeedbackDialog(
                self.master,
                'Problem Feedback: {}'.format(self.current_tutorial.name),
                self.current_tutorial.name, self.editor.get_text())
        else:
            FeedbackDialog(self.master, 'General Feedback')

    # help
    def show_help_dialog(self):
        """
        Show the help dialog.

        """
        HelpDialog(self.master, 'Help')

    def show_about_dialog(self):
        """
        Show the about dialog.

        """
        TutAboutDialog(self.master, 'About MyPyTutor')

    ## TutorEditorDelegate
    check_solution = run_tests
    quit_editor = close
示例#12
0
def update_default_tutorial_package(force_update=False):
    """
    Update the default tutorial package if necessary.

    Args:
      force_update (bool, optional): If True, update regardless of whether a
        newer version of the tutorial package is available on the server.

    """
    from tutorlib.config.configuration import load_config
    from tutorlib.gui.app.support \
            import remove_directory_contents, safely_extract_zipfile
    from tutorlib.interface.problems \
            import TutorialPackage, TutorialPackageError
    from tutorlib.interface.web_api import WebAPI, WebAPIError

    print('Checking for tutorial package updates...', end='', flush=True)

    # grab our config file
    cfg = load_config()
    package_name = cfg.tutorials.default
    package_options = getattr(cfg, package_name)

    # try to open the tutorial package
    try:
        tutorial_package = TutorialPackage(package_name, package_options)
    except TutorialPackageError:
        print('failed')
        return

    # check if we need to do an update at all
    web_api = WebAPI()

    try:
        timestamp = web_api.get_tutorials_timestamp()
    except WebAPIError:
        print('failed')
        return

    # we need to be comparing as ints
    create_tuple = lambda t: tuple(map(int, t.split('.')))
    server_timestamp = create_tuple(timestamp)
    local_timestamp = create_tuple(tutorial_package.timestamp)

    print('done')

    # we only want to update if the server's version is more recent
    # a more recent local version should only arise in development, anyway
    if server_timestamp <= local_timestamp and not force_update:
        return

    print('Updating tutorial package...', end='', flush=True)

    # grab the zipfile
    try:
        zip_path = web_api.get_tutorials_zipfile()
    except WebAPIError:
        print('failed')
        return

    # extract the zipfile into our empty tutorial directory
    remove_directory_contents(tutorial_package.options.tut_dir)
    safely_extract_zipfile(zip_path, tutorial_package.options.tut_dir)

    print('done')
示例#13
0
def bootstrap_tutorials():
    """
    If the default tutorial path does not exist, download and extract the
    tutorials zipfile.

    If no default tutorial package is specified, CSSE1001Tutorials will be
    assumed (this is the default package name used by this script).

    """
    from tutorlib.config.configuration import load_config, save_config
    from tutorlib.gui.app.support import safely_extract_zipfile
    from tutorlib.interface.problems \
            import TutorialPackage, TutorialPackageError
    from tutorlib.interface.web_api import WebAPI, WebAPIError

    # grab our config file
    cfg = load_config()
    options = getattr(cfg, cfg.tutorials.default or 'CSSE1001Tutorials')

    def tutorials_are_installed():
        if not options.tut_dir:  # no entry in config at all (default)
            return False
        if not os.path.exists(options.tut_dir):  # no package directory at all
            return False
        if not os.path.exists(options.ans_dir):  # no answers dir
            return False

        try:
            _ = TutorialPackage(cfg.tutorials.default, options)
        except TutorialPackageError:
            return False

        return True

    if not tutorials_are_installed():
        print('Downloading default tutorial package...', end='', flush=True)

        web_api = WebAPI()
        try:
            filename = web_api.get_tutorials_zipfile()
        except WebAPIError:
            print('failed')
            sys.exit(1)

        print('done')

        # set the default tutorial directory
        # our default tutorial directory is in the same directory as the script
        # note that this assumes we used the default config, which created the
        # CSSE1001Tutorials key
        print('Installing default tutorial package...', end='', flush=True)

        script_dir = get_script_dir()
        options.tut_dir = os.path.join(script_dir, 'CSSE1001Tutorials')
        options.ans_dir = os.path.join(script_dir, 'CSSE1001Answers')

        safely_extract_zipfile(filename, options.tut_dir)

        if not os.path.exists(options.ans_dir):
            os.mkdir(options.ans_dir)

        save_config(cfg)

        print('done')