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 invocationstests/ 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.py but tests_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/vtk skips whole GUI modules (see above), which shows up as a large, fake coverage drop.
  • .coveragerc omits the generated *_truststore.py twins.

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 in plugins.json with downloadUrl pointing into _old/.
  • NEVER edit REGISTRY/plugins.json manually. It is generated by scripts in scripts/. Any version or metadata changes flow in through the plugin .py file constants and are picked up by the update scripts automatically.
  • NEVER edit trust-store variant files (script-created). These are generated outputs.
  • supported_os is static registry data. Every visible entry in plugins.json carries a supported_os array using exactly the tokens "Windows", "macOS", "Linux", "WSL". Unlike supported_python_version, it is not auto-defaulted by update_intra_repo_metadata.py — that script round-trips the field untouched. New remote entries pick it up from PLUGIN_SUPPORTED_OS via register_remote_plugin.py. Restrict only plugins that actually execute an OS-restricted backend (currently just pyscf_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_optimizer is unrestricted because conda-forge ships tblite-python for win-64 even though PyPI has no Windows wheels. Channel-specific caveats belong in PLUGIN_DESCRIPTION, not in supported_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 .py file only — change PLUGIN_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

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() from initialize())
  • Style-provider plugins (register_3d_style)

PluginContext API

  • context.current_molprimary property for the active RDKit molecule
  • context.current_molecule — backward-compat alias for current_mol (both are correct)
  • context.get_main_window() — host MainWindow (non-None when initialize() is called)
  • context.get_window(key) / context.register_window(key, win) — managed dialog lifecycle
  • context.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:

  1. release.yml (trigger: tags matching v[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]) creates the GitHub release, titled Up to the release of 2026.08.01 — the leading v is 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.
  2. zenodo.yml (trigger: release: published) uploads to Zenodo and mints a DOI, updating record 21522477 — this collection’s own record, hardcoded once as DEPOSITION_ID in scripts/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.py refuses 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: true uploads without publishing).

ZENODO_TOKEN must exist as a repository secret (and ZENODO_SANDBOX_TOKEN for test-zenodo.yml).

Adding a New Plugin

  1. Create plugins/My_Plugin/my_plugin.py with all required PLUGIN_* constants.
  2. Implement initialize(context) (preferred) or run(main_window) (legacy).
  3. Add initialize() smoke test to test_initialize.py (or its own test file for non-trivial logic).
  4. 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.