Repository: repobee/repobee-plug

Issue:
Add platform API hook
The platform API (currently GitHubAPI and GitLabAPI) should be loaded as plugins. This requires a new hook. Most likely, a rudimentary "load_api" that returns the loaded API is enough to fulfill this.

See repobee/repobee#259 and repobee/repobee#260

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/repobee/repobee-plug/pull/20?src=pr&el=h1) Report
> Merging [#20](https://codecov.io/gh/repobee/repobee-plug/pull/20?src=pr&el=desc) into [master](https://codecov.io/gh/repobee/repobee-plug/commit/54feed20852a575688135884eeb014fa1accc4e5?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

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

```diff
@@          Coverage Diff          @@
##           master    #20   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files           7      7           
  Lines          66     72    +6     
=====================================
+ Hits           66     72    +6
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee-plug/pull/20?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [repobee\_plug/pluginmeta.py](https://codecov.io/gh/repobee/repobee-plug/pull/20/diff?src=pr&el=tree#diff-cmVwb2JlZV9wbHVnL3BsdWdpbm1ldGEucHk=) | `100% <ø> (ø)` | :arrow_up: |
| [repobee\_plug/exthooks.py](https://codecov.io/gh/repobee/repobee-plug/pull/20/diff?src=pr&el=tree#diff-cmVwb2JlZV9wbHVnL2V4dGhvb2tzLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [repobee\_plug/util.py](https://codecov.io/gh/repobee/repobee-plug/pull/20/diff?src=pr&el=tree#diff-cmVwb2JlZV9wbHVnL3V0aWwucHk=) | `100% <100%> (ø)` | :arrow_up: |
| [repobee\_plug/\_\_version.py](https://codecov.io/gh/repobee/repobee-plug/pull/20/diff?src=pr&el=tree#diff-cmVwb2JlZV9wbHVnL19fdmVyc2lvbi5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [repobee\_plug/corehooks.py](https://codecov.io/gh/repobee/repobee-plug/pull/20/diff?src=pr&el=tree#diff-cmVwb2JlZV9wbHVnL2NvcmVob29rcy5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [repobee\_plug/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee-plug/pull/20/diff?src=pr&el=tree#diff-cmVwb2JlZV9wbHVnL19faW5pdF9fLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee-plug/pull/20?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-plug/pull/20?src=pr&el=footer). Last update [54feed2...027e8c9](https://codecov.io/gh/repobee/repobee-plug/pull/20?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/conf.py b/docs/conf.py
index f0a2a36..f71d1e1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -68,9 +68,9 @@ author = 'Simon Larsén'
 # built documents.
 #
 # The short X.Y version.
-version = '0.5'
+version = '0.6'
 # The full version, including alpha/beta/rc tags.
-release = '0.5.1'
+release = '0.6.0'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/docs/implementing.rst b/docs/implementing.rst
index 6eb47ad..27469ce 100644
--- a/docs/implementing.rst
+++ b/docs/implementing.rst
@@ -2,9 +2,9 @@ Implementing hooks and writing internal plugins
 ***********************************************
 Implementing a hook is fairly simple, and works the same way regardless of what
 type of hook it is (core or extension). If you are working with your own fork
-of ``repobee``, all you have to do is write a small module implementing some hooks,
+of RepoBee, all you have to do is write a small module implementing some hooks,
 and drop it into the ``repobee.ext`` sub-package (i.e. the in directory
-``repobee/ext`` in the ``repobee`` repo).
+``repobee/ext`` in the RepoBee repo).
 
 There are two ways to implement hooks: as standalone functions or wrapped in a
 class. In the following two sections, we'll implement the
@@ -16,7 +16,7 @@ Hook functions in a plugin class
 ================================
 Wrapping hook implementations in a class inheriting from
 :py:class:`~repobee_plug.pluginmeta.Plugin` is the recommended way to write
-plugins for ``repobee``. The class does some checks to make sure that all
+plugins for RepoBee. The class does some checks to make sure that all
 public functions have hook function names, which comes in handy if you are
 in the habit of misspelling stuff (aren't we all?). Doing it this way,
 ``exampleplug.py`` would look like this:
@@ -35,12 +35,14 @@ in the habit of misspelling stuff (aren't we all?). Doing it this way,
     class ExamplePlugin(plug.Plugin):
         """Example plugin that implements the act_on_cloned_repo hook."""
 
-        def act_on_cloned_repo(self,
-                               path: Union[str, pathlib.Path]) -> plug.HookResult:
+        def act_on_cloned_repo(
+            self, path: Union[str, pathlib.Path], api,
+        ) -> plug.HookResult:
             """Do something with a cloned repo.
             
             Args:
                 path: Path to the student repo.
+                api: A platform API instance.
             Returns:
                 a plug.HookResult specifying the outcome.
             """
@@ -59,8 +61,8 @@ fact, all public methods in a class deriving from
 :py:class:`~repobee_plug.pluginmeta.Plugin` must have names of hook functions,
 or the class will fail to be created. You can see that the hook returns a
 :py:class:`~repobee_plug.util.HookResult`. This is used for reporting the
-results in ``repobee``, and is entirely optional (not all hooks support it,
-though). Do note that if ``None`` is returned instead, ``repobee`` will not
+results in RepoBee, and is entirely optional (not all hooks support it,
+though). Do note that if ``None`` is returned instead, RepoBee will not
 report anything for the hook. It is recommended that hooks that can return
 ``HookResult`` do. For a comprehensive example of an internal plugin
 implemented with a class, see the built-in `javac plugin`_.
diff --git a/docs/overview.rst b/docs/overview.rst
index b38466b..3d1d74e 100644
--- a/docs/overview.rst
+++ b/docs/overview.rst
@@ -3,7 +3,7 @@ Plugin system overview
 
 Conventions
 ===========
-For ``repobee`` to discover a plugin and its hooks, the following conventions
+For RepoBee to discover a plugin and its hooks, the following conventions
 need to be adhered to:
 
 1. The PyPi package should be named ``repobee-<plugin>``, where ``<plugin>``
@@ -16,32 +16,36 @@ need to be adhered to:
 
 For an example plugin that follows these conventions, have a look at
 repobee-junit4_.  Granted that the plugin follows these conventions and is
-installed, it can be loaded like any other ``repobee`` plugin (see `Using
+installed, it can be loaded like any other RepoBee plugin (see `Using
 Existing Plugins`_).
 
 Hooks
 =====
-There are two types of hooks in ``repobee``: *core hooks* and *extension
+There are two types of hooks in RepoBee: *core hooks* and *extension
 hooks*.
 
 Core hooks
 ----------
-Core hooks provide core functionality for ``repobee``, and always have a
+Core hooks provide core functionality for RepoBee, and always have a
 default implementation in :py:mod:`repobee.ext.defaults`. Providing a
 different plugin implementation will override this behavior, thereby
-changing some core part of ``repobee``. In general, only one implementation
-of a core hook will run per invocation of ``repobee``. All core hooks are
+changing some core part of RepoBee. In general, only one implementation
+of a core hook will run per invocation of RepoBee. All core hooks are
 defined in :py:mod:`repobee_plug.corehooks`.
 
+.. important::
+
+   Note that the default implementations in :py:mod:`repobee.ext.defaults` may
+   simply be *imported* into the module. They are not necessarily defined
+   there.
+
 Extension hooks
 ---------------
-Extension hooks extend the functionality of ``repobee`` in various ways.
+Extension hooks extend the functionality of RepoBee in various ways.
 Unlike the core hooks, there are no default implementations of the extension
 hooks, and multiple implementations can be run on each invocation of
-``repobee``. All extension hooks are defined in
-:py:mod:`repobee_plug.exthooks`. repobee-junit4_ consists solely of extension hooks,
-and so do all of the `repobee built-ins`_ except for :py:mod:`repobee.ext.defaults`.
+RepoBee. All extension hooks are defined in :py:mod:`repobee_plug.exthooks`.
 
-.. _repobee built-ins: https://repobee.readthedocs.io/en/latest/plugins.html#built-in-plugins
+.. _repobee built-ins: https://repobee.readthedocs.io/en/stable/plugins.html#built-in-plugins
 .. _repobee-junit4: https://github.com/repobee/repobee-junit4
-.. _Using Existing Plugins: https://repobee.readthedocs.io/en/latest/plugins.html#using-existing-plugins
+.. _Using Existing Plugins: https://repobee.readthedocs.io/en/stable/plugins.html#using-existing-plugins
diff --git a/repobee_plug/__init__.py b/repobee_plug/__init__.py
index 1dc0d53..2ade9be 100644
--- a/repobee_plug/__init__.py
+++ b/repobee_plug/__init__.py
@@ -6,10 +6,12 @@ from repobee_plug.util import hookimpl as repobee_hook
 from repobee_plug.util import HookResult
 from repobee_plug.util import Status
 from repobee_plug.corehooks import PeerReviewHook as _peer_hook
+from repobee_plug.corehooks import APIHook as _api_hook
 from repobee_plug.exthooks import CloneHook as _clone_hook
 
 manager = pluggy.PluginManager(__package__)
 manager.add_hookspecs(_clone_hook)
 manager.add_hookspecs(_peer_hook)
+manager.add_hookspecs(_api_hook)
 
 __all__ = ["Plugin", "manager", "repobee_hook", "HookResult", "Status"]
diff --git a/repobee_plug/__version.py b/repobee_plug/__version.py
index 93b60a1..906d362 100644
--- a/repobee_plug/__version.py
+++ b/repobee_plug/__version.py
@@ -1,1 +1,1 @@
-__version__ = '0.5.1'
+__version__ = "0.6.0"
diff --git a/repobee_plug/corehooks.py b/repobee_plug/corehooks.py
index 7742e7c..e6f7c95 100644
--- a/repobee_plug/corehooks.py
+++ b/repobee_plug/corehooks.py
@@ -11,7 +11,7 @@ to allow for this dynamic override.
 .. moduleauthor:: Simon Larsén
 """
 
-from typing import Union, Optional, Iterable, List, Mapping, Callable
+from typing import Union, Optional, Iterable, List, Mapping, Callable, Tuple
 
 from repobee_plug.util import hookspec
 
@@ -21,9 +21,11 @@ class PeerReviewHook:
 
     @hookspec(firstresult=True)
     def generate_review_allocations(
-            self, master_repo_name: str, students: Iterable[str],
-            num_reviews: int,
-            review_team_name_function: Callable[[str, str], str]
+        self,
+        master_repo_name: str,
+        students: Iterable[str],
+        num_reviews: int,
+        review_team_name_function: Callable[[str, str], str],
     ) -> Mapping[str, List[str]]:
         """Generate a (peer_review_team -> reviewers) mapping for each student
         repository (i.e. <student>-<master_repo_name>), where len(reviewers) =
@@ -52,3 +54,25 @@ class PeerReviewHook:
         Returns:
             a (peer_review_team -> reviewers) mapping for each student repository.
         """
+
+
+class APIHook:
+    """Hooks related to platform APIs."""
+
+    @hookspec(firstresult=True)
+    def get_api_class(self):
+        """Return an API platform class. Must be a subclass of apimeta.API.
+        
+        Returns:
+            An apimeta.API subclass.
+        """
+
+    @hookspec(firstresult=True)
+    def api_init_requires(self) -> Tuple[str]:
+        """Return which of the arguments to apimeta.APISpec.__init__ that the
+        given API requires. For example, the GitHubAPI requires all, but the
+        GitLabAPI does not require ``user``.
+
+        Returns:
+            Names of the required arguments.
+        """
diff --git a/repobee_plug/exthooks.py b/repobee_plug/exthooks.py
index 95e3ace..d6b0273 100644
--- a/repobee_plug/exthooks.py
+++ b/repobee_plug/exthooks.py
@@ -23,8 +23,9 @@ class CloneHook:
     """Hook functions related to cloning repos."""
 
     @hookspec
-    def act_on_cloned_repo(self, path: Union[str, pathlib.Path],
-                           api) -> Optional[HookResult]:
+    def act_on_cloned_repo(
+        self, path: Union[str, pathlib.Path], api
+    ) -> Optional[HookResult]:
         """Do something with a cloned repo.
 
         Args:
diff --git a/repobee_plug/pluginmeta.py b/repobee_plug/pluginmeta.py
index 3985e1c..5cd3ace 100644
--- a/repobee_plug/pluginmeta.py
+++ b/repobee_plug/pluginmeta.py
@@ -9,8 +9,10 @@ _HOOK_METHODS = {
     key: value
     for key, value in [
         *exthooks.CloneHook.__dict__.items(),
-        *corehooks.PeerReviewHook.__dict__.items()
-    ] if callable(value) and not key.startswith('_')
+        *corehooks.PeerReviewHook.__dict__.items(),
+        *corehooks.APIHook.__dict__.items(),
+    ]
+    if callable(value) and not key.startswith("_")
 }
 
 
@@ -35,8 +37,7 @@ class _PluginMeta(type):
         methods = cls._extract_public_methods(attrdict)
         cls._check_names(methods)
         hooked_methods = {
-            name: util.hookimpl(method)
-            for name, method in methods.items()
+            name: util.hookimpl(method) for name, method in methods.items()
         }
         attrdict.update(hooked_methods)
 
@@ -49,14 +50,16 @@ class _PluginMeta(type):
         if not method_names.issubset(hook_names):
             raise exception.HookNameError(
                 "public method(s) with non-hook name: {}".format(
-                    ", ".join(method_names - hook_names)))
+                    ", ".join(method_names - hook_names)
+                )
+            )
 
     @staticmethod
     def _extract_public_methods(attrdict):
         return {
             key: value
             for key, value in attrdict.items()
-            if callable(value) and not key.startswith('_')
+            if callable(value) and not key.startswith("_")
         }
 
 
diff --git a/repobee_plug/util.py b/repobee_plug/util.py
index e66ec69..dffdce1 100644
--- a/repobee_plug/util.py
+++ b/repobee_plug/util.py
@@ -12,11 +12,12 @@ import pluggy
 hookspec = pluggy.HookspecMarker(__package__)
 hookimpl = pluggy.HookimplMarker(__package__)
 
-HookResult = collections.namedtuple('HookResult', ('hook', 'status', 'msg'))
+HookResult = collections.namedtuple("HookResult", ("hook", "status", "msg"))
 
 
 class Status(enum.Enum):
     """Status codes enum."""
-    SUCCESS = 'success'
-    WARNING = 'warning'
-    ERROR = 'error'
+
+    SUCCESS = "success"
+    WARNING = "warning"
+    ERROR = "error"


Test patch:
diff --git a/tests/test_pluginmeta.py b/tests/test_pluginmeta.py
index 3789bac..bf111fa 100644
--- a/tests/test_pluginmeta.py
+++ b/tests/test_pluginmeta.py
@@ -47,6 +47,12 @@ class TestPluginInheritance:
             ):
                 pass
 
+            def get_api_class(self):
+                pass
+
+            def api_init_requires(self):
+                pass
+
     def test_with_private_methods(self):
         """Private methods should be able to have any names."""
 
@@ -70,6 +76,12 @@ class TestPluginInheritance:
             ):
                 pass
 
+            def get_api_class(self):
+                pass
+
+            def api_init_requires(self):
+                pass
+
             def _some_method(self, x, y):
                 return x + y