Repository: repobee/repobee

Issue:
Add a run method to use RepoBee as an API
This would be very useful for testing plugins, among other things. It should look like this:

```python
def run(
    cmd: List[str],
    show_all_opts: bool = False,
    config_file: Optional[Union[pathlib.Path, str]] = None,
    plugins: Optional[List[ModuleType]] = None):
```

For example, the CLI call `repobee --plug javac --config-file /home/repobee/config.ini config show` should be equivalent to:

```python
import repobee
from _repobee.ext import javac

repobee.run("config show".split(), config_file="/home/repobee/config.ini", plugins=[javac])
```

The additional arguments (e.g. `show_all_opts` and `config_file`) should correspond to those of the pre-parser.

`run` must load the designated plugins before execution, and unload them after.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/repobee/repobee/pull/505?src=pr&el=h1) Report
> Merging [#505](https://codecov.io/gh/repobee/repobee/pull/505?src=pr&el=desc) into [master](https://codecov.io/gh/repobee/repobee/commit/3181364115723458f061b0d366cc81eb3f5f3ad0&el=desc) will **decrease** coverage by `0.10%`.
> The diff coverage is `83.33%`.

[![Impacted file tree graph](https://codecov.io/gh/repobee/repobee/pull/505/graphs/tree.svg?width=650&height=150&src=pr&token=5vOKgkphZe)](https://codecov.io/gh/repobee/repobee/pull/505?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #505      +/-   ##
==========================================
- Coverage   88.33%   88.22%   -0.11%     
==========================================
  Files          43       43              
  Lines        2443     2455      +12     
  Branches      395      397       +2     
==========================================
+ Hits         2158     2166       +8     
- Misses        243      247       +4     
  Partials       42       42              
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/505?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/\_repobee/main.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL21haW4ucHk=) | `88.15% <82.14%> (-4.15%)` | :arrow_down: |
| [src/\_repobee/cli/dispatch.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9kaXNwYXRjaC5weQ==) | `88.88% <100.00%> (+0.13%)` | :arrow_up: |
| [src/\_repobee/plugin.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL3BsdWdpbi5weQ==) | `95.03% <100.00%> (-0.04%)` | :arrow_down: |
| [src/\_repobee/ext/query.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9xdWVyeS5weQ==) | `48.48% <0.00%> (-1.52%)` | :arrow_down: |
| [src/\_repobee/ext/gitlab.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9naXRsYWIucHk=) | `63.86% <0.00%> (-0.40%)` | :arrow_down: |
| [src/\_repobee/ext/defaults/github.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kZWZhdWx0cy9naXRodWIucHk=) | `90.29% <0.00%> (-0.11%)` | :arrow_down: |
| [src/repobee\_plug/\_containers.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fY29udGFpbmVycy5weQ==) | `93.08% <0.00%> (-0.05%)` | :arrow_down: |
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `98.63% <0.00%> (-0.04%)` | :arrow_down: |
| [src/repobee\_plug/\_corehooks.py](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fY29yZWhvb2tzLnB5) | `100.00% <0.00%> (ø)` | |
| ... and [2 more](https://codecov.io/gh/repobee/repobee/pull/505/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/505?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/repobee/repobee/pull/505?src=pr&el=footer). Last update [3181364...4b91f3f](https://codecov.io/gh/repobee/repobee/pull/505?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/_repobee/cli/dispatch.py b/src/_repobee/cli/dispatch.py
index 827cd1f..60a2d38 100644
--- a/src/_repobee/cli/dispatch.py
+++ b/src/_repobee/cli/dispatch.py
@@ -25,7 +25,7 @@ def dispatch_command(
     api: plug.API,
     config_file: pathlib.Path,
     ext_commands: Optional[List[plug.ExtensionCommand]] = None,
-):
+) -> Mapping[str, List[plug.Result]]:
     """Handle parsed CLI arguments and dispatch commands to the appropriate
     functions. Expected exceptions are caught and turned into SystemExit
     exceptions, while unexpected exceptions are allowed to propagate.
@@ -66,6 +66,8 @@ def dispatch_command(
             hook_results=hook_results, filepath=args.hook_results_file
         )
 
+    return hook_results
+
 
 def _dispatch_repos_command(
     args: argparse.Namespace, config_file: pathlib.Path, api: plug.API
diff --git a/src/_repobee/main.py b/src/_repobee/main.py
index ac789bd..d423e27 100644
--- a/src/_repobee/main.py
+++ b/src/_repobee/main.py
@@ -6,8 +6,10 @@
 .. moduleauthor:: Simon Larsén
 """
 
+import pathlib
 import sys
-from typing import List
+from typing import List, Optional, Union, Mapping
+from types import ModuleType
 
 import daiquiri
 import repobee_plug as plug
@@ -33,6 +35,80 @@ and supply the stack trace below.""".replace(
 )
 
 
+def run(
+    cmd: List[str],
+    show_all_opts: bool = False,
+    config_file: Union[str, pathlib.Path] = "",
+    plugins: Optional[List[Union[ModuleType, plug.Plugin]]] = None,
+) -> Mapping[str, List[plug.Result]]:
+    """Run RepoBee with the provided options. This function is mostly intended
+    to be used for testing plugins.
+
+    .. important::
+
+        This function will always unregister all plugins after execution,
+        including anly plugins that may have been registered prior to running
+        this function.
+
+    Running this function is almost equivalent to running RepoBee from the CLI,
+    with the following exceptions:
+
+    1. Preparser options must be passed as arguments to this function (i.e.
+       cannot be given as part of ``cmd``).
+    2. There is no error handling at the top level, so exceptions are raised
+       instead of just logged.
+
+    As an example, the following CLI call:
+
+    .. code-block:: bash
+
+        $ repobee --plug ext.py --config-file config.ini config show
+
+    Can be executed as follows:
+
+    .. code-block:: python
+
+        import ext
+        from repobee import run
+
+        run(["config", "show"], config_file="config.ini", plugins=[ext])
+
+    Args:
+        cmd: The command to run.
+        show_all_opts: Equivalent to the ``--show-all-opts`` flag.
+        config_file: Path to the configuration file.
+        plugins: A list of plugin modules and/or plugin classes.
+    Returns:
+        A mapping (plugin_name -> plugin_results).
+    """
+    config_file = pathlib.Path(config_file)
+
+    def _ensure_is_module(p: Union[ModuleType, plug.Plugin]):
+        if issubclass(p, plug.Plugin):
+            mod = ModuleType(p.__name__.lower())
+            mod.__package__ = f"__{p.__name__}"
+            setattr(mod, p.__name__, p)
+            return mod
+        elif isinstance(p, ModuleType):
+            return p
+        else:
+            raise TypeError(f"not plugin or module: {p}")
+
+    wrapped_plugins = list(map(_ensure_is_module, plugins or []))
+    try:
+        _repobee.cli.parsing.setup_logging()
+        plugin.initialize_default_plugins()
+        plugin.register_plugins(wrapped_plugins)
+        parsed_args, api, ext_commands = _parse_args(
+            cmd, config_file, show_all_opts
+        )
+        _repobee.cli.dispatch.dispatch_command(
+            parsed_args, api, config_file, ext_commands
+        )
+    finally:
+        plugin.unregister_all_plugins()
+
+
 def main(sys_args: List[str], unload_plugins: bool = True):
     """Start the repobee CLI.
 
@@ -69,13 +145,8 @@ def main(sys_args: List[str], unload_plugins: bool = True):
             ) or []
             plugin.initialize_plugins(plugin_names, allow_filepath=True)
 
-        config.execute_config_hooks(config_file)
-        ext_commands = plug.manager.hook.create_extension_command()
-        parsed_args, api = _repobee.cli.parsing.handle_args(
-            app_args,
-            show_all_opts=parsed_preparser_args.show_all_opts,
-            ext_commands=ext_commands,
-            config_file=config_file,
+        parsed_args, api, ext_commands = _parse_args(
+            app_args, config_file, parsed_preparser_args.show_all_opts
         )
         traceback = parsed_args.traceback
         pre_init = False
@@ -108,5 +179,17 @@ def main(sys_args: List[str], unload_plugins: bool = True):
             plugin.unregister_all_plugins()
 
 
+def _parse_args(args, config_file, show_all_opts):
+    config.execute_config_hooks(config_file)
+    ext_commands = plug.manager.hook.create_extension_command()
+    parsed_args, api = _repobee.cli.parsing.handle_args(
+        args,
+        show_all_opts=show_all_opts,
+        ext_commands=ext_commands,
+        config_file=config_file,
+    )
+    return parsed_args, api, ext_commands
+
+
 if __name__ == "__main__":
     main(sys.argv)
diff --git a/src/_repobee/plugin.py b/src/_repobee/plugin.py
index 4493e8f..496fe9d 100644
--- a/src/_repobee/plugin.py
+++ b/src/_repobee/plugin.py
@@ -164,6 +164,7 @@ def register_plugins(modules: List[ModuleType]) -> List[object]:
                 plug.manager.register(obj)
                 registered.append(obj)
 
+    _handle_deprecation()
     return registered
 
 
@@ -273,7 +274,6 @@ def initialize_plugins(
         if p not in registered_plugins
     ]
     registered = register_plugins(plug_modules)
-    _handle_deprecation()
     return registered
 
 
diff --git a/src/repobee.py b/src/repobee.py
index f474ba0..beaee09 100644
--- a/src/repobee.py
+++ b/src/repobee.py
@@ -1,13 +1,20 @@
 import sys
 
-from _repobee.main import main
+from _repobee.main import run
 from _repobee.plugin import unregister_all_plugins, try_register_plugin
 
 __all__ = [
-    main.__name__,
-    try_register_plugin.__name__,
-    unregister_all_plugins.__name__,
+    "run",
+    "try_register_plugin",
+    "unregister_all_plugins",
 ]
 
+
+def main():
+    import _repobee.main
+
+    _repobee.main.main(sys.argv)
+
+
 if __name__ == "__main__":
-    main(sys.argv)
+    main()


Test patch:
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index cd674c2..6f5afea 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -1,4 +1,5 @@
 import argparse
+import pathlib
 
 import pytest
 
@@ -270,7 +271,15 @@ class TestDeclarativeExtensionCommand:
 class TestDeclarativeCommandExtension:
     """Test creating command extensions to existing commands."""
 
-    def test_add_required_option_to_config_show(self, capsys):
+    @pytest.fixture
+    def config_file(self, tmpdir):
+        config_file = pathlib.Path(str(tmpdir)) / "config.ini"
+        config_file.write_text("[DEFAULTS]")
+        return config_file
+
+    def test_add_required_option_to_config_show(
+        self, capsys, tmpdir, config_file
+    ):
         """Tests adding a required option to ``config show``."""
 
         class ConfigShowExt(plug.Plugin, plug.cli.CommandExtension):
@@ -278,17 +287,12 @@ class TestDeclarativeCommandExtension:
 
             silly_new_option = plug.cli.Option(help="your name", required=True)
 
-        plugin_instance = ConfigShowExt("silly-option")
-
-        # TODO remove when using repobee.run instaed of repobee.main
-        plug.manager.register(plugin_instance)
-
         with pytest.raises(SystemExit):
-            # TODO use repobee.run instead, when it is available
-            repobee.main("repobee config show".split())
-
-        # TODO remove when using repobee.run instaed of repobee.main
-        plug.manager.unregister(plugin_instance)
+            repobee.run(
+                "config show".split(),
+                config_file=config_file,
+                plugins=[ConfigShowExt],
+            )
 
         assert (
             "the following arguments are required: --silly-new-option"