Developing plugins¶
nParse+ plugins are written against nparseplus-sdk — a small,
separately versioned Python package that defines the stable contract. The
app bundles one SDK version; your plugin declares the range it supports and
incompatible combinations are refused cleanly.
Installing the SDK¶
# the SDK alone: types, base classes, the validate CLI
pip install nparseplus-sdk
# ...or from a checkout of the app repo
pip install ./sdk
# for full type checking and for running the real app from source, also:
pip install git+https://github.com/prokopto-dev/nparse-plus
In a pyproject.toml, that first form is:
dependencies = [
"nparseplus-sdk>=1.0,<2",
]
The app itself is not a PyPI package — that last line stays a git install, and you only need it for type checking and live runs.
Zero to running¶
- Turn plugins on in the app. nParse+ Settings > Advanced > Add-ons (plugins) > tick Enable plugins (add-ons), Apply && Save, then restart nParse+. Add-ons are off by default; before this step there is no Plugins page and no Open Plugins Folder tray item.
- Install the SDK into a virtualenv (above) so you get the types and
the
nparseplus-pluginCLI. - Write the plugin. Start by copying
examples/plugins/hello_timer.py— one file, one subscription, a timer row and TTS. - Validate it:
nparseplus-plugin validate hello_timer.py. This imports your module and callsactivate()against a fake context, so it catches most mistakes without launching the app. - Drop it into the plugins folder. Tray > Open Plugins Folder (or the
paths table), and copy the
.pyfile or the package directory in. - Restart nParse+. A consent dialog appears naming your plugin; choose Enable plugin.
- Check it loaded: Settings > Plugins should show it as Active. If
not, the status and its tooltip say why, and
nparseplus.loghas the traceback.
Plugin anatomy¶
A plugin is one .py file or one package directory exposing a module-level
create_plugin() factory:
from nparseplus_sdk import NParsePlugin, PluginContext, PluginMeta
class MyPlugin(NParsePlugin):
meta = PluginMeta(
id="my-plugin", # ^[a-z][a-z0-9_-]{1,39}$ — your identity
name="My Plugin",
version="1.0.0",
requires_sdk=">=1.0,<2", # PEP 440 range vs the app's bundled SDK
min_app_version="1.18.0", # optional
author="You",
)
def activate(self, ctx: PluginContext) -> None:
... # register everything here
def deactivate(self) -> None:
... # optional; runs at app shutdown
def create_plugin() -> MyPlugin:
return MyPlugin()
Everything a plugin may touch arrives through the
PluginContext handed to activate:
| Capability | Call | Notes |
|---|---|---|
| React to events | ctx.subscribe(EventClass, fn) |
classes from nparseplus_sdk.events |
| Parse novel log lines | ctx.add_parser(parser) |
runs after every built-in |
| Periodic work | ctx.add_tick(fn) |
~100 ms, driver thread, 250 ms budget |
| Network fetches | ctx.submit(fetch, apply) |
never block a tick/handler |
| PigParse REST | ctx.pigparse |
e.g. item_prices(server, names) |
| Timer rows | ctx.timers + nparseplus_sdk.timers |
spell-timer window sections |
| Text-to-speech | ctx.speaker.speak("text") |
the app's shared voice |
| Persistent data | ctx.storage |
JSON dict + a private data dir |
| Overlay windows | ctx.add_window(PluginWindowSpec(...)) |
see below |
| Settings pages | ctx.add_settings_page(PluginSettingsPageSpec(...)) |
|
| Logging | ctx.logger |
lands in nparseplus.log |
The threading contract (read this one section)¶
activate(ctx)runs once on the GUI thread before the log driver starts — registrations are race-free, but never block here.- Subscriptions, parsers, and ticks run on the app's single driver
thread. That is the only thread where
ctx.timersand event handling are safe — which is exactly where your callbacks run, so mutate freely inside them and nowhere else. - Never do network I/O in a handler or tick. Use
ctx.submit(fetch, apply):fetch()runs on a worker thread andapply(result)is delivered back onto the driver thread. - Windows and settings pages run on the GUI thread. Read plugin state
from a QTimer poll of a snapshot (see the merchant example) or connect to
wctx.bridge.event_receivedfor pushed events.
The tick budget¶
One thread does log tailing, the parser chain, every timer countdown, the
DPS fight tracker and the sharing inbox. A tick that takes 250 ms therefore
costs the whole app 250 ms — two and a half missed poll intervals and a
visible stutter in every countdown. So the driver supervises plugin ticks
(core/driver.py):
- Each
ctx.add_tickcallback is timed on every run. - A run over
TICK_BUDGET_S= 250 ms counts as a breach. A run under it resets the counter — only consecutive breaches matter, so one GC pause or cold import costs you nothing. - Two consecutive breaches and the tick is removed for the rest of the session. The plugin stays active — its subscriptions, parsers and windows keep working — but that callback never runs again, the eviction is logged at ERROR, and Settings > Plugins annotates your row tick disabled (too slow).
Write ticks accordingly: they should be a cheap "is anything due?" check
that hands real work to ctx.submit. If you need to do something expensive
on a schedule, do it in the fetch closure, not in the tick. The merchant
example's _tick is the shape to copy — it compares timestamps, then
returns or submits.
Events vs parsers¶
The app's parser chain is first-match-wins, and the built-ins already
consume everything EverQuest normally logs — including all chat lines
(say/tell/auction/…), which become typed CommsEvents. So:
- to react to chat or anything the app already understands, subscribe
(
nparseplus_sdk.events.CommsEvent,LineEventis the always-fires firehose, plus ~40 more); - reserve
ctx.add_parserfor log lines no built-in claims (custom server messages, novel formats).
Windows¶
Declare windows during activate; the app materializes them, adds a tray
toggle and an in-game chat command (toggle_<id>_<key>), and persists
geometry/opacity/on-top per window automatically:
from nparseplus_sdk import PluginWindowSpec
ctx.add_window(PluginWindowSpec(key="main", title="My Window",
factory=make_window))
The factory runs on the GUI thread with a
PluginWindowContext. Subclass
nparseplus_sdk.ui.PluginWindow to get the full overlay recipe — frameless,
drag to move, resize from any edge, quit safety.
Doing so also earns your window a row in the user's
Settings → Windows grid, labelled
<your plugin name> — <window title>, so they get opacity and always-on-top
with no work on your part. Two consequences: keep title short and human,
because it is what they read there; and note the opacity slider has a 10%
floor (a window must stay findable), so a default_state below 0.1 is
raised to it the first time they hit Save. A factory that returns a plain
widget instead of a PluginWindow still opens and still gets its tray entry,
but has no overlay state to expose, so it gets no row.
Keep Qt out of your plugin's top-level module. nParse+ imports that
module to read your metadata, and so do nparseplus-plugin validate and
your unit tests, which may run without PySide6 (and nparseplus_sdk.ui
resolves PluginWindow from the host, so it only works inside the app).
Both shipped examples do this by putting the Qt code in a separate module
and importing it lazily from the factory:
# my_plugin/__init__.py — no Qt imports at this level
def _make_window(self, wctx):
from .window import MyWindow # imported only when the app builds it
return MyWindow(wctx, self)
# my_plugin/window.py — Qt at module top level is fine here
from PySide6.QtWidgets import QLabel, QVBoxLayout
from nparseplus_sdk.ui import PluginWindow
class MyWindow(PluginWindow):
def __init__(self, wctx, plugin):
super().__init__(wctx)
layout = QVBoxLayout()
layout.addWidget(QLabel("hello", self))
self.setLayout(layout)
self.restore_visibility() # honor the saved shown state — call last
For a single-file plugin, do the same imports inside the factory function itself. Either way, the rule is the same: nothing Qt at import time.
Consent, from your side¶
- Your plugin is inert until the user answers. On the first launch after
installation, nParse+ shows a dialog with your
name,version,authorand install location.activate()is not called until the user accepts. Fill indescriptionandauthor— that dialog is the only pitch you get. - A decline is remembered. Declining records an approved-but-disabled entry, so the user is never re-asked. Your plugin shows as Disabled and they have to enable it in Settings > Plugins by hand.
- A version bump does not re-prompt. Consent keys on
meta.id, so an update installs and runs without another dialog. Treat that as a responsibility, not a convenience — see Security & trust. - Changing your
meta.idcosts you every user's consent and their stored data. Pick it once. - Your distribution root name is part of your identity too. That is the
package directory (or the
.pyfilename) inside your zip. An update is installed over the copy already on disk, and nParse+ refuses an "update" whose root would land somewhere else — otherwise it would install a second copy beside the first, both claiming your id. Renamingmy-plugin/tomy_plugin/between releases therefore strands your users on the uninstall-and-reinstall path, which loses their stored data. Pick that once as well.
Shipping updates without a registry¶
If your plugin is listed in a registry, updates come for free: the app polls
every registry the user has ticked and offers a newer compatible release.
For a plugin distributed any other way — a link on a forum, a release page,
a zip you hand out — set update_url to an https index document and the app
will poll it for your plugin alone:
meta = PluginMeta(
id="my-plugin",
name="My Plugin",
version="1.2.0",
update_url="https://you.example/my-plugin/index.json",
)
The document is the ordinary registry index format — usually a one-entry file you regenerate on each release:
{
"schema_version": 1,
"plugins": [{
"id": "my-plugin",
"name": "My Plugin",
"latest": {
"version": "1.3.0",
"url": "https://you.example/my-plugin/my-plugin-1.3.0.zip",
"sha256": "…64 hex chars…",
"requires_sdk": ">=1.0,<2"
}
}]
}
Rules worth knowing before you rely on it:
- Your feed can only offer your own id. Any listing whose
idis not the id of the plugin that declared the feed is discarded. A feed is an update channel for one plugin, not a registry. - It never appears in Browse. Users discover plugins through registries; a feed only updates something already installed.
- A registry install wins. If the user got your plugin from a registry, that registry's offer is preferred and your feed's offer counts as a different source — which the app makes the user confirm.
- The sha256 is your own claim. It proves the download matches what you published; it does not mean anyone reviewed it. The app says as much next to the offer.
update_urlmust be https, or your plugin will not load. That is deliberate —nparseplus-plugin validatecatches it before your users do.- The user can switch the whole thing off with Settings > Plugins > Check for plugin updates, and it is never polled while your plugin is disabled or its consent was declined.
Version comparison is PEP 440 on meta.version vs the feed's latest.version,
strictly greater — so keep bumping it.
Testing your plugin locally¶
There is no hot reload. Installing, enabling, disabling and uninstalling all take effect on the next launch, and so does every edit to your source — the module is imported once at discovery. Removing that restart is issue #45.
The practical loop, fastest first:
- Unit tests against
FakePluginContext— no app, no Qt, no restart. This is where the bulk of your iteration should happen. nparseplus-plugin validate <path>— imports and activates your plugin the way the app does.-
In the real app. Either copy the plugin into the plugins folder, or symlink it so you edit in place:
# macOS / Linux ln -s ~/code/my-plugin/my_plugin \ ~/Library/Application\ Support/nparseplus/plugins/my_plugin# Windows (developer mode or an elevated shell) New-Item -ItemType SymbolicLink ` -Path "$env:LOCALAPPDATA\nparseplus\nparseplus\plugins\my_plugin" ` -Target "C:\code\my-plugin\my_plugin"Then restart nParse+ after every change. (Symlinks are rejected inside an installed zip archive; a symlink you create yourself in the plugins folder is just a directory entry and loads normally.)
To exercise a plugin without the game running, point nParse+ at a scratch
log directory and append timestamped lines to an
eqlog_<Name>_<server>.txt file there — see the repo's CLAUDE.md for the
line format.
Debugging¶
ctx.loggeris a child of the app's logger tree, namednparseplus.plugins.<your-id>. Everything it emits lands innparseplus.login the app's log directory —~/Library/Logs/nparseplus/(macOS),%LOCALAPPDATA%\nparseplus\nparseplus\Logs\(Windows),~/.local/state/nparseplus/log/(Linux). Read that file, not stderr: a frozen build has no visible console, soprint()goes nowhere.- Exceptions are caught, not swallowed. Every callback the host wires up
is guarded per plugin and the traceback is logged with your plugin id.
A raise in
activate()flips your plugin to Error in Settings > Plugins and unwinds whatever it had already registered. NPARSEPLUS_NO_PLUGINS=1skips all plugin loading. Use it to confirm a startup problem is yours, and to bisect by moving plugins in and out of the folder between runs.- Slow-tick evictions are logged at ERROR with the measured duration and shown on your row in Settings > Plugins.
Validate, test, package¶
Validate — the SDK installs a CLI that loads your plugin exactly like the app does, checks metadata and version compatibility, activates it against a fake context, and runs the advisory static scan:
nparseplus-plugin validate my_plugin.py
nparseplus-plugin validate my_plugin_pkg/ --app-version 1.18.0 --json
Exit status is 0 only when there are no errors; advisory warnings never fail it.
Test — nparseplus_sdk.testing.FakePluginContext records everything
your plugin registers and lets tests drive it without the app:
from nparseplus_sdk.testing import FakePluginContext
def test_activation():
ctx = FakePluginContext()
create_plugin().activate(ctx)
assert len(ctx.windows) == 1
ctx.run_submitted() # execute queued (fetch, apply) pairs
ctx.publish(event) drives your subscriptions (exact-type match, like the
real bus), and ctx.speaker.spoken / ctx.pigparse.calls record what you
asked for.
Package — zip your plugin so the archive contains exactly one top-level
entry: the package folder (with __init__.py) or the single .py file.
That zip is what users feed to Settings > Plugins > Install from file/URL.
Multi-file plugins must be packages using relative imports — either form
works (from .helper import x or from . import helper). Plugins are
imported under the private nparseplus_user_plugins.* namespace via
spec_from_file_location, never via sys.path; absolute imports of your own
modules (import helper) will not resolve.
Third-party dependencies: there aren't any¶
Your plugin may not depend on anything from PyPI. Every end-user build
is a frozen PyInstaller bundle: there is no pip, no site-packages on
sys.path, and no way for the app to install anything at runtime. If your
plugin imports a package the app does not already bundle, it will work in
your development checkout and fail for every real user.
Your options:
- Vendor it. Copy pure-Python source into your package and import it
relatively (
from .vendored.thing import x). Mind the licence. - Use what the app already ships. As of 1.18 the app's own runtime
dependencies are
PySide6,pydantic,httpx,platformdirs,packaging,colorhash,pathvalidate,websocket-client,certifiandnparseplus-sdk([project.dependencies]in the app'spyproject.toml). They are importable inside the frozen app today — but they are the app's dependencies, not part of the SDK contract, and can be dropped or replaced in any release without a major bump. Depending onhttpxandpydanticis fairly safe (the plugin machinery itself uses both); depending oncolorhashis a bet. - Standard library: fine, with one caveat — PyInstaller bundles the
stdlib modules it can trace, plus a handful the spec names explicitly, and
the spec excludes
tkinteroutright. Common modules are present; if you reach for something exotic, verify it against an actual frozen build rather than your dev environment. - C extensions are impossible. A compiled wheel cannot be added to a frozen bundle from the plugins folder. Pure Python only.
What the SDK deliberately does not let you do¶
Knowing the walls up front saves you designing into one:
- No hot reload. Install, enable, disable, uninstall and code edits all require an app restart (#45).
- No inter-plugin dependencies. Plugins can't import, discover, or call each other; load order is not a contract. If two of your plugins need to cooperate, make them one plugin.
- No publishing to the bus.
ctx.subscribeis read-only access to the event stream — there is nopublish. You cannot synthesize app events for built-in handlers to consume. - No replacing or reordering built-in parsers.
ctx.add_parserappends to the end of a first-match-wins chain, so your parser only ever sees lines no built-in claimed. You cannot intercept, pre-empt, or unregister a built-in. - No tray items of your own. You get one tray toggle per window you declare, automatically. There is no API to add arbitrary tray entries or menu items.
- No threads you own. Use
ctx.submit(fetch, apply). A thread you start yourself has no safe way to touchctx.timersor the bus, won't be joined at shutdown, and won't be unwound if youractivatefails. - No async. Every callback in the contract is a plain synchronous function. There is no event loop to await on.
- No settings of your own in
settings.json. The app'sSettingsmodel drops unknown keys; persist throughctx.storage. - No access to the host beyond
PluginContext. There is no supported route toBackend, and reaching for one through private attributes will break without notice.
Distributing via pip (optional): for users who run nParse+ from source,
you can also publish your plugin as a normal package exposing the
entry point group nparseplus.plugins:
[project.entry-points."nparseplus.plugins"]
my-plugin = "my_plugin:create_plugin"
The frozen app cannot see pip-installed packages — the plugins directory is the mechanism that works for everyone, so ship the zip either way.
Starting from the repo template¶
The fastest path to a publishable plugin is the repository template
(currently at
templates/plugin-repo/
in the app repo; moving to its own "Use this template" repository). It
ships a working starter plugin, unit tests on FakePluginContext, a CI
workflow that runs nparseplus-plugin validate on every push, and a
release workflow that — on a vX.Y.Z tag matching your meta.version —
builds the installable zip, computes its sha256, and publishes a GitHub
release whose body contains the ready-made registry entry
JSON. See also the versioning rules.
Learn from the examples¶
examples/plugins/
in the repository:
hello_timer.py— minimal: one event subscription, a timer row, TTS.merchant_prices/— the full API: auction tracking, storage, throttled PigParse price polling, an overlay window, a settings page.