Skip to content

Commit 33de0ca

Browse files
Merge pull request #99 from amd/alex_ext_pkg
Entry points
2 parents 94ba15c + 51d900b commit 33de0ca

File tree

5 files changed

+158
-28
lines changed

5 files changed

+158
-28
lines changed
Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
# node-scraper external plugins (example)
22

33
This directory lives at **`/docs/node-scraper-external`** in the `node-scraper` repo and contains
4-
an example external plugin package you can install in editable mode.
4+
an example external plugin package that demonstrates how to create plugins for node-scraper.
5+
6+
## Overview
7+
8+
External plugins are discovered by node-scraper via **Python entry points**. This allows plugins
9+
to be distributed as separate packages and automatically discovered when installed.
510

611
## Installation
712

@@ -12,44 +17,126 @@ cd ~/node-scraper
1217
source venv/bin/activate
1318
pip install -e ./docs/node-scraper-external
1419
```
15-
You should see `ext-nodescraper-plugins` installed in editable mode.
1620

21+
This installs `ext-nodescraper-plugins` in editable mode and registers the plugin entry points.
1722

18-
## Verify the external package is importable
23+
## Verify Plugin Discovery
24+
25+
Check that node-scraper discovered the external plugin:
1926

2027
```bash
21-
python - <<'PY'
22-
import ext_nodescraper_plugins
23-
print("ext_nodescraper_plugins loaded from:", ext_nodescraper_plugins.__file__)
24-
PY
28+
node-scraper run-plugins -h
2529
```
2630

27-
## Run external plugins
31+
You should see `SamplePlugin` listed alongside built-in plugins.
2832

29-
Confirm the CLI sees your external plugin(s):
33+
## Run the Example Plugin
3034

3135
```bash
32-
node-scraper run-plugins -h
3336
node-scraper run-plugins SamplePlugin
3437
```
3538

36-
## Add your own plugins
39+
## How It Works
40+
41+
### Entry Points
3742

38-
Add new modules under the **`ext_nodescraper_plugins/`** package. Example layout:
43+
Plugins are registered in `pyproject.toml` using entry points:
44+
45+
```toml
46+
[project.entry-points."nodescraper.plugins"]
47+
SamplePlugin = "ext_nodescraper_plugins.sample.sample_plugin:SamplePlugin"
48+
```
49+
50+
When you install the package, Python registers these entry points in the package metadata.
51+
Node-scraper automatically discovers and loads plugins from the `nodescraper.plugins` entry point group.
52+
53+
### Plugin Structure
3954

4055
```
4156
/docs/node-scraper-external
42-
├─ pyproject.toml
43-
└─ ext_nodescraper_plugins/
44-
└─ sample/
57+
├─ pyproject.toml # Package metadata + entry points
58+
└─ ext_nodescraper_plugins/ # Plugin package
59+
└─ sample/ # Plugin module
4560
├─ __init__.py
46-
└─ sample_plugin.py
61+
├─ sample_plugin.py # Plugin class
62+
├─ sample_collector.py # Data collector
63+
├─ sample_analyzer.py # Data analyzer
64+
└─ sample_data.py # Data model
65+
```
66+
67+
## Creating Your Own External Plugins
68+
69+
### Step 1: Create Package Structure
70+
71+
```bash
72+
mkdir my-plugin-package
73+
cd my-plugin-package
74+
mkdir -p ext_nodescraper_plugins/my_plugin
4775
```
4876

77+
### Step 2: Create pyproject.toml
78+
79+
```toml
80+
[project]
81+
name = "my-plugin-package"
82+
version = "0.1.0"
83+
requires-python = ">=3.10"
84+
dependencies = ["amd-node-scraper"]
85+
86+
[project.entry-points."nodescraper.plugins"]
87+
MyPlugin = "ext_nodescraper_plugins.my_plugin:MyPlugin"
88+
89+
[build-system]
90+
requires = ["setuptools", "wheel"]
91+
build-backend = "setuptools.build_meta"
4992
```
5093

51-
Re-install (editable mode picks up code changes automatically, but if you add new files you may
52-
need to re-run):
94+
### Step 3: Implement Your Plugin
95+
96+
Create `ext_nodescraper_plugins/my_plugin/__init__.py`:
97+
98+
```python
99+
from nodescraper.base import InBandDataPlugin, InBandDataCollector
100+
from pydantic import BaseModel
101+
102+
class MyDataModel(BaseModel):
103+
"""Your data model"""
104+
data: dict
105+
106+
class MyCollector(InBandDataCollector[MyDataModel, None]):
107+
"""Your data collector"""
108+
DATA_MODEL = MyDataModel
109+
110+
def collect_data(self, args=None):
111+
# Collection logic
112+
return MyDataModel(data={})
113+
114+
class MyPlugin(InBandDataPlugin[MyDataModel, None, None]):
115+
"""Your plugin"""
116+
DATA_MODEL = MyDataModel
117+
COLLECTOR = MyCollector
118+
```
119+
120+
### Step 4: Install and Test
121+
53122
```bash
54123
pip install -e .
124+
node-scraper run-plugins -h # Should show MyPlugin
125+
node-scraper run-plugins MyPlugin
55126
```
127+
128+
## Adding More Plugins to This Package
129+
130+
To add additional plugins to this example package:
131+
132+
1. **Create a new module** under `ext_nodescraper_plugins/`
133+
2. **Register the entry point** in `pyproject.toml`:
134+
```toml
135+
[project.entry-points."nodescraper.plugins"]
136+
SamplePlugin = "ext_nodescraper_plugins.sample.sample_plugin:SamplePlugin"
137+
AnotherPlugin = "ext_nodescraper_plugins.another:AnotherPlugin"
138+
```
139+
3. **Reinstall** to register the new entry point:
140+
```bash
141+
pip install -e . --force-reinstall --no-deps
142+
```

docs/node-scraper-external/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ version = "0.1.0"
44
requires-python = ">=3.10"
55
dependencies = ["node-scraper"]
66

7+
[project.entry-points."nodescraper.plugins"]
8+
SamplePlugin = "ext_nodescraper_plugins.sample.sample_plugin:SamplePlugin"
9+
710
[build-system]
811
requires = ["setuptools", "wheel"]
912
build-backend = "setuptools.build_meta"

nodescraper/cli/cli.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,6 @@
5454
from nodescraper.pluginexecutor import PluginExecutor
5555
from nodescraper.pluginregistry import PluginRegistry
5656

57-
try:
58-
import ext_nodescraper_plugins as ext_pkg
59-
60-
extra_pkgs = [ext_pkg]
61-
except ImportError:
62-
extra_pkgs = []
63-
6457

6558
def build_parser(
6659
plugin_reg: PluginRegistry,
@@ -376,7 +369,7 @@ def main(arg_input: Optional[list[str]] = None):
376369
if arg_input is None:
377370
arg_input = sys.argv[1:]
378371

379-
plugin_reg = PluginRegistry(plugin_pkg=extra_pkgs)
372+
plugin_reg = PluginRegistry()
380373

381374
config_reg = ConfigRegistry()
382375
parser, plugin_subparser_map = build_parser(plugin_reg, config_reg)

nodescraper/pluginregistry.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#
2525
###############################################################################
2626
import importlib
27+
import importlib.metadata
2728
import inspect
2829
import pkgutil
2930
import types
@@ -45,12 +46,14 @@ def __init__(
4546
self,
4647
plugin_pkg: Optional[list[types.ModuleType]] = None,
4748
load_internal_plugins: bool = True,
49+
load_entry_point_plugins: bool = True,
4850
) -> None:
4951
"""Initialize the PluginRegistry with optional plugin packages.
5052
5153
Args:
5254
plugin_pkg (Optional[list[types.ModuleType]], optional): The module to search for plugins in. Defaults to None.
5355
load_internal_plugins (bool, optional): Whether internal plugin should be loaded. Defaults to True.
56+
load_entry_point_plugins (bool, optional): Whether to load plugins from entry points. Defaults to True.
5457
"""
5558
if load_internal_plugins:
5659
self.plugin_pkg = [internal_plugins, internal_connections, internal_collators]
@@ -70,6 +73,10 @@ def __init__(
7073
PluginResultCollator, self.plugin_pkg
7174
)
7275

76+
if load_entry_point_plugins:
77+
entry_point_plugins = self.load_plugins_from_entry_points()
78+
self.plugins.update(entry_point_plugins)
79+
7380
@staticmethod
7481
def load_plugins(
7582
base_class: type,
@@ -104,3 +111,42 @@ def _recurse_pkg(pkg: types.ModuleType, base_class: type) -> None:
104111
for pkg in search_modules:
105112
_recurse_pkg(pkg, base_class)
106113
return registry
114+
115+
@staticmethod
116+
def load_plugins_from_entry_points() -> dict[str, type]:
117+
"""Load plugins registered via entry points.
118+
119+
Returns:
120+
dict[str, type]: A dictionary mapping plugin names to their classes.
121+
"""
122+
plugins = {}
123+
124+
try:
125+
# Python 3.10+ supports group parameter
126+
try:
127+
eps = importlib.metadata.entry_points(group="nodescraper.plugins") # type: ignore[call-arg]
128+
except TypeError:
129+
# Python 3.9 - entry_points() returns dict-like object
130+
all_eps = importlib.metadata.entry_points() # type: ignore[assignment]
131+
eps = all_eps.get("nodescraper.plugins", []) # type: ignore[assignment, attr-defined]
132+
133+
for entry_point in eps:
134+
try:
135+
plugin_class = entry_point.load() # type: ignore[attr-defined]
136+
137+
if (
138+
inspect.isclass(plugin_class)
139+
and issubclass(plugin_class, PluginInterface)
140+
and not inspect.isabstract(plugin_class)
141+
):
142+
if hasattr(plugin_class, "is_valid") and not plugin_class.is_valid():
143+
continue
144+
145+
plugins[plugin_class.__name__] = plugin_class
146+
except Exception:
147+
pass
148+
149+
except Exception:
150+
pass
151+
152+
return plugins

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ classifiers = ["Topic :: Software Development"]
1212

1313
dependencies = [
1414
"pydantic>=2.8.2",
15-
"paramiko~=3.5.1",
15+
"paramiko>=3.2.0,<4.0.0",
1616
"requests",
17-
"pytz"
17+
"pytz",
18+
"urllib3>=1.26.15,<2.0.0"
1819
]
1920

2021
[project.optional-dependencies]

0 commit comments

Comments
 (0)