[V3 Everything] Package bot and write setup scripts (#964)

Ya'll are gonna hate me.

* Initial modifications

* Add initial setup.py

* working setup py help

* Modify setup file to package stuff

* Move a bunch of shit and fix imports

* Fix or skip tests

* Must add init files for find_packages to work

* Move main to scripts folder and rename

* Add shebangs

* Copy over translation files

* WORKING PIP INSTALL

* add dependency information

* Hardcoded version for now, will need to figure out a better way to do this

* OKAY ITS FINALLY FUCKING WORKING

* Add this guy

* Fix stuff

* Change readme to rst

* Remove double sentry opt in

* Oopsie

* Fix this thing

* Aaaand fix test

* Aaaand fix test

* Fix core cog importing and default cog install path

* Adjust readme

* change instance name from optional to required

* Ayyy let's do more dependency injection
This commit is contained in:
Will
2017-09-08 23:14:32 -04:00
committed by GitHub
parent 6b1fc786ee
commit d69fd63da7
85 changed files with 451 additions and 255 deletions

View File

@@ -0,0 +1,6 @@
from redbot.core.bot import Red
from .downloader import Downloader
def setup(bot: Red):
bot.add_cog(Downloader(bot))

View File

@@ -0,0 +1,45 @@
import asyncio
import discord
from discord.ext import commands
__all__ = ["install_agreement", ]
REPO_INSTALL_MSG = (
"You're about to add a 3rd party repository. The creator of Red"
" and its community have no responsibility for any potential "
"damage that the content of 3rd party repositories might cause."
"\n\nBy typing '**I agree**' you declare that you have read and"
" fully understand the above message. This message won't be "
"shown again until the next reboot.\n\nYou have **30** seconds"
" to reply to this message."
)
def install_agreement():
async def pred(ctx: commands.Context):
downloader = ctx.command.instance
if downloader is None:
return True
elif downloader.already_agreed:
return True
elif ctx.invoked_subcommand is None or \
isinstance(ctx.invoked_subcommand, commands.Group):
return True
def does_agree(msg: discord.Message):
return ctx.author == msg.author and \
ctx.channel == msg.channel and \
msg.content == "I agree"
await ctx.send(REPO_INSTALL_MSG)
try:
await ctx.bot.wait_for('message', check=does_agree, timeout=30)
except asyncio.TimeoutError:
await ctx.send("Your response has timed out, please try again.")
return False
downloader.already_agreed = True
return True
return commands.check(pred)

View File

@@ -0,0 +1,24 @@
import discord
from discord.ext import commands
from .repo_manager import RepoManager
from .installable import Installable
class RepoName(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> str:
return RepoManager.validate_and_normalize_repo_name(arg)
class InstalledCog(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> Installable:
downloader = ctx.bot.get_cog("Downloader")
if downloader is None:
raise commands.CommandError("Downloader not loaded.")
cog = discord.utils.get(downloader.installed_cogs, name=arg)
if cog is None:
raise commands.BadArgument(
"That cog is not installed"
)
return cog

View File

@@ -0,0 +1,394 @@
import os
import shutil
from pathlib import Path
from sys import path as syspath
from typing import Tuple, Union
import discord
from redbot.core import Config
from redbot.core import checks
from redbot.core.i18n import CogI18n
from redbot.core.utils.chat_formatting import box
from discord.ext import commands
from redbot.core.bot import Red
from .checks import install_agreement
from .converters import RepoName, InstalledCog
from .errors import CloningError, ExistingGitRepo
from .installable import Installable
from .log import log
from .repo_manager import RepoManager, Repo
_ = CogI18n('Downloader', __file__)
class Downloader:
def __init__(self, bot: Red):
self.bot = bot
self.conf = Config.get_conf(self, identifier=998240343,
force_registration=True)
self.conf.register_global(
repos={},
installed=[]
)
self.already_agreed = False
self.LIB_PATH = self.bot.main_dir / "lib"
self.SHAREDLIB_PATH = self.LIB_PATH / "cog_shared"
self.SHAREDLIB_INIT = self.SHAREDLIB_PATH / "__init__.py"
self.LIB_PATH.mkdir(parents=True, exist_ok=True)
self.SHAREDLIB_PATH.mkdir(parents=True, exist_ok=True)
if not self.SHAREDLIB_INIT.exists():
with self.SHAREDLIB_INIT.open(mode='w') as _:
pass
if str(self.LIB_PATH) not in syspath:
syspath.insert(1, str(self.LIB_PATH))
self._repo_manager = RepoManager(self.conf)
async def cog_install_path(self):
"""
Returns the current cog install path.
:return:
"""
return await self.bot.cog_mgr.install_path()
async def installed_cogs(self) -> Tuple[Installable]:
"""
Returns the dictionary mapping cog name to install location
and repo name.
:return:
"""
installed = await self.conf.installed()
# noinspection PyTypeChecker
return tuple(Installable.from_json(v) for v in installed)
async def _add_to_installed(self, cog: Installable):
"""
Marks a cog as installed.
:param cog:
:return:
"""
installed = await self.conf.installed()
cog_json = cog.to_json()
if cog_json not in installed:
installed.append(cog_json)
await self.conf.installed.set(installed)
async def _remove_from_installed(self, cog: Installable):
"""
Removes a cog from the saved list of installed cogs.
:param cog:
:return:
"""
installed = await self.conf.installed()
cog_json = cog.to_json()
if cog_json in installed:
installed.remove(cog_json)
await self.conf.installed.set(installed)
async def _reinstall_cogs(self, cogs: Tuple[Installable]) -> Tuple[Installable]:
"""
Installs a list of cogs, used when updating.
:param cogs:
:return: Any cogs that failed to copy
"""
failed = []
for cog in cogs:
if not await cog.copy_to(await self.cog_install_path()):
failed.append(cog)
# noinspection PyTypeChecker
return tuple(failed)
async def _reinstall_libraries(self, cogs: Tuple[Installable]) -> Tuple[Installable]:
"""
Reinstalls any shared libraries from the repos of cogs that
were updated.
:param cogs:
:return: Any libraries that failed to copy
"""
repo_names = set(cog.repo_name for cog in cogs)
unfiltered_repos = (self._repo_manager.get_repo(r) for r in repo_names)
repos = filter(lambda r: r is not None, unfiltered_repos)
failed = []
for repo in repos:
if not await repo.install_libraries(target_dir=self.SHAREDLIB_PATH):
failed.extend(repo.available_libraries)
# noinspection PyTypeChecker
return tuple(failed)
async def _reinstall_requirements(self, cogs: Tuple[Installable]) -> bool:
"""
Reinstalls requirements for given cogs that have been updated.
Returns a bool that indicates if all requirement installations
were successful.
:param cogs:
:return:
"""
# Reduces requirements to a single list with no repeats
requirements = set(r for c in cogs for r in c.requirements)
repo_names = self._repo_manager.get_all_repo_names()
repos = [(self._repo_manager.get_repo(rn), []) for rn in repo_names]
# This for loop distributes the requirements across all repos
# which will allow us to concurrently install requirements
for i, req in enumerate(requirements):
repo_index = i % len(repos)
repos[repo_index][1].append(req)
has_reqs = list(filter(lambda item: len(item[1]) > 0, repos))
ret = True
for repo, reqs in has_reqs:
for req in reqs:
# noinspection PyTypeChecker
ret = ret and await repo.install_raw_requirements([req, ], self.LIB_PATH)
return ret
@staticmethod
async def _delete_cog(target: Path):
"""
Removes an (installed) cog.
:param target: Path pointing to an existing file or directory
:return:
"""
if not target.exists():
return
if target.is_dir():
shutil.rmtree(str(target))
elif target.is_file():
os.remove(str(target))
@commands.group()
@checks.is_owner()
async def repo(self, ctx):
"""
Command group for managing Downloader repos.
"""
if ctx.invoked_subcommand is None:
await self.bot.send_cmd_help(ctx)
@repo.command(name="add")
@install_agreement()
async def _repo_add(self, ctx, name: RepoName, repo_url: str, branch: str=None):
"""
Add a new repo to Downloader.
Name can only contain characters A-z, numbers and underscore
Branch will default to master if not specified
"""
try:
# noinspection PyTypeChecker
await self._repo_manager.add_repo(
name=name,
url=repo_url,
branch=branch
)
except ExistingGitRepo:
await ctx.send(_("That git repo has already been added under another name."))
except CloningError:
await ctx.send(_("Something went wrong during the cloning process."))
log.exception(_("Something went wrong during the cloning process."))
else:
await ctx.send(_("Repo `{}` successfully added.").format(name))
@repo.command(name="delete")
async def _repo_del(self, ctx, repo_name: Repo):
"""
Removes a repo from Downloader and its' files.
"""
await self._repo_manager.delete_repo(repo_name.name)
await ctx.send(_("The repo `{}` has been deleted successfully.").format(repo_name.name))
@repo.command(name="list")
async def _repo_list(self, ctx):
"""
Lists all installed repos.
"""
repos = self._repo_manager.get_all_repo_names()
joined = _("Installed Repos:\n") + "\n".join(["+ " + r for r in repos])
await ctx.send(box(joined, lang="diff"))
@commands.group()
@checks.is_owner()
async def cog(self, ctx):
"""
Command group for managing installable Cogs.
"""
if ctx.invoked_subcommand is None:
await self.bot.send_cmd_help(ctx)
@cog.command(name="install")
async def _cog_install(self, ctx, repo_name: Repo, cog_name: str):
"""
Installs a cog from the given repo.
"""
cog = discord.utils.get(repo_name.available_cogs, name=cog_name)
if cog is None:
await ctx.send(_("Error, there is no cog by the name of"
" `{}` in the `{}` repo.").format(cog_name, repo_name.name))
return
if not await repo_name.install_requirements(cog, self.LIB_PATH):
await ctx.send(_("Failed to install the required libraries for"
" `{}`: `{}`").format(cog.name, cog.requirements))
return
await repo_name.install_cog(cog, await self.cog_install_path())
await self._add_to_installed(cog)
await repo_name.install_libraries(self.SHAREDLIB_PATH)
await ctx.send(_("`{}` cog successfully installed.").format(cog_name))
@cog.command(name="uninstall")
async def _cog_uninstall(self, ctx, cog_name: InstalledCog):
"""
Allows you to uninstall cogs that were previously installed
through Downloader.
"""
# noinspection PyUnresolvedReferences,PyProtectedMember
real_name = cog_name.name
poss_installed_path = (await self.cog_install_path()) / real_name
if poss_installed_path.exists():
await self._delete_cog(poss_installed_path)
# noinspection PyTypeChecker
await self._remove_from_installed(cog_name)
await ctx.send(_("`{}` was successfully removed.").format(real_name))
else:
await ctx.send(_("That cog was installed but can no longer"
" be located. You may need to remove it's"
" files manually if it is still usable."))
@cog.command(name="update")
async def _cog_update(self, ctx, cog_name: InstalledCog=None):
"""
Updates all cogs or one of your choosing.
"""
if cog_name is None:
updated = await self._repo_manager.update_all_repos()
installed_cogs = set(await self.installed_cogs())
updated_cogs = set(cog for repo in updated.keys() for cog in repo.available_cogs)
installed_and_updated = updated_cogs & installed_cogs
# noinspection PyTypeChecker
await self._reinstall_requirements(installed_and_updated)
# noinspection PyTypeChecker
await self._reinstall_cogs(installed_and_updated)
# noinspection PyTypeChecker
await self._reinstall_libraries(installed_and_updated)
await ctx.send(_("Cog update completed successfully."))
@cog.command(name="list")
async def _cog_list(self, ctx, repo_name: Repo):
"""
Lists all available cogs from a single repo.
"""
cogs = repo_name.available_cogs
cogs = _("Available Cogs:\n") + "\n".join(
["+ {}: {}".format(c.name, c.short or "") for c in cogs])
await ctx.send(box(cogs, lang="diff"))
@cog.command(name="info")
async def _cog_info(self, ctx, repo_name: Repo, cog_name: str):
"""
Lists information about a single cog.
"""
cog = discord.utils.get(repo_name.available_cogs, name=cog_name)
if cog is None:
await ctx.send(_("There is no cog `{}` in the repo `{}`").format(
cog_name, repo_name.name
))
return
msg = _("Information on {}:\n{}").format(cog.name, cog.description or "")
await ctx.send(box(msg))
async def is_installed(self, cog_name: str) -> (bool, Union[Installable, None]):
"""
Checks to see if a cog with the given name was installed
through Downloader.
:param cog_name:
:return: is_installed, Installable
"""
for installable in await self.installed_cogs():
if installable.name == cog_name:
return True, installable
return False, None
def format_findcog_info(self, command_name: str,
cog_installable: Union[Installable, object]=None) -> str:
"""
Formats the info for output to discord
:param command_name:
:param cog_installable: Can be an Installable instance or a Cog instance.
:return: str
"""
if isinstance(cog_installable, Installable):
made_by = ", ".join(cog_installable.author) or _("Missing from info.json")
repo = self._repo_manager.get_repo(cog_installable.repo_name)
repo_url = repo.url
cog_name = cog_installable.name
else:
made_by = "26 & co."
repo_url = "https://github.com/Twentysix26/Red-DiscordBot"
cog_name = cog_installable.__class__.__name__
msg = _("Command: {}\nMade by: {}\nRepo: {}\nCog name: {}")
return msg.format(command_name, made_by, repo_url, cog_name)
def cog_name_from_instance(self, instance: object) -> str:
"""
Determines the cog name that Downloader knows from the cog instance.
Probably.
:param instance:
:return:
"""
splitted = instance.__module__.split('.')
return splitted[-2]
@commands.command()
async def findcog(self, ctx: commands.Context, command_name: str):
"""
Figures out which cog a command comes from. Only works with loaded
cogs.
"""
command = ctx.bot.all_commands.get(command_name)
if command is None:
await ctx.send(_("That command doesn't seem to exist."))
return
# Check if in installed cogs
cog_name = self.cog_name_from_instance(command.instance)
installed, cog_installable = await self.is_installed(cog_name)
if installed:
msg = self.format_findcog_info(command_name, cog_installable)
else:
# Assume it's in a base cog
msg = self.format_findcog_info(command_name, command.instance)
await ctx.send(box(msg))

View File

@@ -0,0 +1,84 @@
__all__ = ["DownloaderException", "GitException", "InvalidRepoName", "ExistingGitRepo",
"MissingGitRepo", "CloningError", "CurrentHashError", "HardResetError",
"UpdateError", "GitDiffError", "PipError"]
class DownloaderException(Exception):
"""
Base class for Downloader exceptions.
"""
pass
class GitException(DownloaderException):
"""
Generic class for git exceptions.
"""
class InvalidRepoName(DownloaderException):
"""
Throw when a repo name is invalid. Check
the message for a more detailed reason.
"""
pass
class ExistingGitRepo(DownloaderException):
"""
Thrown when trying to clone into a folder where a
git repo already exists.
"""
pass
class MissingGitRepo(DownloaderException):
"""
Thrown when a git repo is expected to exist but
does not.
"""
pass
class CloningError(GitException):
"""
Thrown when git clone returns a non zero exit code.
"""
pass
class CurrentHashError(GitException):
"""
Thrown when git returns a non zero exit code attempting
to determine the current commit hash.
"""
pass
class HardResetError(GitException):
"""
Thrown when there is an issue trying to execute a hard reset
(usually prior to a repo update).
"""
pass
class UpdateError(GitException):
"""
Thrown when git pull returns a non zero error code.
"""
pass
class GitDiffError(GitException):
"""
Thrown when a git diff fails.
"""
pass
class PipError(DownloaderException):
"""
Thrown when pip returns a non-zero return code.
"""
pass

View File

@@ -0,0 +1,170 @@
import json
import distutils.dir_util
import shutil
from enum import Enum
from pathlib import Path
from typing import Union, MutableMapping, Any
from .log import log
from .json_mixins import RepoJSONMixin
class InstallableType(Enum):
UNKNOWN = 0
COG = 1
SHARED_LIBRARY = 2
class Installable(RepoJSONMixin):
"""
Base class for anything the Downloader cog can install.
- Modules
- Repo Libraries
- Other stuff?
"""
INFO_FILE_DESCRIPTION = """
"""
def __init__(self, location: Path):
"""
Base installable initializer.
:param location: Location (file or folder) to the installable.
"""
super().__init__(location)
self._location = location
self.repo_name = self._location.parent.stem
self.author = ()
self.bot_version = (3, 0, 0)
self.hidden = False
self.required_cogs = {} # Cog name -> repo URL
self.requirements = ()
self.tags = ()
self.type = InstallableType.UNKNOWN
if self._info_file.exists():
self._process_info_file(self._info_file)
if self._info == {}:
self.type = InstallableType.COG
def __eq__(self, other):
# noinspection PyProtectedMember
return self._location == other._location
def __hash__(self):
return hash(self._location)
@property
def name(self):
return self._location.stem
async def copy_to(self, target_dir: Path) -> bool:
"""
Copies this cog/shared_lib to the given directory. This
will overwrite any files in the target directory.
:param pathlib.Path target_dir: The installation directory to install to.
:return: Status of installation
:rtype: bool
"""
if self._location.is_file():
copy_func = shutil.copy2
else:
copy_func = distutils.dir_util.copy_tree
# noinspection PyBroadException
try:
copy_func(
src=str(self._location),
dst=str(target_dir / self._location.stem)
)
except:
log.exception("Error occurred when copying path:"
" {}".format(self._location))
return False
return True
def _read_info_file(self):
super()._read_info_file()
if self._info_file.exists():
self._process_info_file()
def _process_info_file(self, info_file_path: Path=None) -> MutableMapping[str, Any]:
"""
Processes an information file. Loads dependencies among other
information into this object.
:type info_file_path:
:param info_file_path: Optional path to information file, defaults to `self.__info_file`
:return: Raw information dictionary
"""
info_file_path = info_file_path or self._info_file
if info_file_path is None or not info_file_path.is_file():
raise ValueError("No valid information file path was found.")
info = {}
with info_file_path.open(encoding='utf-8') as f:
try:
info = json.load(f)
except json.JSONDecodeError:
info = {}
log.exception("Invalid JSON information file at path:"
" {}".format(info_file_path))
else:
self._info = info
try:
author = tuple(info.get("author", []))
except ValueError:
author = ()
self.author = author
try:
bot_version = tuple(info.get("bot_version", [3, 0, 0]))
except ValueError:
bot_version = 2
self.bot_version = bot_version
try:
hidden = bool(info.get("hidden", False))
except ValueError:
hidden = False
self.hidden = hidden
self.required_cogs = info.get("required_cogs", {})
self.requirements = info.get("requirements", ())
try:
tags = tuple(info.get("tags", ()))
except ValueError:
tags = ()
self.tags = tags
installable_type = info.get("type", "")
if installable_type in ("", "COG"):
self.type = InstallableType.COG
elif installable_type == "SHARED_LIBRARY":
self.type = InstallableType.SHARED_LIBRARY
self.hidden = True
else:
self.type = InstallableType.UNKNOWN
return info
def to_json(self):
return {
"location": self._location.relative_to(Path.cwd()).parts
}
@classmethod
def from_json(cls, data: dict):
location = Path.cwd() / Path(*data["location"])
return cls(location=location)

View File

@@ -0,0 +1,37 @@
import json
from pathlib import Path
class RepoJSONMixin:
INFO_FILE_NAME = "info.json"
def __init__(self, repo_folder: Path):
self._repo_folder = repo_folder
self.author = None
self.install_msg = None
self.short = None
self.description = None
self._info_file = repo_folder / self.INFO_FILE_NAME
if self._info_file.exists():
self._read_info_file()
self._info = {}
def _read_info_file(self):
if not (self._info_file.exists() or self._info_file.is_file()):
return
try:
with self._info_file.open(encoding='utf-8') as f:
info = json.load(f)
except json.JSONDecodeError:
return
else:
self._info = info
self.author = info.get("author")
self.install_msg = info.get("install_msg")
self.short = info.get("short")
self.description = info.get("description")

View File

@@ -0,0 +1,97 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2017-08-26 16:31+EDT\n"
"PO-Revision-Date: 2017-08-26 17:00-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"Last-Translator: tekulvw\n"
"Language-Team: \n"
"Language: de\n"
"X-Generator: Poedit 1.8.7.1\n"
#: ../downloader.py:202
msgid "That git repo has already been added under another name."
msgstr "Diese git repo wurder bereist unter einem anderem Namen hinzugefügt."
#: ../downloader.py:204 ../downloader.py:205
msgid "Something went wrong during the cloning process."
msgstr "Etwas ist beim klonen schief gelaufen."
#: ../downloader.py:207
msgid "Repo `{}` successfully added."
msgstr "Repo `{}` erfolgreich hinzugefügt."
#: ../downloader.py:216
msgid "The repo `{}` has been deleted successfully."
msgstr "Die Repo `{}` wurde erfolgreich gelöscht."
#: ../downloader.py:224
msgid "Installed Repos:\n"
msgstr "Installierte Repos:\n"
#: ../downloader.py:244
msgid "Error, there is no cog by the name of `{}` in the `{}` repo."
msgstr "Fehler: kein Cog mit dem Namen `{}` in der Repo `{}`."
#: ../downloader.py:249
msgid "Failed to install the required libraries for `{}`: `{}`"
msgstr "Installation erforderliche Abhängigkeiten für`{}` fehlgeschlagen: `{}`"
#: ../downloader.py:259
msgid "`{}` cog successfully installed."
msgstr "`{}` Cog erfolgreich installiert."
#: ../downloader.py:275
msgid "`{}` was successfully removed."
msgstr "`{}` erfolgreich entfernt."
#: ../downloader.py:277
msgid "That cog was installed but can no longer be located. You may need to remove it's files manually if it is still usable."
msgstr "Diese Cog ist installiert konnte aber nicht gefunden werden. Wenn es noch benutzbar ist kann es sein das du die Dateien manuell löschen musst."
#: ../downloader.py:301
msgid "Cog update completed successfully."
msgstr "Cog Update erfolgreich."
#: ../downloader.py:309
msgid "Available Cogs:\n"
msgstr "Vorhandene Cogs:\n"
#: ../downloader.py:321
msgid "There is no cog `{}` in the repo `{}`"
msgstr "Kein Cog namens `{}` in der Repo `{}"
#: ../downloader.py:326
msgid ""
"Information on {}:\n"
"{}"
msgstr ""
"Information zu {}:\n"
"{}"
#: ../downloader.py:350
msgid "Missing from info.json"
msgstr "Nicht in info.json"
#: ../downloader.py:359
msgid ""
"Command: {}\n"
"Made by: {}\n"
"Repo: {}\n"
"Cog name: {}"
msgstr ""
"Befehl: {}\n"
"Von: {}\n"
"Repo: {}\n"
"Cog Name: {}"
#: ../downloader.py:383
msgid "That command doesn't seem to exist."
msgstr "Dieser Befehl existiert nicht."

View File

@@ -0,0 +1,96 @@
# Copyright (C) 2017 Red-DiscordBot
# UltimatePancake <pier.gaetani@gmail.com>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2017-08-26 16:31+EDT\n"
"PO-Revision-Date: 2017-08-26 20:44-0600\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"Last-Translator: UltimatePancake <pier.gaetani@gmail.com>\n"
"Language-Team: \n"
"Language: es\n"
"X-Generator: Poedit 2.0.3\n"
#: ../downloader.py:202
msgid "That git repo has already been added under another name."
msgstr "Ese repositorio ya ha sido agregado con otro nombre."
#: ../downloader.py:204 ../downloader.py:205
msgid "Something went wrong during the cloning process."
msgstr "Error durante la clonación."
#: ../downloader.py:207
msgid "Repo `{}` successfully added."
msgstr "Repositorio `{}` agregado exitósamente."
#: ../downloader.py:216
msgid "The repo `{}` has been deleted successfully."
msgstr "Repositorio `{}` eliminado exitósamente."
#: ../downloader.py:224
msgid "Installed Repos:\n"
msgstr "Repositorios instalados:\n"
#: ../downloader.py:244
msgid "Error, there is no cog by the name of `{}` in the `{}` repo."
msgstr "Error: No existe un cog llamado `{}` en el repositorio `{}`."
#: ../downloader.py:249
msgid "Failed to install the required libraries for `{}`: `{}`"
msgstr "Error instalando las librerías requeridas para `{}`: `{}`"
#: ../downloader.py:259
msgid "`{}` cog successfully installed."
msgstr "`{}` instalado exitósamente."
#: ../downloader.py:275
msgid "`{}` was successfully removed."
msgstr "`{}` eliminado exitósamente."
#: ../downloader.py:277
msgid "That cog was installed but can no longer be located. You may need to remove it's files manually if it is still usable."
msgstr "El cog fue instalado pero ya no se puede localizar. Puede ser necesario eliminar sus archivos manualmente si aun es utilizable."
#: ../downloader.py:301
msgid "Cog update completed successfully."
msgstr "Cog actualizado exitósamente."
#: ../downloader.py:309
msgid "Available Cogs:\n"
msgstr "Cogs disponibles:\n"
#: ../downloader.py:321
msgid "There is no cog `{}` in the repo `{}`"
msgstr "No existe un cog `{}` en el repositorio `{}`"
#: ../downloader.py:326
msgid ""
"Information on {}:\n"
"{}"
msgstr ""
"Información sobre {}:\n"
"{}"
#: ../downloader.py:350
msgid "Missing from info.json"
msgstr "Ausente de info.json"
#: ../downloader.py:359
msgid ""
"Command: {}\n"
"Made by: {}\n"
"Repo: {}\n"
"Cog name: {}"
msgstr ""
"Comando: {}\n"
"Creado por: {}\n"
"Repositorio: {}\n"
"Nombre del cog: {}"
#: ../downloader.py:383
msgid "That command doesn't seem to exist."
msgstr "Ese comando no parece existir."

View File

@@ -0,0 +1,97 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2017-08-26 16:31+EDT\n"
"PO-Revision-Date: 2017-08-26 23:14+0200\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: fr\n"
"X-Generator: Poedit 2.0.1\n"
#: ../downloader.py:202
msgid "That git repo has already been added under another name."
msgstr "Ce repo git a déjà été ajouté sous un autre nom"
#: ../downloader.py:204 ../downloader.py:205
msgid "Something went wrong during the cloning process."
msgstr "Quelque chose s'est mal passé pendant l'installation."
#: ../downloader.py:207
msgid "Repo `{}` successfully added."
msgstr "Le repo `{}` a été ajouté avec succès"
#: ../downloader.py:216
msgid "The repo `{}` has been deleted successfully."
msgstr "Le repo `{}` a été supprimé avec succès"
#: ../downloader.py:224
msgid "Installed Repos:\n"
msgstr "Repos installés:\n"
#: ../downloader.py:244
msgid "Error, there is no cog by the name of `{}` in the `{}` repo."
msgstr "Erreur, il n'y a pas de cog du nom de `{}` dans le repo `{}`."
#: ../downloader.py:249
msgid "Failed to install the required libraries for `{}`: `{}`"
msgstr "Échec lors de l'installation des bibliothèques de `{}`: `{}`"
#: ../downloader.py:259
msgid "`{}` cog successfully installed."
msgstr "Le cog `{}` a été ajouté avec succès"
#: ../downloader.py:275
msgid "`{}` was successfully removed."
msgstr "Le cog `{}` a été retiré avec succès"
#: ../downloader.py:277
msgid "That cog was installed but can no longer be located. You may need to remove it's files manually if it is still usable."
msgstr "Ce cog a été installé mais ne peut plus être trouvé. Vous devez retirer manuellement son dossier si il est encore utilisable."
#: ../downloader.py:301
msgid "Cog update completed successfully."
msgstr "Mise à jour du cog effectuée avec succès"
#: ../downloader.py:309
msgid "Available Cogs:\n"
msgstr "Cogs disponibles:\n"
#: ../downloader.py:321
msgid "There is no cog `{}` in the repo `{}`"
msgstr "Il n'y a pas de cog `{}` dans le repo `{}`"
#: ../downloader.py:326
msgid ""
"Information on {}:\n"
"{}"
msgstr ""
"Informations sur {}:\n"
"{}"
#: ../downloader.py:350
msgid "Missing from info.json"
msgstr "Informations manquantes de info.json"
#: ../downloader.py:359
msgid ""
"Command: {}\n"
"Made by: {}\n"
"Repo: {}\n"
"Cog name: {}"
msgstr ""
"Commande: {]\n"
"Créé par: {}\n"
"Repo: {}\n"
"Nom du cog: {}"
#: ../downloader.py:383
msgid "That command doesn't seem to exist."
msgstr "Cette commande ne semble pas exister"

View File

@@ -0,0 +1,93 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2017-08-26 17:05+EDT\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: ENCODING\n"
"Generated-By: pygettext.py 1.5\n"
#: ../downloader.py:202
msgid "That git repo has already been added under another name."
msgstr ""
#: ../downloader.py:204 ../downloader.py:205
msgid "Something went wrong during the cloning process."
msgstr ""
#: ../downloader.py:207
msgid "Repo `{}` successfully added."
msgstr ""
#: ../downloader.py:216
msgid "The repo `{}` has been deleted successfully."
msgstr ""
#: ../downloader.py:224
msgid ""
"Installed Repos:\n"
msgstr ""
#: ../downloader.py:244
msgid "Error, there is no cog by the name of `{}` in the `{}` repo."
msgstr ""
#: ../downloader.py:249
msgid "Failed to install the required libraries for `{}`: `{}`"
msgstr ""
#: ../downloader.py:259
msgid "`{}` cog successfully installed."
msgstr ""
#: ../downloader.py:275
msgid "`{}` was successfully removed."
msgstr ""
#: ../downloader.py:277
msgid "That cog was installed but can no longer be located. You may need to remove it's files manually if it is still usable."
msgstr ""
#: ../downloader.py:301
msgid "Cog update completed successfully."
msgstr ""
#: ../downloader.py:309
msgid ""
"Available Cogs:\n"
msgstr ""
#: ../downloader.py:321
msgid "There is no cog `{}` in the repo `{}`"
msgstr ""
#: ../downloader.py:326
msgid ""
"Information on {}:\n"
"{}"
msgstr ""
#: ../downloader.py:350
msgid "Missing from info.json"
msgstr ""
#: ../downloader.py:359
msgid ""
"Command: {}\n"
"Made by: {}\n"
"Repo: {}\n"
"Cog name: {}"
msgstr ""
#: ../downloader.py:383
msgid "That command doesn't seem to exist."
msgstr ""

View File

@@ -0,0 +1,93 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2017-08-26 16:24+EDT\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: ENCODING\n"
"Generated-By: pygettext.py 1.5\n"
#: ../downloader.py:202
msgid "That git repo has already been added under another name."
msgstr ""
#: ../downloader.py:204 ../downloader.py:205
msgid "Something went wrong during the cloning process."
msgstr ""
#: ../downloader.py:207
msgid "Repo `{}` successfully added."
msgstr ""
#: ../downloader.py:216
msgid "The repo `{}` has been deleted successfully."
msgstr ""
#: ../downloader.py:224
msgid ""
"Installed Repos:\n"
msgstr ""
#: ../downloader.py:244
msgid "Error, there is no cog by the name of `{}` in the `{}` repo."
msgstr ""
#: ../downloader.py:249
msgid "Failed to install the required libraries for `{}`: `{}`"
msgstr ""
#: ../downloader.py:259
msgid "`{}` cog successfully installed."
msgstr ""
#: ../downloader.py:275
msgid "`{}` was successfully removed."
msgstr ""
#: ../downloader.py:277
msgid "That cog was installed but can no longer be located. You may need to remove it's files manually if it is still usable."
msgstr ""
#: ../downloader.py:301
msgid "Cog update completed successfully."
msgstr ""
#: ../downloader.py:309
msgid ""
"Available Cogs:\n"
msgstr ""
#: ../downloader.py:321
msgid "There is no cog `{}` in the repo `{}`"
msgstr ""
#: ../downloader.py:326
msgid ""
"Information on {}:\n"
"{}"
msgstr ""
#: ../downloader.py:350
msgid "Missing from info.json"
msgstr ""
#: ../downloader.py:359
msgid ""
"Command: {}\n"
"Made by: {}\n"
"Repo: {}\n"
"Cog name: {}"
msgstr ""
#: ../downloader.py:383
msgid "That command doesn't seem to exist."
msgstr ""

View File

@@ -0,0 +1,94 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2017-08-26 16:35-0400\n"
"PO-Revision-Date: 2017-08-26 16:42-0400\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 1.8.7.1\n"
"Last-Translator: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Language: nl\n"
#: ../downloader.py:202
msgid "That git repo has already been added under another name."
msgstr ""
#: ../downloader.py:204 ../downloader.py:205
msgid "Something went wrong during the cloning process."
msgstr ""
#: ../downloader.py:207
msgid "Repo `{}` successfully added."
msgstr ""
#: ../downloader.py:216
msgid "The repo `{}` has been deleted successfully."
msgstr ""
#: ../downloader.py:224
msgid "Installed Repos:\n"
msgstr ""
#: ../downloader.py:244
msgid "Error, there is no cog by the name of `{}` in the `{}` repo."
msgstr ""
#: ../downloader.py:249
msgid "Failed to install the required libraries for `{}`: `{}`"
msgstr ""
#: ../downloader.py:259
msgid "`{}` cog successfully installed."
msgstr ""
#: ../downloader.py:275
msgid "`{}` was successfully removed."
msgstr ""
#: ../downloader.py:277
msgid ""
"That cog was installed but can no longer be located. You may need to remove "
"it's files manually if it is still usable."
msgstr ""
#: ../downloader.py:301
msgid "Cog update completed successfully."
msgstr ""
#: ../downloader.py:309
msgid "Available Cogs:\n"
msgstr ""
#: ../downloader.py:321
msgid "There is no cog `{}` in the repo `{}`"
msgstr ""
#: ../downloader.py:326
msgid ""
"Information on {}:\n"
"{}"
msgstr ""
#: ../downloader.py:350
msgid "Missing from info.json"
msgstr ""
#: ../downloader.py:359
msgid ""
"Command: {}\n"
"Made by: {}\n"
"Repo: {}\n"
"Cog name: {}"
msgstr ""
#: ../downloader.py:383
msgid "That command doesn't seem to exist."
msgstr ""

View File

@@ -0,0 +1,3 @@
import logging
log = logging.getLogger("red.downloader")

View File

@@ -0,0 +1,559 @@
import asyncio
import functools
import os
import pkgutil
import shutil
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from subprocess import run as sp_run, PIPE
from sys import executable
from typing import Tuple, MutableMapping, Union
from discord.ext import commands
from redbot.core import Config
from redbot.core import data_manager
from .errors import *
from .installable import Installable, InstallableType
from .json_mixins import RepoJSONMixin
from .log import log
class Repo(RepoJSONMixin):
GIT_CLONE = "git clone -b {branch} {url} {folder}"
GIT_CLONE_NO_BRANCH = "git clone {url} {folder}"
GIT_CURRENT_BRANCH = "git -C {path} rev-parse --abbrev-ref HEAD"
GIT_LATEST_COMMIT = "git -C {path} rev-parse {branch}"
GIT_HARD_RESET = "git -C {path} reset --hard origin/{branch} -q"
GIT_PULL = "git -C {path} pull -q --ff-only"
GIT_DIFF_FILE_STATUS = ("git -C {path} diff --no-commit-id --name-status"
" {old_hash} {new_hash}")
GIT_LOG = ("git -C {path} log --relative-date --reverse {old_hash}.."
" {relative_file_path}")
PIP_INSTALL = "{python} -m pip install -U -t {target_dir} {reqs}"
def __init__(self, name: str, url: str, branch: str, folder_path: Path,
available_modules: Tuple[Installable]=(), loop: asyncio.AbstractEventLoop=None):
self.url = url
self.branch = branch
self.name = name
self.folder_path = folder_path
self.folder_path.mkdir(parents=True, exist_ok=True)
super().__init__(self.folder_path)
self.available_modules = available_modules
self._executor = ThreadPoolExecutor(1)
self._repo_lock = asyncio.Lock()
self._loop = loop
if self._loop is None:
self._loop = asyncio.get_event_loop()
@classmethod
async def convert(cls, ctx: commands.Context, argument: str):
downloader_cog = ctx.bot.get_cog("Downloader")
if downloader_cog is None:
raise commands.CommandError("No Downloader cog found.")
# noinspection PyProtectedMember
repo_manager = downloader_cog._repo_manager
poss_repo = repo_manager.get_repo(argument)
if poss_repo is None:
raise commands.BadArgument("Repo by the name {} does not exist.".format(argument))
return poss_repo
def _existing_git_repo(self) -> (bool, Path):
git_path = self.folder_path / '.git'
return git_path.exists(), git_path
async def _get_file_update_statuses(
self, old_hash: str, new_hash: str) -> MutableMapping[str, str]:
"""
Gets the file update status letters for each changed file between
the two hashes.
:param old_hash: Pre-update
:param new_hash: Post-update
:return: Mapping of filename -> status_letter
"""
p = await self._run(
self.GIT_DIFF_FILE_STATUS.format(
path=self.folder_path,
old_hash=old_hash,
new_hash=new_hash
)
)
if p.returncode != 0:
raise GitDiffError("Git diff failed for repo at path:"
" {}".format(self.folder_path))
stdout = p.stdout.strip().decode().split('\n')
ret = {}
for filename in stdout:
# TODO: filter these filenames by ones in self.available_modules
status, _, filepath = filename.partition('\t')
ret[filepath] = status
return ret
async def _get_commit_notes(self, old_commit_hash: str,
relative_file_path: str) -> str:
"""
Gets the commit notes from git log.
:param old_commit_hash: Point in time to start getting messages
:param relative_file_path: Path relative to the repo folder of the file
to get messages for.
:return: Git commit note log
"""
p = await self._run(
self.GIT_LOG.format(
path=self.folder_path,
old_hash=old_commit_hash,
relative_file_path=relative_file_path
)
)
if p.returncode != 0:
raise GitException("An exception occurred while executing git log on"
" this repo: {}".format(self.folder_path))
return p.stdout.decode().strip()
def _update_available_modules(self) -> Tuple[str]:
"""
Updates the available modules attribute for this repo.
:return: List of available modules.
"""
curr_modules = []
"""
for name in self.folder_path.iterdir():
if name.is_dir():
spec = importlib.util.spec_from_file_location(
name.stem, location=str(name.parent)
)
if spec is not None:
curr_modules.append(
Installable(location=name)
)
"""
for file_finder, name, is_pkg in pkgutil.walk_packages(path=[str(self.folder_path), ]):
curr_modules.append(
Installable(location=self.folder_path / name)
)
self.available_modules = curr_modules
# noinspection PyTypeChecker
return tuple(self.available_modules)
async def _run(self, *args, **kwargs):
env = os.environ.copy()
env['GIT_TERMINAL_PROMPT'] = '0'
kwargs['env'] = env
async with self._repo_lock:
return await self._loop.run_in_executor(
self._executor,
functools.partial(sp_run, *args, stdout=PIPE, **kwargs)
)
async def clone(self) -> Tuple[str]:
"""
Clones a new repo.
:return: List of available module names from this repo.
"""
exists, path = self._existing_git_repo()
if exists:
raise ExistingGitRepo(
"A git repo already exists at path: {}".format(path)
)
if self.branch is not None:
p = await self._run(
self.GIT_CLONE.format(
branch=self.branch,
url=self.url,
folder=self.folder_path
).split()
)
else:
p = await self._run(
self.GIT_CLONE_NO_BRANCH.format(
url=self.url,
folder=self.folder_path
).split()
)
if p.returncode != 0:
raise CloningError("Error when running git clone.")
if self.branch is None:
self.branch = await self.current_branch()
self._read_info_file()
return self._update_available_modules()
async def current_branch(self) -> str:
"""
Determines the current branch using git commands.
:return: Current branch name
"""
exists, _ = self._existing_git_repo()
if not exists:
raise MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run(
self.GIT_CURRENT_BRANCH.format(
path=self.folder_path
).split()
)
if p.returncode != 0:
raise GitException("Could not determine current branch"
" at path: {}".format(self.folder_path))
return p.stdout.decode().strip()
async def current_commit(self, branch: str=None) -> str:
"""
Determines the current commit hash of the repo.
:param branch: Override for repo's branch attribute
:return: Commit hash string
"""
if branch is None:
branch = self.branch
exists, _ = self._existing_git_repo()
if not exists:
raise MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run(
self.GIT_LATEST_COMMIT.format(
path=self.folder_path,
branch=branch
).split()
)
if p.returncode != 0:
raise CurrentHashError("Unable to determine old commit hash.")
return p.stdout.decode().strip()
async def hard_reset(self, branch: str=None) -> None:
"""
Performs a hard reset on the current repo.
:param branch: Override for repo branch attribute.
"""
if branch is None:
branch = self.branch
exists, _ = self._existing_git_repo()
if not exists:
raise MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run(
self.GIT_HARD_RESET.format(
path=self.folder_path,
branch=branch
).split()
)
if p.returncode != 0:
raise HardResetError("Some error occurred when trying to"
" execute a hard reset on the repo at"
" the following path: {}".format(self.folder_path))
async def update(self) -> (str, str):
"""
Updates the current branch of this repo.
:return: tuple of (old commit hash, new commit hash)
:rtype: tuple
"""
curr_branch = await self.current_branch()
old_commit = await self.current_commit(branch=curr_branch)
await self.hard_reset(branch=curr_branch)
p = await self._run(
self.GIT_PULL.format(
path=self.folder_path
).split()
)
if p.returncode != 0:
raise UpdateError("Git pull returned a non zero exit code"
" for the repo located at path: {}".format(self.folder_path))
new_commit = await self.current_commit(branch=curr_branch)
self._update_available_modules()
self._read_info_file()
return old_commit, new_commit
async def install_cog(self, cog: Installable, target_dir: Path) -> bool:
"""
Copies a cog to the target directory.
:param Installable cog: Cog to install.
:param pathlib.Path target_dir: Directory to install the cog in.
:return: Installation success status.
:rtype: bool
"""
if cog not in self.available_cogs:
raise DownloaderException("That cog does not exist in this repo")
if not target_dir.is_dir():
raise ValueError("That target directory is not actually a directory.")
if not target_dir.exists():
raise ValueError("That target directory does not exist.")
return await cog.copy_to(target_dir=target_dir)
async def install_libraries(self, target_dir: Path, libraries: Tuple[Installable]=()) -> bool:
"""
Copies all shared libraries (or a given subset) to the target
directory.
:param pathlib.Path target_dir: Directory to install shared libraries to.
:param tuple(Installable) libraries: A subset of available libraries.
:return: Status of all installs.
:rtype: bool
"""
if libraries:
if not all([i in self.available_libraries for i in libraries]):
raise ValueError("Some given libraries are not available in this repo.")
else:
libraries = self.available_libraries
if libraries:
return all([lib.copy_to(target_dir=target_dir) for lib in libraries])
return True
async def install_requirements(self, cog: Installable, target_dir: Path) -> bool:
"""
Installs the requirements defined by the requirements
attribute on the cog object and puts them in the given
target directory.
:param Installable cog: Cog for which to install requirements.
:param pathlib.Path target_dir: Path to which to install requirements.
:return: Status of requirements install.
:rtype: bool
"""
if not target_dir.is_dir():
raise ValueError("Target directory is not a directory.")
target_dir.mkdir(parents=True, exist_ok=True)
return await self.install_raw_requirements(cog.requirements, target_dir)
async def install_raw_requirements(self, requirements: Tuple[str], target_dir: Path) -> bool:
"""
Installs a list of requirements using pip and places them into
the given target directory.
:param tuple(str) requirements: List of requirement names to install via pip.
:param pathlib.Path target_dir: Directory to install requirements to.
:return: Status of all requirements install.
:rtype: bool
"""
if len(requirements) == 0:
return True
# TODO: Check and see if any of these modules are already available
p = await self._run(
self.PIP_INSTALL.format(
python=executable,
target_dir=target_dir,
reqs=" ".join(requirements)
).split()
)
if p.returncode != 0:
log.error("Something went wrong when installing"
" the following requirements:"
" {}".format(", ".join(requirements)))
return False
return True
@property
def available_cogs(self) -> Tuple[Installable]:
"""
Returns a list of available cogs (not shared libraries and not hidden).
:rtype: tuple(Installable)
"""
# noinspection PyTypeChecker
return tuple(
[m for m in self.available_modules
if m.type == InstallableType.COG and not m.hidden]
)
@property
def available_libraries(self) -> Tuple[Installable]:
"""
Returns a list of available shared libraries in this repo.
:rtype: tuple(Installable)
"""
# noinspection PyTypeChecker
return tuple(
[m for m in self.available_modules
if m.type == InstallableType.SHARED_LIBRARY]
)
def to_json(self):
return {
"url": self.url,
"name": self.name,
"branch": self.branch,
"folder_path": self.folder_path.relative_to(Path.cwd()).parts,
"available_modules": [m.to_json() for m in self.available_modules]
}
@classmethod
def from_json(cls, data):
# noinspection PyTypeChecker
return Repo(data['name'], data['url'], data['branch'],
Path.cwd() / Path(*data['folder_path']),
tuple([Installable.from_json(m) for m in data['available_modules']]))
class RepoManager:
def __init__(self, downloader_config: Config):
self.downloader_config = downloader_config
self._repos = {}
loop = asyncio.get_event_loop()
loop.create_task(self._load_repos(set=True)) # str_name: Repo
@property
def repos_folder(self) -> Path:
data_folder = data_manager.cog_data_path(self)
return data_folder / 'repos'
def does_repo_exist(self, name: str) -> bool:
return name in self._repos
@staticmethod
def validate_and_normalize_repo_name(name: str) -> str:
if not name.isidentifier():
raise InvalidRepoName("Not a valid Python variable name.")
return name.lower()
async def add_repo(self, url: str, name: str, branch: str="master") -> Repo:
"""
Adds a repo and clones it.
:param url: URL of git repo to clone.
:param name: Internal name of repo.
:param branch: Branch to clone.
:return: New repo object representing cloned repo.
:rtype: Repo
"""
name = self.validate_and_normalize_repo_name(name)
if self.does_repo_exist(name):
raise InvalidRepoName(
"That repo name you provided already exists."
" Please choose another."
)
# noinspection PyTypeChecker
r = Repo(url=url, name=name, branch=branch,
folder_path=self.repos_folder / name)
await r.clone()
self._repos[name] = r
await self._save_repos()
return r
def get_repo(self, name: str) -> Union[Repo, None]:
"""
Returns a repo object with the given name.
:param name: Repo name
:return: Repo object or ``None`` if repo does not exist.
:rtype: Union[Repo, None]
"""
return self._repos.get(name, None)
def get_all_repo_names(self) -> Tuple[str]:
"""
Returns a tuple of all repo names
:rtype: tuple(str)
"""
# noinspection PyTypeChecker
return tuple(self._repos.keys())
async def delete_repo(self, name: str):
"""
Deletes a repo and its folders with the given name.
:param name: Name of the repo to delete.
:raises MissingGitRepo: If the repo does not exist.
"""
repo = self.get_repo(name)
if repo is None:
raise MissingGitRepo("There is no repo with the name {}".format(name))
shutil.rmtree(str(repo.folder_path))
try:
del self._repos[name]
except KeyError:
pass
await self._save_repos()
async def update_all_repos(self) -> MutableMapping[Repo, Tuple[str, str]]:
"""
Calls :py:meth:`Repo.update` on all repos.
:return:
A mapping of :py:class:`Repo` objects that received new commits to a tuple containing old and
new commit hashes.
"""
ret = {}
for _, repo in self._repos.items():
old, new = await repo.update()
if old != new:
ret[repo] = (old, new)
await self._save_repos()
return ret
async def _load_repos(self, set=False) -> MutableMapping[str, Repo]:
ret = {
name: Repo.from_json(data) for name, data in
(await self.downloader_config.repos()).items()
}
if set:
self._repos = ret
return ret
async def _save_repos(self):
repo_json_info = {name: r.to_json() for name, r in self._repos.items()}
await self.downloader_config.repos.set(repo_json_info)

View File

@@ -0,0 +1,2 @@
*
!.gitignore