Handle multiple plugin entry points

This commit is contained in:
Rikko 2022-08-08 03:36:49 +05:30
parent a4e9255305
commit 1f246e3bae
2 changed files with 167 additions and 57 deletions

View file

@ -8,6 +8,7 @@ import urllib.request
import json import json
import os import os
import asyncio import asyncio
import re
from typing import Union, Optional from typing import Union, Optional
@ -19,7 +20,11 @@ HEADERS = {
"User-Agent": _env["user_agent_string"], "User-Agent": _env["user_agent_string"],
} }
PLUGIN_DIRECTORY = _env["python_directory_user"] PLUGIN_DIRECTORY = _env["python_directory_user"]
PLUGIN_ENTRYPOINT = "Main" REGEXP = {
"plugin_api_version": re.compile(b"(?<=ba_meta require api )(.*)"),
# "plugin_entry_points": re.compile(b"(ba_meta export plugin\n+class )(.*)\("),
"plugin_entry_points": re.compile(b"(ba_meta export .+\n+class )(.*)\("),
}
VERSION = "0.1.1" VERSION = "0.1.1"
GITHUB_REPO_LINK = "https://github.com/bombsquad-community/plugin-manager/" GITHUB_REPO_LINK = "https://github.com/bombsquad-community/plugin-manager/"
@ -43,12 +48,36 @@ def setup_config():
ba.app.config.commit() ba.app.config.commit()
async def send_network_request(request): def send_network_request(request):
return urllib.request.urlopen(request)
async def async_send_network_request(request):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, urllib.request.urlopen, request) response = await loop.run_in_executor(None, send_network_request, request)
return response return response
def stream_network_response_to_file(request, file):
response = urllib.request.urlopen(request)
chunk_size = 16 * 1024
content = b""
with open(file, "wb") as fout:
while True:
chunk = response.read(chunk_size)
if not chunk:
break
fout.write(chunk)
content += chunk
return content
async def async_stream_network_response_to_file(request, file):
loop = asyncio.get_event_loop()
content = await loop.run_in_executor(None, stream_network_response_to_file, request, file)
return content
def play_sound(): def play_sound():
ba.playsound(ba.getsound('swish')) ba.playsound(ba.getsound('swish'))
@ -58,15 +87,16 @@ class Category:
self.name = name self.name = name
self.base_download_url = base_download_url self.base_download_url = base_download_url
self.meta_url = meta_url self.meta_url = meta_url
self.request_headers = HEADERS
self._plugins = _CACHE.get("categories", {}).get(self.name) self._plugins = _CACHE.get("categories", {}).get(self.name)
async def get_plugins(self): async def get_plugins(self):
if self._plugins is None: if self._plugins is None:
request = urllib.request.Request( request = urllib.request.Request(
self.meta_url, self.meta_url,
headers=HEADERS headers=self.request_headers,
) )
response = await send_network_request(request) response = await async_send_network_request(request)
plugins_info = json.loads(response.read()) plugins_info = json.loads(response.read())
self._plugins = ([Plugin(plugin_info, self.base_download_url) self._plugins = ([Plugin(plugin_info, self.base_download_url)
for plugin_info in plugins_info.items()]) for plugin_info in plugins_info.items()])
@ -106,6 +136,9 @@ class PluginLocal:
""" """
self.name = name self.name = name
self.install_path = os.path.join(PLUGIN_DIRECTORY, f"{name}.py") self.install_path = os.path.join(PLUGIN_DIRECTORY, f"{name}.py")
self._content = None
self._api_version = None
self._entry_points = []
@property @property
def is_installed(self): def is_installed(self):
@ -141,9 +174,99 @@ class PluginLocal:
version = None version = None
return version return version
def _get_content(self):
with open(self.install_path, "rb") as fin:
return fin.read()
def _set_content(self, content):
with open(self.install_path, "wb") as fout:
fout.write(content)
async def get_content(self):
if self._content is None:
if not self.is_installed:
# TODO: Raise a more fitting exception.
raise TypeError("Plugin is not available locally.")
loop = asyncio.get_event_loop()
self._content = await loop.run_in_executor(None, self._get_content)
return self._content
async def get_api_version(self):
if self._api_version is None:
content = await self.get_content()
self._api_version = REGEXP["plugin_api_version"].search(content).group()
return self._api_version
async def get_entry_points(self):
if not self._entry_points:
content = await self.get_content()
groups = REGEXP["plugin_entry_points"].findall(content)
# Actual entry points are stored in the first index inside the matching groups.
entry_points = tuple(f"{self.name}.{group[1].decode('utf-8')}" for group in groups)
self._entry_points = entry_points
return self._entry_points
@property
def is_enabled(self):
"""
Return True even if a single entry point is enabled.
"""
entry_point_initials = f"{self.name}."
for entry_point, plugin_info in ba.app.config["Plugins"].items():
if entry_point.startswith(entry_point_initials) and plugin_info["enabled"]:
return True
# XXX: The below logic is more accurate but less efficient, since it actually
# reads the local plugin file and parses entry points from it.
# for entry_point in await self.get_entry_points():
# if ba.app.config["Plugins"][entry_point]["enabled"]:
# return True
return False
# XXX: Commenting this out for now, since `enable` and `disable` currently have their
# own separate logic.
# async def _set_status(self, to_enable=True):
# for entry_point in await self.get_entry_points:
# if entry_point not in ba.app.config["Plugins"]:
# ba.app.config["Plugins"][entry_point] = {}
# ba.app.config["Plugins"][entry_point]["enabled"] = to_enable
async def enable(self):
for entry_point in await self.get_entry_points():
if entry_point not in ba.app.config["Plugins"]:
ba.app.config["Plugins"][entry_point] = {}
ba.app.config["Plugins"][entry_point]["enabled"] = True
# await self._set_status(to_enable=True)
ba.screenmessage("Plugin Enabled")
async def disable(self):
entry_point_initials = f"{self.name}."
for entry_point, plugin_info in ba.app.config["Plugins"].items():
if entry_point.startswith(entry_point_initials):
plugin_info["enabled"] = False
# XXX: The below logic is more accurate but less efficient, since it actually
# reads the local plugin file and parses entry points from it.
# await self._set_status(to_enable=False)
ba.screenmessage("Plugin Disabled")
def set_version(self, version): def set_version(self, version):
v = version ba.app.config["Community Plugin Manager"]["Installed Plugins"][self.name]["version"] = version
ba.app.config["Community Plugin Manager"]["Installed Plugins"][self.name]["version"] = v return self
# def set_entry_points(self):
# if not "entry_points" in ba.app.config["Community Plugin Manager"]["Installed Plugins"][self.name]:
# ba.app.config["Community Plugin Manager"]["Installed Plugins"][self.name]["entry_points"] = []
# for entry_point in await self.get_entry_points():
# ba.app.config["Community Plugin Manager"]["Installed Plugins"][self.name]["entry_points"].append(entry_point)
async def set_content(self, content):
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._set_content, content)
self._content = content
return self
async def set_content_from_network_response(self, request):
content = await async_stream_network_response_to_file(request, self.install_path)
self._content = content
return self return self
def save(self): def save(self):
@ -151,6 +274,10 @@ class PluginLocal:
return self return self
class PluginVersion:
pass
class Plugin: class Plugin:
def __init__(self, plugin, base_download_url): def __init__(self, plugin, base_download_url):
""" """
@ -159,7 +286,7 @@ class Plugin:
self.name, self.info = plugin self.name, self.info = plugin
self.install_path = os.path.join(PLUGIN_DIRECTORY, f"{self.name}.py") self.install_path = os.path.join(PLUGIN_DIRECTORY, f"{self.name}.py")
self.download_url = f"{base_download_url}/{self.name}.py" self.download_url = f"{base_download_url}/{self.name}.py"
self.entry_point = f"{self.name}.{PLUGIN_ENTRYPOINT}" self._local_plugin = None
def __repr__(self): def __repr__(self):
return f"<Plugin({self.name})>" return f"<Plugin({self.name})>"
@ -168,36 +295,34 @@ class Plugin:
def is_installed(self): def is_installed(self):
return os.path.isfile(self.install_path) return os.path.isfile(self.install_path)
@property
def is_enabled(self):
try:
return ba.app.config["Plugins"][self.entry_point]["enabled"]
except KeyError:
return False
@property @property
def latest_version(self): def latest_version(self):
# TODO: Return an instance of `PluginVersion`.
return next(iter(self.info["versions"])) return next(iter(self.info["versions"]))
async def _download(self):
local_plugin = self.create_local()
await local_plugin.set_content_from_network_response(self.download_url)
local_plugin.set_version(self.latest_version)
local_plugin.save()
return local_plugin
def get_local(self): def get_local(self):
if not self.is_installed: if not self.is_installed:
raise ValueError("Plugin is not installed") raise ValueError("Plugin is not installed")
return PluginLocal(self.name) if self._local_plugin is None:
self._local_plugin = PluginLocal(self.name)
return self._local_plugin
async def _download_plugin(self): def create_local(self):
response = await send_network_request(self.download_url) return (
with open(self.install_path, "wb") as fout: PluginLocal(self.name)
fout.write(response.read())
(
self.get_local()
.initialize() .initialize()
.set_version(self.latest_version)
.save()
) )
async def install(self): async def install(self):
await self._download_plugin() local_plugin = await self._download()
await self.enable() await local_plugin.enable()
ba.screenmessage("Plugin Installed") ba.screenmessage("Plugin Installed")
async def uninstall(self): async def uninstall(self):
@ -205,22 +330,9 @@ class Plugin:
ba.screenmessage("Plugin Uninstalled") ba.screenmessage("Plugin Uninstalled")
async def update(self): async def update(self):
await self._download_plugin() await self._download()
ba.screenmessage("Plugin Updated") ba.screenmessage("Plugin Updated")
async def _set_status(self, to_enable=True):
if self.entry_point not in ba.app.config["Plugins"]:
ba.app.config["Plugins"][self.entry_point] = {}
ba.app.config["Plugins"][self.entry_point]["enabled"] = to_enable
async def enable(self):
await self._set_status(to_enable=True)
ba.screenmessage("Plugin Enabled")
async def disable(self):
await self._set_status(to_enable=False)
ba.screenmessage("Plugin Disabled")
class PluginWindow(popup.PopupWindow): class PluginWindow(popup.PopupWindow):
def __init__(self, plugin, origin_widget, button_callback=lambda: None): def __init__(self, plugin, origin_widget, button_callback=lambda: None):
@ -282,7 +394,8 @@ class PluginWindow(popup.PopupWindow):
button_size = (80 * s, 40 * s) button_size = (80 * s, 40 * s)
if plugin.is_installed: if plugin.is_installed:
if plugin.is_enabled: self.local_plugin = plugin.get_local()
if self.local_plugin.is_enabled:
button1_label = "Disable" button1_label = "Disable"
button1_action = self.disable button1_action = self.disable
else: else:
@ -290,7 +403,7 @@ class PluginWindow(popup.PopupWindow):
button1_action = self.enable button1_action = self.enable
button2_label = "Uninstall" button2_label = "Uninstall"
button2_action = self.uninstall button2_action = self.uninstall
has_update = plugin.get_local().version != plugin.latest_version has_update = self.local_plugin.version != plugin.latest_version
if has_update: if has_update:
button3_label = "Update" button3_label = "Update"
button3_action = self.update button3_action = self.update
@ -357,12 +470,12 @@ class PluginWindow(popup.PopupWindow):
@button @button
async def disable(self) -> None: async def disable(self) -> None:
play_sound() play_sound()
await self.plugin.disable() await self.local_plugin.disable()
@button @button
async def enable(self) -> None: async def enable(self) -> None:
play_sound() play_sound()
await self.plugin.enable() await self.local_plugin.enable()
@button @button
async def install(self): async def install(self):
@ -390,9 +503,9 @@ class PluginManager:
if not self._index: if not self._index:
request = urllib.request.Request( request = urllib.request.Request(
INDEX_META, INDEX_META,
headers=HEADERS headers=self.request_headers,
) )
response = await send_network_request(request) response = await async_send_network_request(request)
self._index = json.loads(response.read()) self._index = json.loads(response.read())
self.set_index_global_cache(self._index) self.set_index_global_cache(self._index)
return self._index return self._index
@ -609,7 +722,7 @@ class PluginManagerWindow(ba.Window, PluginManager):
self.setup_plugin_categories(index), self.setup_plugin_categories(index),
) )
await self.select_category("All") await self.select_category("All")
await self.draw_search_bar() # await self.draw_search_bar()
except RuntimeError: except RuntimeError:
# User probably went back before the PluginManagerWindow could finish loading. # User probably went back before the PluginManagerWindow could finish loading.
pass pass
@ -740,8 +853,8 @@ class PluginManagerWindow(ba.Window, PluginManager):
def draw_plugin_name(self, plugin): def draw_plugin_name(self, plugin):
if plugin.is_installed: if plugin.is_installed:
if plugin.is_enabled:
local_plugin = plugin.get_local() local_plugin = plugin.get_local()
if local_plugin.is_enabled:
if not local_plugin.is_installed_via_plugin_manager: if not local_plugin.is_installed_via_plugin_manager:
color = (0.8, 0.2, 0.2) color = (0.8, 0.2, 0.2)
elif local_plugin.version == plugin.latest_version: elif local_plugin.version == plugin.latest_version:
@ -879,6 +992,7 @@ class PluginManagerSettingsWindow(popup.PopupWindow):
label='Open Github Repo') label='Open Github Repo')
ba.containerwidget(edit=self._root_widget, ba.containerwidget(edit=self._root_widget,
on_cancel_call=self._disappear) on_cancel_call=self._disappear)
# _ba.app.api_version
def _disappear(self) -> None: def _disappear(self) -> None:
ba.containerwidget(edit=self._root_widget, transition='out_scale') ba.containerwidget(edit=self._root_widget, transition='out_scale')

View file

@ -16,10 +16,8 @@
], ],
"versions": { "versions": {
"1.1.0": { "1.1.0": {
"api_version": 7,
"commit_sha": "13a9d128", "commit_sha": "13a9d128",
"known_compatible_game_versions": [
"1.7.0"
],
"dependencies": [], "dependencies": [],
"released_on": "03-06-2022", "released_on": "03-06-2022",
"md5sum": "4b6bbb99037ebda4664da7c510b3717c" "md5sum": "4b6bbb99037ebda4664da7c510b3717c"
@ -38,13 +36,11 @@
], ],
"versions": { "versions": {
"1.0.0": { "1.0.0": {
"api_version": 7,
"commit_sha": "2aa6df31", "commit_sha": "2aa6df31",
"known_compatible_game_versions": [
"1.7.0"
],
"dependencies": [], "dependencies": [],
"released_on": "06-08-2022", "released_on": "06-08-2022",
"md5sum": "4b6bbb99037ebda4664da7c510b3717c" "md5sum": "233dfaa7f0e9394d21454f4ffa7d0205"
} }
} }
} }