Repository: Cog-Creators/Red-DiscordBot

Issue:
[Downloader] Install requirements for shared libraries
# Feature request

<!-- This template is for feature requests. Please fill out the following: -->


#### Select the type of feature you are requesting:

- [ ] Cog
- [ ] Command
- [X] API functionality

#### Describe your requested feature
As far as I can tell, installables which have a type of `SHARED_LIBRARY` [do not have their requirements also installed](https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/cogs/downloader/downloader.py#L303-L315), since the actual installation of requirements happens during Downloader's `install` command, which only operates on things in `available_cogs`.

I create a lot of cogs that share similar utilities which reside in a few shared libraries (eg, communicating with various AWS services). It would be great if I could define the requirements of things in that library once in the library's `info.json`, rather than having to do it in every cog which consumes the library.

From a quick test, adding a call to `Repo.install_requirements` from inside `Repo.install_libraries` seems to do the trick, but that may not be the most performant solution and I'm not sure if there's any terrible downstream impacts of doing this.

Please forgive me in advance if I'm missing functionality which already exists. 🙁


Discussion:

Resolving patch:
diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py
--- a/redbot/cogs/downloader/downloader.py
+++ b/redbot/cogs/downloader/downloader.py
@@ -140,7 +140,9 @@ async def _reinstall_libraries(self, cogs: Iterable[Installable]) -> Tuple[Insta
         failed = []
 
         for repo in repos:
-            if not await repo.install_libraries(target_dir=self.SHAREDLIB_PATH):
+            if not await repo.install_libraries(
+                target_dir=self.SHAREDLIB_PATH, req_target_dir=self.LIB_PATH
+            ):
                 failed.extend(repo.available_libraries)
 
         # noinspection PyTypeChecker
@@ -312,7 +314,7 @@ async def _cog_install(self, ctx, repo: Repo, cog_name: str):
 
         await self._add_to_installed(cog)
 
-        await repo.install_libraries(self.SHAREDLIB_PATH)
+        await repo.install_libraries(target_dir=self.SHAREDLIB_PATH, req_target_dir=self.LIB_PATH)
 
         await ctx.send(_("Cog `{cog_name}` successfully installed.").format(cog_name=cog_name))
         if cog.install_msg is not None:
diff --git a/redbot/cogs/downloader/repo_manager.py b/redbot/cogs/downloader/repo_manager.py
--- a/redbot/cogs/downloader/repo_manager.py
+++ b/redbot/cogs/downloader/repo_manager.py
@@ -383,7 +383,7 @@ async def install_cog(self, cog: Installable, target_dir: Path) -> bool:
         return await cog.copy_to(target_dir=target_dir)
 
     async def install_libraries(
-        self, target_dir: Path, libraries: Tuple[Installable] = ()
+        self, target_dir: Path, req_target_dir: Path, libraries: Tuple[Installable] = ()
     ) -> bool:
         """Install shared libraries to the target directory.
 
@@ -394,6 +394,8 @@ async def install_libraries(
         ----------
         target_dir : pathlib.Path
             Directory to install shared libraries to.
+        req_target_dir : pathlib.Path
+            Directory to install shared library requirements to.
         libraries : `tuple` of `Installable`
             A subset of available libraries.
 
@@ -412,7 +414,11 @@ async def install_libraries(
         if len(libraries) > 0:
             ret = True
             for lib in libraries:
-                ret = ret and await lib.copy_to(target_dir=target_dir)
+                ret = (
+                    ret
+                    and await self.install_requirements(cog=lib, target_dir=req_target_dir)
+                    and await lib.copy_to(target_dir=target_dir)
+                )
             return ret
         return True
 
diff --git a/redbot/pytest/downloader.py b/redbot/pytest/downloader.py
--- a/redbot/pytest/downloader.py
+++ b/redbot/pytest/downloader.py
@@ -14,7 +14,9 @@
     "repo_norun",
     "bot_repo",
     "INFO_JSON",
+    "LIBRARY_INFO_JSON",
     "installable",
+    "library_installable",
     "fake_run_noprint",
 ]
 
@@ -92,6 +94,19 @@ def bot_repo(event_loop):
     "type": "COG",
 }
 
+LIBRARY_INFO_JSON = {
+    "author": ("seputaes",),
+    "bot_version": (3, 0, 0),
+    "description": "A long library description",
+    "hidden": False,  # libraries are always hidden, this tests it will be flipped
+    "install_msg": "A library install message",
+    "required_cogs": {},
+    "requirements": ("tabulate"),
+    "short": "A short library description",
+    "tags": ("libtag1", "libtag2"),
+    "type": "SHARED_LIBRARY",
+}
+
 
 @pytest.fixture
 def installable(tmpdir):
@@ -101,3 +116,13 @@ def installable(tmpdir):
 
     cog_info = Installable(Path(str(cog_path)))
     return cog_info
+
+
+@pytest.fixture
+def library_installable(tmpdir):
+    lib_path = tmpdir.mkdir("test_repo").mkdir("test_lib")
+    info_path = lib_path.join("info.json")
+    info_path.write_text(json.dumps(LIBRARY_INFO_JSON), "utf-8")
+
+    cog_info = Installable(Path(str(lib_path)))
+    return cog_info


Test patch:
diff --git a/tests/cogs/downloader/test_downloader.py b/tests/cogs/downloader/test_downloader.py
--- a/tests/cogs/downloader/test_downloader.py
+++ b/tests/cogs/downloader/test_downloader.py
@@ -39,6 +39,22 @@ async def test_add_repo(monkeypatch, repo_manager):
     assert squid.available_modules == []
 
 
+@pytest.mark.asyncio
+async def test_lib_install_requirements(monkeypatch, library_installable, repo, tmpdir):
+    monkeypatch.setattr("redbot.cogs.downloader.repo_manager.Repo._run", fake_run_noprint)
+    monkeypatch.setattr(
+        "redbot.cogs.downloader.repo_manager.Repo.available_libraries", (library_installable,)
+    )
+
+    lib_path = Path(str(tmpdir)) / "cog_data_path" / "lib"
+    sharedlib_path = lib_path / "cog_shared"
+    sharedlib_path.mkdir(parents=True, exist_ok=True)
+
+    result = await repo.install_libraries(target_dir=sharedlib_path, req_target_dir=lib_path)
+
+    assert result is True
+
+
 @pytest.mark.asyncio
 async def test_remove_repo(monkeypatch, repo_manager):
     monkeypatch.setattr("redbot.cogs.downloader.repo_manager.Repo._run", fake_run_noprint)
diff --git a/tests/cogs/downloader/test_installable.py b/tests/cogs/downloader/test_installable.py
--- a/tests/cogs/downloader/test_installable.py
+++ b/tests/cogs/downloader/test_installable.py
@@ -15,6 +15,17 @@ def test_process_info_file(installable):
             assert getattr(installable, k) == v
 
 
+def test_process_lib_info_file(library_installable):
+    for k, v in LIBRARY_INFO_JSON.items():
+        if k == "type":
+            assert library_installable.type == InstallableType.SHARED_LIBRARY
+        elif k == "hidden":
+            # libraries are always hidden, even if False
+            assert library_installable.hidden is True
+        else:
+            assert getattr(library_installable, k) == v
+
+
 # noinspection PyProtectedMember
 def test_location_is_dir(installable):
     assert installable._location.exists()