CLAUDE.md — moleditpy-plugins
Development guide for the moleditpy-plugins repository — the official plugin collection for MoleditPy.
Repository Layout
moleditpy-plugins/
├── plugins/ # One subdirectory per plugin; each contains a single .py file
│ └── _old/ # RETIRED hidden plugins — never modify (see below)
├── REGISTRY/
│ └── plugins.json # AUTO-GENERATED — never edit manually (script-maintained)
├── scripts/ # Registry update / trust-store / Zenodo release scripts
├── tests/ # Shared pytest suite (all headless)
└── api-checker/ # Static API compatibility scanner
Running Tests
There are two suites, and they must run as two pytest invocations —
tests/ and tests_gui/ each have their own conftest.py, so collecting
both at once fails with ImportError: cannot import name … from 'conftest'.
# 1. Unit suite — everything mocked (no Qt, no chemistry libs, no network)
python -m pytest tests/ -q
# 2. GUI suite — real PyQt6, offscreen
QT_QPA_PLATFORM=offscreen PYTEST_QT_API=pyqt6 python -m pytest tests_gui/ -q
Both suites must be green, with no test left failing or newly skipped. There is no pass-count to compare against — it changes with every added test, so a remembered number only ever tells you it has changed, not whether anything broke. What is expected instead: every visible plugin above 80% coverage, and anything testable tested. Logic that can be exercised headlessly has no excuse for being uncovered; see the coverage recipe below for the per-plugin number, which is the one that matters.
On Windows python -m pytest is normally fine: a real installation puts its
python.exe ahead of the WindowsApps stub on PATH. Only if where python
shows the stub first does python need spelling out as a full path
(C:/Users/<you>/AppData/Local/Programs/Python/Python313/python.exe).
# Single file / single test
python -m pytest tests/test_initialize.py -q
python -m pytest tests/ -k "Atom Colorizer"
Optional dependencies decide what actually runs
Several GUI modules open with pytest.importorskip("pyvista") /
("vtk") — advanced_rendering, bond_editor, charge_editor,
cube_file_viewer, symmetry_analyzer, vector_viewer, xyz_editor
(~450 tests). Without those packages installed they skip silently, so
run the GUI suite in an environment that has pyvista, vtk, rdkit,
numpy and pillow before trusting a green result. CI covers them in the
dedicated test-gui-render job; the main test-gui matrix does not.
Coverage
Whole repo, unit suite only — a quick global number:
python -m pytest tests/ --cov=plugins --cov-report=term-missing
Per-plugin is what actually matters, and it needs unit + GUI combined
via --cov-append, because only the real-Qt GUI tests move the number
(extract_function AST tests validate logic but never execute the plugin’s
own source lines, so they do not register as coverage):
rm -f .coverage
python -m pytest tests/test_plugin_<name>.py --cov=plugins/<Plugin_Dir> --cov-report=
QT_QPA_PLATFORM=offscreen python -m pytest tests_gui/test_gui_plugin_<name>.py \
--cov=plugins/<Plugin_Dir> --cov-append --cov-report=
python -m coverage report --show-missing
Worked example (Plugin Installer → 92%):
rm -f .coverage
python -m pytest tests/test_plugin_installer.py --cov=plugins/Plugin_Installer --cov-report=
QT_QPA_PLATFORM=offscreen python -m pytest tests_gui/test_gui_plugin_installer.py \
--cov=plugins/Plugin_Installer --cov-append --cov-report=
python -m coverage report
Gotchas:
- The two suites do not follow one naming convention. The installer is
tests/test_plugin_installer.pybuttests_gui/test_gui_plugin_installer.py(no second “plugin”). Guessing a path gives no error — pytest reports “no tests ran”, coverage silently reports the remaining run, and the number looks like a regression. Always check the “N passed” line of both runs. - Missing
pyvista/vtkskips whole GUI modules (see above), which shows up as a large, fake coverage drop. .coveragercomits the generated*_truststore.pytwins.
Every visible plugin is expected to stay above 80% (the README badge). To see whether newly changed lines are covered rather than just the total, intersect the diff with the missing lines:
python -m coverage json -o cov.json
git diff -U0 <base> HEAD -- plugins/<Plugin_Dir>/<file>.py # hunk headers give the new line numbers
Critical Constraints
- NEVER modify anything under
plugins/_old/. These are retired plugins (registry entries with"visible": false) kept only for historical download-URL integrity. Do not edit, test, fix, version-bump, or delete them. Their registry entries stay inplugins.jsonwithdownloadUrlpointing into_old/. - NEVER edit
REGISTRY/plugins.jsonmanually. It is generated by scripts inscripts/. Any version or metadata changes flow in through the plugin.pyfile constants and are picked up by the update scripts automatically. - NEVER edit trust-store variant files (script-created). These are generated outputs.
supported_osis static registry data. Every visible entry inplugins.jsoncarries asupported_osarray using exactly the tokens"Windows","macOS","Linux","WSL". Unlikesupported_python_version, it is not auto-defaulted byupdate_intra_repo_metadata.py— that script round-trips the field untouched. New remote entries pick it up fromPLUGIN_SUPPORTED_OSviaregister_remote_plugin.py. Restrict only plugins that actually execute an OS-restricted backend (currently justpyscf_calculator,["macOS", "Linux", "WSL"]); input generators only write text and run everywhere. Judge by whether any install channel works on the OS, not by PyPI wheels alone —xtb_optimizeris unrestricted because conda-forge shipstblite-pythonfor win-64 even though PyPI has no Windows wheels. Channel-specific caveats belong inPLUGIN_DESCRIPTION, not insupported_os."WSL"means Windows support exists only via the Windows Subsystem for Linux, and since WSL reports as Linux it also satisfies a plain"Linux"entry.- Version bumps go in the plugin
.pyfile only — changePLUGIN_VERSION = "YYYY.MM.DD"using today’s date when the plugin source changes. The registry version is updated by scripts afterward.
Plugin Conventions
Required constants (every plugin .py file)
PLUGIN_VERSION = "2026.06.25" # date-based, bump when file changes
PLUGIN_SUPPORTED_MOLEDITPY_VERSION = ">=4.0.0, <5.0.0"
PLUGIN_SUPPORTED_PYTHON_VERSION = ">=3.9, <3.15" # optional; registry scripts default visible plugins to this
PLUGIN_SUPPORTED_OS = ["Windows", "macOS", "Linux", "WSL"] # optional; declare ONLY when OS-restricted
PLUGIN_AUTHOR = "HiroYokoyama"
PLUGIN_NAME = "My Plugin"
PLUGIN_DESCRIPTION = "One-line description."
PLUGIN_DEPENDENCIES = ["numpy"] # optional; packages the plugin cannot run without
PLUGIN_OPTIONAL_DEPENDENCIES = ["matplotlib"] # optional; extra-feature packages, install never gated on them
PLUGIN_OPTIONAL_DEPENDENCIES maps to the registry’s optional_dependencies
field (written only when non-empty). The Plugin Installer lists those packages
in their own details section as “Installed” / “Not installed” and never raises
the missing-dependency prompt for them — so an import the plugin needs at
startup belongs in PLUGIN_DEPENDENCIES, and one behind a guarded
try: import … belongs here.
Entry points
| Function | When to use |
|---|---|
initialize(context: PluginContext) |
V4 API — preferred for all new plugins |
run(main_window) |
Legacy V2 — auto-registered in the Plugin menu by the host |
autorun(main_window) |
Executed automatically on app startup, no menu entry |
Menu registration — initialize() plugins
Inside initialize(context), call one of:
context.add_menu_action("Category/Item Name...", callback)
context.add_export_action("Label...", callback)
context.add_analysis_tool("Label...", callback)
context.register_file_opener(".ext", callback)
context.register_3d_style("style_name", callback)
Do NOT call context.add_menu_action() for plugins that also define run() — those are auto-registered in the Plugin menu by the host app. Adding it would create duplicate entries.
Some initialize() plugins legitimately have no menu entry:
- File-opener-only plugins (
register_file_opener) - Background/autorun plugins (call
autorun()frominitialize()) - Style-provider plugins (
register_3d_style)
PluginContext API
context.current_mol— primary property for the active RDKit moleculecontext.current_molecule— backward-compat alias forcurrent_mol(both are correct)context.get_main_window()— hostMainWindow(non-None wheninitialize()is called)context.get_window(key)/context.register_window(key, win)— managed dialog lifecyclecontext.register_save_handler(fn)/context.register_load_handler(fn)/context.register_reset_handler(fn)— session persistence
Full API: python_molecular_editor/docs/PLUGIN_DEVELOPMENT_MANUAL_V4.md
Test Infrastructure (tests/conftest.py)
| Symbol | Purpose |
|---|---|
BLOCKED_TOPS |
frozenset of top-level package names replaced with MagicMock (PyQt6, rdkit, numpy, moleditpy, …) |
mock_optional_imports() |
Context manager: installs the MetaPathFinder, cleans up on exit |
load_plugin(path) |
Load a plugin .py as an isolated module — call inside mock_optional_imports() |
make_context() |
Return a stub PluginContext (MagicMock with a non-None main window) |
visible_py_plugins(entry_point) |
Iterate visible single-file .py plugins from the registry, optionally filtered by entry-point name |
mocks_with_real_numpy() |
Like mock_optional_imports() but keeps real numpy in sys.modules, for generators/parsers doing real vector math |
P3, FakeAtom, FakeBond, FakeConf, FakeMol |
Shared fake rdkit bond-graph objects |
extract_function(path, class_name, fn_name, extra_globals=None) |
AST-based method/function extractor for methods on Qt-derived (mocked-base) classes |
Writing new tests
from conftest import mock_optional_imports, load_plugin, make_context, visible_py_plugins
from pathlib import Path
PLUGIN_PATH = Path(__file__).resolve().parents[1] / "plugins" / "My_Plugin" / "my_plugin.py"
def test_initialize_registers_action():
with mock_optional_imports():
mod = load_plugin(PLUGIN_PATH)
ctx = make_context()
mod.initialize(ctx)
ctx.add_menu_action.assert_called_once()
For plugins with pure-Python functions (parsers, generators), load the module once at module level:
with mock_optional_imports():
mod = load_plugin(PLUGIN_PATH)
MY_FUNC = mod.my_function
Test File Inventory
| File | Coverage |
|---|---|
test_registry.py |
Registry integrity, sha256, metadata constants, version consistency |
test_imports.py |
Syntax, entry-point presence, stdlib imports (AST-only) |
test_initialize.py |
initialize(ctx) smoke for all visible plugins |
test_run.py |
run(mw) / autorun(mw) smoke for legacy plugins |
test_save_load.py |
Save/load handler round-trips |
test_menu_registration.py |
Verifies add_menu_action / add_export_action / etc. are called for plugins that need them |
test_plugin_<name>.py |
One file per plugin with non-trivial logic (e.g. test_plugin_atom_colorizer.py, test_plugin_povray_export.py, test_plugin_installer.py) — pure-function tests, initialize() registration checks, save/load handlers, and Qt-method-extraction tests all live together for that plugin |
test_shared_chat_variants.py |
Chat Neo ChatGPT + Local: settings round-trip, latex_to_html fallback, PubChemResolver guards, run() smoke |
test_shared_ai_tool_parsing.py |
Chat Neo Gemini/ChatGPT/Local: tool-call regex, collect_tools, SMILES links, module constants, append_log — parametrized across all 3 variants |
test_shared_chat_optimizer.py |
Chat Neo (all 3 variants) history pruning regressions, Local variant log_usage |
test_shared_input_generator_guards.py |
MOPAC/GAMESS/PySCF/Psi4/NWChem: shared no-molecule run(mw) warning guard |
test_api.py |
Static mw.attr compatibility scan against the main app (skipped if main app absent) |
CI
Six GitHub Actions jobs in .github/workflows/test-plugins.yml:
| Job | Python | Main app cloned | Tests |
|---|---|---|---|
changes |
— | No | No tests: decides whether anything outside REGISTRY/ changed, gating the GUI and API jobs |
test-registry |
3.12 | No | validate_json.py, registry integrity tests, and the registry-sync check |
test-plugins |
3.9 – 3.14 | No | tests/ except test_registry |
test-gui |
3.9 – 3.14 | Auto-cloned by fixture | tests_gui/ (real PyQt6, QT_QPA_PLATFORM=offscreen); no pyvista/vtk, so the rendering modules skip |
test-gui-render |
3.12 | Auto-cloned by fixture | Only the pyvista/vtk-gated tests_gui/ modules, with those packages installed under xvfb-run |
test-api |
3.11 | Yes (--depth 1) |
test_api.py only |
The matrix spans the full range declared by PLUGIN_SUPPORTED_PYTHON_VERSION
(>=3.9, <3.15). Both matrices use fail-fast: false so one interpreter
failing does not cancel the others.
The test-registry job also runs scripts/update_intra_repo_metadata.py and
fails if it produces a diff. This catches two things at once: a registry left
stale after a plugin version bump, and a registry script that no longer imports
on a supported interpreter — scripts are not imported by any test, so nothing
else would notice.
Beyond that workflow: release.yml and zenodo.yml handle publishing (see
Releasing), and test-zenodo.yml rehearses an upload against Zenodo
Sandbox. auto-register-remote-plugin.yml / register-remote-plugin.yml add
externally hosted plugins to the registry when their own repos release.
Releasing
One action starts everything: push a v-prefixed date tag.
git pull --ff-only origin main # never tag behind the remote
git tag v2026.08.01 # today's date, v-prefixed
git push origin v2026.08.01
That sets off a two-step chain:
release.yml(trigger: tags matchingv[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]) creates the GitHub release, titledUp to the release of 2026.08.01— the leadingvis dropped from the title so it reads like the releases made before the prefix was adopted. This repo publishes no build artifacts; the tag’s source archive is the payload.zenodo.yml(trigger:release: published) uploads to Zenodo and mints a DOI, updating record 21522477 — this collection’s own record, hardcoded once asDEPOSITION_IDinscripts/update_zenodo.py.
Nothing else is needed. There is no version constant to bump: a release here
marks a snapshot of the whole collection, and each plugin carries its own
PLUGIN_VERSION.
Guards, so a mistake is loud rather than silent:
- A tag not matching the pattern publishes nothing. If you push a bare
2026.08.01(the old scheme), the release workflow will not fire — dispatch it manually with the tag as input. - Pre-releases are skipped by the Zenodo job: a permanent DOI should not point at one.
update_zenodo.pyrefuses a version identical to the record’s current one, so a re-run cannot mint a duplicate DOI. It fails after uploading the file, which leaves an unpublished draft behind — Zenodo permits only one draft per record, so discard it in the Zenodo UI or the next upload cannot start.- Both workflows also accept
workflow_dispatch, for archiving an older tag or rehearsing against a draft (draft: trueuploads without publishing).
ZENODO_TOKEN must exist as a repository secret (and ZENODO_SANDBOX_TOKEN
for test-zenodo.yml).
Adding a New Plugin
- Create
plugins/My_Plugin/my_plugin.pywith all requiredPLUGIN_*constants. - Implement
initialize(context)(preferred) orrun(main_window)(legacy). - Add
initialize()smoke test totest_initialize.py(or its own test file for non-trivial logic). - Run the registry update script to add the entry to
REGISTRY/plugins.json.
Relationship with the Main App
Plugins are validated against the real PluginContext contract defined in:
../python_molecular_editor/moleditpy/src/moleditpy/plugins/plugin_interface.py
When the main app’s PluginContext API changes, plugin repos may need updates. The test-api CI job catches regressions automatically.