Building the engine, and the toolchain
Two halves. The first is how the PyMOL engine inpackages/engine/ compiles — upstream’s build,
which this fork inherits unchanged and which is the part that goes wrong. The second is the
JavaScript toolchain and CI.
The short version is bash scripts/bootstrap.sh; this document is what that script encodes and why
each line of it is there.
1. How the engine builds
Paths in this section are relative topackages/engine/.
1.1 There is one build path, and it is pip
packages/engine/CMakeLists.txt exists but is not a standalone build system:
TARGET_NAME, ALL_SRC, ALL_INC_DIR, ALL_DEF, ALL_LIB, ALL_LIB_DIR, ALL_COMP_ARGS,
ALL_EXT_LINK and SHARED_SUFFIX are all injected by setup.py:381-393. Running cmake . by hand
produces an empty target. The CMake path is the pip path.
Details worth knowing: SUFFIX is Python’s EXT_SUFFIX, so the output is literally
_cmd.cpython-313-darwin.so; PREFIX "", so there is no lib prefix; cxx_std_17; on Apple,
-undefined dynamic_lookup, which is why the extension does not link against libpython;
CMAKE_VERBOSE_MAKEFILE on, which is why build logs run to thousands of lines.
1.2 The PEP 517 chain
packages/engine/_custom_build/backend.py subclasses setuptools’ build_meta and rewrites
sys.argv:
--config-settings use-msgpackc=c++11 reaches setup.py’s argparse; the key-to-flag
mapping is literal.
Gotcha: the backend exports only build_wheel and build_editable. It does not re-export
prepare_metadata_for_build_wheel, so pip cannot ask for metadata cheaply and builds a whole
wheel just to read METADATA, then builds it again. cmake --build gets invoked four times in
one pip wheel run. The 2nd–4th are incremental, but a cold build with a wiped build/ pays
roughly double.
1.3 What setup.py does, in order
- Codegen at import time.
create_all(generated_dir)runs at module scope, on every invocation — even--help.create_shadertext()slurpspackages/engine/data/shaders/*into aconst char* _shader_cache_raw[]string table plus#include/#ifdefdependency tables;create_buildinfo()shells out togit rev-parse HEAD(a non-git copy printsfatal: not a git repositoryand produces an empty SHA — harmless). Output goes to$PYMOL_BLD/generated, defaultbuild/generated, i.e. inside the source tree. - Option parsing — defaults in
class optionsatsetup.py:193-204. - Prefix search —
get_prefix_path(), then each prefix is scanned forinclude,include/freetype2,include/libxml2,include/openvr,lib64,lib. - Two extensions are declared:
pymol._cmd(the whole engine) andchempy.champ._champ(10 C files fromcontrib/champ). build_cmake()makesbuild/temp.../<target>,os.chdirs into it, runscmake <srcdir> -D...thencmake --build . --config Release -j<os.cpu_count()>. Note thechdir: the build is not reentrant.- Version comes from
layer0/Version.hby regex. Currently3.2.0a.
install_pymol is effectively dead code under pip: the launcher that actually gets installed is the
setuptools console script from [project.scripts] pymol = "pymol:launch".
1.4 What actually gets compiled
Roughly 254 objects in a default macOS build. A new
layer4/*.cpp file needs no build-file
edit: setup.py globs the directory and layer4 is in pymol_src_dirs. That is why this fork’s
geometry accessor could be added without touching setup.py or CMakeLists.txt.
1.5 Native dependencies
1.6 The failure catalogue
fatal error: 'mmtf.hpp' file not found. The default isuse_msgpackc = "guess";guess_msgpackc()finds Homebrew’s msgpack headers, returns"c++11", andMoleculeExporter.cppthen wantsmmtf.hpp, which nobody installed. Any dev withmsgpack-cxxin Homebrew hits this on the firstpip install. The fix is to vendor mmtf-cpp, which is what the bootstrap does. Do not “fix” it with--config-settings use-msgpackc=no— see item 2.use-msgpackc=nosilently amputates MMTF and BCIF I/O, which are parity rows. It is not an acceptable default, andscripts/bootstrap.shrefuses to fall back to it.libxml2is keg-only. Without$(brew --prefix)/opt/libxml2onPREFIX_PATHthe build fails on missinglibxml/parser.h. The alternative,--libxml=no, drops COLLADA export.--osx-frameworks=falseis broken in this tree. It appends the relative pathusr/X11, which never resolves. Treat it as unsupported and keep the defaultTrue, which links-framework OpenGL.pymol -c script.pyexits 0 even when the script raises, and with-yit can produce zero output and exit 0. Upstream CI’spymol -ckqy testing/testing.py --run allis therefore only weakly load-bearing. Assert on parsed output, not on exit code.- Ray-traced image-diff tests are not bit-stable on darwin/arm64. Do not gate CI on image tests on macOS.
pip install -e .writes a ~10 MB.sointo the source tree, and upstream’s.gitignoredoes not ignore*.so. This is why the bootstrap installs the engine non-editable and why it appends the build’s leavings to.git/info/exclude(a local-only file that git never merges, so an upstream merge can never see it).PYMOL_PATHmoves between install modes.guess_pymol_path()resolves it to the parent ofmodules/when running from a source tree, and to<site-packages>/pymol/pymol_pathunder a wheel install;PYMOL_DATAandPYMOL_SCRIPTSderive from it. Nothing may hardcode either layout.- Upstream’s macOS CI does not test arm64 — it downloads the x86_64 Miniforge on an Apple Silicon runner, so upstream builds PyMOL under Rosetta. This fork’s arm64 build is, as far as upstream CI is concerned, untested territory.
1.7 Build flags that matter
2. scripts/bootstrap.sh
One command takes a clean clone with only native deps installed to a working pnpm dev. Seven
steps, in order:
- Platform and native deps. macOS requires Homebrew and hard-fails listing whatever of
cmake libpng freetype glew glm netcdf msgpack-cxx libxml2is missing. Linux probes for the headers and warns rather than failing. Catch2 is deliberately not required. - Python venv at
packages/bridge/.venv(override with--venvor$TENMOL_VENV). It prefers an explicit, non-shimmed interpreter — a pyenv shim or a conda python changessetup.py’s prefix search — and warns if the chosen one looks conda-flavoured. Build requirements (pip,numpy,setuptools,cmake) are installed before the engine build, because the build runs--no-build-isolation. - Vendor mmtf-cpp into
packages/engine/.deps/mmtf-cpp(override with$TENMOL_DEPS_DIRor point$TENMOL_MMTF_INCLUDEat an existing copy). A--depth 1clone ofrcsb/mmtf-cpp, unpinned. It goes out of tree and ontoPREFIX_PATHrather than intopackages/engine/include/, which is upstream’s and must stay pristine. - Build the engine into the venv, non-editable, with
--no-build-isolation --config-settings use-msgpackc=c++11andPREFIX_PATHcovering the brew prefix, the keg-only libxml2, and the vendored headers. On failure it tails the log and refuses to suggestuse-msgpackc=no. It then removes thepymol.egg-infopip leaves in the upstream tree. pip install -e packages/bridge[dev]. PySide6 from the engine’s[dev]extra is deliberately not installed: this client replaces the Qt GUI and PySide6 is ~200 MB..git/info/excludegains the build’s leavings (packages/engine/modules/pymol.egg-info/,packages/engine/testing/timings.tab,_cmd*.so,_champ*.so). Local-only, never merged.pnpm install, after checking node ≥ 22 and enabling pnpm through corepack if needed.
--force-pymol (rebuild even if import pymol works), --skip-pymol,
--skip-node, --python PATH, --venv PATH, -q.
The C++ inner loop is not the bootstrap. After editing one packages/engine/layer*/ file:
bash scripts/bootstrap.sh --force-pymol only when you want a clean rebuild.
One upstream dependency to watch. setup.py carries a # TODO: Remove when we move to setup-CMake comment: upstream intends to migrate to a real CMake build, at which point the
--config-settings flag names this script relies on may vanish. All build invocation is kept in
this one script so the blast radius is one file.
3. scripts/doctor.mjs
node scripts/doctor.mjs (or pnpm run doctor — plain pnpm doctor hits pnpm’s own builtin)
preflights everything pnpm dev needs and names the one thing that is missing: node, pnpm, the
workspace install, the venv, import pymol, an MMTF round-trip (which is how you find out the
engine was built with use-msgpackc=no), import tenmol_bridge, offscreen GL context creation, the
two dev ports, and whether the upstream tree is clean.
Its GL probe is a hard-coded CGL recipe with no other branch, so on Linux it reports no offscreen GL
even though packages/bridge/tenmol_bridge/glcontext/egl.py works. That is the doctor’s limitation, not the
bridge’s; bash scripts/test-gl-linux.sh is what actually exercises the Linux path.
4. The JavaScript toolchain
pnpm workspace at the repo root;apps/* and packages/* are the workspace globs. No
Turborepo — pnpm -r run build is the whole task graph.
Two conventions the root tooling enforces:
- The upstream tree is never linted or formatted.
eslint.config.jsignores everypackages/engine/subdirectory by name, plusdocs/, and the Prettier scripts name their targets explicitly instead of using.. Apnpm formatmust never produce a diff insidepackages/engine/. tsconfig.base.jsondoes not setcomposite. A package that wants project references opts in locally. Settingcompositein the shared base makes any app that imports package sources through a path alias fail withTS6307.declaration,declarationMapandincrementalare in the base, so opting in is a three-line change.
strict, noUncheckedIndexedAccess, noImplicitOverride,
noFallthroughCasesInSwitch, verbatimModuleSyntax, isolatedModules. noUnusedLocals and
noUnusedParameters are left to ESLint as warnings so a work-in-progress file still typechecks.
The Python side is deliberately not a pnpm workspace project. It is driven by
scripts/dev-bridge.sh, which takes any python -m tenmol_bridge flag and finds its interpreter in
$TENMOL_VENV, then packages/bridge/.venv, then .venv.
5. CI
Three workflows in.github/workflows/:
Two things about
webclient-ci.yml that are easy to get wrong and are commented in the file
itself: it has no paths-ignore, because two of its gates parse docs/ — scripts/parity.mjs
reads docs/feature-parity.md and scripts/ownership.mjs reads
docs/code-ownership.md — so a docs-only commit can break CI and therefore has to run it.
And the GL-dependent steps on macOS are reported but not fatal, because GitHub’s macOS runners have
no logged-in window server and hardware context creation can legitimately fail there.