Adding a Wrapper or Adapter¶
This page shows how to integrate external CLI tools (wrappers) and external Python libraries (adapters).
The distinction¶
| Wrapper | Adapter | |
|---|---|---|
| Crosses | Execution boundary (subprocess) | Representation boundary (in-memory) |
| Concerns | Executable, environment, return codes, files | Field mapping, conversion fidelity |
| May | Run subprocesses, produce files | Build one object model from the other |
| Must not | Own workflow logic or chemistry semantics | Execute subprocesses, mutate its input, or cache a copy |
Adding a Wrapper¶
Subclass molpy.wrapper.Wrapper and name its configuration table in tool.
The shared tool core (molpy.wrapper.Tool) resolves, in order, the settings
([wrapper.<tool>], falling back to [wrapper]), the conda / venv
environment, the executable, the environment variables, the time limit and
the logger (molpy.wrapper.<tool>). Any table under [wrapper] is a
tool's, so a third-party wrapper is configured exactly like a built-in one;
give it a default executable by overriding _default_executable(), or leave
the executable to the configuration.
import subprocess
from pathlib import Path
from molpy.wrapper import Wrapper
class PackmolWrapper(Wrapper):
"""Wrapper for the packmol CLI (reads [wrapper.packmol])."""
tool = "packmol"
def _default_executable(self) -> str:
return "packmol"
def run_from_input(self, input_text: str) -> subprocess.CompletedProcess[str]:
"""Run packmol on an input deck given on stdin."""
return self.run(input_text=input_text)
Higher-level methods (like run_from_input) are thin conveniences around
self.run(). Workflow logic — which files to require, what to do with them —
belongs in the calling code, not in the wrapper.
Key points¶
self.run(args, *, input_text=None, capture_output=True, check=True)runs[env prefix] executable argsinself.workdir(created if needed) and returnssubprocess.CompletedProcess- Every failure is a
molpy.wrapper.ToolErrorcarryingtool("wrapper.packmol"),returncodeandtail(the last lines of stderr, else stdout): a non-zero exit (unlesscheck=False), a program that cannot start, a timeout, a missing executable self.check_executable()returns the resolved path or raisesToolErrornaming the settings to fix;self.is_available()is the boolean form. Both look the executable up once per instancerun_step(tool, output, call)checks the executable, runs one step and raisesToolErrorif the file it must write is missing- Wrappers are safe to instantiate even if the tool is not installed
- An engine is the same recipe on
molpy.engine.Engine(Simulation Engines)
Adding an Adapter¶
An adapter is a pair of pure functions in molpy.adapter, named after the
library: to_<lib>(atomistic) and from_<lib>(obj). There is no base class to
subclass — to_rdkit / from_rdkit (src/molpy/adapter/_rdkit.py) is the
reference.
# docs: skip — ASE is not a molpy dependency
import ase
from molpy.core import Atomistic
_XYZ = ("x", "y", "z")
def to_ase(atomistic: Atomistic) -> ase.Atoms:
"""New ``ase.Atoms`` from ``atomistic``; the input is not touched."""
atoms = list(atomistic.atoms)
return ase.Atoms(
numbers=[int(a["atomic_number"]) for a in atoms],
positions=[[a[k] for k in _XYZ] for a in atoms],
)
def from_ase(atoms: ase.Atoms) -> Atomistic:
"""New ``Atomistic`` from ``atoms``; the input is not touched."""
out = Atomistic()
for atom in atoms:
out.add_atom(
atomic_number=int(atom.number),
**dict(zip(_XYZ, map(float, atom.position))),
)
return out
Key points¶
- Build, never mutate. Each function returns a new object. Do not write bookkeeping (ids, tags) onto the caller's structure, and do not keep a cached copy of either side — a cache goes stale the moment the user edits one.
- Carry what the other side can hold. Map the fields the library owns
natively; carry the rest in whatever per-atom / per-bond metadata it offers
(RDKit properties, ASE
arrays/info) so it comes back unchanged. - Document what is lost. List, in the module docstring, every field or
relation that does not survive
from_<lib>(to_<lib>(x)). - Never run subprocesses inside an adapter — that is a wrapper's job.
- Test round-trip fidelity:
from_<lib>(to_<lib>(x))should preserve atom count, connectivity, coordinates and every carried component, and neither function may change its input.
Handling optional dependencies¶
The library is an optional extra, so molpy.adapter must import without it.
Import the module that needs it inside a guard that treats only the library's
own absence as "not installed" — any other ModuleNotFoundError is a broken
install and must surface — and make the names raise a clear ImportError on use
(src/molpy/adapter/__init__.py):
# docs: skip — illustrative; molpy has no ASE adapter
_ASE_MISSING: ModuleNotFoundError | None = None
try:
from molpy.adapter._ase import from_ase, to_ase
except ModuleNotFoundError as exc:
if exc.name != "ase":
raise
_ASE_MISSING = exc
def __getattr__(name: str) -> object:
if name in {"to_ase", "from_ase"} and _ASE_MISSING is not None:
raise ImportError(
f"molpy.adapter.{name} needs ASE: pip install ase"
) from _ASE_MISSING
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Never bind a missing name to None: the failure then surfaces far away, as
'NoneType' object is not callable.
Checklist¶
- Wrapper: subclass
Wrapper, settool, keep.run()calls thin - Adapter: subclass
Adapter[I, E], implement_do_sync_to_internal/external - Optional deps: guard imports, fail at usage not import
- Tests: round-trip fidelity for adapters,
ToolErroron failure for wrappers - Add tests in
tests/test_wrapper/ortests/test_adapter/ - Unit-test wrappers/engines with mocks and script literals (never launch the binary)