diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7476c20776..ae5f499fa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,13 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] image: - ubuntu-latest include: - - python: '3.11' + - python: '3.12' image: macos-latest - - python: '3.11' + - python: '3.12' image: windows-latest runs-on: '${{ matrix.image }}' steps: @@ -84,7 +84,7 @@ jobs: strategy: matrix: python: - - '3.11' + - '3.12' runs-on: ubuntu-latest steps: - name: Check out code @@ -112,6 +112,7 @@ jobs: matrix: python: - '3.11' + - '3.12' runs-on: ubuntu-latest steps: - name: Check out code diff --git a/CHANGES.txt b/CHANGES.txt index 12e58f07d3..63870f5cca 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,11 +4,16 @@ New in master Features -------- +* Implement a new plugin manager from scratch to replace Yapsy, + which does not work on Python 3.12 due to Python 3.12 carelessly + removing parts of the standard library (Issue #3719) * Support for Discourse as comment system (Issue #3689) Bugfixes -------- +* Fix loading of templates from plugins with ``__init__.py`` files + (Issue #3725) * Fix margins of paragraphs at the end of sections (Issue #3704) * Ignore ``.DS_Store`` files in listing indexes (Issue #3698) * Fix baguetteBox.js invoking in the base theme (Issue #3687) @@ -16,6 +21,14 @@ Bugfixes for non-root SITE_URL, in particular when URL_TYPE is full_path. (Issue #3715) +For plugin developers +--------------------- + +Nikola now requires the ``.plugin`` file to contain a ``[Nikola]`` +section with a ``PluginCategory`` entry set to the name of the plugin +category class. This was already required by ``plugins.getnikola.com``, +but you may have custom plugins that don’t have this set. + New in v8.2.4 ============= diff --git a/docs/internals.rst b/docs/internals.rst index 0180f0d395..a3b7af2fca 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -15,7 +15,7 @@ So, this is a short document explaining what each piece of Nikola does and how it all fits together. Nikola is a Pile of Plugins - Most of Nikola is implemented as plugins using `Yapsy `_. + Most of Nikola is implemented as plugins (using a custom plugin manager inspired by Yapsy). You can ignore that they are plugins and just think of them as regular python modules and packages with a funny little ``.plugin`` file next to them. @@ -65,7 +65,7 @@ basename:name If you ever want to do your own tasks, you really should read the doit `documentation on tasks `_. - + Notably, by default doit redirects ``stdout`` and ``stderr``. To get a proper PDB debugging shell, you need to use doit's own `set_trace `_ function. diff --git a/nikola/nikola.py b/nikola/nikola.py index 056f606dd6..dd39e82d93 100644 --- a/nikola/nikola.py +++ b/nikola/nikola.py @@ -35,6 +35,7 @@ import os import pathlib import sys +import typing import mimetypes from collections import defaultdict from copy import copy @@ -47,27 +48,14 @@ import PyRSS2Gen as rss from pkg_resources import resource_filename from blinker import signal -from yapsy.PluginManager import PluginManager from . import DEBUG, SHOW_TRACEBACKS, filters, utils, hierarchy_utils, shortcodes from . import metadata_extractors from .metadata_extractors import default_metadata_extractors_by from .post import Post # NOQA +from .plugin_manager import PluginCandidate, PluginInfo, PluginManager from .plugin_categories import ( - Command, - LateTask, - PageCompiler, - CompilerExtension, - MarkdownExtension, - RestExtension, - MetadataExtractor, - ShortcodePlugin, - Task, - TaskMultiplier, TemplateSystem, - SignalHandler, - ConfigPlugin, - CommentSystem, PostScanner, Taxonomy, ) @@ -377,25 +365,15 @@ def _enclosure(post, lang): return url, length, mime -def _plugin_load_callback(plugin_info): - """Set the plugin's module path. - - So we can find its template path later. - """ - try: - plugin_info.plugin_object.set_module_path(plugin_info.path) - except AttributeError: - # this is just for safety in case plugin_object somehow - # isn't set - pass - - class Nikola(object): """Class that handles site generation. Takes a site config as argument on creation. """ + plugin_manager: PluginManager + _template_system: TemplateSystem + def __init__(self, **config): """Initialize proper environment for running tasks.""" # Register our own path handlers @@ -1019,57 +997,47 @@ def __init__(self, **config): # WebP files have no official MIME type yet, but we need to recognize them (Issue #3671) mimetypes.add_type('image/webp', '.webp') - def _filter_duplicate_plugins(self, plugin_list): + def _filter_duplicate_plugins(self, plugin_list: typing.Iterable[PluginCandidate]): """Find repeated plugins and discard the less local copy.""" - def plugin_position_in_places(plugin): + def plugin_position_in_places(plugin: PluginInfo): # plugin here is a tuple: # (path to the .plugin file, path to plugin module w/o .py, plugin metadata) for i, place in enumerate(self._plugin_places): - if plugin[0].startswith(place): + place: pathlib.Path + try: + # Path.is_relative_to backport + plugin.source_dir.relative_to(place) return i - utils.LOGGER.warn("Duplicate plugin found in unexpected location: {}".format(plugin[0])) + except ValueError: + pass + utils.LOGGER.warning("Duplicate plugin found in unexpected location: {}".format(plugin.source_dir)) return len(self._plugin_places) plugin_dict = defaultdict(list) - for data in plugin_list: - plugin_dict[data[2].name].append(data) + for plugin in plugin_list: + plugin_dict[plugin.name].append(plugin) result = [] - for _, plugins in plugin_dict.items(): + for name, plugins in plugin_dict.items(): if len(plugins) > 1: # Sort by locality plugins.sort(key=plugin_position_in_places) utils.LOGGER.debug("Plugin {} exists in multiple places, using {}".format( - plugins[-1][2].name, plugins[-1][0])) + name, plugins[-1].source_dir)) result.append(plugins[-1]) return result def init_plugins(self, commands_only=False, load_all=False): """Load plugins as needed.""" - self.plugin_manager = PluginManager(categories_filter={ - "Command": Command, - "Task": Task, - "LateTask": LateTask, - "TemplateSystem": TemplateSystem, - "PageCompiler": PageCompiler, - "TaskMultiplier": TaskMultiplier, - "CompilerExtension": CompilerExtension, - "MarkdownExtension": MarkdownExtension, - "RestExtension": RestExtension, - "MetadataExtractor": MetadataExtractor, - "ShortcodePlugin": ShortcodePlugin, - "SignalHandler": SignalHandler, - "ConfigPlugin": ConfigPlugin, - "CommentSystem": CommentSystem, - "PostScanner": PostScanner, - "Taxonomy": Taxonomy, - }) - self.plugin_manager.getPluginLocator().setPluginInfoExtension('plugin') extra_plugins_dirs = self.config['EXTRA_PLUGINS_DIRS'] + self._loading_commands_only = commands_only self._plugin_places = [ resource_filename('nikola', 'plugins'), os.path.expanduser(os.path.join('~', '.nikola', 'plugins')), os.path.join(os.getcwd(), 'plugins'), ] + [path for path in extra_plugins_dirs if path] + self._plugin_places = [pathlib.Path(p) for p in self._plugin_places] + + self.plugin_manager = PluginManager(plugin_places=self._plugin_places) compilers = defaultdict(set) # Also add aliases for combinations with TRANSLATIONS_PATTERN @@ -1088,41 +1056,37 @@ def init_plugins(self, commands_only=False, load_all=False): self.disabled_compilers = {} self.disabled_compiler_extensions = defaultdict(list) - self.plugin_manager.getPluginLocator().setPluginPlaces(self._plugin_places) - self.plugin_manager.locatePlugins() - bad_candidates = set([]) + candidates = self.plugin_manager.locate_plugins() + good_candidates = set() if not load_all: - for p in self.plugin_manager._candidates: + for p in candidates: if commands_only: - if p[-1].details.has_option('Nikola', 'PluginCategory'): - # FIXME TemplateSystem should not be needed - if p[-1].details.get('Nikola', 'PluginCategory') not in {'Command', 'Template'}: - bad_candidates.add(p) - else: - bad_candidates.add(p) + if p.category != 'Command': + continue elif self.configured: # Not commands-only, and configured # Remove blacklisted plugins - if p[-1].name in self.config['DISABLED_PLUGINS']: - bad_candidates.add(p) - utils.LOGGER.debug('Not loading disabled plugin {}', p[-1].name) - # Remove compilers we don't use - if p[-1].details.has_option('Nikola', 'PluginCategory') and p[-1].details.get('Nikola', 'PluginCategory') in ('Compiler', 'PageCompiler'): - bad_candidates.add(p) - self.disabled_compilers[p[-1].name] = p + if p.name in self.config['DISABLED_PLUGINS']: + utils.LOGGER.debug('Not loading disabled plugin {}', p.name) + continue + # Remove compilers - will be loaded later based on usage + if p.category == "PageCompiler": + self.disabled_compilers[p.name] = p + continue # Remove compiler extensions we don't need - if p[-1].details.has_option('Nikola', 'compiler') and p[-1].details.get('Nikola', 'compiler') in self.disabled_compilers: - bad_candidates.add(p) - self.disabled_compiler_extensions[p[-1].details.get('Nikola', 'compiler')].append(p) - self.plugin_manager._candidates = list(set(self.plugin_manager._candidates) - bad_candidates) + if p.compiler and p.compiler in self.disabled_compilers: + self.disabled_compiler_extensions[p.compiler].append(p) + continue + good_candidates.add(p) - self.plugin_manager._candidates = self._filter_duplicate_plugins(self.plugin_manager._candidates) - self.plugin_manager.loadPlugins(callback_after=_plugin_load_callback) + good_candidates = self._filter_duplicate_plugins(good_candidates) + self.plugin_manager.load_plugins(good_candidates) # Search for compiler plugins which we disabled but shouldn't have self._activate_plugins_of_category("PostScanner") if not load_all: file_extensions = set() - for post_scanner in [p.plugin_object for p in self.plugin_manager.getPluginsOfCategory('PostScanner')]: + for post_scanner in [p.plugin_object for p in self.plugin_manager.get_plugins_of_category('PostScanner')]: + post_scanner: PostScanner exts = post_scanner.supported_extensions() if exts is not None: file_extensions.update(exts) @@ -1141,13 +1105,13 @@ def init_plugins(self, commands_only=False, load_all=False): for p in self.disabled_compiler_extensions.pop(k, []): to_add.append(p) for _, p in self.disabled_compilers.items(): - utils.LOGGER.debug('Not loading unneeded compiler {}', p[-1].name) + utils.LOGGER.debug('Not loading unneeded compiler %s', p.name) for _, plugins in self.disabled_compiler_extensions.items(): for p in plugins: - utils.LOGGER.debug('Not loading compiler extension {}', p[-1].name) + utils.LOGGER.debug('Not loading compiler extension %s', p.name) if to_add: - self.plugin_manager._candidates = self._filter_duplicate_plugins(to_add) - self.plugin_manager.loadPlugins(callback_after=_plugin_load_callback) + extra_candidates = self._filter_duplicate_plugins(to_add) + self.plugin_manager.load_plugins(extra_candidates) # Jupyter theme configuration. If a website has ipynb enabled in post_pages # we should enable the Jupyter CSS (leaving that up to the theme itself). @@ -1160,7 +1124,8 @@ def init_plugins(self, commands_only=False, load_all=False): self._activate_plugins_of_category("Taxonomy") self.taxonomy_plugins = {} - for taxonomy in [p.plugin_object for p in self.plugin_manager.getPluginsOfCategory('Taxonomy')]: + for taxonomy in [p.plugin_object for p in self.plugin_manager.get_plugins_of_category('Taxonomy')]: + taxonomy: Taxonomy if not taxonomy.is_enabled(): continue if taxonomy.classification_name in self.taxonomy_plugins: @@ -1186,10 +1151,9 @@ def init_plugins(self, commands_only=False, load_all=False): # Activate all required compiler plugins self.compiler_extensions = self._activate_plugins_of_category("CompilerExtension") - for plugin_info in self.plugin_manager.getPluginsOfCategory("PageCompiler"): + for plugin_info in self.plugin_manager.get_plugins_of_category("PageCompiler"): if plugin_info.name in self.config["COMPILERS"].keys(): - self.plugin_manager.activatePluginByName(plugin_info.name) - plugin_info.plugin_object.set_site(self) + self._activate_plugin(plugin_info) # Activate shortcode plugins self._activate_plugins_of_category("ShortcodePlugin") @@ -1198,10 +1162,8 @@ def init_plugins(self, commands_only=False, load_all=False): self.compilers = {} self.inverse_compilers = {} - for plugin_info in self.plugin_manager.getPluginsOfCategory( - "PageCompiler"): - self.compilers[plugin_info.name] = \ - plugin_info.plugin_object + for plugin_info in self.plugin_manager.get_plugins_of_category("PageCompiler"): + self.compilers[plugin_info.name] = plugin_info.plugin_object # Load comment systems, config plugins and register templated shortcodes self._activate_plugins_of_category("CommentSystem") @@ -1344,13 +1306,26 @@ def _set_all_page_deps_from_config(self): self.ALL_PAGE_DEPS['index_read_more_link'] = self.config.get('INDEX_READ_MORE_LINK') self.ALL_PAGE_DEPS['feed_read_more_link'] = self.config.get('FEED_READ_MORE_LINK') - def _activate_plugins_of_category(self, category): + def _activate_plugin(self, plugin_info: PluginInfo) -> None: + plugin_info.plugin_object.set_site(self) + + if plugin_info.category == "TemplateSystem" or self._loading_commands_only: + return + + templates_directory_candidates = [ + plugin_info.source_dir / "templates" / self.template_system.name, + plugin_info.source_dir / plugin_info.module_name / "templates" / self.template_system.name + ] + for candidate in templates_directory_candidates: + if candidate.exists() and candidate.is_dir(): + self.template_system.inject_directory(str(candidate)) + + def _activate_plugins_of_category(self, category) -> typing.List[PluginInfo]: """Activate all the plugins of a given category and return them.""" # this code duplicated in tests/base.py plugins = [] - for plugin_info in self.plugin_manager.getPluginsOfCategory(category): - self.plugin_manager.activatePluginByName(plugin_info.name) - plugin_info.plugin_object.set_site(self) + for plugin_info in self.plugin_manager.get_plugins_of_category(category): + self._activate_plugin(plugin_info) plugins.append(plugin_info) return plugins @@ -1414,13 +1389,12 @@ def _get_template_system(self): if self._template_system is None: # Load template plugin template_sys_name = utils.get_template_engine(self.THEMES) - pi = self.plugin_manager.getPluginByName( - template_sys_name, "TemplateSystem") + pi = self.plugin_manager.get_plugin_by_name(template_sys_name, "TemplateSystem") if pi is None: sys.stderr.write("Error loading {0} template system " "plugin\n".format(template_sys_name)) sys.exit(1) - self._template_system = pi.plugin_object + self._template_system = typing.cast(TemplateSystem, pi.plugin_object) lookup_dirs = ['templates'] + [os.path.join(utils.get_theme_path(name), "templates") for name in self.THEMES] self._template_system.set_directories(lookup_dirs, @@ -2089,7 +2063,7 @@ def flatten(task): yield ft task_dep = [] - for pluginInfo in self.plugin_manager.getPluginsOfCategory(plugin_category): + for pluginInfo in self.plugin_manager.get_plugins_of_category(plugin_category): for task in flatten(pluginInfo.plugin_object.gen_tasks()): if 'basename' not in task: raise ValueError("Task {0} does not have a basename".format(task)) @@ -2098,7 +2072,7 @@ def flatten(task): task['task_dep'] = [] task['task_dep'].extend(self.injected_deps[task['basename']]) yield task - for multi in self.plugin_manager.getPluginsOfCategory("TaskMultiplier"): + for multi in self.plugin_manager.get_plugins_of_category("TaskMultiplier"): flag = False for task in multi.plugin_object.process(task, name): flag = True @@ -2207,7 +2181,7 @@ def scan_posts(self, really=False, ignore_quit=False, quiet=False): self.timeline = [] self.pages = [] - for p in sorted(self.plugin_manager.getPluginsOfCategory('PostScanner'), key=operator.attrgetter('name')): + for p in sorted(self.plugin_manager.get_plugins_of_category('PostScanner'), key=operator.attrgetter('name')): try: timeline = p.plugin_object.scan() except Exception: diff --git a/nikola/plugin_categories.py b/nikola/plugin_categories.py index 08316284d5..980f9106a9 100644 --- a/nikola/plugin_categories.py +++ b/nikola/plugin_categories.py @@ -33,7 +33,6 @@ import doit from doit.cmd_base import Command as DoitCommand -from yapsy.IPlugin import IPlugin from .utils import LOGGER, first_line, get_logger, req_missing @@ -58,7 +57,7 @@ ) -class BasePlugin(IPlugin): +class BasePlugin: """Base plugin class.""" logger = None @@ -66,7 +65,6 @@ class BasePlugin(IPlugin): def set_site(self, site): """Set site, which is a Nikola instance.""" self.site = site - self.inject_templates() self.logger = get_logger(self.name) if not site.debug: self.logger.level = logging.INFO @@ -75,21 +73,6 @@ def set_module_path(self, module_path): """Set the plugin's module path.""" self.module_path = module_path - def inject_templates(self): - """Inject 'templates/' (if exists) very early in the theme chain.""" - try: - mod_dir = os.path.dirname(self.module_path) - tmpl_dir = os.path.join( - mod_dir, 'templates', self.site.template_system.name - ) - if os.path.isdir(tmpl_dir): - # Inject tmpl_dir low in the theme chain - self.site.template_system.inject_directory(tmpl_dir) - except AttributeError: - # This would likely mean we don't have module_path set. - # this should not usually happen, so log a warning - LOGGER.warning("Could not find template path for module {0}".format(self.name)) - def inject_dependency(self, target, dependency): """Add 'dependency' to the target task's task_deps.""" self.site.injected_deps[target].append(dependency) @@ -359,7 +342,7 @@ def get_compiler_extensions(self) -> list: """Activate all the compiler extension plugins for a given compiler and return them.""" plugins = [] for plugin_info in self.site.compiler_extensions: - if plugin_info.plugin_object.compiler_name == self.name: + if plugin_info.compiler == self.name or plugin_info.plugin_object.compiler_name == self.name: plugins.append(plugin_info) return plugins @@ -371,11 +354,8 @@ class CompilerExtension(BasePlugin): (a) create a new plugin class for them; or (b) use this class and filter them yourself. If you choose (b), you should the compiler name to the .plugin - file in the Nikola/Compiler section and filter all plugins of - this category, getting the compiler name with: - p.details.get('Nikola', 'Compiler') - Note that not all compiler plugins have this option and you might - need to catch configparser.NoOptionError exceptions. + file in the Nikola/compiler section and filter all plugins of + this category, getting the compiler name with `plugin_info.compiler`. """ name = "dummy_compiler_extension" @@ -904,3 +884,23 @@ def get_other_language_variants(self, classification: str, lang: str, classifica Provided is a set of classifications per language (`classifications_per_language`). """ return [] + + +CATEGORIES = { + "Command": Command, + "Task": Task, + "LateTask": LateTask, + "TemplateSystem": TemplateSystem, + "PageCompiler": PageCompiler, + "TaskMultiplier": TaskMultiplier, + "CompilerExtension": CompilerExtension, + "MarkdownExtension": MarkdownExtension, + "RestExtension": RestExtension, + "MetadataExtractor": MetadataExtractor, + "ShortcodePlugin": ShortcodePlugin, + "SignalHandler": SignalHandler, + "ConfigPlugin": ConfigPlugin, + "CommentSystem": CommentSystem, + "PostScanner": PostScanner, + "Taxonomy": Taxonomy, +} diff --git a/nikola/plugin_manager.py b/nikola/plugin_manager.py new file mode 100644 index 0000000000..5c1a29a103 --- /dev/null +++ b/nikola/plugin_manager.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""The Nikola plugin manager. Inspired by yapsy.""" + +import configparser +import importlib +import importlib.util +import time +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Type, TYPE_CHECKING, Set + +from .plugin_categories import BasePlugin, CATEGORIES +from .utils import get_logger + +if TYPE_CHECKING: + import logging + +LEGACY_PLUGIN_NAMES: Dict[str, str] = { + "Compiler": "PageCompiler", + "Shortcode": "ShortcodePlugin", + "Template": "TemplateSystem", +} + +CATEGORY_NAMES: Set[str] = set(CATEGORIES.keys()) +CATEGORY_TYPES: Set[Type[BasePlugin]] = set(CATEGORIES.values()) + + +@dataclass(frozen=True) +class PluginCandidate: + """A candidate plugin that was located but not yet loaded (imported).""" + + name: str + description: Optional[str] + plugin_id: str + category: str + compiler: Optional[str] + source_dir: Path + module_name: str + + +@dataclass(frozen=True) +class PluginInfo: + """A plugin that was loaded (imported).""" + + name: str + description: Optional[str] + plugin_id: str + category: str + compiler: Optional[str] + source_dir: Path + module_name: str + module_object: object + plugin_object: BasePlugin + + +class PluginManager: + """The Nikola plugin manager.""" + + categories_filter: Dict[str, Type[BasePlugin]] + plugin_places: List[Path] + logger: "logging.Logger" + candidates: List[PluginCandidate] + plugins: List[PluginInfo] + _plugins_by_category: Dict[str, List[PluginInfo]] + has_warnings: bool = False + + def __init__(self, plugin_places: List[Path]): + """Initialize the plugin manager.""" + self.plugin_places = plugin_places + self.candidates = [] + self.plugins = [] + self._plugins_by_category = {} + self.logger = get_logger("PluginManager") + + def locate_plugins(self) -> List[PluginCandidate]: + """Locate plugins in plugin_places.""" + self.candidates = [] + + plugin_files: List[Path] = [] + for place in self.plugin_places: + plugin_files += place.rglob("*.plugin") + + for plugin_file in plugin_files: + source_dir = plugin_file.parent + config = configparser.ConfigParser() + config.read(plugin_file) + name = config["Core"]["name"] + module_name = config["Core"]["module"] + plugin_id = f"Plugin {name} from {plugin_file}" + description = None + if "Documentation" in config: + description = config["Documentation"].get("Description") + if "Nikola" not in config: + self.logger.warning(f"{plugin_id} does not specify Nikola configuration - plugin will not be loaded") + self.logger.warning("Please add a [Nikola] section to the {plugin_file} file with a PluginCategory entry") + self.has_warnings = True + continue + category = config["Nikola"].get("PluginCategory") + compiler = config["Nikola"].get("Compiler") + if not category: + self.logger.warning(f"{plugin_id} does not specify any category (Nikola.PluginCategory is missing in .plugin file) - plugin will not be loaded") + self.has_warnings = True + continue + if category in LEGACY_PLUGIN_NAMES: + category = LEGACY_PLUGIN_NAMES[category] + if category not in CATEGORY_NAMES: + self.logger.warning(f"{plugin_id} specifies invalid category '{category}' in the .plugin file - plugin will not be loaded") + self.has_warnings = True + continue + self.logger.debug(f"Discovered {plugin_id}") + self.candidates.append( + PluginCandidate( + name=name, + description=description, + plugin_id=plugin_id, + category=category, + compiler=compiler, + source_dir=source_dir, + module_name=module_name, + ) + ) + return self.candidates + + def load_plugins(self, candidates: List[PluginCandidate]) -> None: + """Load selected candidate plugins.""" + plugins_root = Path(__file__).parent.parent + + for candidate in candidates: + name = candidate.name + module_name = candidate.module_name + source_dir = candidate.source_dir + py_file_location = source_dir / f"{module_name}.py" + plugin_id = candidate.plugin_id + if not py_file_location.exists(): + py_file_location = source_dir / module_name / "__init__.py" + if not py_file_location.exists(): + self.logger.warning(f"{plugin_id} could not be loaded (no valid module detected)") + self.has_warnings = True + continue + + plugin_id += f" ({py_file_location})" + full_module_name = module_name + + try: + name_parts = list(py_file_location.relative_to(plugins_root).parts) + if name_parts[-1] == "__init__.py": + name_parts.pop(-1) + elif name_parts[-1].endswith(".py"): + name_parts[-1] = name_parts[-1][:-3] + full_module_name = ".".join(name_parts) + except ValueError: + pass + + try: + spec = importlib.util.spec_from_file_location(full_module_name, py_file_location) + module_object = importlib.util.module_from_spec(spec) + if full_module_name not in sys.modules: + sys.modules[full_module_name] = module_object + spec.loader.exec_module(module_object) + except Exception: + self.logger.exception(f"{plugin_id} threw an exception while loading") + self.has_warnings = True + continue + + plugin_classes = [ + c + for c in vars(module_object).values() + if isinstance(c, type) and issubclass(c, BasePlugin) and c not in CATEGORY_TYPES + ] + if len(plugin_classes) == 0: + self.logger.warning(f"{plugin_id} does not have any plugin classes - plugin will not be loaded") + self.has_warnings = True + continue + elif len(plugin_classes) > 1: + self.logger.warning(f"{plugin_id} has multiple plugin classes; this is not supported - plugin will not be loaded") + self.has_warnings = True + continue + + plugin_class = plugin_classes[0] + + if not issubclass(plugin_class, CATEGORIES[candidate.category]): + self.logger.warning(f"{plugin_id} has category '{candidate.category}' in the .plugin file, but the implementation class {plugin_class} does not inherit from this category - plugin will not be loaded") + self.has_warnings = True + continue + + try: + plugin_object = plugin_class() + except Exception: + self.logger.exception(f"{plugin_id} threw an exception while creating the instance") + self.has_warnings = True + continue + self.logger.debug(f"Loaded {plugin_id}") + info = PluginInfo( + name=name, + description=candidate.description, + plugin_id=candidate.plugin_id, + category=candidate.category, + compiler=candidate.compiler, + source_dir=source_dir, + module_name=module_name, + module_object=module_object, + plugin_object=plugin_object, + ) + self.plugins.append(info) + + self._plugins_by_category = {category: [] for category in CATEGORY_NAMES} + for plugin_info in self.plugins: + self._plugins_by_category[plugin_info.category].append(plugin_info) + + if self.has_warnings: + self.logger.warning("Some plugins failed to load. Please review the above warning messages.") + # TODO remove following messages and delay in v8.3.1 + self.logger.warning("You may need to update some plugins (from plugins.getnikola.com) or to fix their .plugin files.") + self.logger.warning("Waiting 2 seconds before continuing.") + time.sleep(2) + + def get_plugins_of_category(self, category: str) -> List[PluginInfo]: + """Get loaded plugins of a given category.""" + return self._plugins_by_category.get(category, []) + + def get_plugin_by_name(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]: + """Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded.""" + for p in self.plugins: + if p.name == name and (category is None or p.category == category): + return p + + # Aliases for Yapsy compatibility + # TODO: remove in v9 + def getPluginsOfCategory(self, category: str) -> List[PluginInfo]: + """Get loaded plugins of a given category.""" + self.logger.warning("Legacy getPluginsOfCategory method was used, it may be removed in the future. Please change it to get_plugins_of_category.") + return self._plugins_by_category.get(category, []) + + # TODO: remove in v9 + def getPluginByName(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]: + """Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded.""" + self.logger.warning("Legacy getPluginByName method was used, it may be removed in the future. Please change it to get_plugin_by_name.") + return self.get_plugin_by_name(name, category) diff --git a/nikola/plugins/command/import_wordpress.py b/nikola/plugins/command/import_wordpress.py index e419c8a1b7..27f58b0da6 100644 --- a/nikola/plugins/command/import_wordpress.py +++ b/nikola/plugins/command/import_wordpress.py @@ -66,7 +66,7 @@ def install_plugin(site, plugin_name, output_dir=None, show_install_notes=False) """Install a Nikola plugin.""" LOGGER.info("Installing plugin '{0}'".format(plugin_name)) # Get hold of the 'plugin' plugin - plugin_installer_info = site.plugin_manager.getPluginByName('plugin', 'Command') + plugin_installer_info = site.plugin_manager.get_plugin_by_name('plugin', 'Command') if plugin_installer_info is None: LOGGER.error('Internal error: cannot find the "plugin" plugin which is supposed to come with Nikola!') return False @@ -236,10 +236,9 @@ def _get_compiler(self): self._find_wordpress_compiler() if self.wordpress_page_compiler is not None: return self.wordpress_page_compiler - plugin_info = self.site.plugin_manager.getPluginByName('markdown', 'PageCompiler') + plugin_info = self.site.plugin_manager.get_plugin_by_name('markdown', 'PageCompiler') if plugin_info is not None: if not plugin_info.is_activated: - self.site.plugin_manager.activatePluginByName(plugin_info.name) plugin_info.plugin_object.set_site(self.site) return plugin_info.plugin_object else: @@ -249,7 +248,7 @@ def _find_wordpress_compiler(self): """Find WordPress compiler plugin.""" if self.wordpress_page_compiler is not None: return - plugin_info = self.site.plugin_manager.getPluginByName('wordpress', 'PageCompiler') + plugin_info = self.site.plugin_manager.get_plugin_by_name('wordpress', 'PageCompiler') if plugin_info is not None: if not plugin_info.is_activated: self.site.plugin_manager.activatePluginByName(plugin_info.name) diff --git a/nikola/plugins/command/new_page.py b/nikola/plugins/command/new_page.py index cde5e5c6c6..780e5db2ff 100644 --- a/nikola/plugins/command/new_page.py +++ b/nikola/plugins/command/new_page.py @@ -109,5 +109,5 @@ def _execute(self, options, args): options['date-path'] = False # Even though stuff was split into `new_page`, it’s easier to do it # there not to duplicate the code. - p = self.site.plugin_manager.getPluginByName('new_post', 'Command').plugin_object + p = self.site.plugin_manager.get_plugin_by_name('new_post', 'Command').plugin_object return p.execute(options, args) diff --git a/nikola/plugins/command/new_post.py b/nikola/plugins/command/new_post.py index 45ca1a11f8..2c03fa5c73 100644 --- a/nikola/plugins/command/new_post.py +++ b/nikola/plugins/command/new_post.py @@ -216,9 +216,7 @@ class CommandNewPost(Command): def _execute(self, options, args): """Create a new post or page.""" global LOGGER - compiler_names = [p.name for p in - self.site.plugin_manager.getPluginsOfCategory( - "PageCompiler")] + compiler_names = [p.name for p in self.site.plugin_manager.get_plugins_of_category("PageCompiler")] if len(args) > 1: print(self.help()) @@ -298,7 +296,7 @@ def _execute(self, options, args): self.print_compilers() return - compiler_plugin = self.site.plugin_manager.getPluginByName( + compiler_plugin = self.site.plugin_manager.get_plugin_by_name( content_format, "PageCompiler").plugin_object # Guess where we should put this diff --git a/nikola/plugins/command/rst2html/__init__.py b/nikola/plugins/command/rst2html/__init__.py index a133bd5ec8..d2854df94b 100644 --- a/nikola/plugins/command/rst2html/__init__.py +++ b/nikola/plugins/command/rst2html/__init__.py @@ -44,7 +44,7 @@ class CommandRst2Html(Command): def _execute(self, options, args): """Compile reStructuredText to standalone HTML files.""" - compiler = self.site.plugin_manager.getPluginByName('rest', 'PageCompiler').plugin_object + compiler = self.site.plugin_manager.get_plugin_by_name('rest', 'PageCompiler').plugin_object if len(args) != 1: print("This command takes only one argument (input file name).") return 2 diff --git a/nikola/plugins/compile/pandoc.py b/nikola/plugins/compile/pandoc.py index c79c4d88e7..f510256179 100644 --- a/nikola/plugins/compile/pandoc.py +++ b/nikola/plugins/compile/pandoc.py @@ -62,10 +62,10 @@ def _get_pandoc_options(self, source: str) -> List[str]: try: pandoc_options = list(config_options[ext]) except KeyError: - self.logger.warn('Setting PANDOC_OPTIONS to [], because extension {} is not defined in PANDOC_OPTIONS: {}.'.format(ext, config_options)) + self.logger.warning('Setting PANDOC_OPTIONS to [], because extension {} is not defined in PANDOC_OPTIONS: {}.'.format(ext, config_options)) pandoc_options = [] else: - self.logger.warn('Setting PANDOC_OPTIONS to [], because PANDOC_OPTIONS is expected to be of type Union[List[str], Dict[str, List[str]]] but this is not: {}'.format(config_options)) + self.logger.warning('Setting PANDOC_OPTIONS to [], because PANDOC_OPTIONS is expected to be of type Union[List[str], Dict[str, List[str]]] but this is not: {}'.format(config_options)) pandoc_options = [] return pandoc_options diff --git a/nikola/plugins/compile/rest/chart.py b/nikola/plugins/compile/rest/chart.py index d217c1bf2e..558fe7c904 100644 --- a/nikola/plugins/compile/rest/chart.py +++ b/nikola/plugins/compile/rest/chart.py @@ -153,7 +153,7 @@ class Chart(Directive): def run(self): """Run the directive.""" self.options['site'] = None - html = _site.plugin_manager.getPluginByName( + html = _site.plugin_manager.get_plugin_by_name( 'chart', 'ShortcodePlugin').plugin_object.handler( self.arguments[0], data='\n'.join(self.content), diff --git a/nikola/plugins/compile/rest/post_list.py b/nikola/plugins/compile/rest/post_list.py index f5b86baef2..9f66073643 100644 --- a/nikola/plugins/compile/rest/post_list.py +++ b/nikola/plugins/compile/rest/post_list.py @@ -88,7 +88,7 @@ def run(self): date = self.options.get('date') filename = self.state.document.settings._nikola_source_path - output, deps = self.site.plugin_manager.getPluginByName( + output, deps = self.site.plugin_manager.get_plugin_by_name( 'post_list', 'ShortcodePlugin').plugin_object.handler( start, stop, diff --git a/nikola/plugins/misc/scan_posts.plugin b/nikola/plugins/misc/scan_posts.plugin index f4af811276..0fb946c792 100644 --- a/nikola/plugins/misc/scan_posts.plugin +++ b/nikola/plugins/misc/scan_posts.plugin @@ -8,3 +8,5 @@ Version = 1.0 Website = https://getnikola.com/ Description = Scan posts and create timeline +[Nikola] +PluginCategory = PostScanner diff --git a/nikola/plugins/shortcode/chart.plugin b/nikola/plugins/shortcode/chart.plugin index edcbc1310a..be1fbc6efd 100644 --- a/nikola/plugins/shortcode/chart.plugin +++ b/nikola/plugins/shortcode/chart.plugin @@ -3,7 +3,7 @@ name = chart module = chart [Nikola] -PluginCategory = Shortcode +PluginCategory = ShortcodePlugin [Documentation] author = Roberto Alsina diff --git a/nikola/plugins/shortcode/emoji.plugin b/nikola/plugins/shortcode/emoji.plugin index c9a272cdcd..4c09f03157 100644 --- a/nikola/plugins/shortcode/emoji.plugin +++ b/nikola/plugins/shortcode/emoji.plugin @@ -3,7 +3,7 @@ name = emoji module = emoji [Nikola] -PluginCategory = Shortcode +PluginCategory = ShortcodePlugin [Documentation] author = Roberto Alsina diff --git a/nikola/plugins/shortcode/gist.plugin b/nikola/plugins/shortcode/gist.plugin index b610763835..ee62c27069 100644 --- a/nikola/plugins/shortcode/gist.plugin +++ b/nikola/plugins/shortcode/gist.plugin @@ -3,7 +3,7 @@ name = gist module = gist [Nikola] -PluginCategory = Shortcode +PluginCategory = ShortcodePlugin [Documentation] author = Roberto Alsina diff --git a/nikola/plugins/shortcode/listing.plugin b/nikola/plugins/shortcode/listing.plugin index 90fb6ebb28..70fa1cf416 100644 --- a/nikola/plugins/shortcode/listing.plugin +++ b/nikola/plugins/shortcode/listing.plugin @@ -3,7 +3,7 @@ name = listing_shortcode module = listing [Nikola] -PluginCategory = Shortcode +PluginCategory = ShortcodePlugin [Documentation] author = Roberto Alsina diff --git a/nikola/plugins/shortcode/post_list.plugin b/nikola/plugins/shortcode/post_list.plugin index 494a1d8755..9c39eb9e99 100644 --- a/nikola/plugins/shortcode/post_list.plugin +++ b/nikola/plugins/shortcode/post_list.plugin @@ -3,7 +3,7 @@ name = post_list module = post_list [Nikola] -PluginCategory = Shortcode +PluginCategory = ShortcodePlugin [Documentation] author = Udo Spallek diff --git a/nikola/plugins/shortcode/thumbnail.plugin b/nikola/plugins/shortcode/thumbnail.plugin index e55d34f6c3..bd36169002 100644 --- a/nikola/plugins/shortcode/thumbnail.plugin +++ b/nikola/plugins/shortcode/thumbnail.plugin @@ -3,7 +3,7 @@ name = thumbnail module = thumbnail [Nikola] -PluginCategory = Shortcode +PluginCategory = ShortcodePlugin [Documentation] author = Chris Warrick diff --git a/nikola/plugins/task/bundles.plugin b/nikola/plugins/task/bundles.plugin index 939065b7cb..4afc3ecd3f 100644 --- a/nikola/plugins/task/bundles.plugin +++ b/nikola/plugins/task/bundles.plugin @@ -9,5 +9,5 @@ website = https://getnikola.com/ description = Bundle assets [Nikola] -PluginCategory = Task +PluginCategory = LateTask diff --git a/nikola/plugins/task/gzip.plugin b/nikola/plugins/task/gzip.plugin index 70e966b83a..b1aab25dc8 100644 --- a/nikola/plugins/task/gzip.plugin +++ b/nikola/plugins/task/gzip.plugin @@ -9,5 +9,5 @@ website = https://getnikola.com/ description = Create gzipped copies of files [Nikola] -PluginCategory = Task +PluginCategory = TaskMultiplier diff --git a/nikola/plugins/task/listings.py b/nikola/plugins/task/listings.py index 4c1636e558..f4933e9379 100644 --- a/nikola/plugins/task/listings.py +++ b/nikola/plugins/task/listings.py @@ -113,7 +113,7 @@ def render_listing(in_name, out_name, input_folder, output_folder, folders=[], f needs_ipython_css = False if in_name and in_name.endswith('.ipynb'): # Special handling: render ipynbs in listings (Issue #1900) - ipynb_plugin = self.site.plugin_manager.getPluginByName("ipynb", "PageCompiler") + ipynb_plugin = self.site.plugin_manager.get_plugin_by_name("ipynb", "PageCompiler") if ipynb_plugin is None: msg = "To use .ipynb files as listings, you must set up the Jupyter compiler in COMPILERS and POSTS/PAGES." utils.LOGGER.error(msg) diff --git a/nikola/plugins/task/robots.plugin b/nikola/plugins/task/robots.plugin index 51f778183c..81c4c9a6b9 100644 --- a/nikola/plugins/task/robots.plugin +++ b/nikola/plugins/task/robots.plugin @@ -9,5 +9,5 @@ website = https://getnikola.com/ description = Generate /robots.txt exclusion file and promote sitemap. [Nikola] -PluginCategory = Task +PluginCategory = LateTask diff --git a/nikola/plugins/task/sitemap.plugin b/nikola/plugins/task/sitemap.plugin index c8aa832392..8367d8ecaa 100644 --- a/nikola/plugins/task/sitemap.plugin +++ b/nikola/plugins/task/sitemap.plugin @@ -9,5 +9,5 @@ website = https://getnikola.com/ description = Generate google sitemap. [Nikola] -PluginCategory = Task +PluginCategory = LateTask diff --git a/nikola/plugins/task/sitemap.py b/nikola/plugins/task/sitemap.py index bc9a5d6569..3c045eb0df 100644 --- a/nikola/plugins/task/sitemap.py +++ b/nikola/plugins/task/sitemap.py @@ -309,7 +309,7 @@ def get_lastmod(self, p): # RFC 3339 (web ISO 8601 profile) represented in UTC with Zulu # zone desgignator as recommeded for sitemaps. Second and # microsecond precision is stripped for compatibility. - lastmod = datetime.datetime.utcfromtimestamp(os.stat(p).st_mtime).replace(tzinfo=dateutil.tz.gettz('UTC'), second=0, microsecond=0).isoformat().replace('+00:00', 'Z') + lastmod = datetime.datetime.fromtimestamp(os.stat(p).st_mtime, dateutil.tz.tzutc()).replace(second=0, microsecond=0).isoformat().replace('+00:00', 'Z') return lastmod diff --git a/nikola/plugins/template/jinja.plugin b/nikola/plugins/template/jinja.plugin index 629b20e888..45dd621643 100644 --- a/nikola/plugins/template/jinja.plugin +++ b/nikola/plugins/template/jinja.plugin @@ -9,5 +9,5 @@ website = https://getnikola.com/ description = Support for Jinja2 templates. [Nikola] -PluginCategory = Template +PluginCategory = TemplateSystem diff --git a/nikola/plugins/template/mako.plugin b/nikola/plugins/template/mako.plugin index 2d353bf1d9..a46575255e 100644 --- a/nikola/plugins/template/mako.plugin +++ b/nikola/plugins/template/mako.plugin @@ -9,5 +9,5 @@ website = https://getnikola.com/ description = Support for Mako templates. [Nikola] -PluginCategory = Template +PluginCategory = TemplateSystem diff --git a/nikola/post.py b/nikola/post.py index 1dcd33d609..d4172d548b 100644 --- a/nikola/post.py +++ b/nikola/post.py @@ -350,8 +350,8 @@ def _set_date(self, default_metadata): if self.config['__invariant__']: default_metadata['date'] = datetime.datetime(2013, 12, 31, 23, 59, 59, tzinfo=self.config['__tzinfo__']) else: - default_metadata['date'] = datetime.datetime.utcfromtimestamp( - os.stat(self.source_path).st_ctime).replace(tzinfo=dateutil.tz.tzutc()).astimezone(self.config['__tzinfo__']) + default_metadata['date'] = datetime.datetime.fromtimestamp( + os.stat(self.source_path).st_ctime, dateutil.tz.tzutc()).astimezone(self.config['__tzinfo__']) # If time zone is set, build localized datetime. try: diff --git a/requirements.txt b/requirements.txt index e4ef1a36a2..5e7f0b01b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,6 @@ mako>=1.0.0 Markdown>=3.0.0 unidecode>=0.04.16 lxml>=3.3.5 -Yapsy>=1.12.0 PyRSS2Gen>=1.1 blinker>=1.3 setuptools>=24.2.0 diff --git a/setup.py b/setup.py index f2f5615ad8..5db7f89474 100755 --- a/setup.py +++ b/setup.py @@ -133,6 +133,7 @@ def run(self): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Text Processing :: Markup'], diff --git a/snapcraft.yaml b/snapcraft.yaml index 50962eaa65..bd7ce8b5f8 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -40,7 +40,6 @@ parts: - mako - unidecode - lxml - - Yapsy - PyRSS2Gen - blinker - setuptools diff --git a/tests/data/plugin_manager/broken.plugin b/tests/data/plugin_manager/broken.plugin new file mode 100644 index 0000000000..1a5f9e05ce --- /dev/null +++ b/tests/data/plugin_manager/broken.plugin @@ -0,0 +1,12 @@ +[Core] +name = broken +module = broken + +[Documentation] +author = Chris Warrick +version = 1.0 +website = https://getnikola.com/ +description = Broken (wrong category) + +[Nikola] +PluginCategory = Task diff --git a/tests/data/plugin_manager/broken.py b/tests/data/plugin_manager/broken.py new file mode 100644 index 0000000000..68af857cb5 --- /dev/null +++ b/tests/data/plugin_manager/broken.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""The second plugin.""" + +from nikola.plugin_categories import TemplateSystem + + +class BrokenPlugin(TemplateSystem): + """The TemplateSystem plugin whose .plugin file says it’s a Task plugin.""" + + name = "broken" + broken_site_set = False + + def set_site(self, site): + super().set_site(site) + print("Site for broken was set") + self.broken_site_set = True + raise Exception("Site for broken was set") diff --git a/tests/data/plugin_manager/first.plugin b/tests/data/plugin_manager/first.plugin new file mode 100644 index 0000000000..cae4442025 --- /dev/null +++ b/tests/data/plugin_manager/first.plugin @@ -0,0 +1,13 @@ +[Core] +name = first +module = one + +[Documentation] +author = Chris Warrick +version = 1.0 +website = https://getnikola.com/ +description = Do one thing + +[Nikola] +PluginCategory = Command +compiler = foo diff --git a/tests/data/plugin_manager/one.py b/tests/data/plugin_manager/one.py new file mode 100644 index 0000000000..817bd0770c --- /dev/null +++ b/tests/data/plugin_manager/one.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""The first command.""" + +from nikola.plugin_categories import Command + + +class CommandOne(Command): + """The first command.""" + + name = "one" + doc_purpose = "do one thing" + doc_description = "Do a thing." + one_site_set = False + + def set_site(self, site): + super().set_site(site) + print("Site for 1 was set") + self.one_site_set = True + + def _execute(self, options, args): + """Run the command.""" + print("Hello world!") diff --git a/tests/data/plugin_manager/second/second.plugin b/tests/data/plugin_manager/second/second.plugin new file mode 100644 index 0000000000..ad1e4b8caa --- /dev/null +++ b/tests/data/plugin_manager/second/second.plugin @@ -0,0 +1,13 @@ +[Core] +name = 2nd +module = two + +[Documentation] +author = Chris Warrick +version = 1.0 +website = https://getnikola.com/ +description = Do another thing + +[Nikola] +PluginCategory = ConfigPlugin + diff --git a/tests/data/plugin_manager/second/two/__init__.py b/tests/data/plugin_manager/second/two/__init__.py new file mode 100644 index 0000000000..14794c07ec --- /dev/null +++ b/tests/data/plugin_manager/second/two/__init__.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +"""The second plugin.""" + +from nikola.plugin_categories import ConfigPlugin + + +class TwoConfigPlugin(ConfigPlugin): + """The second plugin.""" + + name = "2nd" + two_site_set = False + + def set_site(self, site): + super().set_site(site) + print("Site for 2 was set") + self.two_site_set = True diff --git a/tests/helper.py b/tests/helper.py index a39ad88f70..8fbebdb45b 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -6,23 +6,12 @@ """ import os +import pathlib from contextlib import contextmanager -from yapsy.PluginManager import PluginManager - -import nikola.utils import nikola.shortcodes -from nikola.plugin_categories import ( - Command, - Task, - LateTask, - TemplateSystem, - PageCompiler, - TaskMultiplier, - CompilerExtension, - MarkdownExtension, - RestExtension, -) +import nikola.utils +from nikola.plugin_manager import PluginManager __all__ = ["cd", "FakeSite"] @@ -55,24 +44,11 @@ def __init__(self): "TRANSLATIONS": {"en": ""}, } self.EXTRA_PLUGINS = self.config["EXTRA_PLUGINS"] - self.plugin_manager = PluginManager( - categories_filter={ - "Command": Command, - "Task": Task, - "LateTask": LateTask, - "TemplateSystem": TemplateSystem, - "PageCompiler": PageCompiler, - "TaskMultiplier": TaskMultiplier, - "CompilerExtension": CompilerExtension, - "MarkdownExtension": MarkdownExtension, - "RestExtension": RestExtension, - } - ) + places = [pathlib.Path(nikola.utils.__file__).parent / "plugins"] + self.plugin_manager = PluginManager(plugin_places=places) self.shortcode_registry = {} - self.plugin_manager.setPluginInfoExtension("plugin") - places = [os.path.join(os.path.dirname(nikola.utils.__file__), "plugins")] - self.plugin_manager.setPluginPlaces(places) - self.plugin_manager.collectPlugins() + candidates = self.plugin_manager.locate_plugins() + self.plugin_manager.load_plugins(candidates) self.compiler_extensions = self._activate_plugins_of_category( "CompilerExtension" ) @@ -88,13 +64,9 @@ def _activate_plugins_of_category(self, category): """Activate all the plugins of a given category and return them.""" # this code duplicated in nikola/nikola.py plugins = [] - for plugin_info in self.plugin_manager.getPluginsOfCategory(category): - if plugin_info.name in self.config.get("DISABLED_PLUGINS"): - self.plugin_manager.removePluginFromCategory(plugin_info, category) - else: - self.plugin_manager.activatePluginByName(plugin_info.name) - plugin_info.plugin_object.set_site(self) - plugins.append(plugin_info) + for plugin_info in self.plugin_manager.get_plugins_of_category(category): + plugin_info.plugin_object.set_site(self) + plugins.append(plugin_info) return plugins def render_template(self, name, _, context): diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py new file mode 100644 index 0000000000..8deb7a60b6 --- /dev/null +++ b/tests/test_plugin_manager.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2024 Chris Warrick and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import nikola.plugin_manager + +from .helper import FakeSite +from nikola.plugin_manager import PluginManager +from pathlib import Path + + +def test_locate_plugins_finds_core_plugins(): + """Ensure that locate_plugins can find some core plugins.""" + places = [Path(nikola.plugin_manager.__file__).parent / "plugins"] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugin_names = [p.name for p in candidates] + assert plugin_manager.candidates == candidates + + assert "emoji" in plugin_names + assert "copy_assets" in plugin_names + assert "scan_posts" in plugin_names + + template_plugins = [p for p in candidates if p.category == "TemplateSystem"] + template_plugins.sort(key=lambda p: p.name) + assert len(template_plugins) == 2 + assert template_plugins[0].name == "jinja" + assert template_plugins[1].name == "mako" + + +def test_locate_plugins_finds_core_and_custom_plugins(): + """Ensure that locate_plugins can find some custom plugins.""" + places = [ + Path(nikola.plugin_manager.__file__).parent / "plugins", + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugin_names = [p.name for p in candidates] + assert plugin_manager.candidates == candidates + + assert "emoji" in plugin_names + assert "copy_assets" in plugin_names + assert "scan_posts" in plugin_names + + assert "first" in plugin_names + assert "2nd" in plugin_names + + first_plugin = next(p for p in candidates if p.name == "first") + second_plugin = next(p for p in candidates if p.name == "2nd") + + assert first_plugin.category == "Command" + assert first_plugin.compiler == "foo" + assert first_plugin.source_dir == places[1] + + assert second_plugin.category == "ConfigPlugin" + assert second_plugin.compiler is None + assert second_plugin.source_dir == places[1] / "second" + + +def test_load_plugins(): + """Ensure that locate_plugins can load some core and custom plugins.""" + places = [ + Path(nikola.plugin_manager.__file__).parent / "plugins", + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugins_to_load = [p for p in candidates if p.name in {"first", "2nd", "emoji"}] + + plugin_manager.load_plugins(plugins_to_load) + + assert len(plugin_manager.plugins) == 3 + assert plugin_manager._plugins_by_category["ShortcodePlugin"][0].name == "emoji" + assert plugin_manager._plugins_by_category["Command"][0].name == "first" + assert plugin_manager._plugins_by_category["ConfigPlugin"][0].name == "2nd" + + site = FakeSite() + for plugin in plugin_manager.plugins: + plugin.plugin_object.set_site(site) + + assert "emoji" in site.shortcode_registry + assert plugin_manager.get_plugin_by_name("first", "Command").plugin_object.one_site_set + assert plugin_manager.get_plugin_by_name("2nd").plugin_object.two_site_set + assert plugin_manager.get_plugin_by_name("2nd", "Command") is None + + +def test_load_plugins_twice(): + """Ensure that extra plugins can be added.""" + places = [ + Path(nikola.plugin_manager.__file__).parent / "plugins", + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugins_to_load_first = [p for p in candidates if p.name in {"first", "emoji"}] + plugins_to_load_second = [p for p in candidates if p.name in {"2nd"}] + + plugin_manager.load_plugins(plugins_to_load_first) + assert len(plugin_manager.plugins) == 2 + plugin_manager.load_plugins(plugins_to_load_second) + assert len(plugin_manager.plugins) == 3 + + +def test_load_plugins_skip_mismatching_category(caplog): + """If a plugin specifies a different category than it actually implements, refuse to load it.""" + places = [ + Path(__file__).parent / "data" / "plugin_manager", + ] + plugin_manager = PluginManager(places) + candidates = plugin_manager.locate_plugins() + plugins_to_load = [p for p in candidates if p.name in {"broken"}] + plugin_to_load = plugins_to_load[0] + assert len(plugins_to_load) == 1 + + plugin_manager.load_plugins(plugins_to_load) + + py_file = plugin_to_load.source_dir / "broken.py" + assert f"{plugin_to_load.plugin_id} ({py_file}) has category '{plugin_to_load.category}' in the .plugin file, but the implementation class does not inherit from this category - plugin will not be loaded" in caplog.text + assert len(plugin_manager.plugins) == 0 diff --git a/tests/test_template_shortcodes.py b/tests/test_template_shortcodes.py index c6c948da97..148f7e6091 100644 --- a/tests/test_template_shortcodes.py +++ b/tests/test_template_shortcodes.py @@ -67,7 +67,7 @@ class ShortcodeFakeSite(Nikola): def _get_template_system(self): if self._template_system is None: # Load template plugin - self._template_system = self.plugin_manager.getPluginByName( + self._template_system = self.plugin_manager.get_plugin_by_name( "jinja", "TemplateSystem" ).plugin_object self._template_system.set_directories(".", "cache")