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.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 — including name, author, description, tags, dependencies and supported_os, which follow PLUGIN_NAME / PLUGIN_AUTHOR / PLUGIN_DESCRIPTION / PLUGIN_TAGS / PLUGIN_DEPENDENCIES / PLUGIN_SUPPORTED_OS whenever the plugin declares them. To change any of these, edit the constant and bump PLUGIN_VERSION.
  • 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 — the script only overwrites it when the plugin declares PLUGIN_SUPPORTED_OS, and otherwise leaves the field untouched. 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 = ">=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

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_mol — primary 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_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:

  1. release 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. 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 current main registry with the Release URL, source metadata and SHA-256. A concurrent external-plugin registration is preserved by retrying the registry update on the newest main. Manual backfills cannot downgrade newer ZIP entries.
  2. zenodo-upload uploads the generated ZIP assets and tagged source archive to Zenodo and mints a DOI, updating record 21522477 — this collection’s own record, hardcoded once as DEPOSITION_ID in scripts/update_zenodo.py. A tag push always archives; a manual dispatch can opt out with the zenodo input.

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 no deploy branch. External-plugin release notifications continue registering into main.
  • scripts/package_intra_repo_plugins.py --output-dir <temporary-directory> --manifest <temporary-directory>/manifest.json builds 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.py to publish the exact URLs/checksums to REGISTRY/plugins.json. Do not edit the registry manually.
  • sourcePath is the script-maintained path identifying the package sources after downloadUrl moves 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 _old archives.
  • The release workflow explicitly requests a Pages build after its registry commit, because a GITHUB_TOKEN push 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.py resolves 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 use application/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_id input (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.yml and zenodo.yml both 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.

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.