Repository: ImperialCollegeLondon/guikit (language: python)

Issue:
Implements configuration file
Implements a settings file for configuring the application general behaviour, plugging collection and other aspects of the tool. Not that exciting at the moment, but it will become more populated in the future.


Discussion / hints:

Fix patch:
diff --git a/pyguitemp/__main__.py b/pyguitemp/__main__.py
index c534c4f..7eafd1e 100644
--- a/pyguitemp/__main__.py
+++ b/pyguitemp/__main__.py
@@ -2,17 +2,7 @@
 The entry point of the program. It launches the main application - i.e. it starts
 whatever represents the mainloop in the chosen GUI toolkit.
 """
-from . import APP_NAME
-from .config import APP_LONG_NAME, AUTO_PLUGINS, NOTEBOOK_LAYOUT, PLUGINS, TAB_STYLE
-from .core import MainApp
-from .logging import logger
+from .scripts import main
 
-logger.app_name = APP_NAME
-
-app = MainApp(
-    title=APP_LONG_NAME,
-    plugins_list=PLUGINS + AUTO_PLUGINS,
-    notebook_layout=NOTEBOOK_LAYOUT,
-    tab_style=TAB_STYLE,
-)
-app.MainLoop()
+if __name__ == "__main__":
+    main()
diff --git a/pyguitemp/scripts.py b/pyguitemp/scripts.py
new file mode 100644
index 0000000..66dd049
--- /dev/null
+++ b/pyguitemp/scripts.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import argparse
+from abc import ABC, abstractmethod
+from pathlib import Path
+from shutil import copytree
+from typing import Dict, List
+
+from . import APP_NAME
+from .config import APP_LONG_NAME, AUTO_PLUGINS, NOTEBOOK_LAYOUT, PLUGINS, TAB_STYLE
+from .core import MainApp
+from .logging import logger
+
+logger.app_name = APP_NAME
+
+
+def _run_app():
+    """Runs pyguitemp as an application, loading all the plugins available."""
+    app = MainApp(
+        title=APP_LONG_NAME,
+        plugins_list=PLUGINS + AUTO_PLUGINS,
+        notebook_layout=NOTEBOOK_LAYOUT,
+        tab_style=TAB_STYLE,
+    )
+    app.MainLoop()
+
+
+def _init_repo(path: Path, name: str):
+    """Initialises repository with the files/folders required to use pyguitemp.
+
+    This is done by copying the skeleton repository structure within pyguitemp to the
+    desired location.
+
+    Args:
+        path: Where the repository should be initialized. Default: current.
+        name: Name of the application.
+    """
+    full_dir = path / name
+    logger.info(f"Initialising '{name}' in `{full_dir}`...")
+    copytree(Path(__file__).parent / "skeleton", full_dir)
+
+
+class SubCommand(ABC):
+    def __init__(self, name, description):
+        self.name = name
+        self.description = description
+
+    @abstractmethod
+    def add_arguments(self, parser: argparse.ArgumentParser):
+        pass
+
+    @abstractmethod
+    def run(self, args: argparse.Namespace):
+        pass
+
+
+class RunSubCommand(SubCommand):
+    def __init__(self):
+        super().__init__("run", f"Run '{APP_NAME}' as a standalone app.")
+
+    def add_arguments(self, parser: argparse.ArgumentParser):
+        pass
+
+    def run(self, args: argparse.Namespace):
+        _run_app()
+
+
+class InitSubCommand(SubCommand):
+    def __init__(self):
+        super().__init__(
+            "init", "Initialises the current directory with an skeleton of a GUI app"
+        )
+
+    def add_arguments(self, parser: argparse.ArgumentParser):
+        parser.add_argument(
+            "name",
+            type=str,
+            help="Name of the app. To be used as package name.",
+        )
+        parser.add_argument(
+            "-t",
+            "--target",
+            default=".",
+            type=str,
+            help="Target location for the app. Default: Current directory.",
+        )
+
+    def run(self, args: argparse.Namespace):
+        _init_repo(Path(args.target).absolute(), args.name)
+
+
+_SUB_COMMANDS: List[SubCommand] = [RunSubCommand(), InitSubCommand()]
+
+_SUB_COMMAND_BY_NAME: Dict[str, SubCommand] = {
+    sub_command.name: sub_command for sub_command in _SUB_COMMANDS
+}
+
+
+def _parse_args(argv: List[str] = None) -> argparse.Namespace:
+    """Parse the input arguments list populating the relevant subcommands
+
+    Args:
+        argv: List of arguments
+
+    Returns:
+        The namespace containing the relevant sub command information and arguments.
+    """
+    parser = argparse.ArgumentParser()
+    subparsers = parser.add_subparsers(dest="command")
+    subparsers.required = True
+    for sub_command in _SUB_COMMANDS:
+        sub_parser = subparsers.add_parser(
+            sub_command.name, help=sub_command.description
+        )
+        sub_command.add_arguments(sub_parser)
+
+    return parser.parse_args(argv)
+
+
+def main(argv: List[str] = None):
+    """Main entry point for pyguitemp, calling the relevant subcommands.
+
+    Args:
+        argv: List of input arguments, including subcommand.
+
+    Returns:
+
+    """
+    args = _parse_args(argv)
+    _SUB_COMMAND_BY_NAME[args.command].run(args)
diff --git a/pyguitemp/skeleton/__init__.py b/pyguitemp/skeleton/__init__.py
new file mode 100644
index 0000000..d1c883d
--- /dev/null
+++ b/pyguitemp/skeleton/__init__.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+
+from pyguitemp.logging import logger
+
+VERSION = "0.0.1"
+APP_NAME = Path(__file__).parent.stem
+logger.app_name = APP_NAME
diff --git a/pyguitemp/skeleton/__main__.py b/pyguitemp/skeleton/__main__.py
new file mode 100644
index 0000000..46d1cb9
--- /dev/null
+++ b/pyguitemp/skeleton/__main__.py
@@ -0,0 +1,15 @@
+"""
+The entry point of the program. It launches the main application - i.e. it starts
+whatever represents the mainloop in the chosen GUI toolkit.
+"""
+from pyguitemp.core import MainApp
+
+from .config import APP_LONG_NAME, AUTO_PLUGINS, NOTEBOOK_LAYOUT, PLUGINS, TAB_STYLE
+
+app = MainApp(
+    title=APP_LONG_NAME,
+    plugins_list=PLUGINS + AUTO_PLUGINS,
+    notebook_layout=NOTEBOOK_LAYOUT,
+    tab_style=TAB_STYLE,
+)
+app.MainLoop()
diff --git a/pyguitemp/skeleton/config.py b/pyguitemp/skeleton/config.py
new file mode 100644
index 0000000..1d2765d
--- /dev/null
+++ b/pyguitemp/skeleton/config.py
@@ -0,0 +1,22 @@
+"""
+Settings for configuring the application general behavior, plugging collection and other
+aspects of the tool.
+"""
+from typing import List
+
+from pyguitemp.plugins import collect_builtin_extensions
+
+APP_LONG_NAME: str = "Python GUI Template"
+"""Long, human meaningful app name."""
+
+PLUGINS: List[str] = []
+"""Explicit list of plugins that will be loaded in the order provided."""
+
+AUTO_PLUGINS: List[str] = collect_builtin_extensions()
+"""Plugins collected from the given locations. See `plugins.collect_plugins`"""
+
+NOTEBOOK_LAYOUT: bool = True
+"""Indicate if a notebook layout should be used in contrast to a central widget only."""
+
+TAB_STYLE: str = "top"
+"""Location of the tabs. Valid values are `top`, `bottom`, `left` and `right`."""
diff --git a/pyguitemp/skeleton/extensions/__init__.py b/pyguitemp/skeleton/extensions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/setup.cfg b/setup.cfg
index 0b1cd57..335880d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -45,6 +45,7 @@ addopts =
 	-v --flake8 --mypy -p no:warnings
 	--cov=pyguitemp  --cov-report=html:htmlcov/coverage
 	--doctest-modules --ignore=pyguitemp/__main__.py
+    --ignore=pyguitemp/skeleton/__main__.py
 
 [pycodestyle]
 max-line-length = 88


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_scripts.py b/tests/test_scripts.py
new file mode 100644
index 0000000..653c0e7
--- /dev/null
+++ b/tests/test_scripts.py
@@ -0,0 +1,99 @@
+from unittest.mock import MagicMock, patch
+
+
+def test_run_app():
+    class App:
+        MainLoop = MagicMock()
+
+    with patch("pyguitemp.scripts.MainApp", MagicMock(return_value=App)):
+        from pyguitemp.scripts import MainApp, _run_app
+
+        _run_app()
+
+        MainApp.assert_called_once()
+        App.MainLoop.assert_called_once()
+
+
+def test_init_repo(tmpdir):
+    with patch("pyguitemp.scripts.copytree", MagicMock()):
+        from pathlib import Path
+
+        from pyguitemp.scripts import _init_repo, copytree
+
+        skeleton = Path(__file__).parent.parent / "pyguitemp" / "skeleton"
+        _init_repo(tmpdir, "my_app")
+        copytree.assert_called_once_with(skeleton, tmpdir / "my_app")
+
+
+class TestRunSubCommand:
+    def test_add_arguments(self):
+        import argparse
+
+        from pyguitemp.scripts import RunSubCommand
+
+        command = RunSubCommand()
+        parser = argparse.ArgumentParser()
+        command.add_arguments(parser)
+        assert set([a.dest for a in parser._actions]) == {"help"}
+
+    def test_run(self):
+        import argparse
+
+        with patch("pyguitemp.scripts._run_app", MagicMock()):
+            from pyguitemp.scripts import RunSubCommand, _run_app
+
+            command = RunSubCommand()
+            command.run(argparse.Namespace())
+            _run_app.assert_called_once()
+
+
+class TestInitSubCommand:
+    def test_add_arguments(self):
+        import argparse
+
+        from pyguitemp.scripts import InitSubCommand
+
+        command = InitSubCommand()
+        parser = argparse.ArgumentParser()
+        command.add_arguments(parser)
+        assert set([a.dest for a in parser._actions]) == {"help", "name", "target"}
+
+    def test_run(self):
+        import argparse
+
+        with patch("pyguitemp.scripts._init_repo", MagicMock()):
+            from pyguitemp.scripts import InitSubCommand, _init_repo
+
+            command = InitSubCommand()
+            args = argparse.Namespace(target="", name="")
+            command.run(args)
+            _init_repo.assert_called_once()
+
+
+def test_parse_args():
+
+    with patch("argparse.ArgumentParser.parse_args", MagicMock()):
+        from pyguitemp.scripts import _SUB_COMMANDS, _parse_args
+
+        for command in _SUB_COMMANDS:
+            command.add_arguments = MagicMock()
+
+        _parse_args()
+
+        for command in _SUB_COMMANDS:
+            command.add_arguments.assert_called_once()
+
+
+def test_main():
+    class Namespace:
+        command = "run"
+
+    with patch("pyguitemp.scripts._parse_args", MagicMock(return_value=Namespace)):
+        from pyguitemp.scripts import _SUB_COMMAND_BY_NAME, _parse_args, main
+
+        _SUB_COMMAND_BY_NAME[Namespace.command].run = MagicMock()
+
+        main()
+
+        _parse_args.assert_called_once()
+        _SUB_COMMAND_BY_NAME[Namespace.command].run.assert_called_once()