We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
New plugin manager does not support symlinks
### Environment

**Python Version:** 3.11

**Nikola Version:** 8.3.0

**Operating System:** openSUSE Tumbleweed

### Description:

The new plugin manager does not detect plugins if the plugin folder is a symlink (e.g. `ln -s ../nikola-plugins/v8/projectpages plugins/projectpages`).

Apparently, `pathlib.Path.rglob` ignores symlinks. We’ll need a custom thing to scan directories that does not ignore symlinks.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `nikola/plugin_manager.py`
Content:
```
1 # -*- coding: utf-8 -*-
2 
3 # Copyright © 2012-2024 Chris Warrick and others.
4 
5 # Permission is hereby granted, free of charge, to any
6 # person obtaining a copy of this software and associated
7 # documentation files (the "Software"), to deal in the
8 # Software without restriction, including without limitation
9 # the rights to use, copy, modify, merge, publish,
10 # distribute, sublicense, and/or sell copies of the
11 # Software, and to permit persons to whom the Software is
12 # furnished to do so, subject to the following conditions:
13 #
14 # The above copyright notice and this permission notice
15 # shall be included in all copies or substantial portions of
16 # the Software.
17 #
18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
19 # KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
20 # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
21 # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
22 # OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
23 # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
24 # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
25 # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26 
27 """The Nikola plugin manager. Inspired by yapsy."""
28 
29 import configparser
30 import importlib
31 import importlib.util
32 import time
33 import sys
34 from dataclasses import dataclass
35 from pathlib import Path
36 from typing import Dict, List, Optional, Type, TYPE_CHECKING, Set
37 
38 from .plugin_categories import BasePlugin, CATEGORIES
39 from .utils import get_logger
40 
41 if TYPE_CHECKING:
42     import logging
43 
44 LEGACY_PLUGIN_NAMES: Dict[str, str] = {
45     "Compiler": "PageCompiler",
46     "Shortcode": "ShortcodePlugin",
47     "Template": "TemplateSystem",
48 }
49 
50 CATEGORY_NAMES: Set[str] = set(CATEGORIES.keys())
51 CATEGORY_TYPES: Set[Type[BasePlugin]] = set(CATEGORIES.values())
52 
53 
54 @dataclass(frozen=True)
55 class PluginCandidate:
56     """A candidate plugin that was located but not yet loaded (imported)."""
57 
58     name: str
59     description: Optional[str]
60     plugin_id: str
61     category: str
62     compiler: Optional[str]
63     source_dir: Path
64     module_name: str
65 
66 
67 @dataclass(frozen=True)
68 class PluginInfo:
69     """A plugin that was loaded (imported)."""
70 
71     name: str
72     description: Optional[str]
73     plugin_id: str
74     category: str
75     compiler: Optional[str]
76     source_dir: Path
77     module_name: str
78     module_object: object
79     plugin_object: BasePlugin
80 
81 
82 class PluginManager:
83     """The Nikola plugin manager."""
84 
85     categories_filter: Dict[str, Type[BasePlugin]]
86     plugin_places: List[Path]
87     logger: "logging.Logger"
88     candidates: List[PluginCandidate]
89     plugins: List[PluginInfo]
90     _plugins_by_category: Dict[str, List[PluginInfo]]
91     has_warnings: bool = False
92 
93     def __init__(self, plugin_places: List[Path]):
94         """Initialize the plugin manager."""
95         self.plugin_places = plugin_places
96         self.candidates = []
97         self.plugins = []
98         self._plugins_by_category = {}
99         self.logger = get_logger("PluginManager")
100 
101     def locate_plugins(self) -> List[PluginCandidate]:
102         """Locate plugins in plugin_places."""
103         self.candidates = []
104 
105         plugin_files: List[Path] = []
106         for place in self.plugin_places:
107             plugin_files += place.rglob("*.plugin")
108 
109         for plugin_file in plugin_files:
110             source_dir = plugin_file.parent
111             config = configparser.ConfigParser()
112             config.read(plugin_file)
113             name = config["Core"]["name"]
114             module_name = config["Core"]["module"]
115             plugin_id = f"Plugin {name} from {plugin_file}"
116             description = None
117             if "Documentation" in config:
118                 description = config["Documentation"].get("Description")
119             if "Nikola" not in config:
120                 self.logger.warning(f"{plugin_id} does not specify Nikola configuration - plugin will not be loaded")
121                 self.logger.warning("Please add a [Nikola] section to the {plugin_file} file with a PluginCategory entry")
122                 self.has_warnings = True
123                 continue
124             category = config["Nikola"].get("PluginCategory")
125             compiler = config["Nikola"].get("Compiler")
126             if not category:
127                 self.logger.warning(f"{plugin_id} does not specify any category (Nikola.PluginCategory is missing in .plugin file) - plugin will not be loaded")
128                 self.has_warnings = True
129                 continue
130             if category in LEGACY_PLUGIN_NAMES:
131                 category = LEGACY_PLUGIN_NAMES[category]
132             if category not in CATEGORY_NAMES:
133                 self.logger.warning(f"{plugin_id} specifies invalid category '{category}' in the .plugin file - plugin will not be loaded")
134                 self.has_warnings = True
135                 continue
136             self.logger.debug(f"Discovered {plugin_id}")
137             self.candidates.append(
138                 PluginCandidate(
139                     name=name,
140                     description=description,
141                     plugin_id=plugin_id,
142                     category=category,
143                     compiler=compiler,
144                     source_dir=source_dir,
145                     module_name=module_name,
146                 )
147             )
148         return self.candidates
149 
150     def load_plugins(self, candidates: List[PluginCandidate]) -> None:
151         """Load selected candidate plugins."""
152         plugins_root = Path(__file__).parent.parent
153 
154         for candidate in candidates:
155             name = candidate.name
156             module_name = candidate.module_name
157             source_dir = candidate.source_dir
158             py_file_location = source_dir / f"{module_name}.py"
159             plugin_id = candidate.plugin_id
160             if not py_file_location.exists():
161                 py_file_location = source_dir / module_name / "__init__.py"
162             if not py_file_location.exists():
163                 self.logger.warning(f"{plugin_id} could not be loaded (no valid module detected)")
164                 self.has_warnings = True
165                 continue
166 
167             plugin_id += f" ({py_file_location})"
168             full_module_name = module_name
169 
170             try:
171                 name_parts = list(py_file_location.relative_to(plugins_root).parts)
172                 if name_parts[-1] == "__init__.py":
173                     name_parts.pop(-1)
174                 elif name_parts[-1].endswith(".py"):
175                     name_parts[-1] = name_parts[-1][:-3]
176                 full_module_name = ".".join(name_parts)
177             except ValueError:
178                 pass
179 
180             try:
181                 spec = importlib.util.spec_from_file_location(full_module_name, py_file_location)
182                 module_object = importlib.util.module_from_spec(spec)
183                 if full_module_name not in sys.modules:
184                     sys.modules[full_module_name] = module_object
185                 spec.loader.exec_module(module_object)
186             except Exception:
187                 self.logger.exception(f"{plugin_id} threw an exception while loading")
188                 self.has_warnings = True
189                 continue
190 
191             plugin_classes = [
192                 c
193                 for c in vars(module_object).values()
194                 if isinstance(c, type) and issubclass(c, BasePlugin) and c not in CATEGORY_TYPES
195             ]
196             if len(plugin_classes) == 0:
197                 self.logger.warning(f"{plugin_id} does not have any plugin classes - plugin will not be loaded")
198                 self.has_warnings = True
199                 continue
200             elif len(plugin_classes) > 1:
201                 self.logger.warning(f"{plugin_id} has multiple plugin classes; this is not supported - plugin will not be loaded")
202                 self.has_warnings = True
203                 continue
204 
205             plugin_class = plugin_classes[0]
206 
207             if not issubclass(plugin_class, CATEGORIES[candidate.category]):
208                 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")
209                 self.has_warnings = True
210                 continue
211 
212             try:
213                 plugin_object = plugin_class()
214             except Exception:
215                 self.logger.exception(f"{plugin_id} threw an exception while creating the instance")
216                 self.has_warnings = True
217                 continue
218             self.logger.debug(f"Loaded {plugin_id}")
219             info = PluginInfo(
220                 name=name,
221                 description=candidate.description,
222                 plugin_id=candidate.plugin_id,
223                 category=candidate.category,
224                 compiler=candidate.compiler,
225                 source_dir=source_dir,
226                 module_name=module_name,
227                 module_object=module_object,
228                 plugin_object=plugin_object,
229             )
230             self.plugins.append(info)
231 
232         self._plugins_by_category = {category: [] for category in CATEGORY_NAMES}
233         for plugin_info in self.plugins:
234             self._plugins_by_category[plugin_info.category].append(plugin_info)
235 
236         if self.has_warnings:
237             self.logger.warning("Some plugins failed to load. Please review the above warning messages.")
238             # TODO remove following messages and delay in v8.3.1
239             self.logger.warning("You may need to update some plugins (from plugins.getnikola.com) or to fix their .plugin files.")
240             self.logger.warning("Waiting 2 seconds before continuing.")
241             time.sleep(2)
242 
243     def get_plugins_of_category(self, category: str) -> List[PluginInfo]:
244         """Get loaded plugins of a given category."""
245         return self._plugins_by_category.get(category, [])
246 
247     def get_plugin_by_name(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]:
248         """Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded."""
249         for p in self.plugins:
250             if p.name == name and (category is None or p.category == category):
251                 return p
252 
253     # Aliases for Yapsy compatibility
254     # TODO: remove in v9
255     def getPluginsOfCategory(self, category: str) -> List[PluginInfo]:
256         """Get loaded plugins of a given category."""
257         self.logger.warning("Legacy getPluginsOfCategory method was used, it may be removed in the future. Please change it to get_plugins_of_category.")
258         return self._plugins_by_category.get(category, [])
259 
260     # TODO: remove in v9
261     def getPluginByName(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]:
262         """Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded."""
263         self.logger.warning("Legacy getPluginByName method was used, it may be removed in the future. Please change it to get_plugin_by_name.")
264         return self.get_plugin_by_name(name, category)
265
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/nikola/plugin_manager.py b/nikola/plugin_manager.py
--- a/nikola/plugin_manager.py
+++ b/nikola/plugin_manager.py
@@ -31,6 +31,8 @@
 import importlib.util
 import time
 import sys
+
+from collections import deque
 from dataclasses import dataclass
 from pathlib import Path
 from typing import Dict, List, Optional, Type, TYPE_CHECKING, Set
@@ -102,9 +104,13 @@
         """Locate plugins in plugin_places."""
         self.candidates = []
 
+        plugin_folders: deque = deque([place for place in self.plugin_places if place.exists() and place.is_dir()])
         plugin_files: List[Path] = []
-        for place in self.plugin_places:
-            plugin_files += place.rglob("*.plugin")
+        while plugin_folders:
+            base_folder = plugin_folders.popleft()
+            items = list(base_folder.iterdir())
+            plugin_folders.extend([item for item in items if item.is_dir() and item.name != "__pycache__"])
+            plugin_files.extend([item for item in items if item.suffix == ".plugin" and not item.is_dir()])
 
         for plugin_file in plugin_files:
             source_dir = plugin_file.parent

{"golden_diff": "diff --git a/nikola/plugin_manager.py b/nikola/plugin_manager.py\n--- a/nikola/plugin_manager.py\n+++ b/nikola/plugin_manager.py\n@@ -31,6 +31,8 @@\n import importlib.util\n import time\n import sys\n+\n+from collections import deque\n from dataclasses import dataclass\n from pathlib import Path\n from typing import Dict, List, Optional, Type, TYPE_CHECKING, Set\n@@ -102,9 +104,13 @@\n         \"\"\"Locate plugins in plugin_places.\"\"\"\n         self.candidates = []\n \n+        plugin_folders: deque = deque([place for place in self.plugin_places if place.exists() and place.is_dir()])\n         plugin_files: List[Path] = []\n-        for place in self.plugin_places:\n-            plugin_files += place.rglob(\"*.plugin\")\n+        while plugin_folders:\n+            base_folder = plugin_folders.popleft()\n+            items = list(base_folder.iterdir())\n+            plugin_folders.extend([item for item in items if item.is_dir() and item.name != \"__pycache__\"])\n+            plugin_files.extend([item for item in items if item.suffix == \".plugin\" and not item.is_dir()])\n \n         for plugin_file in plugin_files:\n             source_dir = plugin_file.parent\n", "issue": "New plugin manager does not support symlinks\n### Environment\r\n\r\n**Python Version:** 3.11\r\n\r\n**Nikola Version:** 8.3.0\r\n\r\n**Operating System:** openSUSE Tumbleweed\r\n\r\n### Description:\r\n\r\nThe new plugin manager does not detect plugins if the plugin folder is a symlink (e.g. `ln -s ../nikola-plugins/v8/projectpages plugins/projectpages`).\r\n\r\nApparently, `pathlib.Path.rglob` ignores symlinks. We\u2019ll need a custom thing to scan directories that does not ignore symlinks.\n", "before_files": [{"content": "# -*- coding: utf-8 -*-\n\n# Copyright \u00a9 2012-2024 Chris Warrick and others.\n\n# Permission is hereby granted, free of charge, to any\n# person obtaining a copy of this software and associated\n# documentation files (the \"Software\"), to deal in the\n# Software without restriction, including without limitation\n# the rights to use, copy, modify, merge, publish,\n# distribute, sublicense, and/or sell copies of the\n# Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice\n# shall be included in all copies or substantial portions of\n# the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY\n# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE\n# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR\n# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS\n# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR\n# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\n# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n\"\"\"The Nikola plugin manager. Inspired by yapsy.\"\"\"\n\nimport configparser\nimport importlib\nimport importlib.util\nimport time\nimport sys\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, Type, TYPE_CHECKING, Set\n\nfrom .plugin_categories import BasePlugin, CATEGORIES\nfrom .utils import get_logger\n\nif TYPE_CHECKING:\n    import logging\n\nLEGACY_PLUGIN_NAMES: Dict[str, str] = {\n    \"Compiler\": \"PageCompiler\",\n    \"Shortcode\": \"ShortcodePlugin\",\n    \"Template\": \"TemplateSystem\",\n}\n\nCATEGORY_NAMES: Set[str] = set(CATEGORIES.keys())\nCATEGORY_TYPES: Set[Type[BasePlugin]] = set(CATEGORIES.values())\n\n\n@dataclass(frozen=True)\nclass PluginCandidate:\n    \"\"\"A candidate plugin that was located but not yet loaded (imported).\"\"\"\n\n    name: str\n    description: Optional[str]\n    plugin_id: str\n    category: str\n    compiler: Optional[str]\n    source_dir: Path\n    module_name: str\n\n\n@dataclass(frozen=True)\nclass PluginInfo:\n    \"\"\"A plugin that was loaded (imported).\"\"\"\n\n    name: str\n    description: Optional[str]\n    plugin_id: str\n    category: str\n    compiler: Optional[str]\n    source_dir: Path\n    module_name: str\n    module_object: object\n    plugin_object: BasePlugin\n\n\nclass PluginManager:\n    \"\"\"The Nikola plugin manager.\"\"\"\n\n    categories_filter: Dict[str, Type[BasePlugin]]\n    plugin_places: List[Path]\n    logger: \"logging.Logger\"\n    candidates: List[PluginCandidate]\n    plugins: List[PluginInfo]\n    _plugins_by_category: Dict[str, List[PluginInfo]]\n    has_warnings: bool = False\n\n    def __init__(self, plugin_places: List[Path]):\n        \"\"\"Initialize the plugin manager.\"\"\"\n        self.plugin_places = plugin_places\n        self.candidates = []\n        self.plugins = []\n        self._plugins_by_category = {}\n        self.logger = get_logger(\"PluginManager\")\n\n    def locate_plugins(self) -> List[PluginCandidate]:\n        \"\"\"Locate plugins in plugin_places.\"\"\"\n        self.candidates = []\n\n        plugin_files: List[Path] = []\n        for place in self.plugin_places:\n            plugin_files += place.rglob(\"*.plugin\")\n\n        for plugin_file in plugin_files:\n            source_dir = plugin_file.parent\n            config = configparser.ConfigParser()\n            config.read(plugin_file)\n            name = config[\"Core\"][\"name\"]\n            module_name = config[\"Core\"][\"module\"]\n            plugin_id = f\"Plugin {name} from {plugin_file}\"\n            description = None\n            if \"Documentation\" in config:\n                description = config[\"Documentation\"].get(\"Description\")\n            if \"Nikola\" not in config:\n                self.logger.warning(f\"{plugin_id} does not specify Nikola configuration - plugin will not be loaded\")\n                self.logger.warning(\"Please add a [Nikola] section to the {plugin_file} file with a PluginCategory entry\")\n                self.has_warnings = True\n                continue\n            category = config[\"Nikola\"].get(\"PluginCategory\")\n            compiler = config[\"Nikola\"].get(\"Compiler\")\n            if not category:\n                self.logger.warning(f\"{plugin_id} does not specify any category (Nikola.PluginCategory is missing in .plugin file) - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n            if category in LEGACY_PLUGIN_NAMES:\n                category = LEGACY_PLUGIN_NAMES[category]\n            if category not in CATEGORY_NAMES:\n                self.logger.warning(f\"{plugin_id} specifies invalid category '{category}' in the .plugin file - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n            self.logger.debug(f\"Discovered {plugin_id}\")\n            self.candidates.append(\n                PluginCandidate(\n                    name=name,\n                    description=description,\n                    plugin_id=plugin_id,\n                    category=category,\n                    compiler=compiler,\n                    source_dir=source_dir,\n                    module_name=module_name,\n                )\n            )\n        return self.candidates\n\n    def load_plugins(self, candidates: List[PluginCandidate]) -> None:\n        \"\"\"Load selected candidate plugins.\"\"\"\n        plugins_root = Path(__file__).parent.parent\n\n        for candidate in candidates:\n            name = candidate.name\n            module_name = candidate.module_name\n            source_dir = candidate.source_dir\n            py_file_location = source_dir / f\"{module_name}.py\"\n            plugin_id = candidate.plugin_id\n            if not py_file_location.exists():\n                py_file_location = source_dir / module_name / \"__init__.py\"\n            if not py_file_location.exists():\n                self.logger.warning(f\"{plugin_id} could not be loaded (no valid module detected)\")\n                self.has_warnings = True\n                continue\n\n            plugin_id += f\" ({py_file_location})\"\n            full_module_name = module_name\n\n            try:\n                name_parts = list(py_file_location.relative_to(plugins_root).parts)\n                if name_parts[-1] == \"__init__.py\":\n                    name_parts.pop(-1)\n                elif name_parts[-1].endswith(\".py\"):\n                    name_parts[-1] = name_parts[-1][:-3]\n                full_module_name = \".\".join(name_parts)\n            except ValueError:\n                pass\n\n            try:\n                spec = importlib.util.spec_from_file_location(full_module_name, py_file_location)\n                module_object = importlib.util.module_from_spec(spec)\n                if full_module_name not in sys.modules:\n                    sys.modules[full_module_name] = module_object\n                spec.loader.exec_module(module_object)\n            except Exception:\n                self.logger.exception(f\"{plugin_id} threw an exception while loading\")\n                self.has_warnings = True\n                continue\n\n            plugin_classes = [\n                c\n                for c in vars(module_object).values()\n                if isinstance(c, type) and issubclass(c, BasePlugin) and c not in CATEGORY_TYPES\n            ]\n            if len(plugin_classes) == 0:\n                self.logger.warning(f\"{plugin_id} does not have any plugin classes - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n            elif len(plugin_classes) > 1:\n                self.logger.warning(f\"{plugin_id} has multiple plugin classes; this is not supported - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n\n            plugin_class = plugin_classes[0]\n\n            if not issubclass(plugin_class, CATEGORIES[candidate.category]):\n                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\")\n                self.has_warnings = True\n                continue\n\n            try:\n                plugin_object = plugin_class()\n            except Exception:\n                self.logger.exception(f\"{plugin_id} threw an exception while creating the instance\")\n                self.has_warnings = True\n                continue\n            self.logger.debug(f\"Loaded {plugin_id}\")\n            info = PluginInfo(\n                name=name,\n                description=candidate.description,\n                plugin_id=candidate.plugin_id,\n                category=candidate.category,\n                compiler=candidate.compiler,\n                source_dir=source_dir,\n                module_name=module_name,\n                module_object=module_object,\n                plugin_object=plugin_object,\n            )\n            self.plugins.append(info)\n\n        self._plugins_by_category = {category: [] for category in CATEGORY_NAMES}\n        for plugin_info in self.plugins:\n            self._plugins_by_category[plugin_info.category].append(plugin_info)\n\n        if self.has_warnings:\n            self.logger.warning(\"Some plugins failed to load. Please review the above warning messages.\")\n            # TODO remove following messages and delay in v8.3.1\n            self.logger.warning(\"You may need to update some plugins (from plugins.getnikola.com) or to fix their .plugin files.\")\n            self.logger.warning(\"Waiting 2 seconds before continuing.\")\n            time.sleep(2)\n\n    def get_plugins_of_category(self, category: str) -> List[PluginInfo]:\n        \"\"\"Get loaded plugins of a given category.\"\"\"\n        return self._plugins_by_category.get(category, [])\n\n    def get_plugin_by_name(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]:\n        \"\"\"Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded.\"\"\"\n        for p in self.plugins:\n            if p.name == name and (category is None or p.category == category):\n                return p\n\n    # Aliases for Yapsy compatibility\n    # TODO: remove in v9\n    def getPluginsOfCategory(self, category: str) -> List[PluginInfo]:\n        \"\"\"Get loaded plugins of a given category.\"\"\"\n        self.logger.warning(\"Legacy getPluginsOfCategory method was used, it may be removed in the future. Please change it to get_plugins_of_category.\")\n        return self._plugins_by_category.get(category, [])\n\n    # TODO: remove in v9\n    def getPluginByName(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]:\n        \"\"\"Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded.\"\"\"\n        self.logger.warning(\"Legacy getPluginByName method was used, it may be removed in the future. Please change it to get_plugin_by_name.\")\n        return self.get_plugin_by_name(name, category)\n", "path": "nikola/plugin_manager.py"}], "after_files": [{"content": "# -*- coding: utf-8 -*-\n\n# Copyright \u00a9 2012-2024 Chris Warrick and others.\n\n# Permission is hereby granted, free of charge, to any\n# person obtaining a copy of this software and associated\n# documentation files (the \"Software\"), to deal in the\n# Software without restriction, including without limitation\n# the rights to use, copy, modify, merge, publish,\n# distribute, sublicense, and/or sell copies of the\n# Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice\n# shall be included in all copies or substantial portions of\n# the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY\n# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE\n# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR\n# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS\n# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR\n# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\n# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n\"\"\"The Nikola plugin manager. Inspired by yapsy.\"\"\"\n\nimport configparser\nimport importlib\nimport importlib.util\nimport time\nimport sys\n\nfrom collections import deque\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, Type, TYPE_CHECKING, Set\n\nfrom .plugin_categories import BasePlugin, CATEGORIES\nfrom .utils import get_logger\n\nif TYPE_CHECKING:\n    import logging\n\nLEGACY_PLUGIN_NAMES: Dict[str, str] = {\n    \"Compiler\": \"PageCompiler\",\n    \"Shortcode\": \"ShortcodePlugin\",\n    \"Template\": \"TemplateSystem\",\n}\n\nCATEGORY_NAMES: Set[str] = set(CATEGORIES.keys())\nCATEGORY_TYPES: Set[Type[BasePlugin]] = set(CATEGORIES.values())\n\n\n@dataclass(frozen=True)\nclass PluginCandidate:\n    \"\"\"A candidate plugin that was located but not yet loaded (imported).\"\"\"\n\n    name: str\n    description: Optional[str]\n    plugin_id: str\n    category: str\n    compiler: Optional[str]\n    source_dir: Path\n    module_name: str\n\n\n@dataclass(frozen=True)\nclass PluginInfo:\n    \"\"\"A plugin that was loaded (imported).\"\"\"\n\n    name: str\n    description: Optional[str]\n    plugin_id: str\n    category: str\n    compiler: Optional[str]\n    source_dir: Path\n    module_name: str\n    module_object: object\n    plugin_object: BasePlugin\n\n\nclass PluginManager:\n    \"\"\"The Nikola plugin manager.\"\"\"\n\n    categories_filter: Dict[str, Type[BasePlugin]]\n    plugin_places: List[Path]\n    logger: \"logging.Logger\"\n    candidates: List[PluginCandidate]\n    plugins: List[PluginInfo]\n    _plugins_by_category: Dict[str, List[PluginInfo]]\n    has_warnings: bool = False\n\n    def __init__(self, plugin_places: List[Path]):\n        \"\"\"Initialize the plugin manager.\"\"\"\n        self.plugin_places = plugin_places\n        self.candidates = []\n        self.plugins = []\n        self._plugins_by_category = {}\n        self.logger = get_logger(\"PluginManager\")\n\n    def locate_plugins(self) -> List[PluginCandidate]:\n        \"\"\"Locate plugins in plugin_places.\"\"\"\n        self.candidates = []\n\n        plugin_folders: deque = deque([place for place in self.plugin_places if place.exists() and place.is_dir()])\n        plugin_files: List[Path] = []\n        while plugin_folders:\n            base_folder = plugin_folders.popleft()\n            items = list(base_folder.iterdir())\n            plugin_folders.extend([item for item in items if item.is_dir() and item.name != \"__pycache__\"])\n            plugin_files.extend([item for item in items if item.suffix == \".plugin\" and not item.is_dir()])\n\n        for plugin_file in plugin_files:\n            source_dir = plugin_file.parent\n            config = configparser.ConfigParser()\n            config.read(plugin_file)\n            name = config[\"Core\"][\"name\"]\n            module_name = config[\"Core\"][\"module\"]\n            plugin_id = f\"Plugin {name} from {plugin_file}\"\n            description = None\n            if \"Documentation\" in config:\n                description = config[\"Documentation\"].get(\"Description\")\n            if \"Nikola\" not in config:\n                self.logger.warning(f\"{plugin_id} does not specify Nikola configuration - plugin will not be loaded\")\n                self.logger.warning(\"Please add a [Nikola] section to the {plugin_file} file with a PluginCategory entry\")\n                self.has_warnings = True\n                continue\n            category = config[\"Nikola\"].get(\"PluginCategory\")\n            compiler = config[\"Nikola\"].get(\"Compiler\")\n            if not category:\n                self.logger.warning(f\"{plugin_id} does not specify any category (Nikola.PluginCategory is missing in .plugin file) - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n            if category in LEGACY_PLUGIN_NAMES:\n                category = LEGACY_PLUGIN_NAMES[category]\n            if category not in CATEGORY_NAMES:\n                self.logger.warning(f\"{plugin_id} specifies invalid category '{category}' in the .plugin file - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n            self.logger.debug(f\"Discovered {plugin_id}\")\n            self.candidates.append(\n                PluginCandidate(\n                    name=name,\n                    description=description,\n                    plugin_id=plugin_id,\n                    category=category,\n                    compiler=compiler,\n                    source_dir=source_dir,\n                    module_name=module_name,\n                )\n            )\n        return self.candidates\n\n    def load_plugins(self, candidates: List[PluginCandidate]) -> None:\n        \"\"\"Load selected candidate plugins.\"\"\"\n        plugins_root = Path(__file__).parent.parent\n\n        for candidate in candidates:\n            name = candidate.name\n            module_name = candidate.module_name\n            source_dir = candidate.source_dir\n            py_file_location = source_dir / f\"{module_name}.py\"\n            plugin_id = candidate.plugin_id\n            if not py_file_location.exists():\n                py_file_location = source_dir / module_name / \"__init__.py\"\n            if not py_file_location.exists():\n                self.logger.warning(f\"{plugin_id} could not be loaded (no valid module detected)\")\n                self.has_warnings = True\n                continue\n\n            plugin_id += f\" ({py_file_location})\"\n            full_module_name = module_name\n\n            try:\n                name_parts = list(py_file_location.relative_to(plugins_root).parts)\n                if name_parts[-1] == \"__init__.py\":\n                    name_parts.pop(-1)\n                elif name_parts[-1].endswith(\".py\"):\n                    name_parts[-1] = name_parts[-1][:-3]\n                full_module_name = \".\".join(name_parts)\n            except ValueError:\n                pass\n\n            try:\n                spec = importlib.util.spec_from_file_location(full_module_name, py_file_location)\n                module_object = importlib.util.module_from_spec(spec)\n                if full_module_name not in sys.modules:\n                    sys.modules[full_module_name] = module_object\n                spec.loader.exec_module(module_object)\n            except Exception:\n                self.logger.exception(f\"{plugin_id} threw an exception while loading\")\n                self.has_warnings = True\n                continue\n\n            plugin_classes = [\n                c\n                for c in vars(module_object).values()\n                if isinstance(c, type) and issubclass(c, BasePlugin) and c not in CATEGORY_TYPES\n            ]\n            if len(plugin_classes) == 0:\n                self.logger.warning(f\"{plugin_id} does not have any plugin classes - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n            elif len(plugin_classes) > 1:\n                self.logger.warning(f\"{plugin_id} has multiple plugin classes; this is not supported - plugin will not be loaded\")\n                self.has_warnings = True\n                continue\n\n            plugin_class = plugin_classes[0]\n\n            if not issubclass(plugin_class, CATEGORIES[candidate.category]):\n                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\")\n                self.has_warnings = True\n                continue\n\n            try:\n                plugin_object = plugin_class()\n            except Exception:\n                self.logger.exception(f\"{plugin_id} threw an exception while creating the instance\")\n                self.has_warnings = True\n                continue\n            self.logger.debug(f\"Loaded {plugin_id}\")\n            info = PluginInfo(\n                name=name,\n                description=candidate.description,\n                plugin_id=candidate.plugin_id,\n                category=candidate.category,\n                compiler=candidate.compiler,\n                source_dir=source_dir,\n                module_name=module_name,\n                module_object=module_object,\n                plugin_object=plugin_object,\n            )\n            self.plugins.append(info)\n\n        self._plugins_by_category = {category: [] for category in CATEGORY_NAMES}\n        for plugin_info in self.plugins:\n            self._plugins_by_category[plugin_info.category].append(plugin_info)\n\n        if self.has_warnings:\n            self.logger.warning(\"Some plugins failed to load. Please review the above warning messages.\")\n            # TODO remove following messages and delay in v8.3.1\n            self.logger.warning(\"You may need to update some plugins (from plugins.getnikola.com) or to fix their .plugin files.\")\n            self.logger.warning(\"Waiting 2 seconds before continuing.\")\n            time.sleep(2)\n\n    def get_plugins_of_category(self, category: str) -> List[PluginInfo]:\n        \"\"\"Get loaded plugins of a given category.\"\"\"\n        return self._plugins_by_category.get(category, [])\n\n    def get_plugin_by_name(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]:\n        \"\"\"Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded.\"\"\"\n        for p in self.plugins:\n            if p.name == name and (category is None or p.category == category):\n                return p\n\n    # Aliases for Yapsy compatibility\n    # TODO: remove in v9\n    def getPluginsOfCategory(self, category: str) -> List[PluginInfo]:\n        \"\"\"Get loaded plugins of a given category.\"\"\"\n        self.logger.warning(\"Legacy getPluginsOfCategory method was used, it may be removed in the future. Please change it to get_plugins_of_category.\")\n        return self._plugins_by_category.get(category, [])\n\n    # TODO: remove in v9\n    def getPluginByName(self, name: str, category: Optional[str] = None) -> Optional[PluginInfo]:\n        \"\"\"Get a loaded plugin by name and optionally by category. Returns None if no such plugin is loaded.\"\"\"\n        self.logger.warning(\"Legacy getPluginByName method was used, it may be removed in the future. Please change it to get_plugin_by_name.\")\n        return self.get_plugin_by_name(name, category)\n", "path": "nikola/plugin_manager.py"}]}