Handle plugin dependencies

This commit is contained in:
Rikko 2022-08-27 17:29:21 +05:30
parent 0686ce921b
commit e347bf0498
2 changed files with 184 additions and 47 deletions

View file

@ -84,6 +84,12 @@ def play_sound():
ba.playsound(ba.getsound('swish')) ba.playsound(ba.getsound('swish'))
def plugin_category_url_from_repository(repository):
plugin_category_url = partial_format(_CACHE["index"]["external_source_url"],
repository=repository)
return plugin_category_url
def partial_format(string_template, **kwargs): def partial_format(string_template, **kwargs):
for key, value in kwargs.items(): for key, value in kwargs.items():
string_template = string_template.replace("{" + key + "}", value) string_template = string_template.replace("{" + key + "}", value)
@ -140,11 +146,13 @@ class StartupTasks:
if not ba.app.config["Community Plugin Manager"]["Settings"]["Auto Update Plugins"]: if not ba.app.config["Community Plugin Manager"]["Settings"]["Auto Update Plugins"]:
return return
await self.plugin_manager.setup_index() await self.plugin_manager.setup_index()
all_plugins = await self.plugin_manager.categories["All"].get_plugins() all_plugins = (await self.plugin_manager.categories["All"].get_plugins()).values()
plugins_to_update = [] plugins_to_update = []
for plugin in all_plugins: for plugin in all_plugins:
if plugin.is_installed and await plugin.get_local().is_enabled() and plugin.has_update(): if plugin.is_installed:
plugins_to_update.append(plugin.update()) local_plugin = await plugin.get_local()
if await local_plugin.is_enabled() and await plugin.has_update():
plugins_to_update.append(plugin.update())
await asyncio.gather(*plugins_to_update) await asyncio.gather(*plugins_to_update)
async def execute(self): async def execute(self):
@ -206,14 +214,14 @@ class Category:
async def get_plugins(self): async def get_plugins(self):
if self._plugins is None: if self._plugins is None:
await self.fetch_metadata() await self.fetch_metadata()
self._plugins = ([ self._plugins = {
Plugin( plugin_info[0]: Plugin(
plugin_info, plugin_info,
f"{await self.get_plugins_base_url()}/{plugin_info[0]}.py", f"{await self.get_plugins_base_url()}/{plugin_info[0]}.py",
is_3rd_party=self.is_3rd_party, is_3rd_party=self.is_3rd_party,
) )
for plugin_info in self._metadata["plugins"].items() for plugin_info in self._metadata["plugins"].items()
]) }
self.set_category_global_cache("plugins", self._plugins) self.set_category_global_cache("plugins", self._plugins)
return self._plugins return self._plugins
@ -267,6 +275,8 @@ class PluginLocal:
self._api_version = None self._api_version = None
self._entry_points = [] self._entry_points = []
self._has_minigames = None self._has_minigames = None
self._resolved_dependants = None
self.dependencies = []
@property @property
def is_installed(self): def is_installed(self):
@ -281,7 +291,25 @@ class PluginLocal:
ba.app.config["Community Plugin Manager"]["Installed Plugins"][self.name] = {} ba.app.config["Community Plugin Manager"]["Installed Plugins"][self.name] = {}
return self return self
async def resolve_dependants(self):
if self._resolved_dependants is None:
self._resolved_dependants = {}
for plugin_category_url in _CACHE["index"]["categories"]:
category = Category(plugin_category_url)
plugins = await category.get_plugins()
for plugin_name, plugin in plugins.items():
if plugin.is_installed:
local_plugin = await plugin.get_local()
installed_plugin_version = plugin.versions[local_plugin.version]
if self.name in installed_plugin_version.dependencies:
self._resolved_dependants[plugin_name] = plugin
return self._resolved_dependants
async def uninstall(self): async def uninstall(self):
dependants = await self.resolve_dependants()
dependants_to_uninstall = tuple(dependant.uninstall() for dependant in dependants.values())
await asyncio.gather(*dependants_to_uninstall)
if await self.has_minigames(): if await self.has_minigames():
self.unload_minigames() self.unload_minigames()
try: try:
@ -404,6 +432,16 @@ class PluginLocal:
# ba.app.config["Plugins"][entry_point]["enabled"] = to_enable # ba.app.config["Plugins"][entry_point]["enabled"] = to_enable
async def enable(self): async def enable(self):
local_dependencies = await asyncio.gather(
*tuple(dependency.get_local() for dependency in self.dependencies.values()),
return_exceptions=True,
)
dependencies_to_enable = []
for dependency in local_dependencies:
if isinstance(dependency, ValueError):
raise ValueError(f"plugin needs reinstallation due to missing dependencies")
dependencies_to_enable.append(dependency.enable())
await asyncio.gather(*dependencies_to_enable)
for entry_point in await self.get_entry_points(): for entry_point in await self.get_entry_points():
if entry_point not in ba.app.config["Plugins"]: if entry_point not in ba.app.config["Plugins"]:
ba.app.config["Plugins"][entry_point] = {} ba.app.config["Plugins"][entry_point] = {}
@ -422,7 +460,15 @@ class PluginLocal:
loaded_plugin_class.on_app_running() loaded_plugin_class.on_app_running()
ba.app.plugins.active_plugins[entry_point] = loaded_plugin_class ba.app.plugins.active_plugins[entry_point] = loaded_plugin_class
def disable(self): async def disable(self):
dependants = await self.resolve_dependants()
local_dependants = await asyncio.gather(
*tuple(dependant.get_local() for dependant in dependants.values())
)
await asyncio.gather(
*tuple(dependant.disable() for dependant in local_dependants)
)
for entry_point, plugin_info in ba.app.config["Plugins"].items(): for entry_point, plugin_info in ba.app.config["Plugins"].items():
if entry_point.startswith(self._entry_point_initials): if entry_point.startswith(self._entry_point_initials):
# if plugin_info["enabled"]: # if plugin_info["enabled"]:
@ -453,6 +499,10 @@ class PluginLocal:
self._content = content self._content = content
return self return self
def set_dependencies(self, dependencies):
self.dependencies = dependencies
return self
async def set_content_from_network_response(self, request, md5sum=None): async def set_content_from_network_response(self, request, md5sum=None):
if not self._content: if not self._content:
self._content = await async_stream_network_response_to_file( self._content = await async_stream_network_response_to_file(
@ -469,12 +519,13 @@ class PluginLocal:
class PluginVersion: class PluginVersion:
def __init__(self, plugin, version, tag=None): def __init__(self, plugin, version, tag=None):
self.number, info = version self.number, self.info = version
self.plugin = plugin self.plugin = plugin
self.api_version = info["api_version"] self.api_version = self.info["api_version"]
self.commit_sha = info["commit_sha"] self.commit_sha = self.info["commit_sha"]
self.dependencies = info["dependencies"] self.md5sum = self.info["md5sum"]
self.md5sum = info["md5sum"] self._resolved_dependencies = None
self.dependencies = self.info["dependencies"]
if tag is None: if tag is None:
tag = self.commit_sha tag = self.commit_sha
@ -489,19 +540,49 @@ class PluginVersion:
def __repr__(self): def __repr__(self):
return f"<PluginVersion({self.plugin.name} {self.number})>" return f"<PluginVersion({self.plugin.name} {self.number})>"
async def _download(self, retries=3): async def resolve_dependencies(self):
if self._resolved_dependencies is None:
self._resolved_dependencies = {}
for dependency in self.info["dependencies"]:
for plugin_category_url in _CACHE["index"]["categories"]:
category = Category(plugin_category_url)
plugins = await category.get_plugins()
plugin = plugins.get(dependency)
if plugin:
break
self._resolved_dependencies[dependency] = plugin
return self._resolved_dependencies
async def _download(self, dependencies=[], retries=3):
local_plugin = self.plugin.create_local() local_plugin = self.plugin.create_local()
await local_plugin.set_content_from_network_response(self.download_url, md5sum=self.md5sum) await local_plugin.set_content_from_network_response(self.download_url, md5sum=self.md5sum)
local_plugin.set_version(self.number) local_plugin.set_version(self.number)
local_plugin.save() local_plugin.save()
local_plugin.set_dependencies(await self.resolve_dependencies())
return local_plugin return local_plugin
async def install(self): async def install(self):
dependencies_to_install = []
for dependency in (await self.resolve_dependencies()).values():
try:
local_plugin = await dependency.get_local()
except ValueError:
# Dependency isn't installed.
dependencies_to_install.append(dependency.latest_compatible_version.install())
else:
if local_plugin.version != dependency.latest_compatible_version.number:
# This dependency is already installed but out-of-date. Use this chance
# to update it.
dependencies_to_install.append(depenedency.latest_compatible_version.update())
await asyncio.gather(*dependencies_to_install)
local_plugin = await self._download() local_plugin = await self._download()
ba.screenmessage(f"{self.plugin.name} installed", color=(0, 1, 0)) ba.screenmessage(f"{self.plugin.name} installed", color=(0, 1, 0))
check = ba.app.config["Community Plugin Manager"]["Settings"] settings = ba.app.config["Community Plugin Manager"]["Settings"]
if check["Auto Enable Plugins After Installation"]: if settings["Auto Enable Plugins After Installation"]:
await local_plugin.enable() await local_plugin.enable()
for dependency in (await self.resolve_dependencies()).values():
local_plugin = await dependency.get_local()
await local_plugin.enable()
class Plugin: class Plugin:
@ -536,12 +617,12 @@ class Plugin:
@property @property
def versions(self): def versions(self):
if self._versions is None: if self._versions is None:
self._versions = [ self._versions = {
PluginVersion( version[0]: PluginVersion(
self, self,
version, version,
) for version in self.info["versions"].items() ) for version in self.info["versions"].items()
] }
return self._versions return self._versions
@property @property
@ -567,11 +648,14 @@ class Plugin:
break break
return self._latest_compatible_version return self._latest_compatible_version
def get_local(self): async def get_local(self):
if not self.is_installed: if not self.is_installed:
raise ValueError(f"{self.name} is not installed") raise ValueError(f"{self.name} is not installed")
if self._local_plugin is None: if self._local_plugin is None:
self._local_plugin = PluginLocal(self.name) local_plugin = PluginLocal(self.name)
dependencies = await self.versions[local_plugin.version].resolve_dependencies()
local_plugin.set_dependencies(dependencies)
self._local_plugin = local_plugin
return self._local_plugin return self._local_plugin
def create_local(self): def create_local(self):
@ -581,11 +665,13 @@ class Plugin:
) )
async def uninstall(self): async def uninstall(self):
await self.get_local().uninstall() local_plugin = await self.get_local()
await local_plugin.uninstall()
ba.screenmessage(f"{self.name} uninstalled", color=(0, 1, 0)) ba.screenmessage(f"{self.name} uninstalled", color=(0, 1, 0))
def has_update(self): async def has_update(self):
return self.get_local().version != self.latest_compatible_version.number local_plugin = await self.get_local()
return local_plugin.version != self.latest_compatible_version.number
async def update(self): async def update(self):
await self.latest_compatible_version.install() await self.latest_compatible_version.install()
@ -593,13 +679,32 @@ class Plugin:
color=(0, 1, 0)) color=(0, 1, 0))
class PluginDependenciesWindow(popup.PopupMenuWindow):
def __init__(self, choices, origin_widget):
self.scale_origin = origin_widget.get_screen_space_center()
super().__init__(
position=(200, 0),
scale=(2.3 if _uiscale is ba.UIScale.SMALL else
1.65 if _uiscale is ba.UIScale.MEDIUM else 1.23),
choices=choices,
current_choice=None,
delegate=self)
def popup_menu_selected_choice(self, window, choice):
pass
def popup_menu_closing(self, window):
pass
class PluginWindow(popup.PopupWindow): class PluginWindow(popup.PopupWindow):
def __init__(self, plugin, origin_widget, button_callback=lambda: None): def __init__(self, plugin, origin_widget, refresh_plugin_ui_field_async_cb=lambda: None):
self.plugin = plugin self.plugin = plugin
self.button_callback = button_callback self.refresh_plugin_ui_field_async_cb = refresh_plugin_ui_field_async_cb
self.scale_origin = origin_widget.get_screen_space_center() self.scale_origin = origin_widget.get_screen_space_center()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.create_task(self.draw_ui()) loop.create_task(self.draw_ui())
self.plugins_ui_to_refresh = [plugin]
async def draw_ui(self): async def draw_ui(self):
# print(ba.app.plugins.active_plugins) # print(ba.app.plugins.active_plugins)
@ -662,7 +767,7 @@ class PluginWindow(popup.PopupWindow):
to_draw_button1 = True to_draw_button1 = True
to_draw_button4 = False to_draw_button4 = False
if self.plugin.is_installed: if self.plugin.is_installed:
self.local_plugin = self.plugin.get_local() self.local_plugin = await self.plugin.get_local()
if await self.local_plugin.has_minigames(): if await self.local_plugin.has_minigames():
to_draw_button1 = False to_draw_button1 = False
else: else:
@ -676,7 +781,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 = self.plugin.has_update() has_update = await self.plugin.has_update()
if has_update: if has_update:
button3_label = "Update" button3_label = "Update"
button3_action = self.update button3_action = self.update
@ -718,8 +823,24 @@ class PluginWindow(popup.PopupWindow):
button_type='square', button_type='square',
text_scale=1, text_scale=1,
label=button3_label) label=button3_label)
ba.containerwidget(edit=self._root_widget,
on_cancel_call=self._ok) plugin_dependencies = self.plugin.latest_compatible_version.dependencies
if plugin_dependencies:
dependencies_text = "dependency" if len(plugin_dependencies) == 1 else "dependencies"
dependencies_pos_x = (300 if _uiscale is ba.UIScale.SMALL else
360 if _uiscale is ba.UIScale.MEDIUM else 190)
dependencies_pos_y = (100 if _uiscale is ba.UIScale.SMALL else
110 if _uiscale is ba.UIScale.MEDIUM else 125)
dependencies_button = ba.buttonwidget(parent=self._root_widget,
autoselect=True,
position=(dependencies_pos_x-7.5, dependencies_pos_y-15),
size=(95, 30),
button_type="square",
label=f"{len(plugin_dependencies)} {dependencies_text}",
on_activate_call=lambda: PluginDependenciesWindow(
plugin_dependencies,
self._root_widget,
))
open_pos_x = (300 if _uiscale is ba.UIScale.SMALL else open_pos_x = (300 if _uiscale is ba.UIScale.SMALL else
360 if _uiscale is ba.UIScale.MEDIUM else 350) 360 if _uiscale is ba.UIScale.MEDIUM else 350)
@ -763,6 +884,8 @@ class PluginWindow(popup.PopupWindow):
texture=ba.gettexture("settingsIcon"), texture=ba.gettexture("settingsIcon"),
draw_controller=settings_button) draw_controller=settings_button)
ba.containerwidget(edit=self._root_widget,
on_cancel_call=self._ok)
# ba.containerwidget(edit=self._root_widget, selected_child=button3) # ba.containerwidget(edit=self._root_widget, selected_child=button3)
# ba.containerwidget(edit=self._root_widget, start_button=button3) # ba.containerwidget(edit=self._root_widget, start_button=button3)
@ -773,16 +896,16 @@ class PluginWindow(popup.PopupWindow):
def button(fn): def button(fn):
async def asyncio_handler(fn, self, *args, **kwargs): async def asyncio_handler(fn, self, *args, **kwargs):
await fn(self, *args, **kwargs) await fn(self, *args, **kwargs)
await self.button_callback() to_refresh = tuple(
self.refresh_plugin_ui_field_async_cb(plugin)
for plugin in self.plugins_ui_to_refresh
)
await asyncio.gather(*to_refresh)
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
self._ok() self._ok()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
if asyncio.iscoroutinefunction(fn): loop.create_task(asyncio_handler(fn, self, *args, **kwargs))
loop.create_task(asyncio_handler(fn, self, *args, **kwargs))
else:
fn(self, *args, **kwargs)
loop.create_task(self.button_callback())
return wrapper return wrapper
@ -790,20 +913,33 @@ class PluginWindow(popup.PopupWindow):
self.local_plugin.launch_settings() self.local_plugin.launch_settings()
@button @button
def disable(self) -> None: async def disable(self) -> None:
self.local_plugin.disable() self.plugins_ui_to_refresh.extend(await self.local_plugin.resolve_dependants())
await self.local_plugin.disable()
@button @button
async def enable(self) -> None: async def enable(self) -> None:
await self.local_plugin.enable() try:
self.plugins_ui_to_refresh.extend(await self.plugin.resolve_dependencies())
await self.local_plugin.enable()
except ValueError as e:
# A dependant plugin is not installed
ba.screenmessage(str(e), color=(1,0,0))
@button @button
async def install(self): async def install(self):
self.plugins_ui_to_refresh.extend(
await self.plugin.latest_compatible_version.resolve_dependencies()
)
await self.plugin.latest_compatible_version.install() await self.plugin.latest_compatible_version.install()
@button @button
async def uninstall(self): async def uninstall(self):
await self.plugin.uninstall() try:
self.plugins_ui_to_refresh.extend(await self.local_plugin.resolve_dependants())
await self.plugin.uninstall()
except ValueError as e:
ba.screenmessage(str(e), color=(1,0,0))
@button @button
async def update(self): async def update(self):
@ -846,17 +982,16 @@ class PluginManager:
request = category.fetch_metadata() request = category.fetch_metadata()
requests.append(request) requests.append(request)
for repository in ba.app.config["Community Plugin Manager"]["Custom Sources"]: for repository in ba.app.config["Community Plugin Manager"]["Custom Sources"]:
plugin_category_url = partial_format(plugin_index["external_source_url"], plugin_category_url = plugin_category_url_from_repository(repository)
repository=repository)
category = Category(plugin_category_url, is_3rd_party=True) category = Category(plugin_category_url, is_3rd_party=True)
request = category.fetch_metadata() request = category.fetch_metadata()
requests.append(request) requests.append(request)
categories = await asyncio.gather(*requests) categories = await asyncio.gather(*requests)
all_plugins = [] all_plugins = {}
for category in categories: for category in categories:
self.categories[await category.get_name()] = category self.categories[await category.get_name()] = category
all_plugins.extend(await category.get_plugins()) all_plugins.update(await category.get_plugins())
self.categories["All"] = CategoryAll(plugins=all_plugins) self.categories["All"] = CategoryAll(plugins=all_plugins)
def cleanup(self): def cleanup(self):
@ -1407,7 +1542,7 @@ class PluginManagerWindow(ba.Window):
if not to_draw_plugin_names: if not to_draw_plugin_names:
return return
category_plugins = await self.plugin_manager.categories[category].get_plugins() category_plugins = (await self.plugin_manager.categories[category].get_plugins()).values()
if search_filter: if search_filter:
plugins = [] plugins = []
@ -1432,7 +1567,7 @@ class PluginManagerWindow(ba.Window):
async def draw_plugin_name(self, plugin): async def draw_plugin_name(self, plugin):
if plugin.is_installed: if plugin.is_installed:
local_plugin = plugin.get_local() local_plugin = await plugin.get_local()
if await local_plugin.is_enabled(): if await 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)
@ -1467,7 +1602,7 @@ class PluginManagerWindow(ba.Window):
# text_widget.add_delete_callback(lambda: self.plugins_in_current_view.pop(plugin.name)) # text_widget.add_delete_callback(lambda: self.plugins_in_current_view.pop(plugin.name))
def show_plugin_window(self, plugin): def show_plugin_window(self, plugin):
PluginWindow(plugin, self._root_widget, lambda: self.draw_plugin_name(plugin)) PluginWindow(plugin, self._root_widget, lambda plugin: self.draw_plugin_name(plugin))
def show_categories_window(self): def show_categories_window(self):
play_sound() play_sound()

View file

@ -68,7 +68,9 @@
"0.0.1": { "0.0.1": {
"api_version": 7, "api_version": 7,
"commit_sha": "776fc174", "commit_sha": "776fc174",
"dependencies": ["colorscheme"], "dependencies": [
"colorscheme"
],
"released_on": "26-08-2022", "released_on": "26-08-2022",
"md5sum": "4243b4208fc10fa0de17241e6f51188a" "md5sum": "4243b4208fc10fa0de17241e6f51188a"
} }