The cmd API, and the RPC contract over it
Map of PyMOL’s cmd surface: what it contains, how command strings are parsed, how feedback
escapes, what the two bundled RPC servers do, and what the C++ core can and cannot notify about.
Every claim carries a file:line out of packages/engine/, which is unmodified upstream.
Where an API does not exist, it is called out explicitly as DOES NOT EXIST.
Deployment: one local PyMOL process, one browser client, loopback only, full filesystem access.
Where the port stands. The bridge described in §8 is built:
packages/bridge/tenmol_bridge/ (server.py transport, session.py envelope, dispatch.py
resolution, policy/ capability grants, pump.py the engine thread, codec.py, blobs.py,
feedback.py). The wire types are packages/protocol/src/envelope.ts +
packages/protocol/src/topics/; the generated API descriptor of §9 is
packages/protocol/src/generated/api.ts; the client is packages/client/src/.
1. What the cmd API surface actually is
1.1 packages/engine/modules/pymol/api.py — the public Python API
api.py is a pure re-export manifest. It contains no function definitions, only
from .<module> import a, b, c blocks (packages/engine/modules/pymol/api.py:4-489). cmd.py does
from .api import * (packages/engine/modules/pymol/cmd.py:319), which is what makes cmd.load,
cmd.get_view, etc. exist.
Measured by AST-parsing api.py: 404 unique symbols (405 import entries, mpng appears
twice — packages/engine/modules/pymol/api.py:337 and :347). Distribution by defining module:
Plus three whole modules exposed as namespaces for
module.xxx command syntax:
cmd.util, cmd.movie, cmd.gui (packages/engine/modules/pymol/api.py:487-489).
Aliases created outside api.py: matrix_transfer = matrix_copy (packages/engine/modules/pymol/api.py:270),
get_setting_legacy = get_setting_float (packages/engine/modules/pymol/api.py:429).
1.2 packages/engine/modules/pymol/keywords.py — the command-language keyword table
get_command_keywords() returns a dict {keyword: [function, min_arg, max_arg, separator, mode]}
(packages/engine/modules/pymol/keywords.py:5-333). Measured: 314 command keywords + 31 help-only
keywords from get_help_only_keywords() (packages/engine/modules/pymol/keywords.py:357-390).
min_arg, max_arg, separator are dead legacy fields for all STRICT/NO_CHECK commands —
they are 0, 0, '' for nearly every row and the header comment says so
(packages/engine/modules/pymol/keywords.py:9-13). The only field that matters for the bridge is mode.
Measured mode distribution over the 314 rows:
fix_dict() adds non-hashed aliases after table construction: show_as, colour,
set_colour, recolour, bg_colour, matrix_transfer, util.mrock, util.mroll
(packages/engine/modules/pymol/keywords.py:339-355).
The table is instantiated per-instance in cmd.py (packages/engine/modules/pymol/cmd.py:328-339) and again
in the multi-instance proxy packages/engine/modules/pymol2/cmd2.py:57-68.
1.3 Runtime-discoverable tables (important for codegen — see §9)
cmd.write_html_ref(file) already walks cmd.keyword, filters python_help entries, and dumps
every docstring to HTML (packages/engine/modules/pymol/cmd.py:211-310). This is a working precedent for
programmatic API extraction and is the model the TS generator should follow.
2. How command strings are parsed and executed (parser.py / parsing.py)
There are two entirely different execution paths, and the bridge exposes both.
2.1 Path A — string command line (cmd.do → C → parser.parse)
cmd.do(commands, log=1, echo=1, flush=0)splits on newlines, setsdefer_updateswhen given >1 command, and calls_cmd.do()underlockcm(packages/engine/modules/pymol/commanding.py:441-475).- C queues/executes and calls back into Python:
G->P_inst->parseis a closure built byparser.new_parse_closure(cmd)(packages/engine/layer1/P.cpp:2038-2040,packages/engine/modules/pymol/parser.py:595-601), invoked asparse(buffer, 0)(packages/engine/layer1/P.cpp:2352,:2390). Parser._parse()(packages/engine/modules/pymol/parser.py:182-481) does, in order:- embed/python-block sentinel handling (
:190-215,:220-222) \line continuation (:226-235);splitting viaparsing.split(:239)/-prefix ⇒ literal Python (:248-252)- assignment-operator sniffing (
=,+=, … frompy_delims,packages/engine/modules/pymol/parser.py:43-46) ⇒ Python (:253-254) - keyword lookup through
cmd.kwhashabbreviation resolution, with ambiguity error (:257-268) - for
mode >= NO_CHECK:parsing.parse_argthenparsing.prepare_call, thenself.result = layer.kw[0](*args, **kwargs)(packages/engine/modules/pymol/parser.py:287-292) @filescript inclusion with recursion +stop_on_exceptions(:402-445)- fallback: unknown token is executed as literal Python (
:446-452)
- embed/python-block sentinel handling (
- Return code semantics:
1= ok,0= exception,None= abort (packages/engine/modules/pymol/parser.py:481). Exceptions are caught and printed, not propagated (:465-478).parser.resultholds the last return value (:137,:292,:337).
cmd.do() returns None and swallows errors. A web client
that only uses do() cannot get return values or structured errors. See §7.
parsing.parse_arg(st, mode) returns [(name|None, value_string), ...] — everything is a
string at this stage (packages/engine/modules/pymol/parsing.py:150-268). Nesters ((...), [...]) are
kept intact so selections and lists survive (:178-227).
parsing.prepare_call(fn, lst, mode, name) (packages/engine/modules/pymol/parsing.py:329-421) is the real
argument binder:
- unwraps decorators, reads
fn.__code__.co_varnames/co_argcount/co_kwonlyargcount/co_posonlyargcount/__defaults__(:346-360) - disables checking for
*args/**kwfunctions viaco_flags & 0xC(:352-353) cmd ?⇒ prints the usage line viadump_argand raisesQuietException(:365-366,dump_argat:311-327) — this is thecolor ?→Usage: color color [, selection ...]feature documented inpackages/engine/modules/pymol/cmd.py:251-255LEGACYmode rewriteskey=valueinto two positional args (:384-392)- injects
_self=when the target accepts it (:379,:414-415) - injects
quiet=0whenfb_mask.resultsis enabled and the caller didn’t pass it (:418-420) - raises
QuietExceptionfor missing required args (:409-411) and too many positionals (:400-403)
prepare_call performs NO type conversion. Every value stays a str. The
individual API functions coerce with int(...)/float(...) internally (e.g.
packages/engine/modules/pymol/setting.py:420-433, packages/engine/modules/pymol/internal.py:262-265).
2.2 Path B — direct Python call (cmd.color("red", "sele"))
Bypasses the parser entirely. Real Python types accepted, real return values, real exceptions
(pymol.CmdException, packages/engine/modules/pymol/__init__.py:468-480). This is the path the RPC bridge
should use for programmatic calls.
2.3 The one typed entry point that already exists: cmd.new_command
commanding.new_command(name, function) (packages/engine/modules/pymol/commanding.py:722-782) is a modern
replacement for extend:
- resolves PEP-563 string annotations via
get_type_hints(:734-740) - wraps the function so that when the caller is
parser.py(detected by comparingsys._getframe(1).f_code.co_filenameagainstpymol.parser.__file__,:748-750) each argument string is coerced through_into_types(packages/engine/modules/pymol/commanding.py:619-712) _into_typessupportsAny,bool(yes/1/true/on/yvsno/0/false/off/n,:629-646),Union/|(:650-661),tuple[...]viashlex.split(:663-685),list[...](:687-693),StrEnum(:695-702),Enumby member name (:705-713), and any class accepting a singlestr(:716-723)- registers into
cmd.keyword,cmd.kwhash,cmd.help_scwithparsing.STRICT(:775-777)
new_command itself exists — a grep for callers finds zero uses
outside its own definition and the cmd.py re-export (packages/engine/modules/pymol/cmd.py:205). And
annotations on the actual API are essentially absent: grepping
^ def <name>(<arg>: <type> across all packages/engine/modules/pymol/*.py yields 6 hits total
(packages/engine/modules/pymol/viewing.py:228, packages/engine/modules/pymol/commanding.py:548,
packages/engine/modules/pymol/editing.py:2141, packages/engine/modules/pymol/editing.py:2167, plus 2 in cgobuilder.py).
get_type_hints-based codegen therefore cannot be the primary strategy today — see §9.
Older extension mechanisms, still used by plugins and needed by the web client’s plugin story:
extend (packages/engine/modules/pymol/commanding.py:788-826), extendaa (:834-857, registers auto-complete
entries), alias (:859-893, builds a lambda: do('…') via eval).
cmd.async_(func, *args) runs a keyword or callable on a daemon thread and pushes a
“please wait …” Message wizard (packages/engine/modules/pymol/commanding.py:897-935); it tracks live
threads in async_threads which cmd.sync() joins (packages/engine/modules/pymol/commanding.py:382-383).
3. Tab completion (parser.complete + completing.py)
Parser.complete(st) acquires lockcm and delegates to _complete
(packages/engine/modules/pymol/parser.py:524-593). Behaviour:
- no space/
@in the string ⇒ complete a command name againstcmd.kwhashwith mode 1 and' 'postfix (:532-536) - otherwise resolve the command, count commas outside
[...]to determine the argument index (remove_lists_re,packages/engine/modules/pymol/parser.py:48,:538-540), then look upcmd.auto_arg[count][command](:543-557) - fallback: filesystem glob completion plus
$ENVVARcompletion (:560-589)
complete_sc(st, sc, type_name, postfix, mode) (packages/engine/modules/pymol/parser.py:50-87) is the shared
worker: it calls sc() if the shortcut is a lambda (:53-57), returns match+postfix on a
unique hit, otherwise prints the candidate list through colorprinting.suggest and returns
the longest common substring (:64-86).
cmd.auto_arg is a list of 4 dicts, one per argument position
(packages/engine/modules/pymol/completing.py:85-315): 1st arg (:87-203, ~130 commands), 2nd (:205-276,
~70), 3rd (:278-304, ~25), 4th (:306-314, 7). Each entry is
[shortcut_or_lambda, type_name, postfix] (packages/engine/modules/pymol/completing.py:52-66).
Dynamic ones are lambdas re-evaluated at completion time: names_sc (:50), fragments_sc
(:37-43), vol_ramp_sc (:46-48), wizard_sc (:68-72), aa_scene_e (:83), volume/ramp
object lists (:77-82).
ExprShortcut special-cases s.<setting> completion inside alter/iterate/label
expressions (packages/engine/modules/pymol/completing.py:7-34).
The Qt GUI drives all of this with a single call: self.cmd._parser.complete(self.command_get())
(packages/engine/modules/pymol/_gui.py:899-904), bound to Tab (packages/engine/modules/pmg_qt/pymol_qt_gui.py:422-424).
It also feeds a plain QCompleter from cmd.kwhash.keywords (packages/engine/modules/pmg_qt/pymol_qt_gui.py:214-215).
Contract problem for the web: complete() returns only a string (or None) and prints
the ambiguity list to the feedback stream. A web autocomplete dropdown needs the candidate
list as data. See §8.4.
Command history is pure Python and GUI-side: _setup_history, back, forward, back_search,
_jump_history, 255-entry cap (packages/engine/modules/pymol/_gui.py:894-942). Trivially reimplemented in React.
4. Feedback / stdout capture (pcatch, feedingback.py, Ortho queue)
4.1 pcatch — stdout/stderr hijack
PCatchInit creates a built-in C module named pcatch with write, writelines, flush,
isatty, _install (packages/engine/layer1/P.cpp:2722-2743). pcatch._install() executes
sys.stderr = sys.stdout = pcatch (packages/engine/layer1/P.cpp:2713-2721). The Qt GL widget calls it at
startup (packages/engine/modules/pmg_qt/pymol_gl_widget.py:104-105).
PCatchWrite gates on Feedback(G, FB_Python, FB_Output) and pushes to
OrthoAddOutput (packages/engine/layer1/P.cpp:2663-2673); PCatchWritelines (:2676) does the same per sequence item
(packages/engine/layer1/P.cpp:2676-2699).
4.2 The Ortho feedback queue
OrthoFeedbackIn(G, buffer) pushes onto I->feedback only when G->Option->pmgui is true
(packages/engine/layer1/Ortho.cpp:492-500). OrthoFeedbackOut(G, ortho) (:502) pops one string and strips ANSI escapes
unless the colored_feedback setting is on (packages/engine/layer1/Ortho.cpp:501-515, decl packages/engine/layer1/Ortho.h:115).
_cmd.get_feedback (CmdGetFeedback, packages/engine/layer4/Cmd.cpp:3866-3899, registered :6463) pops one
string per call, guarded by G->Ready, and is explicitly “ALLOWED DURING MODAL DRAWING”
(packages/engine/layer4/Cmd.cpp:3891).
cmd._get_feedback() (packages/engine/modules/pymol/internal.py:593-606) loops _cmd.get_feedback until empty
and returns a list; it uses lock_attempt and returns None if the lock is busy (:596, :605).
The Qt console polls it on a 500 ms QTimer (packages/engine/modules/pmg_qt/pymol_qt_gui.py:391-394,
:941-958), converts to HTML via colorprinting.text2html (packages/engine/modules/pymol/colorprinting.py:17-25),
and restarts the timer at 0 ms right after a typed command (:960-964). Tk skin does the same
(packages/engine/modules/pmg_tk/skins/normal/__init__.py:489).
G->Option->pmgui gate is a landmine: in a headless/-cq bridge process, OrthoFeedbackIn
drops everything and _get_feedback() returns [] forever. The bridge must launch with a GUI
option profile that sets pmgui, or add a separate sink (§8.3).
4.3 Feedback levels
cmd.feedback(action, module, mask) (packages/engine/modules/pymol/feedingback.py:42-176) — action from
fb_action (set/enable/disable/push/pop, packages/engine/modules/pymol/constants.py:236-241), module from
fb_module (~60 C subsystems with positive indices packages/engine/modules/pymol/constants.py:243-328, plus two
Python-only negative ones: parser=-1, cmd=-2 at :330-331), mask from fb_mask
(output=0x01, results=0x02, errors=0x04, actions=0x08, warnings=0x10, details=0x20, blather=0x40, debugging=0x80, everything=0xFF — packages/engine/modules/pymol/constants.py:331-340).
Positive modules route to _cmd.set_feedback (packages/engine/modules/pymol/feedingback.py:152-156); negative
modules are kept in a per-instance Python dict _self._fb_dict (:157-172, seeded at :34-40).
Querying is cmd._feedback(module, mask) (packages/engine/modules/pymol/feedingback.py:11-26).
colorprinting is currently a no-op shim: error, warning, suggest, parrot are all
literally print (packages/engine/modules/pymol/colorprinting.py:27-31). So severity is lost by the time text
reaches the Ortho queue — everything arrives as one undifferentiated string stream.
print_exc(strip_filenames) trims the traceback of parser frames before printing
(packages/engine/modules/pymol/colorprinting.py:33-47, used at packages/engine/modules/pymol/parser.py:475-476).
4.4 Progress / busy
cmd.get_progress(reset=0) → _cmd.get_progress (packages/engine/modules/pymol/monitoring.py:5-7,
packages/engine/layer4/Cmd.cpp:4315-4345, registered :6486), backed by PyMOL_GetProgress /
PyMOL_GetProgressChanged (packages/engine/layer5/PyMOL.cpp:1874-1880, alongside PyMOL_GetProgress at :1862). Returns a float; <0 means idle
(Qt hides the bar, packages/engine/modules/pmg_qt/pymol_qt_gui.py:931-939).
cmd.ready() wraps _cmd.ready (packages/engine/modules/pymol/monitoring.py:9-11).
Busy text lives only in C: OrthoBusyMessage (packages/engine/layer1/Ortho.cpp:530-539), OrthoBusySlow
(packages/engine/layer1/Ortho.cpp:542) — not exposed to Python.
5. Existing bridge #1: packages/engine/modules/pymol/pymolhttpd.py (529 lines)
5.1 What it provides
HTTPServer+BaseHTTPRequestHandler, single-threaded, one request perhandle_request()in a daemon thread (packages/engine/modules/pymol/pymolhttpd.py:475-499).- Hard localhost check: rejects any client whose address doesn’t start with
127.0.with HTTP 403 (packages/engine/modules/pymol/pymolhttpd.py:61-68). - Three URL verbs, dispatched by splitting
self.urlpathon/(packages/engine/modules/pymol/pymolhttpd.py:98-124):/apply/<method>→pymol_apply(:117, impl:209-335)/getattr/<attr>→pymol_getattr, returnsrepr()of a value that was pre-registered under the key/getattr/<attr>in the session dict (:118-119, impl:126-142)/echo/<...>→ debug echo (:120-122, impl:410-437— noteecho_argstakes no args but is called with one at:122, i.e. this endpoint is broken)- anything else → static file serving from
pymol_root(:123-124, impl:337-362)
- Static serving blocks
..(:342-344), auto-appendsindex.htmlfor dirs (:351-352), and guesses MIME from extension for.html/.js/.jpg/.png/.gif/.sdf/.mol/.pwg(:364-385).
5.2 Wire format of /apply
GET query string is parsed with parse.parse_qs (packages/engine/modules/pymol/pymolhttpd.py:93-96).
POST is not actually parsed — self.fs = self.headers (:89-91), so POST bodies are ignored.
That is a real bug, not a simplification.
Underscore-prefixed params are control params (packages/engine/modules/pymol/pymolhttpd.py:219-251):
All non-underscore params become string kwargs, taking only
value[0]
(packages/engine/modules/pymol/pymolhttpd.py:250-251) — so no repeated params, and every value is a string.
Method resolution (packages/engine/modules/pymol/pymolhttpd.py:279-281): look up the exact name in
self.session, else if it starts with pymol.cmd. strip that 10-char prefix and getattr on
the cmd module. So the entire cmd namespace is reachable as
/apply/pymol.cmd.<anything>. Registered session overrides
(packages/engine/modules/pymol/pymolhttpd.py:462-473):
_quit→ shuts the server down and emits an HTML page withwindow.close()/document.location.replace(href)(:306-327)pymol.cmd.delete_andpymol.cmd.super_— trailing-underscore aliases becausedeleteandsuperare JS/Python reserved-ish wordspymol.cmd.labelis remapped tocmd.label2— the no-eval variant, explicitly for safety
packages/engine/modules/pymol/pymolhttpd.py:144-207):
wrap_natives=1⇒{"status": "OK"|"ERROR", "result": ...}, else the bare result (:144-149)- content negotiation on
Acceptagainst['text/json','application/json'](packages/engine/modules/pymol/pymolhttpd.py:33,:163-172); anything else gets an HTML<pre>debug page - errors:
send_json_error(code, message)(:174-187) andsend_exception_jsonwhich appends the full Python traceback split into lines (:189-207) - batching: when
_jsonis a list of lists, only the last result is returned —send_multi_result_listis initialisedFalseand then re-set toFalse, so the multi-result branch at:329-330is dead code (:216,:269,:331-332) - no-cache headers + optional custom headers on every response (
:387-408)
5.3 How it is launched
Only via.pwg files: importing._processPWG (packages/engine/modules/pymol/importing.py:516-610) parses
port, header add K "v", logging, root, browser, launch <module>, report <url>,
delete, options, wrap_native_return_types and then constructs
PymolHttpd(port, root, logging, wrap_native, headers=headers) and .start()s it
(packages/engine/modules/pymol/importing.py:592-597), optionally opening a browser (:598-601).
5.4 Why it was not built on
Replace, do not build on. Concretely: no WebSocket and therefore no server→client push; no streaming of feedback; POST bodies discarded (:89-91); every argument arrives as a string with
no type information; batch semantics broken (:269, :329-332); /echo broken (:122 vs :410);
threading.Event.isSet() and Thread.setDaemon() are removed/deprecated Python APIs
(:490, :497, :502); no auth token at all — localhost-IP-only (:64-67) means any local
process, including any other browser tab via a plain <img>/fetch to
http://localhost:8080/apply/pymol.cmd.system?..., can drive PyMOL. That is unacceptable for a
process with full filesystem access.
Worth keeping as design input: the pymol.cmd.<name> flat namespace idea, the
[name, args, kwds] triple, the {status, result} envelope, the delete_/super_ reserved-word
convention, the label→label2 no-eval substitution, and the localhost check as a first layer.
6. Existing bridge #2: packages/engine/modules/pymol/rpc.py (474 lines, XML-RPC)
launch_XMLRPC(hostname='', port=9123, nToTry=5) (packages/engine/modules/pymol/rpc.py:411-472):
- host from
$PYMOL_RPCHOSTelselocalhost(:422-424) - tries 5 consecutive ports (
:428-435) SimpleXMLRPCServer(..., logRequests=0, allow_none=True)(:430-431)serv.register_instance(cmd)(packages/engine/modules/pymol/rpc.py:441) — this exposes the ENTIREcmdmodule over XML-RPC by attribute lookup. Every one of the 404 api symbols is callable.- plus 17 hand-written legacy
rpcXxxwrappers registered under camelCase names (:444-465):ping,resetCGO,renderCGO,sphere,spheres,cylinder,deleteObject,deleteAll,loadPDB,loadMolBlock,loadSurface,loadSurfaceData,loadFile,getNames,countAtoms,idAtom,help,getAtomCoords labelandrotateare registered last and therefore SHADOW the realcmd.labelandcmd.rotate— the source itself flags this: “legacy stuff, should be removed because overwrites API names!” (packages/engine/modules/pymol/rpc.py:463-465).rpcLabelactually creates a pseudoatom (:38-54) andrpcRotatetakes an xyz vector (:354-367).register_introspection_functions()givessystem.listMethods/system.methodSignature/system.methodHelp(packages/engine/modules/pymol/rpc.py:467)rpcHelp(what)reflects on__defaults__/__code__.co_varnamesto build a usage string (packages/engine/modules/pymol/rpc.py:382-408) — another codegen precedent- module-global
cgoDictaccumulates CGO buffers per id (:426-427, used:56-147) - launched with the
-RCLI flag →options.rpcServer = 1(packages/engine/modules/pymol/invocation.py:184,:453) which defers'_do__ /import pymol.rpc;pymol.rpc.launch_XMLRPC()'(packages/engine/modules/pymol/invocation.py:521-522)
6.1 Why it was not built on
Replace. XML-RPC is synchronous request/response only, has no binary type (base64-bloated), no push, no streaming, no kwargs (XML-RPC is positional-only — socmd.load(f, object='x') is
unreachable via the generic instance registration), and register_instance on a module means
arbitrary attribute traversal into cmd (including cmd.system, cmd.run, cmd.spawn,
cmd._quit). It binds to ''/$PYMOL_RPCHOST with no localhost restriction and no auth
(:422-424, :430) — strictly worse than pymolhttpd.
Worth keeping as design input: the “expose the whole cmd module generically” decision, and
rpcHelp’s signature reflection.
7. What the C++ layer can emit
7.1 Exists
Big caveat on
enabledCallback: it is wrapped in #ifdef _PYMOL_LIB
(packages/engine/layer3/Executive.cpp:315-319), so in a normal Python build it is compiled out. And
PyMOL_SetIsEnabledCallback takes a raw C function pointer (packages/engine/layer5/PyMOL.h:565) — there is
no Python binding; grepping the whole tree for SetIsEnabledCallback finds only the
declaration, the definition, and the call site. It is the right hook shape but is not usable
from Python today.
7.2 DOES NOT EXIST
A repo-wide grep forNotify/notify across layer0–layer5 returns zero matches. There is
no event bus in the C++ core. The following have no notification whatsoever:
- Object list changed (object created/deleted/renamed/reordered/grouped). Only pollable via
cmd.get_names()(packages/engine/modules/pymol/querying.py:1155-1199, 10 modes) /cmd.get_names_of_type(:1459) /cmd.get_object_list(:131). - Object enabled/disabled —
ReportEnabledChangeexists but is_PYMOL_LIB-only and unbound (see above). Pollable viacmd.get_names(..., enabled_only=1)orcmd.get_vis()(packages/engine/modules/pymol/viewing.py:899-901). - View changed (camera moved by mouse drag,
zoom,orient, scene recall). Onlycmd.get_view()polling (packages/engine/modules/pymol/viewing.py:634-733; returns an 18-float tuple, layout documented at:663-677). - Frame / state changed (movie playing,
frame,mset). Onlycmd.get_frame()(packages/engine/modules/pymol/moving.py:984),cmd.get_state()(:958),cmd.get_movie_playing()(:64). - Selection changed (named selection created/modified, atom picked in viewport). There is a
click string (
packages/engine/layer4/Cmd.cpp:1420-1430) but greppingpackages/engine/modules/forget_click_stringreturns zero Python callers — it is dead from Python’s side. - Representation/color changed on an object.
- Scene list changed —
cmd.get_scene_list()poll only (packages/engine/modules/pymol/viewing.py:919). - Wizard prompt/panel changed —
cmd.get_wizard()/get_wizard_stack()poll only (packages/engine/modules/pymol/wizarding.py:156-174);dirty_wizard(:146) sets a C flag, no Python signal. - Undo/redo stack changed —
cmd.undo/redo/push_undoexist (packages/engine/modules/pymol/api.py:223,:227,:256) with no depth query and no event. - Busy message text —
OrthoBusyMessageis C-internal (packages/engine/layer1/Ortho.cpp:530-539). - Severity/category on feedback lines —
colorprinting.error/warning/suggest/parrotare allprint(packages/engine/modules/pymol/colorprinting.py:27-31); the stream is untyped text. - Return value from a parsed command line —
cmd.do()returnsNone(packages/engine/modules/pymol/commanding.py:441-475);parser.resultis stored (packages/engine/modules/pymol/parser.py:292) but is not returned through_cmd.do.
8. The bridge
8.1 Process & transport
Single Python process, launched withpmgui enabled so OrthoFeedbackIn actually queues
(packages/engine/layer1/Ortho.cpp:494). It uses pymol2.SingletonPyMOL, not
pymol2.PyMOL (packages/engine/modules/pymol2/__init__.py:79-131): pcatch writes through the
file-scope SingletonPyMOLGlobals pointer (packages/engine/layer1/P.cpp:2667), so a non-singleton
instance loses stdout capture. PyMOL.cmd is a pymol2.cmd2.Cmd proxy that builds its own
keyword/shortcut tables (packages/engine/modules/pymol2/cmd2.py:57-78).
- HTTP/1.1 on
127.0.0.1:<port>(bind explicitly to loopback, unlikerpc.py:430) for:GET /healthz→{version, renderer, pid}fromcmd.get_version()(packages/engine/modules/pymol/api.py:147) andcmd.get_renderer()(packages/engine/modules/pymol/api.py:142)GET /schema→ the generated API descriptor (§9)GET /blob/{id}andPOST /blobfor large payloads (PNG fromcmd.pngpackages/engine/modules/pymol/exporting.py:499, session bytes fromcmd.get_sessionpackages/engine/modules/pymol/exporting.py:371,cmd.get_bytes:679, geometry buffers)POST /uploadfor drag-and-drop file ingestion (mirrorspackages/engine/modules/pmg_qt/pymol_gl_widget.py:262-270)
- WebSocket on
127.0.0.1:<port>/ws— the primary channel. Binary frames carry MessagePack (msgpackis already a dev dependency,pyproject.toml:33); text frames carry JSON for debuggability. One socket per client; server rejects a second concurrent socket. - Auth: a 256-bit token minted at startup, written to a 0600 file under the user’s runtime dir
and passed as
?token=. Reject on mismatch and enforceOriginallow-listing and keep the127.0.peer check frompackages/engine/modules/pymol/pymolhttpd.py:63-67. Without this, any web page can reachcmd.system(packages/engine/modules/pymol/api.py:279) /cmd.run(:436).
8.2 Message envelope
idis a client-monotonic u32; everycall/batch/do/completegets exactly one terminalokorerr. Events carry noid.mis a flatcmdattribute name —"get_view", not"pymol.cmd.get_view". Droppymolhttpd’s 10-char-prefix trick (packages/engine/modules/pymol/pymolhttpd.py:280-281) but keep its reserved-word aliasing convention (delete_,super_,packages/engine/modules/pymol/pymolhttpd.py:468-469) as a client-side concern only — the wire uses real names.- Dotted names
util.cbag,movie.produceetc. resolve by splitting on.and walking, matching the keyword table (packages/engine/modules/pymol/keywords.py:307-332). - Allow-list, not attribute traversal. The dispatcher resolves
magainst a frozen dict built fromapi.py’s exports ∪cmd.keyword, minus a deny-list. Deny by default:system(packages/engine/modules/pymol/api.py:279),run/spawn(:436-437),quit/_quit(packages/engine/modules/pymol/keywords.py:281-282),cd(:39), everything starting with_. Gate them behind an explicit--allow-unsafebridge flag. Notepymolhttpdalready made this call forlabel→label2to avoideval(packages/engine/modules/pymol/pymolhttpd.py:473); do the same, and additionally sandboxalter/iterate/alter_state/iterate_state(theLITERAL1/LITERAL2commands,packages/engine/modules/pymol/keywords.py:19,21,144,145,147) behind the same flag since their last argument iseval’d Python.
8.3 Typed arguments
Becauseprepare_call never converts types (§2.1) but direct Python calls do accept real types
(§2.2), the bridge should always take Path B: JSON/MessagePack values are passed straight
through as Python objects to getattr(cmd, m)(*a, **k).
- ints/floats/bools/strings/lists/dicts map 1:1
- 18-float view vectors are plain arrays →
cmd.set_view(list)(packages/engine/modules/pymol/viewing.py:734) bytesride as MessagePack bin, or via/blobfor anything > 256 KiBNone⇒ PythonNone(needed for e.g.set_bond(..., selection2=None),packages/engine/modules/pymol/setting.py:116)- Never send
_self; the dispatcher injects the instance’sCmdproxy itself, mirroringparsing.prepare_call’s behaviour (packages/engine/modules/pymol/parsing.py:379,:414-415). - Set
quiet=1by default; the parser’s implicitquiet=0injection (packages/engine/modules/pymol/parsing.py:418-420) applies only to thedopath and should be preserved there so the console behaves like PyMOL’s. - Wrap every dispatch in
try/except; mappymol.CmdException(packages/engine/modules/pymol/__init__.py:468-480, carries.messageand.label) andparsing.QuietException(packages/engine/modules/pymol/parsing.py:71-72) tot:"err"withkind/label/message, and everything else tokind:"PythonError"plus atraceback.format_exceptionarray — the same shapesend_exception_jsonalready produces (packages/engine/modules/pymol/pymolhttpd.py:189-207).
do path (t:"do") exists for the console widget only. It calls cmd.do(line)
(packages/engine/modules/pymol/commanding.py:441), which returns None and swallows errors by design
(packages/engine/modules/pymol/parser.py:465-478). The ok value for a do is therefore always null; the
result surfaces as feedback text. Do not use do for programmatic UI actions.
Long-running calls (ray packages/engine/modules/pymol/viewing.py:1662, png with ray=1
packages/engine/modules/pymol/exporting.py:499, align/super, map_generate) must run off the socket
read loop. Use cmd.async_-style threading (packages/engine/modules/pymol/commanding.py:897-935) or a bounded
worker pool, streaming t:"prog" frames from cmd.get_progress()
(packages/engine/modules/pymol/monitoring.py:5-7) until the terminal ok. cmd.sync(timeout, poll)
(packages/engine/modules/pymol/commanding.py:382-439) is the barrier when the client needs
“everything queued has run” semantics.
8.4 Completion contract
t:"complete" must return data, not a string, because parser._complete prints the
candidate list instead of returning it (packages/engine/modules/pymol/parser.py:64-69, :583-586).
Two options, in order of preference:
- Reimplement in the bridge. Duplicate the 60-line dispatch of
Parser._complete(packages/engine/modules/pymol/parser.py:528-596) but return{ "prefix": str, "candidates": [str], "kind": "command"|"selection"|"color"|"setting"|"file"|…, "commonPrefix": str }. All inputs are public:cmd.kwhash(packages/engine/modules/pymol/cmd.py:332),cmd.auto_arg(packages/engine/modules/pymol/cmd.py:380), andShortcut.interpret(keyword, mode)(packages/engine/modules/pymol/shortcut.py, used atpackages/engine/modules/pymol/parser.py:58).kindcomes for free — it is thetype_nameelement of eachauto_argtriple (packages/engine/modules/pymol/completing.py:52-66). - Wrap
cmd._parser.complete(st)(packages/engine/modules/pymol/parser.py:524-526) and read the printed candidate list out of the feedback queue. This is what shipped: the console callscmd._parser.complete(granted inpackages/bridge/tenmol_bridge/policy/grants/wp-11-console.py) and pairs the completed string with the feedback lines (apps/web/src/features/console/CommandLine.tsx).
t:"usage" → run the ? path: parsing.dump_arg(name, arg_names, nreq)
(packages/engine/modules/pymol/parsing.py:311-327) reimplemented to return the usage string, giving the web
command line the same color ? behaviour documented at packages/engine/modules/pymol/cmd.py:251-255.
And t:"help" → cmd.keyword[name][0].__doc__ / cmd.help_only[name][0].__doc__, exactly as
helping.help does (packages/engine/modules/pymol/helping.py:62-87) and write_html_ref does in bulk
(packages/engine/modules/pymol/cmd.py:285-307).
8.5 Change events
Given §7.2, change detection is built in three tiers. Tier 0 — free today, no C++ change. A bridge-side “tick” task (default 100 ms, coalesced, suspended when no client is connected) that:- drains
cmd._get_feedback()(packages/engine/modules/pymol/internal.py:593-606) →topic:"feedback". Must tolerate theNonereturn when the lock is contended (:605). Cap and coalesce; the Qt console uses a 500 ms period and resets to 0 ms right after a typed command (packages/engine/modules/pmg_qt/pymol_qt_gui.py:958,:964) — mirror that: bump to 0 ms after anydo/call, back off to 250 ms when idle. - calls
cmd.get_progress()(packages/engine/modules/pymol/monitoring.py:5-7) →topic:"progress"when the value changes or crosses the<0idle threshold. - calls
cmd.get_setting_updates()(packages/engine/modules/pymol/setting.py:440-447) →topic:"settings", emitting{name, value}per changed index viasetting.name_dict(packages/engine/modules/pymol/setting.py:41) +cmd.get_setting_tuple(:413). This one is a real push-quality signal — it is exactly what the Qt GUI uses to keep its setting widgets in sync (packages/engine/modules/pmg_qt/pymol_qt_gui.py:952-957). - calls
_cmd._getRedisplay(COb, reset)(packages/engine/layer4/Cmd.cpp:6378,packages/engine/modules/pymol2/__init__.py:36-37) as a coarse dirty bit. When set, re-poll the cheap aggregates and diff.
Gate all of Tier 1 behind the redisplay dirty bit so an idle session costs ~one syscall per tick.
Cost concern:
view at 100 ms is fine; objects on a 500-object session is not — key it to the
dirty bit and to a “mutating call just completed” hint from the dispatcher (any m not starting
with get_/count_).
Tier 2 — C++ additions. Tier 0 + Tier 1 shipped first (pure Python, zero C++ risk, works
against an unmodified backend). Three of the seven items below then landed in the engine, each
inside a /* tenmol web client -- BEGIN/END */ sentinel block so the diff against upstream is
greppable:
- Landed. Four monotonic counters on
struct CExecutive—m_web_panel_version,m_web_enable_version,m_web_name_version,m_web_rep_version(packages/engine/layer3/ExecutiveDef.h:89-98), bumped atExecutiveInvalidatePanelList(Executive.cpp:1521),ReportEnabledChange(:315), the rename path (:3686),ExecutiveUpdateCoordDepends(:1931) and the transform paths (:7695,:7717). Read through_cmd.web_get_versions(packages/engine/layer4/Cmd.cpp:6472, implementationpackages/engine/layer4/CmdWebGeometry.cpp). This alone turns Tier 1 from “diff N object names” into “compare four integers”, which is what made an idle session cost ~1 us per poll. Consumed bypackages/bridge/tenmol_bridge/state/repversions.py. - Partly landed.
ReportEnabledChange(Executive.cpp:313) now bumpsm_web_enable_versionfor every enable and disable; the#ifdef _PYMOL_LIBcallback above it is untouched. No_cmd.get_events()queue was added — the counter made one unnecessary. - Not landed. There is still no view counter in
SceneSetView/SceneRotate/SceneTranslate; the camera is detected by comparing the 18-floatget_view()with an epsilon (Tier 1). - Landed differently. Rather than emit an event from
PyMOL_SetClickReady(packages/engine/layer5/PyMOL.cpp:2594-2600), the client resolves a pick it already made against the engine through_cmd.web_resolve_pick(Cmd.cpp:6473)._cmd.get_click_string(Cmd.cpp:6451) still has zero Python callers upstream. - Not landed.
colorprinting.error/warning/suggest/parrot(packages/engine/modules/pymol/colorprinting.py:27-31) are still bareprint, so the stream is untyped text; the bridge classifies lines itself inpackages/bridge/tenmol_bridge/feedback.py. - Not needed. The
G->Option->pmguigate onOrthoFeedbackIn(packages/engine/layer1/Ortho.cpp:494) stands; the bridge boots withpmguienabled so the queue fills (§8.1). - Not landed.
parser.result(packages/engine/modules/pymol/parser.py:292) is still not returned through_cmd.do, so adoframe still resolves tonulland the result surfaces as feedback text.
8.6 Key bindings
cmd.set_key(key, fn, arg, kw) (packages/engine/modules/pymol/controlling.py:719-780) writes into
cmd.key_mappings (packages/engine/modules/pymol/cmd.py:345, defaults from keyboard.get_default_keys()).
Invocation from C goes through cmd._special / _ctrl / _alt / _ctsh
(packages/engine/modules/pymol/internal.py:447-511), which resolve via _invoke_key
(packages/engine/modules/pymol/internal.py:427-446) and fall back to matching scene names and view names
(packages/engine/modules/pymol/internal.py:470-483). Special-key numeric codes are GLUT’s, mapped in
special_key_codes (packages/engine/modules/pymol/internal.py:398-423) with modifier prefixes
''/SHFT/CTRL/CTSH/ALT (packages/engine/modules/pymol/internal.py:390-396).
Web contract: expose GET /keymap (serialise cmd.key_mappings; string values are PML, tuple
values are opaque Python and should serialise as {"kind":"python","repr":...}), and let the
React key handler send {t:"key", key:"CTRL-C"} which the bridge routes to
cmd._invoke_key(key). Do not try to synthesise GLUT codes in the browser.
9. Generating the TypeScript client from Python
404 method signatures are generated, not hand-written. The generator lives intools/gen-api/
(extract.py, emit.mjs, api-schema.json) and its output is
packages/protocol/src/generated/api.ts. Signatures come from a live PyMOL via
inspect.signature, not from parsing api.py — which is a re-export manifest with no function
bodies.
9.1 Reality of the source material
- Type annotations: 6 in the entire API (
packages/engine/modules/pymol/viewing.py:228,packages/engine/modules/pymol/commanding.py:548,packages/engine/modules/pymol/editing.py:2141,:2167, pluscgobuilder.py). Soget_type_hintsalone yields almost nothing. - Defaults: universally present and informative. Every API function is
def f(a='(all)', state=-1, quiet=1, *, _self=cmd).inspect.signaturegives name, kind (POSITIONAL_ONLY / POSITIONAL_OR_KEYWORD / KEYWORD_ONLY / VAR_*), and default value.parsing.prepare_callalready relies on exactly this data (packages/engine/modules/pymol/parsing.py:346-360) — so doesrpcHelp(packages/engine/modules/pymol/rpc.py:392-405). - Docstrings: highly regular. Uppercase section headers
DESCRIPTION,USAGE,ARGUMENTS,NOTES,PYMOL API,EXAMPLES,SEE ALSO.write_html_refalready parses them by detectingline.isupper()andSEE ALSO(packages/engine/modules/pymol/cmd.py:290-303).ARGUMENTSblocks arename = type: description {default: x}— e.g.packages/engine/modules/pymol/commanding.py:107-110,packages/engine/modules/pymol/feedingback.py:52-58,packages/engine/modules/pymol/viewing.py:650-656. - Completion tables give semantic domains:
cmd.auto_arg[i][command]says argumentiofcommandis a'selection'/'color'/'setting'/'representation'/'object'/'scene'/'palette'/ … (packages/engine/modules/pymol/completing.py:52-66,:85-315). - Runtime enums:
_cmd.get_setting_indices()(packages/engine/modules/pymol/setting.py:38) for all setting names;cmd.get_color_indices()(packages/engine/modules/pymol/internal.py:579) for all colors; theShortcutobjectsviewing.cartoon_sc,viewing.clip_action_sc,viewing.scene_action_sc,controlling.button_sc/but_mod_sc/but_act_sc,editing.flag_sc/flag_action_sc/order_sc,creating.map_type_sc/group_action_sc,exporting.cache_action_sc,moving.mview_action_sc,commanding.reinit_sc— all referenced frompackages/engine/modules/pymol/completing.py:97-307and each exposes.keywords(packages/engine/modules/pymol/shortcut.py:41-49). keywords.get_command_keywords()gives themodeper command, which tells the generator which commands areLITERAL*/PYTHON/SECUREand must be typed specially or excluded (packages/engine/modules/pymol/keywords.py:14-333).
9.2 Pipeline
extract.py (a build-time script, run under pymol -cq extract.py, never shipped):
cmd.auto_arg (packages/engine/modules/pymol/cmd.py:380),
setting.index_dict (packages/engine/modules/pymol/setting.py:38), cmd.get_color_indices
(packages/engine/modules/pymol/api.py:112), keywords.get_command_keywords (packages/engine/modules/pymol/keywords.py:5).
Step 2 — type inference, in priority order per parameter:
- explicit annotation, if present (the 6 cases)
auto_argdomain → branded alias:'selection'→Selection,'color'→ColorName,'object'→ObjectName,'setting'→SettingName,'representation'→RepName,'scene'→SceneName,'palette'→PaletteName- default-value type:
1/0on a param namedquiet/updates/animate/hand/ray→boolean \| 0 \| 1; other ints →number; strings →string; tuples → fixed-length tuple ARGUMENTSdocstring linename = int: …/= str:/= float:/= list:— parsed with the same uppercase-section walkwrite_html_ref(packages/engine/modules/pymol/cmd.py:211) uses (:290-303)- name heuristics:
state/frame/width/height/dpi→number,filename/prefix→string,*_sele/selection*→Selection - fallback
ApiValue = string \| number \| boolean \| null \| ApiValue[] \| {[k:string]:ApiValue}
get_view → View18
(packages/engine/modules/pymol/viewing.py:663-677), get_names → string[]
(packages/engine/modules/pymol/querying.py:1198), get_setting_tuple → [number, unknown[]]
(packages/engine/modules/pymol/setting.py:413-418), count_atoms/count_states/count_frames/get_frame/
get_state → number, get_extent → [Vec3, Vec3], get_color_tuple → RGB,
get_progress → number (packages/engine/modules/pymol/monitoring.py:5-7). Everything else defaults to
unknown and is narrowed over time. The override table lives in the repo and is diff-reviewed;
the rest is regenerated.
Step 3 — emit.ts produces, per function:
KEYWORD_ONLY params always go in the options object; _self is dropped; JSDoc is the
DESCRIPTION block; @see from SEE ALSO (already extracted by packages/engine/modules/pymol/cmd.py:298-300).
Emit LITERAL1/LITERAL2/PYTHON/SECURE-mode commands (packages/engine/modules/pymol/keywords.py:19-21,
:144-147, :235, :238, :265, :275, :283) into a separate unsafe.ts module so their
import is a visible, greppable decision.
Step 4 — enums emit as string-literal unions regenerated from the runtime dumps
(SettingName from setting.index_dict, ColorName from get_color_indices,
CartoonType from viewing.cartoon_sc.keywords, etc.).
Step 5 — drift CI. A test re-runs extract.py and fails if api-schema.json changed without
regeneration. This is the only defence against the backend and client silently diverging — and it
is the reason to generate rather than hand-write.
9.3 Runtime client shape
packages/client/src/ implements this over packages/protocol/src/envelope.ts.
client.call, so the generated surface stays
dependency-free and tree-shakable; a React app that only imports color and show ships two
wrappers, not 404.
10. Constraints this area lives under
G->Option->pmguigates the feedback queue (packages/engine/layer1/Ortho.cpp:492-499), so a bridge that boots withoutpmguigets zero console output. The bridge enables it (§8.1).- No change notifications exist at all (
grep -r Notify layer0..layer5gives 0 hits), so the whole event story is polling. §8.5 Tier 2 item 1 is what keeps that affordable: four integers instead of an O(objects) diff per tick. cmd.doswallows return values and exceptions (packages/engine/modules/pymol/parser.py:465-481,packages/engine/modules/pymol/commanding.py:441-475), so anything built on thedopath is blind to failure. That is whydois the console path only and UI actions take the typedcallpath.- Both bundled bridges are unauthenticated.
rpc.py:441register_instance(cmd)plusrpc.py:422-430(no localhost binding) is remote code execution by design.pymolhttpd’s127.0.check (:63-67) does not stop a hostile web page in the user’s own browser. The replacement ships a token, anOriginallow-list and a loopback peer check together. LITERAL1/LITERAL2commandsevaluser strings (alter,iterate,alter_state,iterate_state,label,alias,set_key—packages/engine/modules/pymol/keywords.py:16,19,21,144,145,147,257).pymolhttpddodgedlabelby substitutinglabel2(:473). Parity with the desktop app means an arbitrary-Python surface reachable from the browser; the transport is what bounds it, which is why §8.1 spends its security budget there rather than on a symbol deny-list.prepare_calldoes no type coercion (packages/engine/modules/pymol/parsing.py:329-421); functions coerce internally and inconsistently. Sending a JSON number where PyMOL expected a string usually works but is untested across all 404 functions.- Six type annotations in the entire API, so generated types are largely heuristic. The override table and the CI drift check in §9 are what keep the long tail honest.
new_commandhas zero callers (packages/engine/modules/pymol/commanding.py:722). It is the intended modern path but is unexercised upstream.- Threading.
cmdis protected bylock_api/lockcm(packages/engine/modules/pymol/cmd.py:135-142,packages/engine/modules/pymol/locking.py) andcmd._get_feedbackcan returnNoneunder contention (packages/engine/modules/pymol/internal.py:605)._call_in_gui_threadis a plain passthrough in the module singleton (packages/engine/modules/pymol/cmd.py:164-165) — Qt overrides it (packages/engine/modules/pmg_qt/pymol_qt_gui.py:1243-1251). The bridge marshals everything that touches the engine onto one thread (packages/bridge/tenmol_bridge/pump.py). - Deprecated stdlib usage in
pymolhttpd(Event.isSet:490/:502,Thread.setDaemon:497) breaks on newer Pythons regardless of anything this port does. - The
.pwglaunch path (packages/engine/modules/pymol/importing.py:516-610) is the only waypymolhttpdstarts, so any workflow that depends on.pwgdepends onpymolhttpd. rpc.pyshadowscmd.labelandcmd.rotate(packages/engine/modules/pymol/rpc.py:463-465), so a client written against XML-RPC has the wrong semantics for those two.