diff --git a/addons/source-python/packages/source-python/core/command/__init__.py b/addons/source-python/packages/source-python/core/command/__init__.py index 6323abc8d..c6ff2c266 100644 --- a/addons/source-python/packages/source-python/core/command/__init__.py +++ b/addons/source-python/packages/source-python/core/command/__init__.py @@ -16,8 +16,6 @@ from core import core_logger from core.manager import core_plugin_manager from core.version import VERSION -# Cvars -from cvars import ConVar # Engines from engines.server import execute_server_command from engines.server import queue_command_string @@ -56,7 +54,7 @@ class _CoreCommandManager(SubCommandManager): def print_plugins(self): """List all currently loaded plugins. - + .. todo:: Move this to :class:`plugins.command.SubCommandManager`? """ # Get header messages @@ -69,24 +67,30 @@ def print_plugins(self): # Was an PluginInfo instance found? if info is not None: + message += plugin_name + ' ({}):\n'.format(info.verbose_name) - # Add message with the current plugin's name - message += plugin_name + ':\n' + if info.author is not None: + message += ' author: {}\n'.format(info.author) + + if info.description is not None: + message += ' description: {}\n'.format(info.description) + + if info.version != 'unversioned': + message += ' version: {}\n'.format(info.version) - # Loop through all items in the PluginInfo instance - for item, value in info.items(): + if info.url is not None: + message += ' url: {}\n'.format(info.url) - # Is the value a ConVar? - if isinstance(value, ConVar): + if info.permissions: + message += ' permissions:\n' + for permission, description in info.permissions: + message += ' {}:'.format(permission).ljust(30) + description + '\n' - # Get the ConVar's text - value = '{0}:\n\t\t\t{1}: {2}'.format( - value.name, - value.help_text, - value.get_string()) + if info.public_convar is not None: + message += ' public convar: {}\n'.format(info.public_convar.name) - # Add message for the current item and its value - message += '\t{0}:\n\t\t{1}\n'.format(item, value) + for attr in info.display_in_listing: + message += ' {}:'.format(attr).ljust(20) + str(getattr(info, attr)) + '\n' # Was no PluginInfo instance found? else: @@ -133,7 +137,7 @@ def print_credits(self): self._log_message(message + '=' * 61 + '\n\n') # Get the _CoreCommandManager instance -_core_command = _CoreCommandManager('sp', 'Source.Python base command.') +_core_command = _CoreCommandManager('sp') # ============================================================================= diff --git a/addons/source-python/packages/source-python/plugins/command.py b/addons/source-python/packages/source-python/plugins/command.py index a0c8f46ee..23e62fe9b 100644 --- a/addons/source-python/packages/source-python/plugins/command.py +++ b/addons/source-python/packages/source-python/plugins/command.py @@ -49,23 +49,17 @@ class SubCommandManager(AutoUnload, list): logger = plugins_command_logger translations = _plugin_strings - def __init__(self, command, description='', prefix=''): + def __init__(self, command, prefix=''): """Called on instance initialization.""" # Re-call OrderedDict's __init__ to properly setup the object super().__init__() # Does the class have a proper manager object assigned? - if not (hasattr(self, 'manager') and - isinstance(self.manager, PluginManager)): - - # If not, raise an error + if not isinstance(self.manager, PluginManager): raise PluginManagerError(PluginManagerError.__doc__) # Does the class have a proper instance class assigned? - if not (hasattr(self, 'instance') and - issubclass(self.instance, LoadedPlugin)): - - # If not, raise an error + if not issubclass(self.instance, LoadedPlugin): raise PluginInstanceError(PluginInstanceError.__doc__) # Store the command diff --git a/addons/source-python/packages/source-python/plugins/info.py b/addons/source-python/packages/source-python/plugins/info.py index 079ac4f5a..02ebc4bc6 100644 --- a/addons/source-python/packages/source-python/plugins/info.py +++ b/addons/source-python/packages/source-python/plugins/info.py @@ -5,9 +5,9 @@ # ============================================================================= # >> IMPORTS # ============================================================================= -# Python Imports -# Collections -from collections import OrderedDict +# Source.Python Imports +# Cvars +from cvars.public import PublicConVar # ============================================================================= @@ -20,32 +20,115 @@ # ============================================================================= # >> CLASSES # ============================================================================= -class PluginInfo(OrderedDict): - """Stores information for a plugin.""" +class PluginInfo(dict): + """Store information for a plugin.""" - def __getattr__(self, attribute): - """Redirect to __getitem__.""" - # Is the attribute private? - if attribute.startswith('_'): + def __init__(self, name, verbose_name=None, author=None, description=None, + version=None, url=None, permissions=None, public_convar=True, + display_in_listing=None, **kwargs): + """Initialize the instance. - # Raise an error - # This is done to fix an issue with OrderedDict.__init__ - raise AttributeError('Private attributes not allowed') + :param str name: + Name of the plugin on the file system. + :param str verbose_name: + A verbose name for the plugin (e.g. GunGame). + :param str author: + Name of the author. + :param str description: + A short description of what the plugin does. + :param str version: + Current version of the plugin. + :param str url: + A link to a thread in the 'Plugin Releases' forum section or the + plugin's SPPM link. + :param list permissions: + A list of permissions defined or used by the plugin. The list + should contain tuples that define the permission and a short + description of the permission. + :param public_convar: + If set to ``True``, a public convar will be generated based on the + plugin name, verbose name and version. Set it to ``False`` if you + don't want a public convar or set it to a dictionary containing + the parameters to create a :class:`cvars.public.PublicConvar` + instance. + :param list display_in_listing: + A list that contains custom attributes that should appear in the + plugin listing (e.g. sp plugin list). + :param kwargs: + Any additional attributes you want to set. If you want those + attributes to appear in the plugin listing, update + :attr:`display_in_listing`. + """ + super().__init__(**kwargs) + self.name = name + self._verbose_name = verbose_name + self.author = author + self.description = description + self._version = version + self.url = url - # Redirect to __getitem__ - return self[attribute] + # All permissions defined by this plugin + # A list that contains tuples: + # Example: + # [('test1.kick', 'Permission to kick players.'), + # ('test1.ban', 'Permission to ban players.'), + # ('test1.start_vote', 'Permission to start a vote.')] + self.permissions = [] if permissions is None else permissions + self.public_convar = public_convar - def __setattr__(self, attribute, value): - """Redirect to __setitem__.""" - # Is the attribute private? - if attribute.startswith('_'): + self.display_in_listing = [] if display_in_listing is None else display_in_listing - # Re-call __setattr__ - # This is done to fix an issue with OrderedDict.__init__ - super().__setattr__(attribute, value) + def _create_public_convar(self): + """Create a public convar if :attr:`public_convar` is set to True.""" + name = '{}_version'.format(self.name) + description = '{} version.'.format(self.verbose_name) + if self.public_convar is True: + self.public_convar = PublicConVar( + name, + self.version, + description + ) + elif isinstance(self.public_convar, dict): + self.public_convar = PublicConVar( + self.public_convar.pop('name', name), + self.public_convar.pop('value', self.version), + self.public_convar.pop('description', description), + **self.public_convar) - # No need to go further - return + def get_verbose_name(self): + """Return the verbose name of the plugin. - # Redirect to __setitem__ - self[attribute] = value + If no verbose name has been set, the plugin name will be titled. + + :rtype: str + """ + if self._verbose_name is None: + return self.name.replace('_', ' ').title() + + return self._verbose_name + + def set_verbose_name(self, value): + """Set the verbose name of the plugin.""" + self._verbose_name = value + + verbose_name = property(get_verbose_name, set_verbose_name) + + def get_version(self): + """Return the plugin's version. + + :rtype: str + """ + if self._version is None: + return 'unversioned' + + return self._version + + def set_version(self, value): + """Set the plugin's version.""" + self._version = value + + version = property(get_version, set_version) + + # Redirect __getitem__ and __setitem__ to __getattr__ and __setattr__ + __getattr__ = dict.__getitem__ + __setattr__ = dict.__setitem__ diff --git a/addons/source-python/packages/source-python/plugins/instance.py b/addons/source-python/packages/source-python/plugins/instance.py index ca22449e8..cb78d20a7 100644 --- a/addons/source-python/packages/source-python/plugins/instance.py +++ b/addons/source-python/packages/source-python/plugins/instance.py @@ -40,64 +40,72 @@ class LoadedPlugin(object): """Stores a plugin's instance.""" - def __init__(self, plugin_name, base_import): - """Called when a plugin's instance is initialized.""" - # Does the object have a logger set? - if not hasattr(self, 'logger'): + logger = None + translations = None + prefix = None - # If not, set the default logger - self.logger = plugins_instance_logger + def __init__(self, plugin_name, manager): + """Called when a plugin's instance is initialized. - # Does the object have a translations value set? - if not hasattr(self, 'translations'): + :param str plugin_name: + Name of the plugin to load. + :param PluginManager manager: + A plugin manager instance. + """ + self.manager = manager + self.file_path = None + self.import_name = None + self.globals = None + self.plugin_name = plugin_name + self.directory = self.manager.get_plugin_directory(plugin_name) + self.file_path = self.directory / plugin_name + '.py' + self.info = self.manager._create_plugin_info(self.plugin_name) + self.info._create_public_convar() + self._plugin = None + + # Fall back to the default logger if none was set + if self.logger is None: + self.logger = plugins_instance_logger - # If not, set the default translations + # Fall back to the default translations if none was set + if self.translations is None: self.translations = _plugin_strings # Print message that the plugin is going to be loaded self.logger.log_message(self.prefix + self.translations[ 'Loading'].get_string(plugin=plugin_name)) - # Get the plugin's main file - file_path = PLUGIN_PATH.joinpath(*tuple( - base_import.split('.')[:~0] + [plugin_name, plugin_name + '.py'])) - # Does the plugin's main file exist? - if not file_path.isfile(): + if not self.file_path.isfile(): # Print a message that the plugin's main file was not found self.logger.log_message(self.prefix + self.translations[ 'No Module'].get_string( - plugin=plugin_name, file=file_path.replace( + plugin=plugin_name, file=self.file_path.replace( GAME_PATH, '').replace('\\', '/'))) # Raise an error so that the plugin # is not added to the PluginManager raise PluginFileNotFoundError - # Get the base import - import_name = base_import + plugin_name + '.' + plugin_name - - # Import the plugin - self._plugin = import_module(import_name) + # Get the import name + self.import_name = (self.manager.base_import + plugin_name + + '.' + plugin_name) - # Set the globals value - self._globals = { + def _load(self): + """Actually load the plugin.""" + self._plugin = import_module(self.import_name) + self.globals = { x: getattr(self._plugin, x) for x in dir(self._plugin)} - @property - def globals(self): - """Return the plugin's globals.""" - return self._globals - - @property - def info(self): - """Return the plugin's PluginInfo object. - - If no PluginInfo was found, None will be returned. - """ - for obj in self.globals.values(): - if isinstance(obj, PluginInfo): - return obj - - return None + if 'load' in self.globals: + self.globals['load']() + + def _unload(self): + """Actually unload the plugin.""" + if 'unload' in self.globals: + # Use a try/except here to still allow the plugin to be unloaded + try: + self.globals['unload']() + except: + except_hooks.print_exception() diff --git a/addons/source-python/packages/source-python/plugins/manager.py b/addons/source-python/packages/source-python/plugins/manager.py index 7d8b80a9b..4c2f9e8d5 100644 --- a/addons/source-python/packages/source-python/plugins/manager.py +++ b/addons/source-python/packages/source-python/plugins/manager.py @@ -8,6 +8,9 @@ # Python Imports # Collections from collections import OrderedDict +# Configobj +from configobj import ConfigObj +from configobj import Section # Sys import sys @@ -20,10 +23,13 @@ # Listeners from listeners import on_plugin_loaded_manager from listeners import on_plugin_unloaded_manager +# Paths +from paths import PLUGIN_PATH # Plugins from plugins import plugins_logger from plugins import _plugin_strings from plugins.errors import PluginFileNotFoundError +from plugins.info import PluginInfo # ============================================================================= @@ -46,39 +52,49 @@ class PluginManager(OrderedDict): """Stores plugins and their instances.""" + instance = None + prefix = None + logger = None + translations = None + def __init__(self, base_import=''): """Called when the class instance is initialized.""" # Re-call OrderedDict's __init__ to properly setup the object super().__init__() - - # Store the base import path self._base_import = base_import # Does the object have a logger set? - if not hasattr(self, 'logger'): - - # If not, set the default logger + if self.logger is None: self.logger = plugins_manager_logger # Does the object have a translations value set? - if not hasattr(self, 'translations'): - - # If not, set the default translations + if self.translations is None: self.translations = _plugin_strings + def _create_plugin_instance(self, plugin_name): + """Create a new plugin instance. + + :rtype: LoadedPlugin + """ + # TODO: + # Rename "instance" to a better name? Perphaps completely remove it? + # Subclasses should implement this method instead. + return self.instance(plugin_name, self) + def __missing__(self, plugin_name): """Try to load a plugin that is not loaded.""" # Try to get the plugin's instance try: + instance = self._create_plugin_instance(plugin_name) - # Get the plugin's instance - instance = self.instance(plugin_name, self.base_import) - - # Does the plugin have a load function? - if 'load' in instance.globals: + # Add the instance here, so we can use get_plugin_instance() etc. + # within the plugin itself before the plugin has been fully + # loaded. This is also required e.g. for retrieving the PluginInfo + # instance. + self[plugin_name] = instance - # Call the plugin's load function - instance.globals['load']() + # Actually load the plugin + instance._load() # Was the file not found? # We use this check because we already printed the error to console @@ -89,6 +105,10 @@ def __missing__(self, plugin_name): # Was a different error encountered? except: + try: + super().__delitem__(plugin_name) + except KeyError: + pass # Get the error error = sys.exc_info() @@ -115,8 +135,6 @@ def __missing__(self, plugin_name): # Return None as the value to show the addon was not loaded return None - # Add the plugin to the dictionary with its instance - self[plugin_name] = instance on_plugin_loaded_manager.notify(plugin_name) return instance @@ -124,29 +142,13 @@ def __delitem__(self, plugin_name): """Remove a plugin from the manager.""" # Is the plugin in the dictionary? if plugin_name not in self: - - # Do nothing return # Print a message about the plugin being unloaded self.logger.log_message(self.prefix + self.translations[ 'Unloading'].get_string(plugin=plugin_name)) - # Does the plugin have an unload function? - if 'unload' in self[plugin_name].globals: - - # Use a try/except here to still allow the plugin to be unloaded - try: - - # Call the plugin's unload function - self[plugin_name].globals['unload']() - - # Was an exception raised? - except: - - # Print the error to console, but - # allow the plugin to still be unloaded - except_hooks.print_exception() + self[plugin_name]._unload() # Remove all modules from sys.modules self._remove_modules(plugin_name) @@ -157,24 +159,125 @@ def __delitem__(self, plugin_name): @property def base_import(self): - """Return the base import path for the manager.""" + """Return the base import path for the manager. + + :rtype: str + """ return self._base_import + @property + def plugins_directory(self): + """Return the directory where the plugins are stored. + + :rtype: path.Path + """ + return PLUGIN_PATH.joinpath(*tuple(self.base_import.split('.')[:~0])) + def is_loaded(self, plugin_name): - """Return whether or not a plugin is loaded.""" + """Return whether or not a plugin is loaded. + + :param str plugin_name: + The plugin to check. + :rtype: bool + """ return plugin_name in self + def plugin_exists(self, plugin_name): + """Return whether of not a plugin exists. + + :param str plugin_name: + The plugin to check. + :rtype: bool + """ + return self.get_plugin_directory(plugin_name).isdir() + def get_plugin_instance(self, plugin_name): - """Return a plugin's instance, if it is loaded.""" - # Is the plugin loaded? - if plugin_name in self: + """Return a plugin's instance, if it is loaded. + + :param str plugin_name: + The plugin to check. You can pass ``__name__`` from one of your + plugin files to retrieve its own plugin instance. + :rtype: LoadedPlugin + """ + # This allows passing __name__ to this method + if plugin_name.startswith(self.base_import): + plugin_name = plugin_name.replace( + self.base_import, '', 1).split('.', 1)[0] - # Return the plugin's instance + if plugin_name in self: return self[plugin_name] - # Return None if the plugin is not loaded return None + def get_plugin_directory(self, plugin_name): + """Return the directory of the given plugin. + + :rtype: path.Path + """ + return self.plugins_directory / plugin_name + + def get_plugin_info(self, plugin_name): + """Return information about the given plugin. + + :param str plugin_name: + The plugin to check. You can pass ``__name__`` from one of your + plugin files to retrieve its own plugin instance. + :rtype: PluginInfo + """ + plugin = self.get_plugin_instance(plugin_name) + if plugin is not None: + return plugin.info + + return self._create_plugin_info(plugin_name) + + def _create_plugin_info(self, plugin_name): + """Create a new :class:`plugins.info.PluginInfo` instance. + + :param str plugin_name: + Name of the plugin whose plugin info should be created. + :rtype: PluginInfo + """ + if not self.plugin_exists(plugin_name): + raise ValueError( + 'Plugin "{}" does not exist.'.format(plugin_name)) + + info_file = self.get_plugin_directory(plugin_name) / 'info.ini' + if not info_file.isfile(): + # Just return an "empty" PluginInfo instance. We don't have more + # information. + return PluginInfo(plugin_name) + + info = ConfigObj(info_file) + return PluginInfo( + plugin_name, + info.pop('verbose_name', None), + info.pop('author', None), + info.pop('description', None), + info.pop('version', None), + info.pop('url', None), + tuple(info.pop('permissions', dict()).items()), + self._get_public_convar_from_info_file(info), + self._get_display_in_listing_from_info_file(info), + **info.dict() + ) + + @staticmethod + def _get_public_convar_from_info_file(info): + data = info.pop('public_convar', 'True') + if isinstance(data, Section): + return data.dict() + + # False and 0 result in False, everything else is True + return data.lower() not in ('false', '0') + + @staticmethod + def _get_display_in_listing_from_info_file(info): + data = info.pop('display_in_listing', []) + if isinstance(data, (tuple, list)): + return list(data) + + return [data] + def _remove_modules(self, plugin_name): """Remove all modules from the plugin.""" # Get the plugins import path @@ -204,7 +307,10 @@ def _remove_modules(self, plugin_name): @staticmethod def _is_related_module(base_name, module): - """Check if a plugin's base name is related to a module name.""" + """Check if a plugin's base name is related to a module name. + + :rtype: bool + """ return (module.startswith('{}.'.format(base_name)) or module == base_name)