diff --git a/docs/source/getting_started/modes.rst b/docs/source/getting_started/modes.rst index 9a2576d3..fdce4e9e 100644 --- a/docs/source/getting_started/modes.rst +++ b/docs/source/getting_started/modes.rst @@ -18,6 +18,12 @@ To deactivate all e2xgrader modes: e2xgrader deactivate --sys-prefix +To show the current active mode: + +.. code-block:: sh + + e2xgrader show_mode + Teacher Mode ------------ diff --git a/e2xgrader/apps/__init__.py b/e2xgrader/apps/__init__.py index e69de29b..4c6653be 100644 --- a/e2xgrader/apps/__init__.py +++ b/e2xgrader/apps/__init__.py @@ -0,0 +1,15 @@ +from .activatemodeapp import ActivateModeApp +from .baseapp import E2xGrader +from .deactivatemodeapp import DeactivateModeApp +from .e2xgraderapp import E2xGraderApp +from .showmodeapp import ShowModeApp +from .togglemodeapp import ToggleModeApp + +__all__ = [ + "E2xGrader", + "E2xGraderApp", + "ActivateModeApp", + "DeactivateModeApp", + "ShowModeApp", + "ToggleModeApp", +] diff --git a/e2xgrader/apps/activatemodeapp.py b/e2xgrader/apps/activatemodeapp.py new file mode 100644 index 00000000..a71f4da3 --- /dev/null +++ b/e2xgrader/apps/activatemodeapp.py @@ -0,0 +1,22 @@ +from ..utils.mode import E2xGraderMode +from .togglemodeapp import ToggleModeApp + + +class ActivateModeApp(ToggleModeApp): + description = "Activate a specific mode (teacher, student, student_exam)" + + def start(self) -> None: + super().start() + if len(self.extra_args) != 1: + self.fail("Exactly one mode has to be specified") + if self.extra_args[0] not in [ + E2xGraderMode.TEACHER.value, + E2xGraderMode.STUDENT.value, + E2xGraderMode.STUDENT_EXAM.value, + ]: + self.fail( + f"Mode {self.extra_args[0]} is not a valid mode that can be activated." + ) + self.mode = self.extra_args[0] + + self.activate_mode() diff --git a/e2xgrader/apps/baseapp.py b/e2xgrader/apps/baseapp.py new file mode 100644 index 00000000..4759f510 --- /dev/null +++ b/e2xgrader/apps/baseapp.py @@ -0,0 +1,33 @@ +from textwrap import dedent + +from jupyter_core.application import JupyterApp +from traitlets import Enum + +from ..utils.mode import E2xGraderMode, infer_e2xgrader_mode + + +class E2xGrader(JupyterApp): + + mode = Enum( + values=[mode.value for mode in E2xGraderMode], + default_value=E2xGraderMode.INACTIVE.value, + help=dedent( + """ + Which mode is activated, can be teacher, student, student_exam or deactivated. + Is set to invalid if the mode cannot be inferred. + """ + ), + ) + + def fail(self, msg, *args): + self.log.error(msg, *args) + self.exit(1) + + def initialize(self, argv=None): + try: + mode = infer_e2xgrader_mode() + self.mode = mode + except ValueError as e: + self.log.error(str(e)) + self.mode = E2xGraderMode.INVALID.value + super().initialize(argv) diff --git a/e2xgrader/apps/deactivatemodeapp.py b/e2xgrader/apps/deactivatemodeapp.py new file mode 100644 index 00000000..86684257 --- /dev/null +++ b/e2xgrader/apps/deactivatemodeapp.py @@ -0,0 +1,13 @@ +from ..utils.mode import E2xGraderMode +from .togglemodeapp import ToggleModeApp + + +class DeactivateModeApp(ToggleModeApp): + description = "Deactivate all e2xgrader extensions" + + def start(self) -> None: + super().start() + if len(self.extra_args) != 0: + self.fail("e2xgrader deactivate does not take any arguments.") + self.mode = E2xGraderMode.INACTIVE.value + self.activate_mode() diff --git a/e2xgrader/apps/e2xgraderapp.py b/e2xgrader/apps/e2xgraderapp.py index d466a4c6..dd482209 100644 --- a/e2xgrader/apps/e2xgraderapp.py +++ b/e2xgrader/apps/e2xgraderapp.py @@ -1,105 +1,48 @@ -import sys -from argparse import ArgumentParser from textwrap import dedent -from ..extensions import E2xExtensionManager +from .activatemodeapp import ActivateModeApp +from .baseapp import E2xGrader +from .deactivatemodeapp import DeactivateModeApp +from .showmodeapp import ShowModeApp -class Manager: - def __init__(self): - self.extension_manager = E2xExtensionManager() - parser = ArgumentParser( - description="E2X extension manager.", - usage=dedent( - """ - e2xgrader [] - - Available sub commands are: - activate activate a specific mode (teacher, student, student-exam) - deactivate deactivate all extensions""" - ), - ) - - parser.add_argument("command", help="Subcommand to run") +class E2xGraderApp(E2xGrader): - args = parser.parse_args(sys.argv[1:2]) - if not hasattr(self, args.command): - print("Unrecognized command") - parser.print_help() - exit(1) - getattr(self, args.command)() - - def activate(self): - parser = ArgumentParser( - description="Activate different modes", - usage=dedent( + subcommands = dict( + activate=( + ActivateModeApp, + dedent( + """\ + Activate a specific mode (teacher, student, student_exam) """ - e2xgrader activate [--sys-prefix] [--user] - - Available modes are: - teacher activate the grader and all teaching extensions - student activate the student extensions - student_exam activate the student extensions in exam mode""" - ), - ) - # prefixing the argument with -- means it's optional - parser.add_argument( - "mode", - help="Which mode to activate, can be teacher, student or student-exam", - ) - parser.add_argument( - "--sys-prefix", - action="store_true", - help="If the extensions should be installed to sys.prefix", - ) - parser.add_argument( - "--user", - action="store_true", - help="If the extensions should be installed to the user space", - ) - - args = parser.parse_args(sys.argv[2:]) - if not hasattr(self.extension_manager, f"activate_{args.mode}"): - print("Unrecognized mode") - parser.print_help() - exit(1) - sys_prefix = False - user = False - if args.sys_prefix: - sys_prefix = True - if args.user: - user = True - getattr(self.extension_manager, f"activate_{args.mode}")( - sys_prefix=sys_prefix, user=user - ) - - def deactivate(self): - parser = ArgumentParser( - description="Deactivate extensions", - usage=dedent("python -m e2xgrader deactivate [--sys-prefix] [--user]"), - ) - # prefixing the argument with -- means it's optional - parser.add_argument( - "--sys-prefix", - action="store_true", - help="If the extensions should be uninstalled from sys.prefix", - ) - parser.add_argument( - "--user", - action="store_true", - help="If the extensions should be uninstalled from the user space", - ) - - args = parser.parse_args(sys.argv[2:]) + ).strip(), + ), + deactivate=( + DeactivateModeApp, + dedent( + """\ + Deactivate all e2xgrader extensions + """ + ).strip(), + ), + show_mode=( + ShowModeApp, + dedent( + """\ + Show the currently active mode + """ + ).strip(), + ), + ) - sys_prefix = False - user = False - if args.sys_prefix: - sys_prefix = True - if args.user: - user = True - self.extension_manager.deactivate(sys_prefix=sys_prefix, user=user) + def start(self) -> None: + super().start() + if self.subapp is None: + print( + "No subcommand given (run with --help for options). List of subcommands:\n" + ) + self.print_subcommands() def main(): - Manager() + E2xGraderApp.launch_instance() diff --git a/e2xgrader/apps/showmodeapp.py b/e2xgrader/apps/showmodeapp.py new file mode 100644 index 00000000..0fb379bf --- /dev/null +++ b/e2xgrader/apps/showmodeapp.py @@ -0,0 +1,9 @@ +from .baseapp import E2xGrader + + +class ShowModeApp(E2xGrader): + description = "Show the currently active mode" + + def start(self) -> None: + super().start() + print(f"Current mode: {self.mode}") diff --git a/e2xgrader/apps/togglemodeapp.py b/e2xgrader/apps/togglemodeapp.py new file mode 100644 index 00000000..f3815bae --- /dev/null +++ b/e2xgrader/apps/togglemodeapp.py @@ -0,0 +1,62 @@ +from traitlets import Bool + +from ..extensions import E2xExtensionManager +from ..utils.mode import E2xGraderMode, infer_e2xgrader_mode +from .baseapp import E2xGrader + + +class ToggleModeApp(E2xGrader): + + sys_prefix = Bool(False, help="Install extensions to sys.prefix", config=True) + + user = Bool(False, help="Install extensions to the user space", config=True) + + flags = { + "sys-prefix": ( + {"ToggleModeApp": {"sys_prefix": True}}, + "Install extensions to sys.prefix", + ), + "user": ( + {"ToggleModeApp": {"user": True}}, + "Install extensions to the user space", + ), + } + + def activate_mode(self): + """ + Activates the specified mode by activating the corresponding extensions + using the E2xExtensionManager. + + If the mode is "None", it deactivates all e2xgrader extensions. + """ + extension_manager = E2xExtensionManager() + if self.mode == E2xGraderMode.INACTIVE.value: + print( + f"Deactivating e2xgrader extensions with sys_prefix={self.sys_prefix} " + f"and user={self.user}" + ) + extension_manager.deactivate(sys_prefix=self.sys_prefix, user=self.user) + else: + print( + f"Activating mode {self.mode} with sys_prefix={self.sys_prefix} " + f"and user={self.user}" + ) + getattr(extension_manager, f"activate_{self.mode}")( + sys_prefix=self.sys_prefix, user=self.user + ) + self.log.info(f"Activated mode {self.mode}. ") + try: + mode = infer_e2xgrader_mode() + if mode != self.mode: + self.log.warning( + f"The activated mode {self.mode} does not match the infered mode {mode}. \n" + f"The mode {mode} may be activated on a higher level." + ) + except ValueError as e: + self.log.error(str(e)) + self.mode = E2xGraderMode.INVALID.value + + def start(self) -> None: + super().start() + if self.sys_prefix and self.user: + self.fail("Cannot install in both sys-prefix and user space") diff --git a/e2xgrader/extensions/utils.py b/e2xgrader/extensions/utils.py index a07225f3..8b47009b 100644 --- a/e2xgrader/extensions/utils.py +++ b/e2xgrader/extensions/utils.py @@ -27,6 +27,14 @@ def get_nbextension_utils() -> Optional[ModuleType]: return None +def get_notebook_config_manager() -> Optional[ModuleType]: + if is_installed("jupyter_server"): + return import_module("jupyter_server.config_manager").BaseJSONConfigManager + if is_installed("notebook") and get_notebook_major_version() < 7: + return import_module("notebook.services.config.manager").BaseJSONConfigManager + return None + + def discover_nbextensions(mode: str) -> List[Dict[str, str]]: extensions = list() for nbextension in _jupyter_nbextension_paths(): diff --git a/e2xgrader/server_extensions/base/extension.py b/e2xgrader/server_extensions/base/extension.py index 77cc9326..44d28965 100644 --- a/e2xgrader/server_extensions/base/extension.py +++ b/e2xgrader/server_extensions/base/extension.py @@ -1,11 +1,10 @@ from importlib import import_module +from e2xcore.utils.utils import get_nbgrader_config from jinja2 import Environment, FileSystemLoader from traitlets import Any, List, TraitError, validate from traitlets.config import Application -from e2xgrader.utils import get_nbgrader_config - class BaseExtension(Application): apps = List( diff --git a/e2xgrader/tests/apps/test_activatemodeapp.py b/e2xgrader/tests/apps/test_activatemodeapp.py new file mode 100644 index 00000000..5053a8e6 --- /dev/null +++ b/e2xgrader/tests/apps/test_activatemodeapp.py @@ -0,0 +1,35 @@ +import unittest +from unittest.mock import patch + +from jupyter_core.application import NoStart + +from e2xgrader.apps.activatemodeapp import ActivateModeApp +from e2xgrader.utils.mode import E2xGraderMode + + +class TestActivateModeApp(unittest.TestCase): + + def setUp(self): + self.app = ActivateModeApp() + self.app.initialize([]) + + def test_fail_without_args(self): + with self.assertRaises(SystemExit): + self.app.initialize([]) + self.app.start() + + def test_fail_with_invalid_mode(self): + with self.assertRaises(SystemExit): + self.app.initialize(["invalid_mode"]) + self.app.start() + + @patch("e2xgrader.apps.togglemodeapp.ToggleModeApp.activate_mode") + def test_activate_mode(self, mock_activate_mode): + try: + self.app.initialize([E2xGraderMode.TEACHER.value]) + self.app.start() + except NoStart: + pass + finally: + self.assertEqual(self.app.mode, E2xGraderMode.TEACHER.value) + mock_activate_mode.assert_called_once() diff --git a/e2xgrader/tests/apps/test_baseapp.py b/e2xgrader/tests/apps/test_baseapp.py new file mode 100644 index 00000000..46288b84 --- /dev/null +++ b/e2xgrader/tests/apps/test_baseapp.py @@ -0,0 +1,28 @@ +import unittest +from unittest.mock import patch + +from traitlets import TraitError + +from e2xgrader.apps.baseapp import E2xGrader +from e2xgrader.utils.mode import E2xGraderMode + + +class TestE2xGrader(unittest.TestCase): + + def setUp(self): + self.app = E2xGrader() + self.app.initialize([]) + + def test_set_invalid_mode(self): + with self.assertRaises(TraitError): + self.app.mode = "mode_that_does_not_exist" + + def test_non_matching_mode_causes_log_error(self): + with patch( + "e2xgrader.apps.baseapp.infer_e2xgrader_mode" + ) as mock_infer_e2xgrader_mode: + mock_infer_e2xgrader_mode.side_effect = ValueError("error") + with patch("e2xgrader.apps.baseapp.E2xGrader.log") as mock_log: + self.app.initialize([]) + mock_log.error.assert_called_once_with("error") + self.assertEqual(self.app.mode, E2xGraderMode.INVALID.value) diff --git a/e2xgrader/tests/apps/test_deactivatemodeapp.py b/e2xgrader/tests/apps/test_deactivatemodeapp.py new file mode 100644 index 00000000..3da71459 --- /dev/null +++ b/e2xgrader/tests/apps/test_deactivatemodeapp.py @@ -0,0 +1,30 @@ +import unittest +from unittest.mock import patch + +from jupyter_core.application import NoStart + +from e2xgrader.apps.deactivatemodeapp import DeactivateModeApp +from e2xgrader.utils.mode import E2xGraderMode + + +class TestDeactivateModeApp(unittest.TestCase): + + def setUp(self): + self.app = DeactivateModeApp() + self.app.initialize([]) + + def test_fail_with_args(self): + with self.assertRaises(SystemExit): + self.app.initialize(["arg"]) + self.app.start() + + @patch("e2xgrader.apps.togglemodeapp.ToggleModeApp.activate_mode") + def test_deactivate_mode(self, mock_activate_mode): + self.app.initialize([]) + try: + self.app.start() + except NoStart: + pass + finally: + self.assertEqual(self.app.mode, E2xGraderMode.INACTIVE.value) + mock_activate_mode.assert_called_once() diff --git a/e2xgrader/tests/apps/test_e2xgraderapp.py b/e2xgrader/tests/apps/test_e2xgraderapp.py new file mode 100644 index 00000000..7914c74f --- /dev/null +++ b/e2xgrader/tests/apps/test_e2xgraderapp.py @@ -0,0 +1,42 @@ +import unittest +from unittest.mock import patch + +from jupyter_core.application import NoStart + +from e2xgrader.apps.activatemodeapp import ActivateModeApp +from e2xgrader.apps.e2xgraderapp import E2xGraderApp + + +class TestE2xGraderApp(unittest.TestCase): + + def setUp(self): + self.app = E2xGraderApp() + self.app.initialize([]) + + def test_run_without_subcommand(self): + # When the app starts we need to make sure app.print_subcommands is called + with patch.object(self.app, "print_subcommands") as mock_print_subcommands: + self.app.start() + mock_print_subcommands.assert_called_once() + + def test_run_with_subcommand_activate(self): + # When the app starts with a subcommand we need to make sure the subcommand is called + self.app.initialize(["activate"]) + self.assertIsInstance(self.app.subapp, ActivateModeApp) + with patch( + "e2xgrader.apps.activatemodeapp.ActivateModeApp.start" + ) as mock_start: + try: + self.app.start() + except NoStart: + pass + finally: + mock_start.assert_called_once() + + def test_run_with_invalid_subcommand(self): + # When the app starts with an invalid subcommand we need to make sure the app prints + # the subcommands + self.app.initialize(["invalid_subcommand"]) + with patch.object(self.app, "print_subcommands") as mock_print_subcommands: + self.app.start() + mock_print_subcommands.assert_called_once() diff --git a/e2xgrader/tests/apps/test_e2xmanager.py b/e2xgrader/tests/apps/test_e2xmanager.py index 6868e044..5e489f7d 100644 --- a/e2xgrader/tests/apps/test_e2xmanager.py +++ b/e2xgrader/tests/apps/test_e2xmanager.py @@ -1,9 +1,10 @@ import os import unittest -from notebook.serverextensions import BaseJSONConfigManager, jupyter_config_path +from jupyter_core.paths import jupyter_config_path from e2xgrader.extensions import E2xExtensionManager +from e2xgrader.extensions.utils import get_notebook_config_manager class TestE2XExtensionManager(unittest.TestCase): @@ -16,7 +17,12 @@ def setUp(self): "e2xgrader.server_extensions.teacher", "e2xgrader.server_extensions.student", ] + self.manager = E2xExtensionManager() + # Deactivate all extensions with all flags (sys_prefix, user) + self.manager.deactivate() + + def tearDown(self): self.manager.deactivate() def get_serverextensions(self, role): @@ -68,7 +74,7 @@ def get_nbextensions(self, role): def test_deactivated_serverextensions(self): config_dict = {} for config_dir in jupyter_config_path(): - cm = BaseJSONConfigManager(config_dir=config_dir) + cm = get_notebook_config_manager()(config_dir=config_dir) config_dict.update(cm.get("jupyter_notebook_config")) extensions = config_dict["NotebookApp"]["nbserver_extensions"] for serverextension in self.serverextensions: @@ -80,7 +86,7 @@ def test_teacher_serverextensions(self): config_dict = {} for config_dir in jupyter_config_path(): - cm = BaseJSONConfigManager(config_dir=config_dir) + cm = get_notebook_config_manager()(config_dir=config_dir) config_dict.update(cm.get("jupyter_notebook_config")) extensions = config_dict["NotebookApp"]["nbserver_extensions"] for serverextension, status in teacher_serverextensions.items(): @@ -93,7 +99,7 @@ def test_teacher_nbextensions(self): config_dict = {} for config_dir in jupyter_config_path(): config_dir = os.path.join(config_dir, "nbconfig") - cm = BaseJSONConfigManager(config_dir=config_dir) + cm = get_notebook_config_manager()(config_dir=config_dir) config_dict.update(cm.get(section)) for extension, status in extensions.items(): @@ -108,7 +114,7 @@ def test_student_serverextensions(self): config_dict = {} for config_dir in jupyter_config_path(): - cm = BaseJSONConfigManager(config_dir=config_dir) + cm = get_notebook_config_manager()(config_dir=config_dir) config_dict.update(cm.get("jupyter_notebook_config")) extensions = config_dict["NotebookApp"]["nbserver_extensions"] for serverextension, status in student_serverextensions.items(): @@ -121,7 +127,7 @@ def test_student_nbextensions(self): config_dict = {} for config_dir in jupyter_config_path(): config_dir = os.path.join(config_dir, "nbconfig") - cm = BaseJSONConfigManager(config_dir=config_dir) + cm = get_notebook_config_manager()(config_dir=config_dir) config_dict.update(cm.get(section)) for extension, status in extensions.items(): @@ -137,7 +143,7 @@ def test_student_exam_serverextensions(self): config_dict = {} for config_dir in jupyter_config_path(): - cm = BaseJSONConfigManager(config_dir=config_dir) + cm = get_notebook_config_manager()(config_dir=config_dir) config_dict.update(cm.get("jupyter_notebook_config")) extensions = config_dict["NotebookApp"]["nbserver_extensions"] for serverextension, status in student_serverextensions.items(): @@ -150,7 +156,7 @@ def test_student_exam_nbextensions(self): config_dict = {} for config_dir in jupyter_config_path(): config_dir = os.path.join(config_dir, "nbconfig") - cm = BaseJSONConfigManager(config_dir=config_dir) + cm = get_notebook_config_manager()(config_dir=config_dir) config_dict.update(cm.get(section)) for extension, status in extensions.items(): @@ -158,6 +164,3 @@ def test_student_exam_nbextensions(self): assert config_dict["load_extensions"][extension] == status else: assert not status - - def tearDown(self): - self.manager.deactivate() diff --git a/e2xgrader/tests/apps/test_showmodeapp.py b/e2xgrader/tests/apps/test_showmodeapp.py new file mode 100644 index 00000000..cc93bbb3 --- /dev/null +++ b/e2xgrader/tests/apps/test_showmodeapp.py @@ -0,0 +1,29 @@ +import sys +import unittest +from io import StringIO + +from jupyter_core.application import NoStart + +from e2xgrader.apps.showmodeapp import ShowModeApp + + +class TestShowModeApp(unittest.TestCase): + + def setUp(self): + self.app = ShowModeApp() + self.app.initialize([]) + self.caputered_stdout = StringIO() + sys.stdout = self.caputered_stdout + + def test_show_mode(self): + # Make sure stdout contains the mode + try: + self.app.start() + except NoStart: + pass + finally: + self.assertIn(self.app.mode, self.caputered_stdout.getvalue()) + + def tearDown(self): + sys.stdout = sys.__stdout__ + self.caputered_stdout.close() diff --git a/e2xgrader/tests/apps/test_togglemodeapp.py b/e2xgrader/tests/apps/test_togglemodeapp.py new file mode 100644 index 00000000..ac48a65d --- /dev/null +++ b/e2xgrader/tests/apps/test_togglemodeapp.py @@ -0,0 +1,62 @@ +import unittest +from unittest.mock import patch + +from e2xgrader.apps.togglemodeapp import ToggleModeApp +from e2xgrader.utils.mode import E2xGraderMode + + +class TestToggleModeApp(unittest.TestCase): + + def setUp(self): + self.app = ToggleModeApp() + self.app.initialize([]) + + def test_fail_both_sys_prefix_and_user(self): + self.app.sys_prefix = True + self.app.user = True + with self.assertRaises(SystemExit): + self.app.start() + + def test_activate_mode(self): + with patch( + "e2xgrader.apps.togglemodeapp.E2xExtensionManager" + ) as mock_extension_manager: + + self.app.mode = E2xGraderMode.TEACHER.value + self.app.activate_mode() + mock_extension_manager.return_value.activate_teacher.assert_called_once_with( + sys_prefix=False, user=False + ) + + self.app.mode = E2xGraderMode.STUDENT.value + self.app.activate_mode() + mock_extension_manager.return_value.activate_student.assert_called_once_with( + sys_prefix=False, user=False + ) + + self.app.mode = E2xGraderMode.STUDENT_EXAM.value + self.app.activate_mode() + mock_extension_manager.return_value.activate_student_exam.assert_called_once_with( + sys_prefix=False, user=False + ) + + def test_non_matching_mode_causes_log_error(self): + with patch("e2xgrader.apps.togglemodeapp.E2xExtensionManager"): + with patch( + "e2xgrader.apps.togglemodeapp.infer_e2xgrader_mode" + ) as mock_infer_e2xgrader_mode: + mock_infer_e2xgrader_mode.side_effect = ValueError("error") + with patch( + "e2xgrader.apps.togglemodeapp.ToggleModeApp.log" + ) as mock_log: + self.app.initialize([]) + self.app.activate_mode() + mock_log.error.assert_called_once_with("error") + self.assertEqual(self.app.mode, E2xGraderMode.INVALID.value) + + def test_flags(self): + with patch("e2xgrader.apps.togglemodeapp.E2xExtensionManager"): + self.app.initialize(["--sys-prefix"]) + self.assertTrue(self.app.sys_prefix) + self.app.initialize(["--user"]) + self.assertTrue(self.app.user) diff --git a/e2xgrader/tests/extensions/test_utils.py b/e2xgrader/tests/extensions/test_utils.py index 875f66e9..6a9e205b 100644 --- a/e2xgrader/tests/extensions/test_utils.py +++ b/e2xgrader/tests/extensions/test_utils.py @@ -1,12 +1,96 @@ import unittest +from unittest.mock import MagicMock, patch -from e2xgrader.extensions.utils import is_installed +from e2xgrader.extensions.utils import ( + get_nbextension_utils, + get_notebook_config_manager, + is_installed, +) -class TestUtils(unittest.TestCase): - def test_is_installed(self): +class TestIsInstalled(unittest.TestCase): + + def test_installed(self): self.assertTrue(is_installed("e2xgrader"), "e2xgrader should be installed") + + def test_not_installed(self): self.assertFalse( is_installed("e2xgradeeer"), "Non existing package e2xgradeeer should not be installed", ) + + +class TestGetNbextensionUtils(unittest.TestCase): + + def test_nbclassic_is_installed(self): + + with patch("e2xgrader.extensions.utils.is_installed") as mock_is_installed: + mock_is_installed.side_effect = lambda package: package == "nbclassic" + with patch( + "e2xgrader.extensions.utils.import_module" + ) as mock_import_module: + mock_import_module.return_value = MagicMock() + get_nbextension_utils() + mock_import_module.assert_called_once_with("nbclassic.nbextensions") + + def test_notebook_is_installed(self): + with patch( + "e2xgrader.extensions.utils.get_notebook_major_version" + ) as mock_get_notebook_major_version: + mock_get_notebook_major_version.return_value = 6 + with patch("e2xgrader.extensions.utils.is_installed") as mock_is_installed: + mock_is_installed.side_effect = lambda package: package == "notebook" + with patch( + "e2xgrader.extensions.utils.import_module" + ) as mock_import_module: + mock_import_module.return_value = MagicMock() + get_nbextension_utils() + mock_import_module.assert_called_once_with("notebook.nbextensions") + + def test_not_installed(self): + with patch("e2xgrader.extensions.utils.is_installed") as mock_is_installed: + mock_is_installed.return_value = False + self.assertIsNone(get_nbextension_utils()) + + +class TestGetNotebookConfigManager(unittest.TestCase): + + def test_get_notebook_config_manager_is_not_installed(self): + with patch("e2xgrader.extensions.utils.is_installed") as mock_is_installed: + mock_is_installed.return_value = False + config_manager = get_notebook_config_manager() + self.assertIsNone(config_manager) + + def test_get_notebook_config_manager_from_jupyter_server(self): + with patch("e2xgrader.extensions.utils.is_installed") as mock_is_installed: + mock_is_installed.side_effect = lambda package: package == "jupyter_server" + with patch( + "e2xgrader.extensions.utils.import_module" + ) as mock_import_module: + mock_import_module.return_value = MagicMock() + mock_import_module.return_value.BaseJSONConfigManager = "config_manager" + config_manager = get_notebook_config_manager() + mock_import_module.assert_called_once_with( + "jupyter_server.config_manager" + ) + self.assertEqual(config_manager, "config_manager") + + def test_get_notebook_config_manager_from_notebook(self): + with patch( + "e2xgrader.extensions.utils.get_notebook_major_version" + ) as mock_get_notebook_major_version: + mock_get_notebook_major_version.return_value = 6 + with patch("e2xgrader.extensions.utils.is_installed") as mock_is_installed: + mock_is_installed.side_effect = lambda package: package == "notebook" + with patch( + "e2xgrader.extensions.utils.import_module" + ) as mock_import_module: + mock_import_module.return_value = MagicMock() + mock_import_module.return_value.BaseJSONConfigManager = ( + "config_manager" + ) + config_manager = get_notebook_config_manager() + mock_import_module.assert_called_once_with( + "notebook.services.config.manager" + ) + self.assertEqual(config_manager, "config_manager") diff --git a/e2xgrader/tests/utils/test_mode.py b/e2xgrader/tests/utils/test_mode.py new file mode 100644 index 00000000..a032a25a --- /dev/null +++ b/e2xgrader/tests/utils/test_mode.py @@ -0,0 +1,133 @@ +import unittest +from unittest.mock import patch + +from e2xgrader.utils.mode import ( + E2xGraderMode, + infer_e2xgrader_mode, + infer_nbextension_mode, + infer_serverextension_mode, +) + + +class TestInferE2xGraderMode(unittest.TestCase): + + @patch("e2xgrader.utils.mode.infer_nbextension_mode") + @patch("e2xgrader.utils.mode.infer_serverextension_mode") + def test_infer_e2xgrader_mode( + self, mock_infer_serverextension_mode, mock_infer_nbextension_mode + ): + for mode in [ + E2xGraderMode.TEACHER.value, + E2xGraderMode.STUDENT.value, + E2xGraderMode.STUDENT_EXAM.value, + E2xGraderMode.INACTIVE.value, + ]: + mock_infer_serverextension_mode.return_value = mode + mock_infer_nbextension_mode.return_value = mode + self.assertEqual(infer_e2xgrader_mode(), mode) + + @patch("e2xgrader.utils.mode.infer_nbextension_mode") + @patch("e2xgrader.utils.mode.infer_serverextension_mode") + def test_infer_e2xgrader_fails( + self, mock_infer_serverextension_mode, mock_infer_nbextension_mode + ): + mock_infer_serverextension_mode.return_value = "teacher" + mock_infer_nbextension_mode.return_value = "student" + with self.assertRaises(ValueError): + infer_e2xgrader_mode() + + +class TestInferNbExtensionMode(unittest.TestCase): + + @patch("e2xgrader.utils.mode.get_nbextension_config") + def test_infer_nbextension_mode(self, mock_get_nbextension_config): + mock_get_nbextension_config.return_value = { + "tree": {"load_extensions": {"teacher_tree/main": True}}, + "notebook": {"load_extensions": {"teacher_notebook/main": True}}, + } + self.assertEqual(infer_nbextension_mode(), "teacher") + + @patch("e2xgrader.utils.mode.get_nbextension_config") + def test_infer_nbextension_mode_fails_with_multiple_modes_activated( + self, mock_get_nbextension_config + ): + mock_get_nbextension_config.return_value = { + "tree": { + "load_extensions": { + "teacher_tree/main": True, + "student_tree/main": True, + } + }, + "notebook": { + "load_extensions": { + "student_notebook/main": True, + "teacher_notebook/main": True, + } + }, + } + with self.assertRaises(ValueError): + infer_nbextension_mode() + + @patch("e2xgrader.utils.mode.get_nbextension_config") + def test_infer_nbextension_mode_fails_with_tree_and_notebook_mismatch( + self, mock_get_nbextension_config + ): + mock_get_nbextension_config.return_value = { + "tree": {"load_extensions": {"teacher_tree/main": True}}, + "notebook": {"load_extensions": {"student_notebook/main": True}}, + } + with self.assertRaises(ValueError): + infer_nbextension_mode() + + @patch("e2xgrader.utils.mode.get_nbextension_config") + def test_infer_nbextension_mode_fails_with_different_number_of_modes_activated( + self, mock_get_nbextension_config + ): + mock_get_nbextension_config.return_value = { + "tree": { + "load_extensions": { + "teacher_tree/main": True, + "student_tree/main": True, + } + }, + "notebook": { + "load_extensions": { + "student_notebook/main": True, + } + }, + } + with self.assertRaises(ValueError): + infer_nbextension_mode() + + +class TestInferServerExtensionMode(unittest.TestCase): + + @patch("e2xgrader.utils.mode.get_serverextension_config") + def test_infer_serverextension_mode(self, mock_get_serverextension_config): + mock_get_serverextension_config.return_value = { + "e2xgrader.server_extensions.teacher": True, + } + self.assertEqual(infer_serverextension_mode(), E2xGraderMode.TEACHER.value) + + @patch("e2xgrader.utils.mode.get_serverextension_config") + def test_infer_serverextension_mode_with_no_mode_activated( + self, mock_get_serverextension_config + ): + mock_get_serverextension_config.return_value = {} + self.assertEqual(infer_serverextension_mode(), E2xGraderMode.INACTIVE.value) + + @patch("e2xgrader.utils.mode.get_serverextension_config") + def test_infer_serverextension_mode_fails_with_multiple_modes_activated( + self, mock_get_serverextension_config + ): + mock_get_serverextension_config.return_value = { + "nbgrader.server_extensions.formgrader": True, + "nbgrader.server_extensions.validate_assignment": True, + "nbgrader.server_extensions.assignment_list": True, + "nbgrader.server_extensions.course_list": True, + "e2xgrader.server_extensions.teacher": True, + "e2xgrader.server_extensions.student": True, + "e2xgrader.server_extensions.student_exam": True, + } + with self.assertRaises(ValueError): + infer_serverextension_mode() diff --git a/e2xgrader/utils/__init__.py b/e2xgrader/utils/__init__.py index a5d8282f..e69de29b 100644 --- a/e2xgrader/utils/__init__.py +++ b/e2xgrader/utils/__init__.py @@ -1,3 +0,0 @@ -from .utils import get_nbgrader_config - -__all__ = ["get_nbgrader_config"] diff --git a/e2xgrader/utils/mode.py b/e2xgrader/utils/mode.py new file mode 100644 index 00000000..3e90271d --- /dev/null +++ b/e2xgrader/utils/mode.py @@ -0,0 +1,141 @@ +import os +from enum import Enum +from typing import Dict + +from jupyter_core.paths import jupyter_config_path + +from .. import _jupyter_server_extension_paths +from ..extensions.utils import discover_nbextensions, get_notebook_config_manager + + +class E2xGraderMode(Enum): + TEACHER = "teacher" + STUDENT = "student" + STUDENT_EXAM = "student_exam" + INVALID = "invalid" + INACTIVE = "inactive" + + +def get_nbextension_config() -> Dict[str, Dict[str, bool]]: + """ + Get the configuration for nbextensions. + + Returns: + A dictionary containing the configuration for nbextensions. + The dictionary has the following structure: + { + 'tree': { + 'nbextension_name': True/False, + ... + }, + 'notebook': { + 'nbextension_name': True/False, + ... + } + } + """ + config = dict(tree=dict(), notebook=dict()) + for path in jupyter_config_path(): + config_path = os.path.join(path, "nbconfig") + contextmanager = get_notebook_config_manager()(config_dir=config_path) + for key in config: + config[key].update(contextmanager.get(key)) + return config + + +def get_serverextension_config() -> Dict[str, bool]: + """ + Retrieves the server extension configuration from the Jupyter notebook configuration files. + + Returns: + A dictionary containing the server extension configuration, where the keys are the + extension names and the values are boolean values indicating whether the extension + is enabled or not. + """ + config = dict() + for path in jupyter_config_path(): + contextmanager = get_notebook_config_manager()(config_dir=path) + config.update(contextmanager.get("jupyter_notebook_config")) + return config.get("NotebookApp", dict()).get("nbserver_extensions", dict()) + + +def infer_nbextension_mode() -> str: + """ + Infer the active mode for the nbextension based on the current configuration. + + Returns: + str: The active mode for the nbextension. + + Raises: + ValueError: If more than one mode is active or if tree and notebook extensions don't match. + """ + config = get_nbextension_config() + modes = [ + E2xGraderMode.TEACHER.value, + E2xGraderMode.STUDENT_EXAM.value, + E2xGraderMode.STUDENT.value, + ] + is_active = dict(tree=[], notebook=[]) + for mode in modes: + for extension in discover_nbextensions(mode): + if ( + config[extension["section"]] + .get("load_extensions", dict()) + .get(extension["require"], False) + ): + is_active[extension["section"]].append(mode) + + if set(is_active["tree"]) == set(is_active["notebook"]): + if len(is_active["tree"]) == 1: + return is_active["tree"][0] + elif len(is_active["tree"]) == 0: + return E2xGraderMode.INACTIVE.value + raise ValueError( + "More than one mode is active or tree and notebook extensions don't match\n" + f"The current config is {config}" + ) + + +def infer_serverextension_mode() -> str: + """ + Infer the server extension mode based on the configuration. + + Returns: + str: The server extension mode. Possible values are: + - "None" if no server extension is active. + - The name of the active server extension if only one is active. + + Raises: + ValueError: If more than one server extension is active. + """ + config = get_serverextension_config() + active = [] + for extension in _jupyter_server_extension_paths(): + is_active = config.get(extension["module"], False) + if is_active: + active.append(extension["module"].split(".")[-1]) + if len(active) == 0: + return E2xGraderMode.INACTIVE.value + elif len(active) == 1: + return active[0] + raise ValueError("More than one mode is active" f"The current config is {config}") + + +def infer_e2xgrader_mode() -> str: + """ + Infer the mode of e2xgrader. + + Returns: + str: The mode of e2xgrader. + + Raises: + ValueError: If the nbextension mode and serverextension mode do not match. + """ + nbextension_mode = infer_nbextension_mode() + serverextension_mode = infer_serverextension_mode() + if nbextension_mode == serverextension_mode: + return nbextension_mode + raise ValueError( + "The nbextension and serverextension mode does not match" + f"nbextension mode is {nbextension_mode}, serverextension_mode is {serverextension_mode}" + ) diff --git a/e2xgrader/utils/utils.py b/e2xgrader/utils/utils.py deleted file mode 100644 index 42cbf655..00000000 --- a/e2xgrader/utils/utils.py +++ /dev/null @@ -1,7 +0,0 @@ -from nbgrader.apps import NbGrader - - -def get_nbgrader_config(): - nbgrader = NbGrader() - nbgrader.initialize([]) - return nbgrader.config diff --git a/package-lock.json b/package-lock.json index ff323e6d..da2fa4fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "e2xgrader", - "version": "0.2.1", + "version": "0.2.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "e2xgrader", - "version": "0.2.1", + "version": "0.2.3", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -13685,7 +13685,7 @@ }, "packages/api": { "name": "@e2xgrader/api", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "devDependencies": { "webpack": "^5.73.0", @@ -13694,11 +13694,11 @@ }, "packages/assignment-view-celltoolbar": { "name": "@e2xgrader/assignment-view-celltoolbar", - "version": "0.2.1", + "version": "0.2.3", "license": "ISC", "dependencies": { - "@e2xgrader/cells": "0.2.1", - "@e2xgrader/utils": "0.2.1" + "@e2xgrader/cells": "0.2.3", + "@e2xgrader/utils": "0.2.3" }, "devDependencies": { "@babel/preset-env": "^7.16.11", @@ -13731,12 +13731,12 @@ }, "packages/authoring-menubar": { "name": "@e2xgrader/authoring-menubar", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "dependencies": { - "@e2xgrader/api": "0.2.1", - "@e2xgrader/menubar": "0.2.1", - "@e2xgrader/utils": "0.2.1" + "@e2xgrader/api": "0.2.3", + "@e2xgrader/menubar": "0.2.3", + "@e2xgrader/utils": "0.2.3" }, "devDependencies": { "webpack": "^5.73.0", @@ -13745,10 +13745,10 @@ }, "packages/cell-extension": { "name": "@e2xgrader/cell-extension", - "version": "0.2.1", + "version": "0.2.3", "license": "ISC", "dependencies": { - "@e2xgrader/cells": "0.2.1" + "@e2xgrader/cells": "0.2.3" }, "devDependencies": { "@babel/preset-env": "^7.16.11", @@ -13760,10 +13760,10 @@ }, "packages/cells": { "name": "@e2xgrader/cells", - "version": "0.2.1", + "version": "0.2.3", "license": "ISC", "dependencies": { - "@e2xgrader/api": "0.2.1" + "@e2xgrader/api": "0.2.3" }, "devDependencies": { "@babel/preset-env": "^7.16.11", @@ -13775,11 +13775,11 @@ }, "packages/create-assignment-celltoolbar": { "name": "@e2xgrader/create-assignment-celltoolbar", - "version": "0.2.1", + "version": "0.2.3", "license": "ISC", "dependencies": { - "@e2xgrader/cells": "0.2.1", - "@e2xgrader/utils": "0.2.1" + "@e2xgrader/cells": "0.2.3", + "@e2xgrader/utils": "0.2.3" }, "devDependencies": { "@babel/preset-env": "^7.16.11", @@ -13805,11 +13805,11 @@ }, "packages/exam-menubar": { "name": "@e2xgrader/exam-menubar", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "dependencies": { - "@e2xgrader/api": "0.2.1", - "@e2xgrader/menubar": "0.2.1" + "@e2xgrader/api": "0.2.3", + "@e2xgrader/menubar": "0.2.3" }, "devDependencies": { "webpack": "^5.73.0", @@ -13818,10 +13818,10 @@ }, "packages/help-tab": { "name": "@e2xgrader/help-tab", - "version": "0.2.1", + "version": "0.2.3", "license": "ISC", "dependencies": { - "@e2xgrader/api": "0.2.1" + "@e2xgrader/api": "0.2.3" }, "devDependencies": { "@babel/preset-env": "^7.16.11", @@ -13833,7 +13833,7 @@ }, "packages/menubar": { "name": "@e2xgrader/menubar", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "devDependencies": { "webpack": "^5.73.0", @@ -13864,17 +13864,17 @@ }, "packages/notebook-extensions": { "name": "@e2xgrader/notebook-extensions", - "version": "0.2.1", + "version": "0.2.3", "license": "ISC", "dependencies": { - "@e2xgrader/assignment-view-celltoolbar": "0.2.1", - "@e2xgrader/authoring-menubar": "0.2.1", - "@e2xgrader/cell-extension": "0.2.1", - "@e2xgrader/create-assignment-celltoolbar": "0.2.1", - "@e2xgrader/exam-menubar": "0.2.1", - "@e2xgrader/restricted-assignment-notebook": "0.2.1", - "@e2xgrader/restricted-exam-notebook": "0.2.1", - "@e2xgrader/utils": "0.2.1" + "@e2xgrader/assignment-view-celltoolbar": "0.2.3", + "@e2xgrader/authoring-menubar": "0.2.3", + "@e2xgrader/cell-extension": "0.2.3", + "@e2xgrader/create-assignment-celltoolbar": "0.2.3", + "@e2xgrader/exam-menubar": "0.2.3", + "@e2xgrader/restricted-assignment-notebook": "0.2.3", + "@e2xgrader/restricted-exam-notebook": "0.2.3", + "@e2xgrader/utils": "0.2.3" }, "devDependencies": { "@babel/preset-env": "^7.16.11", @@ -13886,10 +13886,10 @@ }, "packages/restricted-assignment-notebook": { "name": "@e2xgrader/restricted-assignment-notebook", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "dependencies": { - "@e2xgrader/utils": "0.2.1" + "@e2xgrader/utils": "0.2.3" }, "devDependencies": { "webpack": "^5.73.0", @@ -13898,10 +13898,10 @@ }, "packages/restricted-exam-notebook": { "name": "@e2xgrader/restricted-exam-notebook", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "dependencies": { - "@e2xgrader/utils": "0.2.1" + "@e2xgrader/utils": "0.2.3" }, "devDependencies": { "webpack": "^5.73.0", @@ -13910,10 +13910,10 @@ }, "packages/tree-extensions": { "name": "@e2xgrader/tree-extensions", - "version": "0.2.1", + "version": "0.2.3", "license": "ISC", "dependencies": { - "@e2xgrader/help-tab": "0.2.1" + "@e2xgrader/help-tab": "0.2.3" }, "devDependencies": { "@babel/preset-env": "^7.16.11", @@ -13925,7 +13925,7 @@ }, "packages/utils": { "name": "@e2xgrader/utils", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "devDependencies": { "webpack": "^5.73.0", @@ -15197,8 +15197,8 @@ "version": "file:packages/assignment-view-celltoolbar", "requires": { "@babel/preset-env": "^7.16.11", - "@e2xgrader/cells": "0.2.1", - "@e2xgrader/utils": "0.2.1", + "@e2xgrader/cells": "0.2.3", + "@e2xgrader/utils": "0.2.3", "css-loader": "^6.6.0", "style-loader": "^3.3.1", "webpack": "^5.70.0", @@ -15208,9 +15208,9 @@ "@e2xgrader/authoring-menubar": { "version": "file:packages/authoring-menubar", "requires": { - "@e2xgrader/api": "0.2.1", - "@e2xgrader/menubar": "0.2.1", - "@e2xgrader/utils": "0.2.1", + "@e2xgrader/api": "0.2.3", + "@e2xgrader/menubar": "0.2.3", + "@e2xgrader/utils": "0.2.3", "webpack": "^5.73.0", "webpack-cli": "^4.9.2" } @@ -15219,7 +15219,7 @@ "version": "file:packages/cell-extension", "requires": { "@babel/preset-env": "^7.16.11", - "@e2xgrader/cells": "0.2.1", + "@e2xgrader/cells": "0.2.3", "css-loader": "^6.6.0", "style-loader": "^3.3.1", "webpack": "^5.70.0", @@ -15230,7 +15230,7 @@ "version": "file:packages/cells", "requires": { "@babel/preset-env": "^7.16.11", - "@e2xgrader/api": "0.2.1", + "@e2xgrader/api": "0.2.3", "css-loader": "^6.6.0", "style-loader": "^3.3.1", "webpack": "^5.70.0", @@ -15241,8 +15241,8 @@ "version": "file:packages/create-assignment-celltoolbar", "requires": { "@babel/preset-env": "^7.16.11", - "@e2xgrader/cells": "0.2.1", - "@e2xgrader/utils": "0.2.1", + "@e2xgrader/cells": "0.2.3", + "@e2xgrader/utils": "0.2.3", "css-loader": "^6.6.0", "style-loader": "^3.3.1", "webpack": "^5.70.0", @@ -15252,8 +15252,8 @@ "@e2xgrader/exam-menubar": { "version": "file:packages/exam-menubar", "requires": { - "@e2xgrader/api": "0.2.1", - "@e2xgrader/menubar": "0.2.1", + "@e2xgrader/api": "0.2.3", + "@e2xgrader/menubar": "0.2.3", "webpack": "^5.73.0", "webpack-cli": "^4.9.2" } @@ -15262,7 +15262,7 @@ "version": "file:packages/help-tab", "requires": { "@babel/preset-env": "^7.16.11", - "@e2xgrader/api": "0.2.1", + "@e2xgrader/api": "0.2.3", "css-loader": "^6.6.0", "style-loader": "^3.3.1", "webpack": "^5.70.0", @@ -15280,14 +15280,14 @@ "version": "file:packages/notebook-extensions", "requires": { "@babel/preset-env": "^7.16.11", - "@e2xgrader/assignment-view-celltoolbar": "0.2.1", - "@e2xgrader/authoring-menubar": "0.2.1", - "@e2xgrader/cell-extension": "0.2.1", - "@e2xgrader/create-assignment-celltoolbar": "0.2.1", - "@e2xgrader/exam-menubar": "0.2.1", - "@e2xgrader/restricted-assignment-notebook": "0.2.1", - "@e2xgrader/restricted-exam-notebook": "0.2.1", - "@e2xgrader/utils": "0.2.1", + "@e2xgrader/assignment-view-celltoolbar": "0.2.3", + "@e2xgrader/authoring-menubar": "0.2.3", + "@e2xgrader/cell-extension": "0.2.3", + "@e2xgrader/create-assignment-celltoolbar": "0.2.3", + "@e2xgrader/exam-menubar": "0.2.3", + "@e2xgrader/restricted-assignment-notebook": "0.2.3", + "@e2xgrader/restricted-exam-notebook": "0.2.3", + "@e2xgrader/utils": "0.2.3", "css-loader": "^6.6.0", "style-loader": "^3.3.1", "webpack": "^5.70.0", @@ -15297,7 +15297,7 @@ "@e2xgrader/restricted-assignment-notebook": { "version": "file:packages/restricted-assignment-notebook", "requires": { - "@e2xgrader/utils": "0.2.1", + "@e2xgrader/utils": "0.2.3", "webpack": "^5.73.0", "webpack-cli": "^4.9.2" } @@ -15305,7 +15305,7 @@ "@e2xgrader/restricted-exam-notebook": { "version": "file:packages/restricted-exam-notebook", "requires": { - "@e2xgrader/utils": "0.2.1", + "@e2xgrader/utils": "0.2.3", "webpack": "^5.73.0", "webpack-cli": "^4.9.2" } @@ -15314,7 +15314,7 @@ "version": "file:packages/tree-extensions", "requires": { "@babel/preset-env": "^7.16.11", - "@e2xgrader/help-tab": "0.2.1", + "@e2xgrader/help-tab": "0.2.3", "css-loader": "^6.6.0", "style-loader": "^3.3.1", "webpack": "^5.70.0",