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 PySide6, offscreen
QT_QPA_PLATFORM=offscreen PYTEST_QT_API=pyside6 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 — includingname,author,description,tags,dependenciesandsupported_os, which followPLUGIN_NAME/PLUGIN_AUTHOR/PLUGIN_DESCRIPTION/PLUGIN_TAGS/PLUGIN_DEPENDENCIES/PLUGIN_SUPPORTED_OSwhenever the plugin declares them. To change any of these, edit the constant and bumpPLUGIN_VERSION. - 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— the script only overwrites it when the plugin declaresPLUGIN_SUPPORTED_OS, and otherwise leaves the field untouched. 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 = ">=5.0.0, <6.0.0"
PLUGIN_SUPPORTED_PYTHON_VERSION = ">=3.10, <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_LICENSE = "LGPL-3.0-or-later" # optional: actual plugin-code SPDX license
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_V5.md
Test Infrastructure (tests/conftest.py)
License metadata: PLUGIN_LICENSE is optional. Sync it into registry license
when declared, otherwise preserve the curated registry value. Workflows also
accept a license input for code that omits it. New unconfirmed entries use
NOASSERTION, displayed as “Not specified”. Do not add license constants across
existing plugin sources just to populate the registry; use
scripts/update_registry_licenses.py --set id=SPDX after reviewing their notices.
The value describes first-party plugin code, not every dependency or data file.
| Symbol | Purpose |
|---|---|
BLOCKED_TOPS |
frozenset of top-level package names replaced with MagicMock (PySide6, 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 (installs rdkit, scipy, pymatgen-core, pillow best-effort) |
test-gui |
3.9 – 3.14 | Auto-cloned by fixture | tests_gui/ (real PySide6, QT_QPA_PLATFORM=offscreen, plus chemistry/scientific libs best-effort); 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.10, <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.
Branch workflow
Work on the shared plugin collection through the dev branch by default:
git pull --ff-only origin main
git switch dev
git pull --ff-only origin dev
If dev does not exist locally, create it from the current main branch with
git switch -c dev and push it with git push -u origin dev. Commit and push
plugin changes to dev, then let CI validate that branch. Merge dev into
main only after CI is green. Release tags must be created from the verified
main commit, never from an unvalidated working branch.
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 runs release.yml (trigger: tags matching
v[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]), which has two jobs:
releasecreates 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. It checks out the exact tag, builds deterministic ZIPs from tracked package source and public license notices, and attaches them to that release. It then updates the ZIP entries in the currentmainregistry with the Release URL, source metadata and SHA-256. A concurrent external-plugin registration is preserved by retrying the registry update on the newestmain. Manual backfills cannot downgrade newer ZIP entries.zenodo-uploaduploads the generated ZIP assets and tagged source archive to Zenodo and mints a DOI, updating record 21522477 — this collection’s own record, hardcoded once asDEPOSITION_IDinscripts/update_zenodo.py. A tag push always archives; a manual dispatch can opt out with thezenodoinput.
Archiving lives in release.yml rather than in zenodo.yml on a
release: published trigger because that trigger would never fire: the
release is minted with GITHUB_TOKEN, and GitHub raises no
workflow-triggering events for token-authored actions. So do not go looking for
a separate Zenodo workflow run after a tag — there isn’t one, and its absence is
not a failure. zenodo.yml still exists, but as workflow_dispatch only, for
manual backfills and re-runs.
There is no collection version constant to bump: each changed collection
plugin gets today’s YYYY.MM.DD in its own PLUGIN_VERSION. Finish the current
release batch, validate the PR and merged main, then push its date tag. Never
move or overwrite an existing tag.
Package ZIPs and Pages
- Pages continues publishing the root of
main. There is nodeploybranch. External-plugin release notifications continue registering intomain. scripts/package_intra_repo_plugins.py --output-dir <temporary-directory> --manifest <temporary-directory>/manifest.jsonbuilds ZIPs from the checked out source. Only tracked package files and the public license notices enter the ZIP; untracked preferences, calculation results and caches are excluded.- The release workflow runs that command after checking out the tag, uploads
the ZIPs, then uses
scripts/update_release_packages.pyto publish the exact URLs/checksums toREGISTRY/plugins.json. Do not edit the registry manually. sourcePathis the script-maintained path identifying the package sources afterdownloadUrlmoves to a GitHub Release asset. Future tags keep finding the package through this field. Published ZIP metadata changes at release time; ordinary source edits must not repoint an existing released URL or its checksum to an unreleased build.- For the initial migration, switch the registry to the upcoming Release URL
and remove the tracked active package ZIP in the same PR. Immediately tag the
merge commit so the release workflow uploads the asset. Keep package sources
and
sourcePath; future releases build without a tracked ZIP. Old tagged snapshots retain their historical ZIP. Do not remove retired_oldarchives. - The release workflow explicitly requests a Pages build after its registry
commit, because a
GITHUB_TOKENpush does not itself start a Pages build. See GitHub Pages build API. - Before reporting completion, verify the release asset contents/version/SHA,
the registry URL and SHA on
main, the live Pages registry, the successful release and Zenodo jobs, and the Pages build. Retry a failed workflow without moving its tag. Existing releases accept deterministic asset re-uploads.
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.pyresolves the latest published version before creating a draft. Re-running that version is a no-op only when its requested assets have matching checksums; missing/different assets fail without creating another draft.- Record requests explicitly use
application/vnd.inveniordm.v1+json; file endpoints useapplication/json. Never rebuild RDM draft metadata from the legacy public JSON serializer: it omits or reshapes fields. Preserve draft metadata, access, DOI and custom fields, changing version/date only. - A failed upload may leave a resumable draft. Supply its ID through the manual
Zenodo workflow’s
draft_idinput (or--draft-id); the script checks its record family before modifying it. Do not discard the draft as routine recovery. - Manual production backfills include release ZIPs and the source archive.
release.ymlandzenodo.ymlboth acceptworkflow_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.
Copied maintenance scripts
See scripts/COMMON_TOOLS.md for canonical ownership, pinned versions and update instructions. Common script unit tests stay in the canonical repository; retain this repository’s own integration checks.