Repository: sopel-irc/sopel

Issue:
Broken plugin symlink -> `PluginError` in CLI
### Description
The `sopel-plugins` command should gracefully handle things that look like plugins but can't be loaded. It does this in some cases (like Python file plugins with syntax errors), but not all. There's room for improvement.

For example, I recently symlinked a file in `~/.sopel/plugins` so it would point to my local repository for that plugin (not yet converted to an entry-point or package) for testing. I did the `ln -s` command wrong, so the link was broken: `pluginfile.py -> ./pluginfile.py`, which is a circular reference.

### Logs
```
Traceback (most recent call last):
  File "/home/dgw/.local/bin/sopel-plugins", line 33, in <module>
    sys.exit(load_entry_point('sopel', 'console_scripts', 'sopel-plugins')())
  File "/home/dgw/github/sopel/sopel/cli/plugins.py", line 556, in main
    return handle_list(options)
  File "/home/dgw/github/sopel/sopel/cli/plugins.py", line 174, in handle_list
    for name, info in plugins.get_usable_plugins(settings).items()
  File "/home/dgw/github/sopel/sopel/plugins/__init__.py", line 225, in get_usable_plugins
    plugins_info = collections.OrderedDict(
  File "/home/dgw/github/sopel/sopel/plugins/__init__.py", line 225, in <genexpr>
    plugins_info = collections.OrderedDict(
  File "/home/dgw/github/sopel/sopel/plugins/__init__.py", line 185, in enumerate_plugins
    for plugin in all_plugins:
  File "/home/dgw/github/sopel/sopel/plugins/__init__.py", line 124, in find_directory_plugins
    yield handlers.PyFilePlugin(abspath)
  File "/home/dgw/github/sopel/sopel/plugins/handlers.py", line 440, in __init__
    raise exceptions.PluginError('Invalid Sopel plugin: %s' % filename)
sopel.plugins.exceptions.PluginError: Invalid Sopel plugin: /home/dgw/.sopel/plugins/bombbot.py
```

### Environment
- Sopel `.version`: 8b463a0e49cac7a27dc528a180243a6a5183d1f9 
- Sopel installed via: `pip -e`
- Python version: 3.9.5
- Operating system: Ubuntu 20.04
- IRCd `/version`: n/a
- Relevant plugins: n/a


Discussion:
Just to note, this came up again on IRC today and I reverified it with Python 3.10.12 on Ubuntu 22.04 (WSL1) + current `master` (d9b6a74f1d4835ecc6544881ad5b38338e1a0f4f). Breaks both `sopel-plugins` and `sopel`, the latter even if the config uses enable-only for loading plugins and broken symlink is for a plugin that isn't listed in `core.enable`. Updated traceback:

```
Traceback (most recent call last):
  File "/home/dgw/.local/bin/sopel-plugins", line 8, in <module>
    sys.exit(main())
  File "/home/dgw/github/sopel-irc/sopel/sopel/cli/plugins.py", line 555, in main
    return handle_list(options)
  File "/home/dgw/github/sopel-irc/sopel/sopel/cli/plugins.py", line 174, in handle_list
    for name, info in plugins.get_usable_plugins(settings).items()
  File "/home/dgw/github/sopel-irc/sopel/sopel/plugins/__init__.py", line 228, in get_usable_plugins
    plugins_info = collections.OrderedDict(
  File "/home/dgw/github/sopel-irc/sopel/sopel/plugins/__init__.py", line 228, in <genexpr>
    plugins_info = collections.OrderedDict(
  File "/home/dgw/github/sopel-irc/sopel/sopel/plugins/__init__.py", line 188, in enumerate_plugins
    for plugin in all_plugins:
  File "/home/dgw/github/sopel-irc/sopel/sopel/plugins/__init__.py", line 127, in find_directory_plugins
    yield handlers.PyFilePlugin(abspath)
  File "/home/dgw/github/sopel-irc/sopel/sopel/plugins/handlers.py", line 477, in __init__
    raise exceptions.PluginError('Invalid Sopel plugin: %s' % filename)
sopel.plugins.exceptions.PluginError: Invalid Sopel plugin: /home/dgw/.sopel/plugins/duel.py
```
Confirmed that this does impact Windows as well as Linux (and presumably MacOS, but I cannot test this). I have a test for the `sopel-plugins` command that reproduces the fault, will open a PR once I have a tentative fix. I can probably adapt my test to work more directly with the core of Sopel, I just couldn't find a suitable example to work from in the tests.

Resolving patch:
diff --git a/sopel/plugins/__init__.py b/sopel/plugins/__init__.py
--- a/sopel/plugins/__init__.py
+++ b/sopel/plugins/__init__.py
@@ -31,7 +31,9 @@
 import collections
 import importlib
 import itertools
+import logging
 import os
+from typing import TYPE_CHECKING, Union
 
 # TODO: use stdlib importlib.metadata when possible, after dropping py3.9.
 # Stdlib does not support `entry_points(group='filter')` until py3.10, but
@@ -41,13 +43,22 @@
 
 from . import exceptions, handlers, rules  # noqa
 
+if TYPE_CHECKING:
+    from collections.abc import Iterable
 
-def _list_plugin_filenames(directory):
+
+LOGGER = logging.getLogger(__name__)
+
+
+def _list_plugin_filenames(directory: Union[str, os.PathLike]) -> Iterable[tuple[str, str]]:
     # list plugin filenames from a directory
     # yield 2-value tuples: (name, absolute path)
     base = os.path.abspath(directory)
     for filename in os.listdir(base):
-        abspath = os.path.join(base, filename)
+        abspath = os.path.realpath(os.path.join(base, filename))
+        if not os.path.exists(abspath):
+            LOGGER.warning("Plugin path does not exist, skipping: %r", abspath)
+            continue
 
         if os.path.isdir(abspath):
             if os.path.isfile(os.path.join(abspath, '__init__.py')):


Test patch:
diff --git a/test/test_plugins.py b/test/test_plugins.py
--- a/test/test_plugins.py
+++ b/test/test_plugins.py
@@ -155,3 +155,12 @@ def test_plugin_load_entry_point(tmpdir):
     assert hasattr(test_mod, 'example_url')
     assert hasattr(test_mod, 'shutdown')
     assert hasattr(test_mod, 'ignored')
+
+
+def test_plugin_skip_broken_links(tmp_path):
+    plugins_path = tmp_path
+    plugin = plugins_path.joinpath("amazing_plugin.py")
+    link_target = plugins_path.joinpath("nonexistent_file.py")
+    plugin.symlink_to(link_target)
+
+    assert list(plugins._list_plugin_filenames(plugins_path)) == []