Skip to content

Feature/python data sources - #13

Draft
kripnerl wants to merge 4 commits into
ukaea:feature/lightweight_plugin_remove_flufffrom
kripnerl:feature/python-data-sources
Draft

Feature/python data sources#13
kripnerl wants to merge 4 commits into
ukaea:feature/lightweight_plugin_remove_flufffrom
kripnerl:feature/python-data-sources

Conversation

@kripnerl

@kripnerl kripnerl commented Aug 27, 2026

Copy link
Copy Markdown

May be required to plug tokamap mapping with the Python libtokamap.Mapper. Currently a draft for discussion.

Using the TOML configuration: requires ukaea/libtokamap#47 to be accepted first.

AI description follows.

Summary

Add optional support for data sources and custom functions implemented in Python.

  • introduce MAPPING_PLUGIN_PYTHON, defaulting to OFF;
  • initialize embedded CPython lazily, only when a configuration is supplied;
  • instantiate and register configured Python data sources;
  • import and register configured Python custom functions;
  • convert between libtokamap arrays, NumPy arrays, JSON values, and Python objects;
  • accept both TOML and JSON/JSONC configuration files.

Motivation

Some mapping data sources already exist as Python implementations. Supporting them directly in the plugin avoids maintaining a second C++ implementation or a separate proxy service.

Python remains entirely optional. Builds with MAPPING_PLUGIN_PYTHON=OFF retain no Python build or runtime
dependency.

Configuration

Configuration is supplied separately through MAPPING_PLUGIN_PYTHON_CONFIG. It cannot be added to the normal
libtokamap configuration because that file is validated against a closed schema.

The interpreter starts only when the configuration declares at least one Python data source or custom-function
library.

TOML is the preferred format. JSON remains supported, including // comments, for backward compatibility. Both
formats are normalized to nlohmann::json before the shared validation and registration path.

Dependency

TOML support depends on the companion libtokamap PR that exports its bundled toml.hpp header:

  • ukaea/libtokamap#

The plugin does not vendor its own copy of toml++.

Runtime considerations

  • the interpreter is initialized once per plugin process and is not finalized;
  • Python and NumPy operations acquire the GIL;
  • Python objects and registered data-source instances live for the process lifetime;
  • the existing non-Python plugin path remains unchanged when the feature is disabled or unconfigured.

Verification

  • built the plugin with Python support enabled against the companion libtokamap change;
  • built the complete uda-compass image;
  • verified the UDA server listener and IMAS_MAP::help() plugin initialization;
  • verified initialization with the deployed TOML configuration;
  • verified backward-compatible initialization using JSONC.

kripnerl and others added 4 commits August 13, 2026 10:51
New option MAPPING_PLUGIN_PYTHON (default OFF). When ON, the plugin gains
lazy embedded-Python data-source and custom-function support, driven by a
TOML file pointed at by the MAPPING_PLUGIN_PYTHON_CONFIG env var:

  [python_data_sources.<name>]
  module/class/args  -- a Python class implementing get(args) -> numpy array
  [python_custom_functions.<library>]
  module/functions    -- Python callables f(inputs, parameters) -> array

If any are declared, init() starts CPython once per uda_server process
(xinetd spawns one per connection; all fields served on a connection share
the interpreter, the data-source instances and their caches), dlopening
libpython RTLD_GLOBAL first because UDA loads plugins RTLD_LOCAL. PYTHONPATH
and MAPPING_PLUGIN_PYTHONPATH are folded into sys.path. When nothing is
configured the interpreter is never started, so non-Python deployments are
unaffected; built with MAPPING_PLUGIN_PYTHON=OFF there is no Python
dependency at all and the registration call is a compile-time no-op.

The config is separate from the libtokamap config because libtokamap
validates it against a compiled-in schema with additionalProperties=false.

The PythonDataSource/PythonCustomFunction bridges mirror the conversions in
libtokamap's clibtokamap module (DataSource.get -> numpy Array; GIL held for
the duration of every Python call), which itself never starts an interpreter
— so the plugin is the right place to own the embedded one.
Code review of feature/python-data-sources against
feature/lightweight_plugin_remove_fluff turned up nine defects in the new
embedded-Python support. All of them are fixed here.

Correctness, in rough order of severity:

- After an IMAS_MAP::reset() every later request in the process failed with
  "Data source with name 'CDB' already exists": reset() clears the plugin's
  m_init without unregistering anything, and the following init() re-ran the
  Python registration, which throws on a duplicate name. The registration is
  now done at most once per process (m_python_init), and registering
  unregisters any previous entry first so a retry after a partially failed
  init heals instead of wedging the process.

- _import_array() ran without the GIL whenever CPython had already been
  initialised by someone else (the whole Py_Initialize block, which is what
  implicitly took the GIL, is skipped on that path). It imports
  numpy.core.multiarray and touches CPython objects, so this was a data race
  on interpreter state. Now covered by a GilLock on both paths.

- The GIL that Py_Initialize() leaves held was never released: there was no
  PyEval_SaveThread(), and the PyGILState_Ensure/Release pair around the
  registration returns LOCKED for that same thread so it did not drop it
  either. Any Python background thread (a connection-pool reaper, an h5py or
  logging worker) could therefore never run while the plugin sat idle, and a
  second server thread calling PyGILState_Ensure() would block forever.
  MainThreadGilRelease now hands the GIL back on the way out, including when
  the initialisation throws.

- A failed TypedDataArray -> NumPy conversion returned nullptr straight into
  PyDict_SetItemString, which does Py_INCREF on it: a segfault in uda_server
  instead of a DataSourceError. Checked at both call sites; wrap_array_copy
  now also checks its malloc and the descr, and json_to_pyobject reports
  failure rather than storing nulls.

- MAPPING_PLUGIN_PYTHONPATH was inserted into sys.path as a single string,
  never split on ':', so any multi-directory value silently matched nothing.
  PYTHONPATH was split, but each entry was inserted at index 0, reversing the
  precedence order CPython gives it. Both now go through one
  prepend_search_path() helper; MAPPING_PLUGIN_PYTHONPATH still wins.

- Constructor args of non-scalar TOML type were silently dropped, so a data
  source configured with a nested table or an array came up with those kwargs
  missing and fell back to its defaults. toml_node_to_json now carries tables
  and arrays through, dispatching on the node type so numeric types are not
  coerced, and errors on dates/times rather than dropping them.

- PyArray_ISCARRAY also requires WRITEABLE, so a read-only but perfectly
  contiguous array (np.broadcast_to, an mmap view, a cache deliberately marked
  read-only) was rejected as "non-C-contiguous". Uses ISCARRAY_RO — the data is
  copied anyway — and the message now names the flag that actually failed.

Build:

- -DMAPPING_PLUGIN_DEFAULT_LIBPYTHON="${Python3_LIBRARIES}" breaks whenever
  FindPython3 returns a list: UDA's foreach/add_definitions loop splits it into
  two arguments and the compile dies on the unterminated quote. Reproduced with
  optimized;<lib>;debug;<lib>. Now picks the single existing python entry.

Also removes six dead lines: an "if (module == nullptr)" block copy-pasted
directly after the identical check that already threw.

Verified: rebuilt the image and ran read/reset/read on one connection — the
third request now reaches the data source (failing only on the placeholder DB
host) where it previously returned "already exists". The interpreter starts,
tokamap_compass imports from MAPPING_PLUGIN_PYTHONPATH and CDBDataSource is
registered, so the sys.path rewrite is exercised. Both files also type-check
clean under -Wall -Wextra against the image's own libtokamap/UDA/NumPy headers,
with and without MAPPING_PLUGIN_PYTHON.

Not addressed: ext_include/toml.hpp is still a byte-identical second copy of
the one in libtokamap, which cannot be resolved from this repo alone —
libtokamap exports nlohmann, valijson, exprtk and inja from ext_include but not
toml.hpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ext_include/toml.hpp was a byte-identical second copy of the toml++ 3.4 header
already inside libtokamap (17,880 lines, 97% of this feature's diff) supporting
roughly 20 lines of config parsing. Two independently-updatable copies of the
same header had to be kept in step, and the include search order made it
non-obvious which one won.

libtokamap installs nlohmann/json.hpp, valijson, exprtk and inja out of its
ext_include but NOT toml.hpp, and its own TOML->JSON loader (load_toml_file) is
in an anonymous namespace, so there is no way to reach a TOML parser from here
without duplicating one. MAPPING_PLUGIN_PYTHON_CONFIG is therefore now read as
JSON, with the nlohmann::json libtokamap already exports and this file already
used. libtokamap accepts JSON for its own config too, so both files stay in
formats libtokamap itself supports. Comments are tolerated (ignore_comments), so
the deployed config keeps its explanatory prose; a .toml path that fails to
parse says so explicitly rather than leaving the reader guessing.

The conversion layer disappears with the parser: config values are already
nlohmann::json, which is exactly what json_to_pyobject consumes, so
toml_node_to_json is gone. Validation got stricter on the way through — a
non-string class_name and a non-object args are now errors instead of being
quietly ignored by toml++'s value_or.

python_data_source.hpp documents the new config shape, why it is JSON, and — for
the libtokamap maintainer — the one-line alternative on their side: adding
ext_include/toml.hpp to EXT_HEADERS (libtokamap CMakeLists.txt:136-141) so it is
installed like the other four ext headers, or promoting load_toml_file() to
public API. Either would let this plugin take TOML configs again with no
vendoring.

Paired with the uda-compass change that converts
config/mapping_plugin_python.toml to .json — the two must land together, since
this plugin no longer parses TOML and the previous plugin cannot parse JSON.

Verified: 20 assertions against the real parse_python_data_sources /
parse_python_custom_functions (deployed shape, class/class_name precedence and
default, nested table+array args carried through with types intact, and all
eight rejection paths with their messages), plus a rebuilt image running
read/reset/read — the CDB source is registered from the JSON config and fails
only on the placeholder DB host, with no parse, validation, import or
duplicate-registration errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant