From 4e06b744370686e52c67dda1fe3e28fdf570979b Mon Sep 17 00:00:00 2001 From: "Ryan M. Richard" Date: Sun, 11 Oct 2026 22:16:10 -0500 Subject: [PATCH 1/8] remove default pt, add design --- .../module_manager/module_manager_class.hpp | 16 - .../detail_/module_manager_pimpl.hpp | 23 -- .../detail_/module_manager_pimpl.ipp | 27 +- .../module_manager/module_manager_class.cpp | 5 - docs/source/developer/design/index.rst | 2 + .../design/pluginplay_initialization.rst | 201 +++++++++ docs/source/developer/design/plugins.rst | 384 ++++++++++++++++++ .../pluginplay/examples/load_modules.hpp | 11 +- .../detail_/module_manager_pimpl.cpp | 56 +-- 9 files changed, 598 insertions(+), 127 deletions(-) create mode 100644 docs/source/developer/design/pluginplay_initialization.rst create mode 100644 docs/source/developer/design/plugins.rst diff --git a/cxx/include/pluginplay/module_manager/module_manager_class.hpp b/cxx/include/pluginplay/module_manager/module_manager_class.hpp index 7ba2befb7..35fdd7280 100644 --- a/cxx/include/pluginplay/module_manager/module_manager_class.hpp +++ b/cxx/include/pluginplay/module_manager/module_manager_class.hpp @@ -169,18 +169,6 @@ class ModuleManager { */ void rename_module(const type::key& old_key, type::key new_key); - /** @brief Defines the default module for a particular property type - * - * @tparam T The type of the property type the default is for - * @param key The module key of the default module - */ - template - void set_default(type::key key) { - auto temp = T::inputs(); - type::input_map inps(temp.begin(), temp.end()); - set_default_(typeid(T), std::move(inps), std::move(key)); - } - /** @brief Changes the value of an input bound to a module * * @tparam T @@ -275,10 +263,6 @@ class ModuleManager { */ bool has_pimpl_() const noexcept; - /// Bridges the gap between the set_default and the PIMPL - void set_default_(const std::type_info& type, type::input_map inps, - type::key key); - /// The object that actually implements the ModuleManager std::unique_ptr pimpl_; }; // End class ModuleManager diff --git a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp index 8ebba6c1e..13f2446ce 100644 --- a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp +++ b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp @@ -54,9 +54,6 @@ struct ModuleManagerPIMPL { /// Type of a map holding usable modules using module_map = module_manager_type::module_map; - /// Type of a map holding the default module key for a given property type - using default_map = std::map; - /// The type of the runtime using runtime_type = module_manager_type::runtime_type; @@ -105,20 +102,6 @@ struct ModuleManagerPIMPL { */ module_map::iterator end() noexcept { return m_modules.end(); }; - /** @brief Sets the default module to use for a given property type - * - * When a user requests a module that module initially has no submodules - * set unless the user has bound some to that module. Instead, the - * ModuleManager is now responsible to provide the module with submodules - * using the defaults. This function allows one to specify what the - * defaults are. - * - * @param type The type of the property type this default is for - * @param key The module key for the module to use as the default - */ - void set_default(const std::type_info& type, type::input_map inputs, - type::key key); - /** @brief This function actually adds a module to the list of available * modules. * @@ -198,12 +181,6 @@ struct ModuleManagerPIMPL { // These are the results of the modules running in the user's states cache_pointer m_pcaches; - // A map of property types - default_map m_defaults; - - // A map of inputs for property types - std::map m_inputs; - // Pointer to this modules current runtime runtime_ptr m_runtime_; ///@} diff --git a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp index 15f7a74df..a7bd4b250 100644 --- a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp +++ b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp @@ -20,14 +20,6 @@ namespace pluginplay::detail_ { -inline void ModuleManagerPIMPL::set_default(const std::type_info& type, - type::input_map inputs, - type::key key) { - if(!count(key)) m_modules.at(key); // Throws a consistent error - m_defaults[std::type_index(type)] = key; - m_inputs[std::type_index(type)] = std::move(inputs); -} - inline void ModuleManagerPIMPL::add_module(type::key key, module_base_ptr base) { assert_unique_key_(key); @@ -75,20 +67,7 @@ inline ModuleManagerPIMPL::shared_module ModuleManagerPIMPL::at( "ModuleManager has no module with key: '" + key + "'"; throw std::out_of_range(msg); } - auto mod = m_modules.at(key); - // Loop over submodules filling them in from the defaults - for(auto& [k, v] : mod->submods()) { - const auto& type = v.type(); - // Only change non-ready submodules - if(!v.ready() && m_defaults.count(type)) { - // Recursive to make sure that that module gets filled in - auto default_mod = at(m_defaults.at(type)); - // Only change if the module is also ready - if(default_mod->ready(m_inputs.at(type))) - mod->change_submod(k, default_mod); - } - } - return mod; + return m_modules.at(key); } inline bool ModuleManagerPIMPL::operator==( @@ -96,7 +75,6 @@ inline bool ModuleManagerPIMPL::operator==( // Try to get out early if(m_bases.size() != rhs.m_bases.size()) return false; if(m_modules.size() != rhs.m_modules.size()) return false; - if(m_defaults.size() != rhs.m_defaults.size()) return false; // TODO: Remove with the rest of the python hack if(m_py_bases.size() != rhs.m_py_bases.size()) return false; @@ -116,9 +94,6 @@ inline bool ModuleManagerPIMPL::operator==( if(*m_modules.at(k) != *v) return false; } - // Easy since not pointers - if(m_defaults != rhs.m_defaults) return false; - return true; } diff --git a/cxx/src/pluginplay/module_manager/module_manager_class.cpp b/cxx/src/pluginplay/module_manager/module_manager_class.cpp index ed83cd81d..75dbef2ee 100644 --- a/cxx/src/pluginplay/module_manager/module_manager_class.cpp +++ b/cxx/src/pluginplay/module_manager/module_manager_class.cpp @@ -60,11 +60,6 @@ const Module& ModuleManager::at(const type::key& module_key) const { return *pimpl_->at(module_key); } -void ModuleManager::set_default_(const std::type_info& type, - type::input_map inps, type::key key) { - pimpl_->set_default(type, std::move(inps), std::move(key)); -} - module_map::iterator ModuleManager::begin() noexcept { return pimpl_->begin(); } module_map::iterator ModuleManager::end() noexcept { return pimpl_->end(); } diff --git a/docs/source/developer/design/index.rst b/docs/source/developer/design/index.rst index c13c83f3c..2026d3b12 100644 --- a/docs/source/developer/design/index.rst +++ b/docs/source/developer/design/index.rst @@ -26,6 +26,7 @@ the design) of PluginPlay. :caption: PluginPlay and Components: module_manager + plugins module property_type field @@ -36,6 +37,7 @@ the design) of PluginPlay. :maxdepth: 2 :caption: Processes + pluginplay_initialization call_graph memoization checkpoint_restart diff --git a/docs/source/developer/design/pluginplay_initialization.rst b/docs/source/developer/design/pluginplay_initialization.rst new file mode 100644 index 000000000..c0eef6539 --- /dev/null +++ b/docs/source/developer/design/pluginplay_initialization.rst @@ -0,0 +1,201 @@ +.. Copyright 2026 NWChemEx-Project +.. +.. Licensed under the Apache License, Version 2.0 (the "License"); +.. you may not use this file except in compliance with the License. +.. You may obtain a copy of the License at +.. +.. http://www.apache.org/licenses/LICENSE-2.0 +.. +.. Unless required by applicable law or agreed to in writing, software +.. distributed under the License is distributed on an "AS IS" BASIS, +.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +.. See the License for the specific language governing permissions and +.. limitations under the License. + +.. _pp_initialization_design: + +############################## +Initializing the ModuleManager +############################## + +This page describes how a ``ModuleManager`` goes from empty to holding a +wired call graph built from a set of plugins. Plugins themselves are +described in :ref:`plugin_design`. The ``ModuleManager`` is described in +:ref:`module_manager_design`. + +************************************* +What is ModuleManager Initialization? +************************************* + +Initialization is the process that: + +#. loads the modules from each plugin the user asks for, and +#. sets default submodules between plugins, *e.g.*, makes SCF's driver use + NUX's Born-Oppenheimer Hamiltonian module when NUX is loaded. + +The ``ModuleManager`` is then ready for the user to change it further and +run it. + +************************************* +Previous Approaches to Initialization +************************************* + +Originally, the end user managed initialization by hand. There was a convention +that plugins would declare/define ``load_modules`` and ``set_defaults`` +functions. The the end user would call them. This approach has several problems: + +- Wiring only works if every provider has been loaded before any + ``set_defaults`` is called. Nothing enforces that order. +- Every package that combines plugins has to rewrite this sequence. +- PluginPlay cannot tell which plugin a module came from or which plugins + are loaded. + +To try to add some level of automation, we added a function +``ModuleManager::set_default``. The idea was that a user could call it to +set a default module for a property type. ``ModuleManagerPIMPL::at`` +then quietly filled unready submodules of that type whenever a module is +looked up. This feature had no users outside PluginPlay's own tests, and was +removed. The problem was that property types are not unique enough for a single +default to make sense. + +***************************** +Initialization Considerations +***************************** + +#. Loading order does not matter + + - Plugins can be loaded in any order. + +#. Wiring happens after loading + + - "Is my preferred provider loaded?" only has a reliable answer once all + plugins are loaded. Wiring is therefore a separate step that runs + afterwards. + +#. User choices win + + - Between loading and wiring, the user may change modules, *e.g.*, pick + submodules or add modules of their own. Wiring must not overwrite those + choices. + +#. Unique names + + - Two plugins may use the same module key, *e.g.*, ``"Overlap"``. Every + module must still be addressable without ambiguity. + +#. Wiring can be run again + + - Running the wiring step again, *e.g.*, after loading another plugin, + only fills submodules that are still unset. + +#. No early errors + + - A submodule that is still unset after wiring is not an error. The user + can set it at any time before running the call graph. + +*********************** +Initialization Strategy +*********************** + +Storage +======= + +The ``ModuleManager`` owns each loaded plugin object, and each plugin owns +its ``ModuleMap`` (see :ref:`plugin_design`). So the ``ModuleManager`` has +one ``ModuleMap`` per loaded plugin, plus a ``ModuleMap`` of its own. The +``ModuleManager``'s own map holds modules that do not come from a plugin, +such as modules added directly by the user, lambda modules, and facade +modules. The maps are **not** merged into one pool. A submodule may still be +any module in any map, so links can cross maps. + +The empty plugin name, ``""``, is reserved for the ``ModuleManager``'s own +map. + +Uniqueness and Lookup +===================== + +- ``ModuleMap`` enforces unique module keys within a plugin. +- The ``ModuleManager`` enforces unique plugin names. Loading a second plugin + with the same name throws. + +Together these guarantee that the pair (plugin name, module key) is unique. +The ``ModuleManager`` therefore offers two ways to look up a module, and +every function that takes a module key (``at``, ``change_input``, +``change_submod``, ``run_as``, and so on) supports both: + +#. With a plugin name and a module key. This always works. The ``""`` plugin + name means the ``ModuleManager``'s own map. +#. With only a module key. This works as long as the key appears in exactly + one map. If it appears in more than one map the call throws and the error + lists the plugins that contain the key. To support this, the + ``ModuleManager`` keeps track of which keys appear in more than one map. + +The key-only form keeps current code working, since today's module keys are +effectively unique across the NWChemEx plugins. + +Loading Plugins +=============== + +.. code-block:: c++ + + pluginplay::ModuleManager mm; + mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); + +``load_plugin()``: + +#. creates a ``T`` (``T`` must derive from ``Plugin`` and be default + constructible). ``T``'s constructor sets the plugin's name and preferred + providers and builds its ``ModuleMap``, including its internal wiring, +#. throws if a plugin with the same ``name()`` is already loaded, +#. takes ownership of the plugin object. The plugin's ``ModuleMap`` is now + the ``ModuleManager``'s map for that plugin; nothing is copied, and +#. updates the record of which keys appear in more than one map. + +Python has an equivalent, ``mm.load_plugin(scf.scf())``, which takes an +instance because Python has no template parameters. Because the plugin holds +live modules, the same instance must not be loaded into two +``ModuleManager`` objects. + +Wiring Defaults +=============== + +After loading, the user may make changes, and then calls: + +.. code-block:: c++ + + mm.wire_defaults(); + +It iterates over the loaded plugins in the order they were loaded, and for each +plugin ``P``: + +#. For each name ``N`` in ``P.preferred_providers()``, in order: + + - If no plugin named ``N`` is loaded, skip it. + - Otherwise call ``P.default_submods_from(N)``. + - The result is a list of defaults, each of which is a triple + (``module_key``, ``callback_key``, ``provider_key``) + +#. For each default triple: + + - If ``P``'s module ``module_key`` has a submodule with key + ``callback_key`` that is already set, move to the next triple. + - Else, if the provider does not have a module with key ``provider_key``, + move to the next triple. + - Otherwise set ``callback_key`` to the provider's module ``provider_key``. + +These changes are done to ``P``'s live map, so it includes any changes the user +made and any wiring done by earlier calls in the loop. Because +``module_defaults_from_plugin`` only sets unset submodules: + +- choices the user made before ``wire_defaults`` are kept, +- the order of ``preferred_providers`` sets priority, since the first + provider to set a submodule wins, and +- calling ``wire_defaults`` again does not change submodules that are + already set. + +The order in which plugins are loaded does not change the result. Each +plugin only changes its own map, so the result for ``P`` depends only on its +own provider list. diff --git a/docs/source/developer/design/plugins.rst b/docs/source/developer/design/plugins.rst new file mode 100644 index 000000000..b87298e6d --- /dev/null +++ b/docs/source/developer/design/plugins.rst @@ -0,0 +1,384 @@ +.. Copyright 2026 NWChemEx-Project +.. +.. Licensed under the Apache License, Version 2.0 (the "License"); +.. you may not use this file except in compliance with the License. +.. You may obtain a copy of the License at +.. +.. http://www.apache.org/licenses/LICENSE-2.0 +.. +.. Unless required by applicable law or agreed to in writing, software +.. distributed under the License is distributed on an "AS IS" BASIS, +.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +.. See the License for the specific language governing permissions and +.. limitations under the License. + +.. _plugin_design: + +########################## +Designing the Plugin Class +########################## + +This page describes the design of the ``Plugin`` class, which represents a +plugin in PluginPlay. How plugins are used to initialize a ``ModuleManager`` +is described in :ref:`pp_initialization_design`. + +***************** +What is a Plugin? +***************** + +A plugin is a named, distributable collection of :ref:`module` objects. A +plugin is also responsible for the default wiring among its own modules, +*e.g.*, telling its driver module which of the plugin's step modules to use. +A plugin is loaded into a ``ModuleManager`` as a single unit. + +.. note:: + + Plugins are ultimately C++ or Python classes. The examples on this page use + three plugins. ``MyPlugin`` is the type of the plugin being written. + ``ProviderA`` and ``ProviderB`` are the types of the other plugins that + ``MyPlugin`` would like to get default submodules from. We will assume that + ``ProviderA`` and ``ProviderB`` are already written and loaded into the + ``ModuleManager`` under keys ``"provider_a"`` and ``"provider_b"``, + respectively. + +****************************** +Why Do We Need a Plugin Class? +****************************** + +The original approach was a set of free functions. ``DECLARE_PLUGIN`` and +``DEFINE_PLUGIN`` (in ``pluginplay/plugin/plugin.hpp``) declare and define a +``load_modules(ModuleManager&)`` function. By convention, most plugins also +wrote a ``set_defaults(ModuleManager&)`` function for wiring that depended on +other plugins. This approach has several problems: + +#. Plugins have no identity. PluginPlay cannot tell which plugins are loaded + or which modules came from which plugin. This makes it cumbersome to know + if a module came from a particular plugin, or if another plugin loaded a + module with the same key. +#. Cross-plugin wiring was ad hoc. Every plugin reinvented "set this submodule + if the provider happens to be loaded." The end user, *e.g.*, a package + that combines several plugins, must call every ``load_modules`` and then + every ``set_defaults`` in the right order. +#. Creating modules was tied to a live ``ModuleManager``. A plugin could not + describe its modules without also inserting them into a particular + ``ModuleManager``. +#. Nothing recorded which plugins a plugin expects to get defaults from. + +Making plugins first-class objects allows PluginPlay to own the process of +loading and wiring plugins. This solves these problems and more. + +********************* +Plugin Considerations +********************* + +#. Identity + + - Each plugin has a name, *e.g.*, ``"my_plugin"``. + - Plugin names must be unique within a ``ModuleManager``. + +#. Module creation is separate from the ``ModuleManager`` + + - A plugin creates its modules and stores them itself. It does not insert + them into a ``ModuleManager``. + - The plugin's modules already include its internal wiring. For example, + ``MyPlugin``'s driver module will already link to any step modules + provided by ``MyPlugin``. + - Plugins can no longer accidentally modify other plugins' modules. + +#. Declared dependencies on default providers + + - A plugin lists the plugins it would like to get default submodules from, + *e.g.*, ``MyPlugin`` lists ``ProviderA`` and ``ProviderB``. + - These are preferences, not requirements. The plugin will still load + when a preferred provider is not loaded, but some of its submodules will + remain in an unset state until the user provides the needed modules. + +#. Cross-plugin defaults never overwrite existing choices + + - A default from a provider only fills a submodule that has not been set + yet. It never replaces a choice made by the user or by a provider with + higher priority. + +#. Ease of use + + - Declaring a plugin should stay about as easy as it is with today's + macros. + - Writing a plugin should feel like writing a module: everything is set + up in the constructor. + - Plugins must be default constructible so the ``ModuleManager`` should be + able to create them from their type alone, *i.e.*, ``load_plugin()``. + +#. Python + + - Plugins written in C++ must be loadable from Python. + - It should also be possible to write a plugin in Python, the same way + modules can be written in Python today. + +#. Provenance + + - Plugins should know their versions, citations, etc. + +#. Options + + - Plugins may have options, e.g., enabling or disabling features. + - Options would need to be set before or while the plugin is loaded. + - Not yet designed. Would conceptually be similar to module options, but at + the plugin level. + + +*************** +Plugin Strategy +*************** + +The design has two new classes: ``Plugin``, the base class for all plugins, +and ``ModuleMap``, the container a plugin stores its modules in. + +The Plugin Class +================ + +``Plugin`` is declared in ``pluginplay/plugin/plugin.hpp``. It follows the +same convention as ``ModuleBase``. A module's constructor calls base class +functions such as ``description``, ``add_input``, and ``add_submodule`` to +describe the module. In the same way, a plugin's constructor calls base class +functions to describe the plugin: + +- ``set_name(name)`` sets the plugin's name, *e.g.*, ``"my_plugin"``. +- ``add_module(key)`` and ``add_module(key, module_base_ptr)`` create a + module and add it to the plugin's ``ModuleMap``. +- ``change_submod(module_key, callback_key, submod_key)`` links two of the + plugin's own modules. It is used for intra-plugin defaults. +- ``set_preferred_providers(name0, name1, ...)`` lists the plugins to get + defaults from, highest priority first. +- ``default_submod_from(provider, module_key, callback_key, provider_key)`` + records a cross-plugin default: "if ``provider`` is loaded, set submodule + ``callback_key`` of my module ``module_key`` to the provider's module + ``provider_key``." The ``ModuleManager`` applies these records during + wiring (see below). +- ``modules()`` returns a non-const reference to the plugin's ``ModuleMap``, + so the constructor can pass it to helper functions (see + `Declaring a Plugin`_). + +These functions are ``protected``, so only the plugin itself can call them. +After construction, the public interface is read-only: ``name()``, +``preferred_providers()``, ``default_submods_from()``, and ``get_modules()``. + +Cross-Plugin Defaults +--------------------- + +Cross-plugin defaults are stored in the Plugin. The plugin does not have to +write any wiring logic. + +- ``provider`` must already be listed in ``set_preferred_providers``. + Otherwise ``default_submod_from`` throws. This catches typos in provider + names, and it means priority always comes from one place: the order of + ``preferred_providers``. +- ``module_key`` must be a module that the plugin has already added. + Otherwise ``default_submod_from`` throws. +- ``provider_key`` cannot be checked in the constructor, because the + provider is not available yet. If the provider is loaded but has no module + ``provider_key``, the record is skipped during wiring. This matches how + plugins' hand-written ``set_defaults`` functions behave today. + +Wiring of the plugins is done when the user calls ``wire_defaults`` (see +:ref:`pp_initialization_design`). + +The Class +--------- + +.. code-block:: c++ + + class Plugin { + public: + using name_type = std::string; + using provider_list_type = std::vector; + + /// One default_submod_from record + struct DefaultSubmod { + type::key module_key; + type::key callback_key; + type::key provider_key; + }; + using default_list_type = std::vector; + + virtual ~Plugin() = default; + + /// The name of the plugin, e.g., "my_plugin" + const name_type& name() const noexcept; + + /// Names of plugins to get defaults from, highest priority first + const provider_list_type& preferred_providers() const noexcept; + + /// Recorded cross-plugin defaults + const default_list_type& default_submods_from( + const name_type& provider) const noexcept; + + /// The plugin's modules, in their current state + const ModuleMap& get_modules() const noexcept; + + protected: + // Called from the derived class's constructor + void set_name(name_type name); + + template + void add_module(type::key key); + + void add_module(type::key key, module_base_ptr base); + + void change_submod(const type::key& module_key, + const type::key& callback_key, + const type::key& submod_key); + + template + void set_preferred_providers(Args&&... names); + + void default_submod_from(name_type provider, type::key module_key, + type::key callback_key, + type::key provider_key); + + ModuleMap& modules() noexcept; + + private: + + name_type m_name_; + std::maps m_providers_; + default_list_type m_defaults_; + ModuleMap m_modules_; + }; + +Notes on the members: + +- **Where modules live.** A plugin object owns its ``ModuleMap``. + ``load_plugin`` takes ownership of the plugin object, and the plugin's map + *is* the ``ModuleManager``'s map for that plugin (see + :ref:`pp_initialization_design`). The ``ModuleManager`` changes the map + through a non-const accessor that only it can use. ``get_modules()`` + therefore always returns the current state of the plugin's modules, + including any changes made by the user or by wiring. +- **One instance per ModuleManager.** Since a plugin holds live modules, a + plugin object must not be loaded into two ``ModuleManager`` objects. + ``load_plugin()`` creates a new ``T`` each time, so this only matters + for the Python form, which takes an instance. +- **Provenance.** Setters for version and citations would follow the same + convention, *e.g.*, ``set_version`` and ``add_citation`` in the + constructor, mirroring ``ModuleBase::citation``. + +The ModuleMap Class +=================== + +``ModuleMap`` is a thin wrapper around +``utilities::CaseInsensitiveMap>``. This is the same +type as the current ``ModuleManager::module_map``. ``ModuleMap`` provides: + +- ``add_module(key)`` and ``add_module(key, module_base_ptr)``, which + mirror the existing ``ModuleManager`` functions. ``Plugin::add_module`` + forwards to these. +- ``count``, ``at``, ``size``, and iteration. +- ``change_submod(module_key, callback_key, submod_key)``, which links two + modules in the same map. ``Plugin::change_submod`` forwards to this. +- ``change_submod(module_key, callback_key, std::shared_ptr)``, which + links a module in this map to a module from another map. It is used for + cross-plugin defaults. +- ``has_submod(module_key, callback_key)``, which returns whether that + submodule has been set. It wraps ``SubmoduleRequest::has_module``. +- ``default_submod(...)``, a convenience function with the same overloads + as ``change_submod``. It sets the submodule only if it is unset. The + ``ModuleManager`` uses it to apply ``default_submod_from`` records, and + overrides of ``module_defaults_from_plugin_`` can use it too. + +``ModuleMap`` enforces that module keys are unique within the map, which +means they are unique within a plugin. Uniqueness across plugins is the +``ModuleManager``'s job (see :ref:`pp_initialization_design`). + +Declaring a Plugin +================== + +The plugin macros mirror the module macros (``DECLARE_MODULE``, +``MODULE_CTOR``, and ``MODULE_RUN``): + +- ``DECLARE_PLUGIN(plugin_name)`` declares a class ``plugin_name`` that + derives from ``Plugin`` and has a default constructor. +- ``PLUGIN_CTOR(plugin_name)`` starts the definition of the constructor. + +A plugin's constructor can also pass ``modules()`` to helper functions that +take a ``ModuleMap&``. This lets a large plugin split module registration +across files, *e.g.*, one helper per subdirectory. Each helper adds its +modules and sets intra-plugin defaults with ``ModuleMap``'s ``add_module`` +and ``change_submod``. + +Example: Declarative Defaults +----------------------------- + +``MyPlugin`` has two modules. ``"My driver"`` has a ``"Step"`` submodule, +which ``MyPlugin`` fills itself, and a ``"Data"`` submodule, which it would +like a provider to fill. ``"My step"`` has a ``"Helper"`` submodule, which it +would also like a provider to fill. ``ProviderA`` provides the modules +``"A data"`` and ``"A helper"``. ``ProviderB`` provides ``"B data"``. + +.. code-block:: c++ + + // my_plugin/my_plugin.hpp + namespace my_plugin { + DECLARE_PLUGIN(MyPlugin); + } + + // my_plugin/driver/driver.hpp + namespace my_plugin { + inline void load_driver_modules(pluginplay::ModuleMap& modules) { + modules.add_module("My driver"); + modules.add_module("My step"); + + // Intra-plugin default + modules.change_submod("My driver", "Step", "My step"); + } + } // namespace my_plugin + + // my_plugin/my_plugin.cpp + namespace my_plugin { + + PLUGIN_CTOR(MyPlugin) { + set_name("my_plugin"); + set_preferred_providers("provider_a", "provider_b"); + + load_driver_modules(modules()); + + // Cross-plugin defaults + default_submod_from("provider_a", "My driver", "Data", "A data"); + default_submod_from("provider_a", "My step", "Helper", "A helper"); + default_submod_from("provider_b", "My driver", "Data", "B data"); + } + + } // namespace my_plugin + +Both providers can fill ``"My driver"``'s ``"Data"`` submodule. Since +``"provider_a"`` comes first in the preferred providers, ``"A data"`` is used +when both are loaded, and ``"B data"`` is used when only ``ProviderB`` is +loaded. :ref:`pp_initialization_design` walks through these cases. +``MyPlugin`` does not need ``PLUGIN_DEFAULTS``. + +Python +====== + +Python support follows the pattern used for modules: + +- A Python plugin subclasses ``pluginplay.Plugin``. Its ``__init__`` calls + ``set_name``, ``add_module``, ``change_submod``, + ``set_preferred_providers``, and ``default_submod_from``, just as a Python + module's ``__init__`` calls ``description`` and ``add_input``. +- ``EXPORT_PLUGIN(plugin_name, m)`` exports the C++ plugin class to Python + (instead of a ``load_modules`` function), so a Python user can pass an + instance to ``ModuleManager.load_plugin``. +- ``ModuleMap`` is exported so Python plugins can use ``default_submod`` and + related functions. + +********************** +Further Considerations +********************** + +- **Missing provider modules.** A ``default_submod_from`` record whose + provider is loaded but has no module ``provider_key`` is skipped, which + matches today's behavior. This can hide a typo or a module that a newer + version of the provider renamed. A warning through the ``ModuleManager``'s + logger may be useful. +- **Plugins that are loaded later.** ``default_submod_from`` records are + kept after wiring, so calling ``wire_defaults`` again after loading + another plugin applies the records for the new plugin. diff --git a/tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp b/tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp index 5527357ef..f3d6bf001 100644 --- a/tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp +++ b/tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp @@ -63,12 +63,9 @@ inline void load_modules(pluginplay::ModuleManager& mm) { mm.add_module("Rectangle", std::make_shared()); mm.add_module("Prism", std::make_shared()); - /* Since our modules introduced two new property types we should also set - * default modules for each of them. These defaults will be used when - * a module needs, say an ``Area``, and the user has not specifically stated - * which submodule to use. Setting the defaults for our library is easy - * since it only provides one of each... + /* Our ``Prism`` module needs a submodule which computes an ``Area``. Since + * our library provides one, we set it as the default submodule. Users can + * still choose a different submodule later. */ - mm.set_default("Rectangle"); - mm.set_default("Prism"); + mm.change_submod("Prism", "area", "Rectangle"); } // end load_modules() diff --git a/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp b/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp index ae69fd447..1c10c971b 100644 --- a/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp +++ b/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp @@ -21,9 +21,8 @@ using namespace pluginplay; using namespace pluginplay::detail_; -using base_map = typename ModuleManagerPIMPL::base_map; -using module_map = typename ModuleManagerPIMPL::module_map; -using default_map = typename ModuleManagerPIMPL::default_map; +using base_map = typename ModuleManagerPIMPL::base_map; +using module_map = typename ModuleManagerPIMPL::module_map; TEST_CASE("ModuleManagerPIMPL : Default ctor") { ModuleManagerPIMPL pimpl; @@ -31,7 +30,6 @@ TEST_CASE("ModuleManagerPIMPL : Default ctor") { REQUIRE(pimpl.size() == 0); REQUIRE(pimpl.m_bases == base_map{}); REQUIRE(pimpl.m_modules == module_map{}); - REQUIRE(pimpl.m_defaults == default_map{}); } } @@ -103,20 +101,6 @@ TEST_CASE("ModuleManagerPIMPL : comparison") { } } } - SECTION("Different defaults") { - SECTION("Different types") { - pimpl1.m_defaults[std::type_index(typeid(double))] = "key1"; - pimpl2.m_defaults[std::type_index(typeid(int))] = "key1"; - REQUIRE(pimpl1 != pimpl2); - REQUIRE(!(pimpl1 == pimpl2)); - } - SECTION("Different keys") { - pimpl1.m_defaults[std::type_index(typeid(int))] = "key1"; - pimpl2.m_defaults[std::type_index(typeid(int))] = "key2"; - REQUIRE(pimpl1 != pimpl2); - REQUIRE(!(pimpl1 == pimpl2)); - } - } } TEST_CASE("ModuleManagerPIMPL : add_module") { @@ -160,23 +144,6 @@ TEST_CASE("ModuleManagerPIMPL : copy_module") { } } -TEST_CASE("ModuleManagerPIMPL : set_default") { - auto ptr1 = std::make_shared(); - ModuleManagerPIMPL pimpl1, pimpl2; - pimpl1.add_module("key", ptr1); - pimpl2.add_module("key", ptr1); - SECTION("Can't set to non-exisitant module") { - REQUIRE_THROWS_AS( - pimpl1.set_default(typeid(double), type::input_map{}, "not a key"), - std::out_of_range); - } - SECTION("Good key") { - pimpl1.set_default(typeid(Area), type::input_map{}, "key"); - pimpl2.m_defaults[typeid(Area)] = "key"; - REQUIRE(pimpl1 == pimpl2); - } -} - TEST_CASE("ModuleManagerPIMPL : count") { auto ptr1 = std::make_shared(); ModuleManagerPIMPL pimpl1; @@ -210,21 +177,10 @@ TEST_CASE("ModuleManagerPIMPL : at") { auto ptr2 = std::make_shared(); pimpl1.add_module("key1", ptr1); pimpl1.add_module("key2", ptr2); - REQUIRE(!pimpl1.m_modules.at("key2")->ready()); - SECTION("No default") { - auto ptr3 = pimpl1.m_modules.at("key2"); - REQUIRE(ptr3 == pimpl1.at("key2")); - REQUIRE(!ptr3->ready()); - } - SECTION("Default") { - pimpl1.set_default(typeid(Area), ptr1->inputs(), "key1"); - auto old_mod = *pimpl1.m_modules.at("key2"); - auto smod = *pimpl1.m_modules.at("key1"); - auto& new_mod = *pimpl1.at("key2"); - REQUIRE(old_mod != new_mod); - REQUIRE(new_mod.ready(ptr2->inputs())); - REQUIRE(new_mod.submods().at("area").value() == smod); - } + // at() does not fill in unset submodules + auto ptr3 = pimpl1.m_modules.at("key2"); + REQUIRE(ptr3 == pimpl1.at("key2")); + REQUIRE(!ptr3->ready()); } } From 2c270ccb17a9c02fa1c0476d57ee628a7675a46e Mon Sep 17 00:00:00 2001 From: "Ryan M. Richard" Date: Mon, 12 Oct 2026 10:27:31 -0500 Subject: [PATCH 2/8] adds Plugin class --- .../module_manager/module_manager.hpp | 1 + .../pluginplay/module_manager/module_map.hpp | 223 +++++++++++++++++ cxx/include/pluginplay/plugin/plugin.hpp | 12 + .../pluginplay/plugin/plugin_class.hpp | 230 ++++++++++++++++++ cxx/src/pluginplay/export_pluginplay.cpp | 2 + .../module_manager/export_module_manager.hpp | 2 + .../module_manager/export_module_map.cpp | 62 +++++ .../pluginplay/module_manager/module_map.cpp | 93 +++++++ cxx/src/pluginplay/plugin/export_plugin.hpp | 26 ++ .../pluginplay/plugin/export_plugin_class.cpp | 81 ++++++ cxx/src/pluginplay/plugin/plugin_class.cpp | 83 +++++++ cxx/src/pluginplay/plugin/py_plugin.hpp | 48 ++++ .../design/pluginplay_initialization.rst | 4 +- docs/source/developer/design/plugins.rst | 9 +- .../pluginplay/module_manager/module_map.cpp | 188 ++++++++++++++ .../pluginplay/plugin/plugin_class.cpp | 191 +++++++++++++++ .../module_manager/test_module_map.py | 111 +++++++++ tests/python/unit_tests/plugin/__init__.py | 13 + tests/python/unit_tests/plugin/test_plugin.py | 131 ++++++++++ 19 files changed, 1502 insertions(+), 8 deletions(-) create mode 100644 cxx/include/pluginplay/module_manager/module_map.hpp create mode 100644 cxx/include/pluginplay/plugin/plugin_class.hpp create mode 100644 cxx/src/pluginplay/module_manager/export_module_map.cpp create mode 100644 cxx/src/pluginplay/module_manager/module_map.cpp create mode 100644 cxx/src/pluginplay/plugin/export_plugin.hpp create mode 100644 cxx/src/pluginplay/plugin/export_plugin_class.cpp create mode 100644 cxx/src/pluginplay/plugin/plugin_class.cpp create mode 100644 cxx/src/pluginplay/plugin/py_plugin.hpp create mode 100644 tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp create mode 100644 tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp create mode 100644 tests/python/unit_tests/module_manager/test_module_map.py create mode 100644 tests/python/unit_tests/plugin/__init__.py create mode 100644 tests/python/unit_tests/plugin/test_plugin.py diff --git a/cxx/include/pluginplay/module_manager/module_manager.hpp b/cxx/include/pluginplay/module_manager/module_manager.hpp index 68fd741e8..cc865eba4 100644 --- a/cxx/include/pluginplay/module_manager/module_manager.hpp +++ b/cxx/include/pluginplay/module_manager/module_manager.hpp @@ -16,3 +16,4 @@ #pragma once #include +#include diff --git a/cxx/include/pluginplay/module_manager/module_map.hpp b/cxx/include/pluginplay/module_manager/module_map.hpp new file mode 100644 index 000000000..2f8ae2269 --- /dev/null +++ b/cxx/include/pluginplay/module_manager/module_map.hpp @@ -0,0 +1,223 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include +#include + +namespace pluginplay { + +/** @brief A container of modules, indexed by module key. + * + * ModuleMap is a thin wrapper around a case-insensitive map from module key + * to module. It decouples creating modules from storing them in a + * ModuleManager, *e.g.*, a Plugin builds a ModuleMap in its constructor + * without needing a ModuleManager. + * + * Copies of a ModuleMap are shallow, *i.e.*, the copy and the original + * share the same Module objects. + */ +class ModuleMap { +public: + /// Type used for module keys + using key_type = type::key; + + /// Type of a pointer to a usable module + using module_pointer = std::shared_ptr; + + /// Type of a pointer to a module's implementation + using module_base_ptr = std::shared_ptr; + + /// Type of the container *this wraps + using map_type = utilities::CaseInsensitiveMap; + + /// Type of a read/write iterator over the key/module pairs + using iterator = typename map_type::iterator; + + /// Type of a read-only iterator over the key/module pairs + using const_iterator = typename map_type::const_iterator; + + /// Type of a container holding the module keys + using key_container_type = std::vector; + + /** @brief Creates a module with the default-constructed implementation + * @p T and stores it under @p key. + * + * @tparam T The type of the module implementation. Must derive from + * ModuleBase and be default constructible. + * + * @param[in] key The key to store the module under. + * + * @throw std::invalid_argument if @p key is already in use. Strong throw + * guarantee. + * @throw std::bad_alloc if there is a problem allocating the module. + * Strong throw guarantee. + */ + template + void add_module(key_type key) { + add_module(std::move(key), std::make_shared()); + } + + /** @brief Creates a module from the implementation @p base and stores it + * under @p key. + * + * @param[in] key The key to store the module under. + * @param[in] base The implementation of the module. + * + * @throw std::invalid_argument if @p key is already in use or if @p base + * is null. Strong throw guarantee. + * @throw std::bad_alloc if there is a problem allocating the module. + * Strong throw guarantee. + */ + void add_module(key_type key, module_base_ptr base); + + /** @brief Determines if *this contains a module with key @p key. + * + * @param[in] key The key to look for. Comparisons are case-insensitive. + * + * @return 1 if *this has a module with key @p key and 0 otherwise. + * + * @throw None No throw guarantee. + */ + type::size count(const key_type& key) const noexcept { + return m_modules_.count(key); + } + + /// The number of modules in *this. No throw guarantee. + type::size size() const noexcept { return m_modules_.size(); } + + /// True if *this has no modules and false otherwise. No throw guarantee. + bool empty() const noexcept { return m_modules_.empty(); } + + /** @brief Returns the module stored under @p key. + * + * @param[in] key The key of the module to retrieve. + * + * @return A pointer to the requested module. + * + * @throw std::out_of_range if *this does not have a module with key + * @p key. Strong throw guarantee. + */ + module_pointer at(const key_type& key) const; + + /// Returns the keys of the modules in *this + key_container_type keys() const; + + ///@{ + /// Iterators over the key/module pairs in *this + iterator begin() noexcept { return m_modules_.begin(); } + const_iterator begin() const noexcept { return m_modules_.begin(); } + iterator end() noexcept { return m_modules_.end(); } + const_iterator end() const noexcept { return m_modules_.end(); } + ///@} + + /** @brief Sets a submodule of one module in *this to another module in + * *this. + * + * @param[in] module_key The key of the module whose submodule is being + * set. + * @param[in] callback_key The key @p module_key uses for the submodule. + * @param[in] submod_key The key of the module in *this to use as the + * submodule. + * + * @throw std::out_of_range if @p module_key or @p submod_key are not in + * *this, or if @p callback_key is not a + * submodule of @p module_key. Strong throw + * guarantee. + * @throw std::runtime_error if the module @p module_key is locked, or if + * the new submodule does not satisfy the + * property type @p callback_key requires. + * Strong throw guarantee. + */ + void change_submod(const key_type& module_key, const key_type& callback_key, + const key_type& submod_key); + + /** @brief Sets a submodule of a module in *this to @p submod. + * + * This overload is for setting a submodule to a module from another + * ModuleMap. + * + * @param[in] module_key The key of the module whose submodule is being + * set. + * @param[in] callback_key The key @p module_key uses for the submodule. + * @param[in] submod The module to use as the submodule. + * + * @throw std::out_of_range if @p module_key is not in *this, or if + * @p callback_key is not a submodule of + * @p module_key. Strong throw guarantee. + * @throw std::runtime_error if the module @p module_key is locked, or if + * the new submodule does not satisfy the + * property type @p callback_key requires. + * Strong throw guarantee. + */ + void change_submod(const key_type& module_key, const key_type& callback_key, + module_pointer submod); + + /** @brief Determines if a submodule of a module in *this has been set. + * + * @param[in] module_key The key of the module to check. + * @param[in] callback_key The key @p module_key uses for the submodule. + * + * @return True if the submodule has been set and false otherwise. + * + * @throw std::out_of_range if @p module_key is not in *this, or if + * @p callback_key is not a submodule of + * @p module_key. Strong throw guarantee. + */ + bool has_submod(const key_type& module_key, + const key_type& callback_key) const; + + ///@{ + /** @brief Sets a submodule, but only if it has not been set yet. + * + * These overloads behave like the corresponding change_submod overloads, + * except that they do nothing if the submodule has already been set. + * + * @return True if the submodule was set and false if it was already set. + * + * @throw ??? Throws if the corresponding change_submod throws. Same + * guarantee. + */ + bool default_submod(const key_type& module_key, + const key_type& callback_key, + const key_type& submod_key); + bool default_submod(const key_type& module_key, + const key_type& callback_key, module_pointer submod); + ///@} + + /** @brief Determines if *this and @p rhs hold the same modules. + * + * Two ModuleMaps are equal if they have the same keys and the modules + * stored under each key compare equal. + * + * @param[in] rhs The ModuleMap to compare to. + * + * @return True if *this and @p rhs are equal and false otherwise. + */ + bool operator==(const ModuleMap& rhs) const; + + /// Negates operator== + bool operator!=(const ModuleMap& rhs) const { return !(*this == rhs); } + +private: + /// The modules in *this + map_type m_modules_; +}; + +} // namespace pluginplay diff --git a/cxx/include/pluginplay/plugin/plugin.hpp b/cxx/include/pluginplay/plugin/plugin.hpp index 3cfc10202..4dbf4598d 100644 --- a/cxx/include/pluginplay/plugin/plugin.hpp +++ b/cxx/include/pluginplay/plugin/plugin.hpp @@ -16,8 +16,20 @@ #pragma once #include +#include #include +/** @brief Starts the definition of a plugin's constructor. + * + * The plugin class must derive from pluginplay::Plugin and declare a default + * constructor. The body of the constructor describes the plugin by calling + * the protected member functions of Plugin, *e.g.*, set_name and add_module. + * + * @param[in] plugin_name The name of the plugin class. + */ +#define PLUGIN_CTOR(plugin_name) \ + plugin_name::plugin_name() : pluginplay::Plugin() + #define DECLARE_PLUGIN(plugin_name) \ void load_modules(pluginplay::ModuleManager& mm) diff --git a/cxx/include/pluginplay/plugin/plugin_class.hpp b/cxx/include/pluginplay/plugin/plugin_class.hpp new file mode 100644 index 000000000..aa36c4f8f --- /dev/null +++ b/cxx/include/pluginplay/plugin/plugin_class.hpp @@ -0,0 +1,230 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include +#include +#include + +namespace pluginplay { + +/** @brief Base class for all plugins. + * + * A plugin is a named collection of modules. Plugins are written like + * modules: the derived class's constructor calls the protected member + * functions of this class to set the plugin's name, add its modules, wire + * its modules together, and record which modules from other plugins it + * would like to use as default submodules. After construction the plugin is + * read-only through its public interface. + * + * See the "Designing the Plugin Class" page of the developer documentation + * for the design. + */ +class Plugin { +public: + /// Type used for the names of plugins + using name_type = std::string; + + /// Type of a list of plugin names + using provider_list_type = std::vector; + + /// Type used for module keys + using key_type = ModuleMap::key_type; + + /// Type of a pointer to a module's implementation + using module_base_ptr = ModuleMap::module_base_ptr; + + /** @brief One default submodule recorded with default_submod_from. + * + * If the provider is loaded, the submodule @p callback_key of this + * plugin's module @p module_key should be set to the provider's module + * @p provider_key. + */ + struct DefaultSubmod { + /// The key of this plugin's module whose submodule is set + key_type module_key; + + /// The key the module uses for the submodule + key_type callback_key; + + /// The key of the provider's module to use as the submodule + key_type provider_key; + + /// True if all three keys are the same + bool operator==(const DefaultSubmod& rhs) const { + return std::tie(module_key, callback_key, provider_key) == + std::tie(rhs.module_key, rhs.callback_key, rhs.provider_key); + } + + /// Negates operator== + bool operator!=(const DefaultSubmod& rhs) const { + return !(*this == rhs); + } + }; + + /// Type of a list of recorded default submodules + using default_list_type = std::vector; + + /// Polymorphic base class, so the destructor is virtual + virtual ~Plugin() = default; + + /// The name of the plugin, *e.g.*, "my_plugin". No throw guarantee. + const name_type& name() const noexcept { return m_name_; } + + /** @brief The plugins to get default submodules from. + * + * @return The names of the preferred providers, highest priority first. + * + * @throw None No throw guarantee. + */ + const provider_list_type& preferred_providers() const noexcept { + return m_providers_; + } + + /** @brief The default submodules recorded for @p provider. + * + * @param[in] provider The name of the provider. + * + * @return The records for @p provider, in the order they were recorded. + * Empty if no records were made for @p provider. + * + * @throw None No throw guarantee. + */ + const default_list_type& default_submods_from( + const name_type& provider) const noexcept; + + /// The plugin's modules, in their current state. No throw guarantee. + const ModuleMap& get_modules() const noexcept { return m_modules_; } + + /** @brief Determines if *this and @p rhs describe the same plugin. + * + * Two plugins are equal if they have the same name, preferred providers, + * recorded default submodules, and modules. + */ + bool operator==(const Plugin& rhs) const; + + /// Negates operator== + bool operator!=(const Plugin& rhs) const { return !(*this == rhs); } + +protected: + /// Plugins are only created through derived classes + Plugin() = default; + + /** @brief Sets the name of the plugin. + * + * @param[in] name The name of the plugin. + * + * @throw std::invalid_argument if @p name is empty. The empty name is + * reserved for the ModuleManager's own + * modules. Strong throw guarantee. + */ + void set_name(name_type name); + + /** @brief Adds a module with implementation @p T to the plugin. + * + * @throw ??? Throws if ModuleMap::add_module throws. Same guarantee. + */ + template + void add_module(key_type key) { + m_modules_.add_module(std::move(key)); + } + + /** @brief Adds a module with implementation @p base to the plugin. + * + * @throw ??? Throws if ModuleMap::add_module throws. Same guarantee. + */ + void add_module(key_type key, module_base_ptr base) { + m_modules_.add_module(std::move(key), std::move(base)); + } + + /** @brief Links two of the plugin's own modules. + * + * @throw ??? Throws if ModuleMap::change_submod throws. Same guarantee. + */ + void change_submod(const key_type& module_key, const key_type& callback_key, + const key_type& submod_key) { + m_modules_.change_submod(module_key, callback_key, submod_key); + } + + /** @brief Sets the plugins to get default submodules from. + * + * @tparam Args The types of the names. Must be convertible to name_type. + * + * @param[in] names The names of the providers, highest priority first. + * + * @throw ??? Throws if the non-template overload throws. Same guarantee. + */ + template + void set_preferred_providers(Args&&... names) { + set_preferred_providers( + provider_list_type{name_type(std::forward(names))...}); + } + + /** @brief Sets the plugins to get default submodules from. + * + * Replaces any previously set preferred providers. + * + * @param[in] names The names of the providers, highest priority first. + * + * @throw std::invalid_argument if a name is empty, if a name appears more + * than once, or if a provider that already + * has recorded defaults is not in @p names. + * Strong throw guarantee. + */ + void set_preferred_providers(provider_list_type names); + + /** @brief Records a default submodule from another plugin. + * + * If @p provider is loaded, wiring sets the submodule @p callback_key of + * this plugin's module @p module_key to the provider's module + * @p provider_key, unless the submodule is already set. + * + * @param[in] provider The name of the plugin providing the module. + * @param[in] module_key The key of this plugin's module. + * @param[in] callback_key The key @p module_key uses for the submodule. + * @param[in] provider_key The key of the provider's module. + * + * @throw std::invalid_argument if @p provider is not a preferred + * provider. Strong throw guarantee. + * @throw std::out_of_range if @p module_key is not one of this plugin's + * modules, or if @p callback_key is not a + * submodule of @p module_key. Strong throw + * guarantee. + */ + void default_submod_from(const name_type& provider, key_type module_key, + key_type callback_key, key_type provider_key); + + /// The plugin's modules, for passing to helper functions + ModuleMap& modules() noexcept { return m_modules_; } + +private: + /// The name of the plugin + name_type m_name_; + + /// The preferred providers, highest priority first + provider_list_type m_providers_; + + /// The recorded default submodules, keyed by provider + std::map m_defaults_; + + /// The plugin's modules + ModuleMap m_modules_; +}; + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/export_pluginplay.cpp b/cxx/src/pluginplay/export_pluginplay.cpp index 6400fa7c9..ab21aa90b 100644 --- a/cxx/src/pluginplay/export_pluginplay.cpp +++ b/cxx/src/pluginplay/export_pluginplay.cpp @@ -19,6 +19,7 @@ #include "fields/export_fields.hpp" #include "module/export_module.hpp" #include "module_manager/export_module_manager.hpp" +#include "plugin/export_plugin.hpp" #include "printing/export_printing.hpp" #include "python/export_python.hpp" @@ -32,6 +33,7 @@ PYBIND11_MODULE(pluginplay, m) { python::export_python(m); export_module(m); export_module_manager(m); + export_plugin(m); export_submodule_request(m); printing::export_printing(m); } diff --git a/cxx/src/pluginplay/module_manager/export_module_manager.hpp b/cxx/src/pluginplay/module_manager/export_module_manager.hpp index b66f741f2..13cb6f673 100644 --- a/cxx/src/pluginplay/module_manager/export_module_manager.hpp +++ b/cxx/src/pluginplay/module_manager/export_module_manager.hpp @@ -20,8 +20,10 @@ namespace pluginplay { void export_module_manager_class(py_module_reference m); +void export_module_map(py_module_reference m); inline void export_module_manager(py_module_reference m) { + export_module_map(m); export_module_manager_class(m); } diff --git a/cxx/src/pluginplay/module_manager/export_module_map.cpp b/cxx/src/pluginplay/module_manager/export_module_map.cpp new file mode 100644 index 000000000..2502af13f --- /dev/null +++ b/cxx/src/pluginplay/module_manager/export_module_map.cpp @@ -0,0 +1,62 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../module/py_module_base.hpp" +#include "export_module_manager.hpp" +#include + +namespace pluginplay { + +void export_module_map(py_module_reference m) { + using key_type = ModuleMap::key_type; + + // Module is bound with the default (unique_ptr) holder, so Python can't + // share ownership of a Module. Hence, only the key-based change_submod and + // default_submod overloads are exposed, and at returns a reference. + using change_by_key = + void (ModuleMap::*)(const key_type&, const key_type&, const key_type&); + using default_by_key = + bool (ModuleMap::*)(const key_type&, const key_type&, const key_type&); + + py_class_type(m, "ModuleMap") + .def(py::init<>()) + .def("count", &ModuleMap::count) + .def("size", &ModuleMap::size) + .def("__len__", &ModuleMap::size) + .def("empty", &ModuleMap::empty) + .def("keys", &ModuleMap::keys) + .def( + "at", + [](ModuleMap& self, const key_type& key) -> Module& { + return *self.at(key); + }, + py::return_value_policy::reference_internal) + .def( + "add_module", + [](ModuleMap& self, key_type key, std::shared_ptr p) { + self.add_module(std::move(key), p); + }, + py::keep_alive<1, 3>()) + .def("change_submod", + static_cast(&ModuleMap::change_submod)) + .def("has_submod", &ModuleMap::has_submod) + .def("default_submod", + static_cast(&ModuleMap::default_submod)) + .def(py::self == py::self) + .def(py::self != py::self); +} + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/module_manager/module_map.cpp b/cxx/src/pluginplay/module_manager/module_map.cpp new file mode 100644 index 000000000..09fc4bac2 --- /dev/null +++ b/cxx/src/pluginplay/module_manager/module_map.cpp @@ -0,0 +1,93 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../module/detail_/module_pimpl.hpp" +#include +#include + +namespace pluginplay { + +void ModuleMap::add_module(key_type key, module_base_ptr base) { + if(count(key)) + throw std::invalid_argument( + "ModuleMap already has a module with key: '" + key + "'"); + if(!base) + throw std::invalid_argument("Module implementation for key: '" + key + + "' is null"); + + auto pimpl = std::make_unique(base); + auto ptr = std::make_shared(std::move(pimpl)); + ptr->set_name(key); + m_modules_.emplace(std::move(key), std::move(ptr)); +} + +ModuleMap::module_pointer ModuleMap::at(const key_type& key) const { + if(!count(key)) + throw std::out_of_range("ModuleMap has no module with key: '" + key + + "'"); + return m_modules_.at(key); +} + +ModuleMap::key_container_type ModuleMap::keys() const { + key_container_type rv; + rv.reserve(size()); + for(const auto& [k, v] : m_modules_) rv.push_back(k); + return rv; +} + +void ModuleMap::change_submod(const key_type& module_key, + const key_type& callback_key, + const key_type& submod_key) { + change_submod(module_key, callback_key, at(submod_key)); +} + +void ModuleMap::change_submod(const key_type& module_key, + const key_type& callback_key, + module_pointer submod) { + at(module_key)->change_submod(callback_key, std::move(submod)); +} + +bool ModuleMap::has_submod(const key_type& module_key, + const key_type& callback_key) const { + return at(module_key)->submods().at(callback_key).has_module(); +} + +bool ModuleMap::default_submod(const key_type& module_key, + const key_type& callback_key, + const key_type& submod_key) { + if(has_submod(module_key, callback_key)) return false; + change_submod(module_key, callback_key, submod_key); + return true; +} + +bool ModuleMap::default_submod(const key_type& module_key, + const key_type& callback_key, + module_pointer submod) { + if(has_submod(module_key, callback_key)) return false; + change_submod(module_key, callback_key, std::move(submod)); + return true; +} + +bool ModuleMap::operator==(const ModuleMap& rhs) const { + if(size() != rhs.size()) return false; + for(const auto& [k, v] : rhs.m_modules_) { + if(!count(k)) return false; + if(*m_modules_.at(k) != *v) return false; + } + return true; +} + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/plugin/export_plugin.hpp b/cxx/src/pluginplay/plugin/export_plugin.hpp new file mode 100644 index 000000000..de72b15a5 --- /dev/null +++ b/cxx/src/pluginplay/plugin/export_plugin.hpp @@ -0,0 +1,26 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include "../export_pluginplay.hpp" + +namespace pluginplay { + +void export_plugin_class(py_module_reference m); + +inline void export_plugin(py_module_reference m) { export_plugin_class(m); } + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/plugin/export_plugin_class.cpp b/cxx/src/pluginplay/plugin/export_plugin_class.cpp new file mode 100644 index 000000000..649d4abfb --- /dev/null +++ b/cxx/src/pluginplay/plugin/export_plugin_class.cpp @@ -0,0 +1,81 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../module/py_module_base.hpp" +#include "export_plugin.hpp" +#include "py_plugin.hpp" + +namespace pluginplay { + +void export_plugin_class(py_module_reference m) { + using name_type = Plugin::name_type; + using key_type = Plugin::key_type; + using module_base_ptr = Plugin::module_base_ptr; + using provider_list_type = Plugin::provider_list_type; + using default_submod = Plugin::DefaultSubmod; + + // Member pointers to the protected members, resolved through the + // publicist. The overloaded members need their overload picked. + using add_module_fxn = void (Plugin::*)(key_type, module_base_ptr); + using providers_fxn = void (Plugin::*)(provider_list_type); + using modules_fxn = ModuleMap& (Plugin::*)(); + auto add_module = static_cast(&PluginPublicist::add_module); + auto set_providers = + static_cast(&PluginPublicist::set_preferred_providers); + auto modules = static_cast(&PluginPublicist::modules); + + auto py_plugin = + py_class_type>(m, "Plugin"); + + py_class_type(py_plugin, "DefaultSubmod") + .def_readonly("module_key", &default_submod::module_key) + .def_readonly("callback_key", &default_submod::callback_key) + .def_readonly("provider_key", &default_submod::provider_key) + .def(py::self == py::self) + .def(py::self != py::self); + + py_plugin.def(py::init_alias<>()) + .def("name", &Plugin::name) + .def("preferred_providers", &Plugin::preferred_providers) + .def("default_submods_from", &Plugin::default_submods_from) + .def("get_modules", &Plugin::get_modules, + py::return_value_policy::reference_internal) + .def("set_name", &PluginPublicist::set_name) + .def( + "add_module", + [add_module](Plugin& self, key_type key, + std::shared_ptr p) { + (self.*add_module)(std::move(key), p); + }, + py::keep_alive<1, 3>()) + .def("change_submod", &PluginPublicist::change_submod) + .def("set_preferred_providers", + [set_providers](Plugin& self, py::args names) { + provider_list_type providers; + for(const auto& name : names) + providers.push_back(name.cast()); + (self.*set_providers)(std::move(providers)); + }) + .def("default_submod_from", &PluginPublicist::default_submod_from) + .def( + "modules", + [modules](Plugin& self) -> ModuleMap& { return (self.*modules)(); }, + py::return_value_policy::reference_internal) + .def(py::self == py::self) + .def(py::self != py::self); +} + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/plugin/plugin_class.cpp b/cxx/src/pluginplay/plugin/plugin_class.cpp new file mode 100644 index 000000000..84c34aff5 --- /dev/null +++ b/cxx/src/pluginplay/plugin/plugin_class.cpp @@ -0,0 +1,83 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include + +namespace pluginplay { + +const Plugin::default_list_type& Plugin::default_submods_from( + const name_type& provider) const noexcept { + static const default_list_type empty; + auto itr = m_defaults_.find(provider); + return itr == m_defaults_.end() ? empty : itr->second; +} + +bool Plugin::operator==(const Plugin& rhs) const { + return std::tie(m_name_, m_providers_, m_defaults_, m_modules_) == + std::tie(rhs.m_name_, rhs.m_providers_, rhs.m_defaults_, + rhs.m_modules_); +} + +void Plugin::set_name(name_type name) { + if(name.empty()) + throw std::invalid_argument( + "Plugin names can not be empty. The empty name is reserved for the " + "ModuleManager's own modules."); + m_name_ = std::move(name); +} + +void Plugin::set_preferred_providers(provider_list_type names) { + std::set seen; + for(const auto& name : names) { + if(name.empty()) + throw std::invalid_argument("Provider names can not be empty"); + if(!seen.insert(name).second) + throw std::invalid_argument("Provider '" + name + + "' is listed more than once"); + } + for(const auto& [provider, records] : m_defaults_) { + if(!seen.count(provider)) + throw std::invalid_argument( + "Provider '" + provider + + "' has recorded defaults, so it must stay a preferred provider"); + } + m_providers_ = std::move(names); +} + +void Plugin::default_submod_from(const name_type& provider, key_type module_key, + key_type callback_key, key_type provider_key) { + auto begin = m_providers_.begin(); + auto end = m_providers_.end(); + if(std::find(begin, end, provider) == end) + throw std::invalid_argument( + "'" + provider + + "' is not a preferred provider. Call set_preferred_providers first."); + + const auto& submods = m_modules_.at(module_key)->submods(); + if(!submods.count(callback_key)) + throw std::out_of_range("Module '" + module_key + + "' has no submodule with key: '" + + callback_key + "'"); + + m_defaults_[provider].push_back(DefaultSubmod{ + std::move(module_key), std::move(callback_key), std::move(provider_key)}); +} + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/plugin/py_plugin.hpp b/cxx/src/pluginplay/plugin/py_plugin.hpp new file mode 100644 index 000000000..5bcd7e108 --- /dev/null +++ b/cxx/src/pluginplay/plugin/py_plugin.hpp @@ -0,0 +1,48 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include + +namespace pluginplay { + +/** @brief The class Python plugins derive from. + * + * Plugin's constructor is protected, so pybind11 cannot create a Plugin + * directly. PyPlugin makes the constructor public. Plugin has no virtual + * functions to forward to Python, so PyPlugin adds nothing else. + */ +class PyPlugin : public Plugin { +public: + PyPlugin() = default; +}; + +/** @brief Exposes Plugin's protected members to the Python bindings. + * + * Naming a protected member through a class derived from Plugin is allowed, + * so the bindings take member pointers through this class. + */ +class PluginPublicist : public Plugin { +public: + using Plugin::add_module; + using Plugin::change_submod; + using Plugin::default_submod_from; + using Plugin::modules; + using Plugin::set_name; + using Plugin::set_preferred_providers; +}; + +} // namespace pluginplay diff --git a/docs/source/developer/design/pluginplay_initialization.rst b/docs/source/developer/design/pluginplay_initialization.rst index c0eef6539..d799d68b8 100644 --- a/docs/source/developer/design/pluginplay_initialization.rst +++ b/docs/source/developer/design/pluginplay_initialization.rst @@ -187,8 +187,8 @@ plugin ``P``: - Otherwise set ``callback_key`` to the provider's module ``provider_key``. These changes are done to ``P``'s live map, so it includes any changes the user -made and any wiring done by earlier calls in the loop. Because -``module_defaults_from_plugin`` only sets unset submodules: +made and any wiring done by earlier calls in the loop. Because wiring only +sets unset submodules: - choices the user made before ``wire_defaults`` are kept, - the order of ``preferred_providers`` sets priority, since the first diff --git a/docs/source/developer/design/plugins.rst b/docs/source/developer/design/plugins.rst index b87298e6d..947f08576 100644 --- a/docs/source/developer/design/plugins.rst +++ b/docs/source/developer/design/plugins.rst @@ -238,10 +238,9 @@ The Class ModuleMap& modules() noexcept; private: - name_type m_name_; - std::maps m_providers_; - default_list_type m_defaults_; + provider_list_type m_providers_; + std::map m_defaults_; ModuleMap m_modules_; }; @@ -282,8 +281,7 @@ type as the current ``ModuleManager::module_map``. ``ModuleMap`` provides: submodule has been set. It wraps ``SubmoduleRequest::has_module``. - ``default_submod(...)``, a convenience function with the same overloads as ``change_submod``. It sets the submodule only if it is unset. The - ``ModuleManager`` uses it to apply ``default_submod_from`` records, and - overrides of ``module_defaults_from_plugin_`` can use it too. + ``ModuleManager`` uses it to apply ``default_submod_from`` records. ``ModuleMap`` enforces that module keys are unique within the map, which means they are unique within a plugin. Uniqueness across plugins is the @@ -353,7 +351,6 @@ Both providers can fill ``"My driver"``'s ``"Data"`` submodule. Since ``"provider_a"`` comes first in the preferred providers, ``"A data"`` is used when both are loaded, and ``"B data"`` is used when only ``ProviderB`` is loaded. :ref:`pp_initialization_design` walks through these cases. -``MyPlugin`` does not need ``PLUGIN_DEFAULTS``. Python ====== diff --git a/tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp b/tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp new file mode 100644 index 000000000..331f6b2e6 --- /dev/null +++ b/tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp @@ -0,0 +1,188 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../catch.hpp" +#include "../examples/writing_a_module.hpp" +#include + +using namespace pluginplay; + +TEST_CASE("ModuleMap") { + ModuleMap defaulted; + + ModuleMap has_mods; + has_mods.add_module("Rectangle"); + has_mods.add_module("Prism"); + + SECTION("Default ctor") { + REQUIRE(defaulted.size() == 0); + REQUIRE(defaulted.empty()); + REQUIRE(defaulted.keys().empty()); + } + + SECTION("add_module") { + defaulted.add_module("Rectangle"); + REQUIRE(defaulted.size() == 1); + REQUIRE(defaulted.count("Rectangle") == 1); + auto mod = defaulted.at("Rectangle"); + REQUIRE(mod->has_name()); + REQUIRE(mod->get_name() == "Rectangle"); + REQUIRE_FALSE(mod->locked()); + } + + SECTION("add_module(key, base)") { + defaulted.add_module("Rectangle", std::make_shared()); + REQUIRE(defaulted.count("Rectangle") == 1); + // Same as adding by type + ModuleMap corr; + corr.add_module("Rectangle"); + REQUIRE(defaulted == corr); + } + + SECTION("add_module throws for duplicate key") { + REQUIRE_THROWS_AS(has_mods.add_module("Rectangle"), + std::invalid_argument); + // Keys are case-insensitive + REQUIRE_THROWS_AS(has_mods.add_module("rectangle"), + std::invalid_argument); + REQUIRE(has_mods.size() == 2); + } + + SECTION("add_module throws for null base") { + REQUIRE_THROWS_AS(defaulted.add_module("key", nullptr), + std::invalid_argument); + REQUIRE(defaulted.empty()); + } + + SECTION("count") { + REQUIRE(has_mods.count("Rectangle") == 1); + REQUIRE(has_mods.count("RECTANGLE") == 1); + REQUIRE(has_mods.count("not a key") == 0); + REQUIRE(defaulted.count("Rectangle") == 0); + } + + SECTION("at") { + REQUIRE(has_mods.at("Prism")->get_name() == "Prism"); + REQUIRE_THROWS_AS(has_mods.at("not a key"), std::out_of_range); + } + + SECTION("keys") { + auto keys = has_mods.keys(); + REQUIRE(keys.size() == 2); + REQUIRE(std::count(keys.begin(), keys.end(), "Rectangle") == 1); + REQUIRE(std::count(keys.begin(), keys.end(), "Prism") == 1); + } + + SECTION("iteration") { + std::size_t n = 0; + for(const auto& [key, mod] : std::as_const(has_mods)) { + REQUIRE(has_mods.at(key) == mod); + ++n; + } + REQUIRE(n == has_mods.size()); + } + + SECTION("change_submod(key, key, key)") { + REQUIRE_FALSE(has_mods.has_submod("Prism", "area")); + has_mods.change_submod("Prism", "area", "Rectangle"); + REQUIRE(has_mods.has_submod("Prism", "area")); + auto prism = has_mods.at("Prism"); + REQUIRE(prism->submods().at("area").value() == + *has_mods.at("Rectangle")); + } + + SECTION("change_submod(key, key, pointer)") { + // The submodule comes from a different ModuleMap + defaulted.add_module("Other rectangle"); + auto rect = defaulted.at("Other rectangle"); + has_mods.change_submod("Prism", "area", rect); + REQUIRE(has_mods.has_submod("Prism", "area")); + REQUIRE(has_mods.at("Prism")->submods().at("area").value() == *rect); + } + + SECTION("change_submod throws") { + // Module is not in *this + REQUIRE_THROWS_AS( + has_mods.change_submod("not a key", "area", "Rectangle"), + std::out_of_range); + // Submodule is not in *this + REQUIRE_THROWS_AS(has_mods.change_submod("Prism", "area", "not a key"), + std::out_of_range); + // Callback is not a submodule of the module + REQUIRE_THROWS_AS( + has_mods.change_submod("Prism", "not a callback", "Rectangle"), + std::out_of_range); + // Prism does not satisfy the Area property type + REQUIRE_THROWS_AS(has_mods.change_submod("Prism", "area", "Prism"), + std::runtime_error); + } + + SECTION("has_submod throws") { + REQUIRE_THROWS_AS(has_mods.has_submod("not a key", "area"), + std::out_of_range); + REQUIRE_THROWS_AS(has_mods.has_submod("Prism", "not a callback"), + std::out_of_range); + } + + SECTION("default_submod(key, key, key)") { + REQUIRE(has_mods.default_submod("Prism", "area", "Rectangle")); + REQUIRE(has_mods.has_submod("Prism", "area")); + + // Already set, so a second default does nothing + has_mods.add_module("Rectangle 2"); + has_mods.at("Rectangle 2")->change_input("dimension 1", 1.23); + REQUIRE_FALSE(has_mods.default_submod("Prism", "area", "Rectangle 2")); + REQUIRE(has_mods.at("Prism")->submods().at("area").value() == + *has_mods.at("Rectangle")); + } + + SECTION("default_submod(key, key, pointer)") { + defaulted.add_module("Other rectangle"); + auto rect = defaulted.at("Other rectangle"); + REQUIRE(has_mods.default_submod("Prism", "area", rect)); + + // Already set, so a second default does nothing + REQUIRE_FALSE( + has_mods.default_submod("Prism", "area", has_mods.at("Rectangle"))); + REQUIRE(has_mods.at("Prism")->submods().at("area").value() == *rect); + } + + SECTION("comparisons") { + REQUIRE(defaulted == ModuleMap{}); + REQUIRE_FALSE(defaulted != ModuleMap{}); + REQUIRE(defaulted != has_mods); + + ModuleMap same; + same.add_module("Rectangle"); + same.add_module("Prism"); + REQUIRE(same == has_mods); + + // Different key + ModuleMap diff_key; + diff_key.add_module("Rectangle"); + diff_key.add_module("Prism 2"); + REQUIRE(diff_key != has_mods); + + // Same keys, but a module's state differs + same.change_submod("Prism", "area", "Rectangle"); + REQUIRE(same != has_mods); + } + + SECTION("copies share modules") { + ModuleMap copy(has_mods); + REQUIRE(copy.at("Prism") == has_mods.at("Prism")); + } +} diff --git a/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp b/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp new file mode 100644 index 000000000..18446fc1a --- /dev/null +++ b/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp @@ -0,0 +1,191 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../catch.hpp" +#include "../examples/writing_a_module.hpp" +#include + +using namespace pluginplay; + +namespace { + +// Adds the plugin's modules, as a helper function would in a real plugin +void load_modules(ModuleMap& modules) { + modules.add_module("Rectangle"); + modules.add_module("Prism"); +} + +// A plugin written the way the design documentation describes +class MyPlugin : public Plugin { +public: + MyPlugin(); +}; + +PLUGIN_CTOR(MyPlugin) { + set_name("my_plugin"); + set_preferred_providers("provider_a", "provider_b"); + load_modules(modules()); + change_submod("Prism", "area", "Rectangle"); + default_submod_from("provider_a", "Prism", "area", "A area"); + default_submod_from("provider_b", "Prism", "area", "B area"); + default_submod_from("provider_a", "Prism", "area", "A area 2"); +} + +// Exposes the protected members so their error checking can be tested +class TestPlugin : public Plugin { +public: + using Plugin::add_module; + using Plugin::change_submod; + using Plugin::default_submod_from; + using Plugin::modules; + using Plugin::set_name; + using Plugin::set_preferred_providers; +}; + +} // namespace + +TEST_CASE("Plugin") { + using default_submod = Plugin::DefaultSubmod; + using default_list = Plugin::default_list_type; + using provider_list = Plugin::provider_list_type; + + TestPlugin defaulted; + MyPlugin my_plugin; + + SECTION("Default state") { + REQUIRE(defaulted.name().empty()); + REQUIRE(defaulted.preferred_providers().empty()); + REQUIRE(defaulted.default_submods_from("provider_a").empty()); + REQUIRE(defaulted.get_modules().empty()); + } + + SECTION("PLUGIN_CTOR") { + REQUIRE(my_plugin.name() == "my_plugin"); + REQUIRE(my_plugin.preferred_providers() == + provider_list{"provider_a", "provider_b"}); + + const auto& modules = my_plugin.get_modules(); + REQUIRE(modules.size() == 2); + REQUIRE(modules.has_submod("Prism", "area")); + + // Records are kept per provider, in the order they were made + default_list corr_a{{"Prism", "area", "A area"}, + {"Prism", "area", "A area 2"}}; + default_list corr_b{{"Prism", "area", "B area"}}; + REQUIRE(my_plugin.default_submods_from("provider_a") == corr_a); + REQUIRE(my_plugin.default_submods_from("provider_b") == corr_b); + REQUIRE(my_plugin.default_submods_from("provider_c").empty()); + } + + SECTION("set_name") { + defaulted.set_name("a name"); + REQUIRE(defaulted.name() == "a name"); + REQUIRE_THROWS_AS(defaulted.set_name(""), std::invalid_argument); + REQUIRE(defaulted.name() == "a name"); + } + + SECTION("add_module") { + defaulted.add_module("Rectangle"); + defaulted.add_module("Prism", std::make_shared()); + ModuleMap corr; + load_modules(corr); + REQUIRE(defaulted.get_modules() == corr); + REQUIRE_THROWS_AS(defaulted.add_module("Rectangle"), + std::invalid_argument); + } + + SECTION("change_submod") { + load_modules(defaulted.modules()); + defaulted.change_submod("Prism", "area", "Rectangle"); + REQUIRE(defaulted.get_modules().has_submod("Prism", "area")); + REQUIRE_THROWS_AS(defaulted.change_submod("Prism", "area", "not a key"), + std::out_of_range); + } + + SECTION("set_preferred_providers") { + defaulted.set_preferred_providers("a", std::string("b")); + REQUIRE(defaulted.preferred_providers() == provider_list{"a", "b"}); + + // Replaces the old list + defaulted.set_preferred_providers(provider_list{"c"}); + REQUIRE(defaulted.preferred_providers() == provider_list{"c"}); + + // Can be emptied + defaulted.set_preferred_providers(); + REQUIRE(defaulted.preferred_providers().empty()); + } + + SECTION("set_preferred_providers throws") { + defaulted.set_preferred_providers("a"); + REQUIRE_THROWS_AS(defaulted.set_preferred_providers(""), + std::invalid_argument); + REQUIRE_THROWS_AS(defaulted.set_preferred_providers("b", "b"), + std::invalid_argument); + REQUIRE(defaulted.preferred_providers() == provider_list{"a"}); + + // Can't drop a provider that has recorded defaults + load_modules(defaulted.modules()); + defaulted.default_submod_from("a", "Prism", "area", "key"); + REQUIRE_THROWS_AS(defaulted.set_preferred_providers("b"), + std::invalid_argument); + REQUIRE(defaulted.preferred_providers() == provider_list{"a"}); + + // Keeping it, and adding others, is fine + defaulted.set_preferred_providers("b", "a"); + REQUIRE(defaulted.preferred_providers() == provider_list{"b", "a"}); + } + + SECTION("default_submod_from") { + load_modules(defaulted.modules()); + defaulted.set_preferred_providers("a"); + defaulted.default_submod_from("a", "Prism", "area", "key"); + REQUIRE(defaulted.default_submods_from("a") == + default_list{default_submod{"Prism", "area", "key"}}); + + // Recording a default doesn't change the module + REQUIRE_FALSE(defaulted.get_modules().has_submod("Prism", "area")); + } + + SECTION("default_submod_from throws") { + load_modules(defaulted.modules()); + defaulted.set_preferred_providers("a"); + + // Not a preferred provider + REQUIRE_THROWS_AS( + defaulted.default_submod_from("b", "Prism", "area", "key"), + std::invalid_argument); + // Not one of the plugin's modules + REQUIRE_THROWS_AS( + defaulted.default_submod_from("a", "not a key", "area", "key"), + std::out_of_range); + // Not a submodule of the module + REQUIRE_THROWS_AS( + defaulted.default_submod_from("a", "Prism", "not a callback", "key"), + std::out_of_range); + REQUIRE(defaulted.default_submods_from("a").empty()); + } + + SECTION("comparisons") { + REQUIRE(defaulted == TestPlugin{}); + REQUIRE(my_plugin == MyPlugin{}); + REQUIRE_FALSE(my_plugin != MyPlugin{}); + REQUIRE(defaulted != my_plugin); + + TestPlugin other; + other.set_name("my_plugin"); + REQUIRE(other != defaulted); + } +} diff --git a/tests/python/unit_tests/module_manager/test_module_map.py b/tests/python/unit_tests/module_manager/test_module_map.py new file mode 100644 index 000000000..b22ed44ba --- /dev/null +++ b/tests/python/unit_tests/module_manager/test_module_map.py @@ -0,0 +1,111 @@ +# Copyright 2026 NWChemEx-Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import pluginplay as pp +import py_test_pluginplay as test_pp + + +class AProvider(pp.ModuleBase): + def __init__(self): + pp.ModuleBase.__init__(self) + self.satisfies_property_type(test_pp.OneInOneOut()) + + def run_(self, inputs, submods): + pt = test_pp.OneInOneOut() + (i0,) = pt.unwrap_inputs(inputs) + return pt.wrap_results(self.results(), i0) + + +class AConsumer(pp.ModuleBase): + def __init__(self): + pp.ModuleBase.__init__(self) + self.satisfies_property_type(test_pp.OneInOneOut()) + self.add_submodule(test_pp.OneInOneOut(), "sub") + + def run_(self, inputs, submods): + pt = test_pp.OneInOneOut() + (i0,) = pt.unwrap_inputs(inputs) + return pt.wrap_results(self.results(), i0) + + +class TestModuleMap(unittest.TestCase): + def setUp(self): + self.defaulted = pp.ModuleMap() + self.has_mods = pp.ModuleMap() + self.has_mods.add_module("Provider", AProvider()) + self.has_mods.add_module("Consumer", AConsumer()) + + def test_default_ctor(self): + self.assertEqual(self.defaulted.size(), 0) + self.assertEqual(len(self.defaulted), 0) + self.assertTrue(self.defaulted.empty()) + self.assertEqual(self.defaulted.keys(), []) + + def test_add_module(self): + self.defaulted.add_module("Provider", AProvider()) + self.assertEqual(self.defaulted.count("Provider"), 1) + self.assertEqual(self.defaulted.at("Provider").get_name(), "Provider") + + # Keys are unique (and case-insensitive) + self.assertRaises( + ValueError, self.has_mods.add_module, "provider", AProvider() + ) + + def test_count(self): + self.assertEqual(self.has_mods.count("Provider"), 1) + self.assertEqual(self.has_mods.count("not a key"), 0) + + def test_at(self): + self.assertEqual(self.has_mods.at("Consumer").get_name(), "Consumer") + self.assertRaises(IndexError, self.has_mods.at, "not a key") + + def test_keys(self): + self.assertEqual( + sorted(self.has_mods.keys()), ["Consumer", "Provider"] + ) + + def test_change_submod_by_key(self): + self.assertFalse(self.has_mods.has_submod("Consumer", "sub")) + self.has_mods.change_submod("Consumer", "sub", "Provider") + self.assertTrue(self.has_mods.has_submod("Consumer", "sub")) + + def test_change_submod_throws(self): + self.assertRaises( + IndexError, + self.has_mods.change_submod, + "not a key", + "sub", + "Provider", + ) + self.assertRaises( + IndexError, + self.has_mods.change_submod, + "Consumer", + "not a callback", + "Provider", + ) + + def test_default_submod(self): + self.assertTrue( + self.has_mods.default_submod("Consumer", "sub", "Provider") + ) + self.assertFalse( + self.has_mods.default_submod("Consumer", "sub", "Provider") + ) + + def test_comparisons(self): + self.assertEqual(self.defaulted, pp.ModuleMap()) + self.assertNotEqual(self.defaulted, self.has_mods) diff --git a/tests/python/unit_tests/plugin/__init__.py b/tests/python/unit_tests/plugin/__init__.py new file mode 100644 index 000000000..1584d820f --- /dev/null +++ b/tests/python/unit_tests/plugin/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 NWChemEx-Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/python/unit_tests/plugin/test_plugin.py b/tests/python/unit_tests/plugin/test_plugin.py new file mode 100644 index 000000000..d1f8a2c0f --- /dev/null +++ b/tests/python/unit_tests/plugin/test_plugin.py @@ -0,0 +1,131 @@ +# Copyright 2026 NWChemEx-Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import pluginplay as pp +import py_test_pluginplay as test_pp + + +class AProvider(pp.ModuleBase): + def __init__(self): + pp.ModuleBase.__init__(self) + self.satisfies_property_type(test_pp.OneInOneOut()) + + def run_(self, inputs, submods): + pt = test_pp.OneInOneOut() + (i0,) = pt.unwrap_inputs(inputs) + return pt.wrap_results(self.results(), i0) + + +class AConsumer(pp.ModuleBase): + def __init__(self): + pp.ModuleBase.__init__(self) + self.satisfies_property_type(test_pp.OneInOneOut()) + self.add_submodule(test_pp.OneInOneOut(), "sub") + + def run_(self, inputs, submods): + pt = test_pp.OneInOneOut() + (i0,) = pt.unwrap_inputs(inputs) + return pt.wrap_results(self.results(), i0) + + +class MyPlugin(pp.Plugin): + """A plugin written in Python, set up in __init__.""" + + def __init__(self): + pp.Plugin.__init__(self) + self.set_name("my_plugin") + self.set_preferred_providers("provider_a", "provider_b") + self.add_module("Provider", AProvider()) + self.add_module("Consumer", AConsumer()) + self.change_submod("Consumer", "sub", "Provider") + self.default_submod_from("provider_a", "Consumer", "sub", "A key") + + +class TestPlugin(unittest.TestCase): + def setUp(self): + self.defaulted = pp.Plugin() + self.my_plugin = MyPlugin() + + def test_default_state(self): + self.assertEqual(self.defaulted.name(), "") + self.assertEqual(self.defaulted.preferred_providers(), []) + self.assertEqual(self.defaulted.default_submods_from("a"), []) + self.assertEqual(self.defaulted.get_modules().size(), 0) + + def test_python_plugin(self): + self.assertEqual(self.my_plugin.name(), "my_plugin") + self.assertEqual( + self.my_plugin.preferred_providers(), ["provider_a", "provider_b"] + ) + modules = self.my_plugin.get_modules() + self.assertEqual(sorted(modules.keys()), ["Consumer", "Provider"]) + self.assertTrue(modules.has_submod("Consumer", "sub")) + + records = self.my_plugin.default_submods_from("provider_a") + self.assertEqual(len(records), 1) + self.assertEqual(records[0].module_key, "Consumer") + self.assertEqual(records[0].callback_key, "sub") + self.assertEqual(records[0].provider_key, "A key") + self.assertEqual(self.my_plugin.default_submods_from("provider_b"), []) + + def test_modules(self): + self.defaulted.modules().add_module("Provider", AProvider()) + self.assertEqual(self.defaulted.get_modules().count("Provider"), 1) + + def test_set_name_throws(self): + self.assertRaises(ValueError, self.defaulted.set_name, "") + + def test_set_preferred_providers_throws(self): + self.assertRaises( + ValueError, self.defaulted.set_preferred_providers, "a", "a" + ) + # Can't drop a provider that has recorded defaults + self.assertRaises( + ValueError, self.my_plugin.set_preferred_providers, "provider_b" + ) + + def test_default_submod_from_throws(self): + # Not a preferred provider + self.assertRaises( + ValueError, + self.my_plugin.default_submod_from, + "provider_c", + "Consumer", + "sub", + "key", + ) + # Not one of the plugin's modules + self.assertRaises( + IndexError, + self.my_plugin.default_submod_from, + "provider_a", + "not a key", + "sub", + "key", + ) + # Not a submodule of the module + self.assertRaises( + IndexError, + self.my_plugin.default_submod_from, + "provider_a", + "Consumer", + "not a callback", + "key", + ) + + def test_comparisons(self): + self.assertEqual(self.defaulted, pp.Plugin()) + self.assertNotEqual(self.defaulted, self.my_plugin) From fdeb2ff670eb322c6a14543b252bd001cd660195 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 12 Oct 2026 10:54:18 -0500 Subject: [PATCH 3/8] Refactor default submodule addition with error handling Refactor default submodule insertion to use try_emplace for better error handling. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cxx/src/pluginplay/plugin/plugin_class.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cxx/src/pluginplay/plugin/plugin_class.cpp b/cxx/src/pluginplay/plugin/plugin_class.cpp index 84c34aff5..c2d4e792d 100644 --- a/cxx/src/pluginplay/plugin/plugin_class.cpp +++ b/cxx/src/pluginplay/plugin/plugin_class.cpp @@ -76,8 +76,15 @@ void Plugin::default_submod_from(const name_type& provider, key_type module_key, "' has no submodule with key: '" + callback_key + "'"); - m_defaults_[provider].push_back(DefaultSubmod{ - std::move(module_key), std::move(callback_key), std::move(provider_key)}); + auto [itr, inserted] = m_defaults_.try_emplace(provider); + try { + itr->second.push_back(DefaultSubmod{std::move(module_key), + std::move(callback_key), + std::move(provider_key)}); + } catch(...) { + if(inserted) m_defaults_.erase(itr); + throw; + } } } // namespace pluginplay From 5e4e259cd06c1b8bbdff064c2f5194c8774f8b90 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 12 Oct 2026 11:12:23 -0500 Subject: [PATCH 4/8] Update documentation for operator== in plugin_class.hpp Clarify operator== documentation to specify comparison with rhs. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cxx/include/pluginplay/plugin/plugin_class.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cxx/include/pluginplay/plugin/plugin_class.hpp b/cxx/include/pluginplay/plugin/plugin_class.hpp index aa36c4f8f..2a579bd5d 100644 --- a/cxx/include/pluginplay/plugin/plugin_class.hpp +++ b/cxx/include/pluginplay/plugin/plugin_class.hpp @@ -66,7 +66,7 @@ class Plugin { /// The key of the provider's module to use as the submodule key_type provider_key; - /// True if all three keys are the same + /// True if all three keys equal their counterparts in @p rhs bool operator==(const DefaultSubmod& rhs) const { return std::tie(module_key, callback_key, provider_key) == std::tie(rhs.module_key, rhs.callback_key, rhs.provider_key); From 50c1e1cf678ce7793355894c73839bd451005884 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 12 Oct 2026 11:12:40 -0500 Subject: [PATCH 5/8] Fix typo in plugin initialization documentation Corrected a typo in the documentation regarding plugin initialization. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/source/developer/design/pluginplay_initialization.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/developer/design/pluginplay_initialization.rst b/docs/source/developer/design/pluginplay_initialization.rst index d799d68b8..8f612822b 100644 --- a/docs/source/developer/design/pluginplay_initialization.rst +++ b/docs/source/developer/design/pluginplay_initialization.rst @@ -42,7 +42,7 @@ Previous Approaches to Initialization Originally, the end user managed initialization by hand. There was a convention that plugins would declare/define ``load_modules`` and ``set_defaults`` -functions. The the end user would call them. This approach has several problems: +functions. The end user would call them. This approach has several problems: - Wiring only works if every provider has been loaded before any ``set_defaults`` is called. Nothing enforces that order. From 9d7aebc08cacba7a99d855aca321cfcf6b28abdb Mon Sep 17 00:00:00 2001 From: "Ryan M. Richard" Date: Tue, 13 Oct 2026 15:14:43 -0500 Subject: [PATCH 6/8] plugins are implemented! --- CMakeLists.txt | 2 +- .../pluginplay/module/module_class.hpp | 3 + .../module_manager/module_manager.hpp | 1 + .../module_manager/module_manager_class.hpp | 224 +++++++++-- .../pluginplay/module_manager/module_map.hpp | 65 +++ .../module_manager/qualified_key.hpp | 146 +++++++ cxx/include/pluginplay/plugin/plugin.hpp | 74 +++- .../pluginplay/plugin/plugin_class.hpp | 12 +- .../pluginplay/printing/document_modules.hpp | 4 + cxx/include/pluginplay/printing/mermaid.hpp | 2 +- .../module/detail_/module_pimpl.hpp | 32 +- .../module/detail_/module_pimpl.ipp | 13 +- .../detail_/module_manager_pimpl.hpp | 172 +++++--- .../detail_/module_manager_pimpl.ipp | 236 +++++++++-- .../module_manager/export_module_manager.hpp | 2 + .../export_module_manager_class.cpp | 29 +- .../module_manager/export_module_map.cpp | 11 + .../module_manager/export_qualified_key.cpp | 54 +++ .../module_manager/module_manager_class.cpp | 53 ++- .../pluginplay/module_manager/module_map.cpp | 14 +- .../pluginplay/plugin/export_plugin_class.cpp | 2 +- cxx/src/pluginplay/plugin/plugin_class.cpp | 14 +- cxx/src/pluginplay/plugin/py_plugin.hpp | 10 +- .../pluginplay/printing/document_modules.cpp | 56 ++- cxx/src/pluginplay/printing/mermaid.cpp | 38 +- .../design/pluginplay_initialization.rst | 83 +++- docs/source/developer/design/plugins.rst | 39 +- .../tutorials/plugins/document_modules.rst | 14 +- docs/source/tutorials/plugins/index.rst | 2 +- .../source/tutorials/plugins/load_modules.rst | 101 ----- docs/source/tutorials/plugins/overview.rst | 6 +- .../tutorials/plugins/writing_a_plugin.rst | 160 ++++++++ .../generate_module_documentation.cpp | 8 +- ...ad_modules.cpp => pluginplay_examples.cpp} | 16 +- ...ad_modules.hpp => pluginplay_examples.hpp} | 2 +- tests/cxx/doc_snippets/test_modules.cpp | 6 +- .../test_tutorial_module_documents.cpp | 18 +- .../pluginplay/examples/lambda_module.cpp | 4 +- .../pluginplay/examples/load_modules.hpp | 71 ---- .../pluginplay/examples/workflow.cpp | 4 +- .../pluginplay/examples/workflow.hpp | 16 +- .../pluginplay/examples/writing_a_plugin.hpp | 84 ++++ .../detail_/module_manager_pimpl.cpp | 84 +++- .../module_manager/module_manager_class.cpp | 380 +++++++++++++++++- .../pluginplay/module_manager/module_map.cpp | 46 +++ .../module_manager/qualified_key.cpp | 62 +++ .../pluginplay/plugin/plugin_class.cpp | 21 +- .../pluginplay/printing/document_modules.cpp | 46 +++ .../export_pluginplay_examples.cpp | 4 +- tests/python/doc_snippets/test_modules.py | 7 +- .../test_module_manager_class.py | 99 ++++- .../module_manager/test_module_map.py | 24 ++ .../module_manager/test_qualified_key.py | 46 +++ 53 files changed, 2236 insertions(+), 486 deletions(-) create mode 100644 cxx/include/pluginplay/module_manager/qualified_key.hpp create mode 100644 cxx/src/pluginplay/module_manager/export_qualified_key.cpp delete mode 100644 docs/source/tutorials/plugins/load_modules.rst create mode 100644 docs/source/tutorials/plugins/writing_a_plugin.rst rename tests/cxx/doc_snippets/{load_modules.cpp => pluginplay_examples.cpp} (64%) rename tests/cxx/doc_snippets/{load_modules.hpp => pluginplay_examples.hpp} (93%) delete mode 100644 tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp create mode 100644 tests/cxx/unit_tests/pluginplay/examples/writing_a_plugin.hpp create mode 100644 tests/cxx/unit_tests/pluginplay/module_manager/qualified_key.cpp create mode 100644 tests/python/unit_tests/module_manager/test_qualified_key.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 59faef772..839f9e21b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,7 +158,7 @@ if(BUILD_PYBIND11_BINDINGS AND BUILD_TESTING) SOURCES "${_pluginplay_examples_dir}/classical_force.cpp" "${_pluginplay_examples_dir}/coulombs_law.cpp" - "${_pluginplay_examples_dir}/load_modules.cpp" + "${_pluginplay_examples_dir}/pluginplay_examples.cpp" "${_pluginplay_examples_dir}/screened_coulombs_law.cpp" "${_pluginplay_examples_dir}/templated_coulombs_law.cpp" TYPE STATIC diff --git a/cxx/include/pluginplay/module/module_class.hpp b/cxx/include/pluginplay/module/module_class.hpp index ca68a5ed2..44f133a8c 100644 --- a/cxx/include/pluginplay/module/module_class.hpp +++ b/cxx/include/pluginplay/module/module_class.hpp @@ -745,6 +745,9 @@ class Module { bool operator!=(const Module& rhs) const { return !((*this) == rhs); } private: + /// Lets the ModuleManager attach its runtime and caches to the module + friend struct detail_::ModuleManagerPIMPL; + /** @brief Unlocks a locked module * * There are very select circumstances when we need to unlock a locked diff --git a/cxx/include/pluginplay/module_manager/module_manager.hpp b/cxx/include/pluginplay/module_manager/module_manager.hpp index cc865eba4..69324771a 100644 --- a/cxx/include/pluginplay/module_manager/module_manager.hpp +++ b/cxx/include/pluginplay/module_manager/module_manager.hpp @@ -17,3 +17,4 @@ #pragma once #include #include +#include diff --git a/cxx/include/pluginplay/module_manager/module_manager_class.hpp b/cxx/include/pluginplay/module_manager/module_manager_class.hpp index 35fdd7280..74bfa8a9b 100644 --- a/cxx/include/pluginplay/module_manager/module_manager_class.hpp +++ b/cxx/include/pluginplay/module_manager/module_manager_class.hpp @@ -20,7 +20,12 @@ #include #include #include +#include +#include +#include #include +#include +#include namespace pluginplay { @@ -30,17 +35,34 @@ struct ModuleManagerPIMPL; /** @brief Class responsible for holding and managing modules. * + * Modules are loaded into a ModuleManager in two ways: as part of a plugin + * (see load_plugin), or one at a time (see add_module). The ModuleManager + * keeps one ModuleMap per loaded plugin, plus a ModuleMap of its own for the + * modules that do not come from a plugin. The empty plugin name, "", refers + * to the ModuleManager's own modules. + * + * Plugin names and module keys are case-insensitive. Module keys are unique + * within a map, but two maps may use the same key. + * Functions that take a module accept a QualifiedKey. A plain module key + * works as long as exactly one map has that key; otherwise the plugin name + * must be given too, *e.g.*, `mm.at({"integrals", "Overlap"})`. */ class ModuleManager { public: /// The type of a pointer to a read/write module using module_base_ptr = std::shared_ptr; - /// The type of a pointer to a read-only module - using const_module_base_ptr = std::shared_ptr; + /// Type used to identify modules + using qualified_key = QualifiedKey; + + /// Type used for the names of plugins + using name_type = Plugin::name_type; - /// Type of a map holding usable modules - using module_map = utilities::CaseInsensitiveMap>; + /// Type of a container of plugin names + using name_container_type = std::vector; + + /// Type of a pointer to a plugin + using plugin_pointer = std::unique_ptr; /// The type of the runtime using runtime_type = parallelzone::runtime::RuntimeView; @@ -49,7 +71,7 @@ class ModuleManager { using runtime_ptr = std::shared_ptr; /// Type of module key container - using key_container_type = std::vector; + using key_container_type = std::vector; /// Type of the cache using cache_type = cache::ModuleManagerCache; @@ -75,32 +97,124 @@ class ModuleManager { /// Standard dtor ~ModuleManager() noexcept; - /** @brief Returns an iterator to the first element of the module map + /** @brief Determines how many modules @p key refers to. + * + * @param[in] key The module to look for. If @p key is key-only, all maps + * are searched. * - * @return Iterator to the first element of the map + * @return For a key-only @p key, the number of maps that have the key. + * For a qualified @p key, 1 if the plugin is loaded and has the + * module and 0 otherwise. + * + * @throw None No throw guarantee. */ - module_map::iterator begin() noexcept; + type::size count(const qualified_key& key) const noexcept; - /** @brief Returns an iterator to the past-the-end element of the module map + /** @brief Gets the number of modules loaded. * - * @return Iterator to the past-the-end element of the map + * @return Number of modules loaded, summed over all maps. */ - module_map::iterator end() noexcept; + type::size size() const noexcept; - /** @brief Used to see if we have a module + /** @brief Creates a plugin of type @p PluginType and loads it. * - * @param key - * @return + * @tparam PluginType The type of the plugin. Must derive from Plugin and + * be default constructible. + * + * @throw std::invalid_argument if a plugin with the same name is already + * loaded. Strong throw guarantee. + * @throw std::bad_alloc if there is a problem allocating the plugin. Weak + * throw guarantee. */ - type::size count(type::key key) const noexcept; + template + void load_plugin(); - /** @brief Gets the number of modules loaded. + /** @brief Loads @p plugin. + * + * *this takes ownership of @p plugin and its modules. Nothing is copied: + * the plugin's ModuleMap is the map *this uses for the plugin's modules. + * Each module is given *this's runtime and caches. A plugin's modules use + * the module cache named `"::"`. * - * @return Number of modules loaded. + * Loading does not set default submodules between plugins; call + * wire_defaults after all plugins are loaded. + * + * @param[in] plugin The plugin to load. If this function throws, the + * plugin is destroyed. + * + * @throw std::invalid_argument if @p plugin is null, has no name, or has + * the same name as a loaded plugin. Strong + * throw guarantee for *this. + * @throw std::bad_alloc if there is a problem allocating the caches. Weak + * throw guarantee. */ - type::size size() const noexcept; + void load_plugin(plugin_pointer plugin); + + /** @brief Sets default submodules between the loaded plugins. + * + * For each loaded plugin, in load order, and for each of its preferred + * providers that is loaded, in priority order, this applies the default + * submodules the plugin recorded for that provider (see + * Plugin::default_submod_from). A default only sets a submodule that is + * still unset, so choices made by the user, or by a provider with higher + * priority, are kept. Defaults that name a module the provider does not + * have are skipped, with a warning through the runtime's logger. + * + * Calling this function again, *e.g.*, after loading another plugin, + * only fills submodules that are still unset. + * + * @throw std::runtime_error if a provider's module does not satisfy the + * property type of the submodule. Weak throw + * guarantee. + */ + void wire_defaults(); + + /** @brief Is a plugin named @p name loaded? + * + * @param[in] name The name of the plugin. + * + * @return True if a plugin named @p name is loaded and false otherwise. + * + * @throw None No throw guarantee. + */ + bool has_plugin(const name_type& name) const noexcept; + + /** @brief The names of the maps in *this. + * + * @return "", for *this's own modules, followed by the names of the loaded + * plugins in the order they were loaded. + * + * @throw std::bad_alloc if there is a problem allocating the return. + * Strong throw guarantee. + */ + name_container_type plugin_names() const; + + /** @brief Returns the loaded plugin named @p name. + * + * @param[in] name The name of the plugin. + * + * @return A read-only reference to the plugin. + * + * @throw std::out_of_range if no plugin named @p name is loaded. Strong + * throw guarantee. + */ + const Plugin& get_plugin(const name_type& name) const; + + /** @brief Returns the modules in the map named @p name. + * + * @param[in] name The name of a loaded plugin, or "" for the modules that + * do not come from a plugin. + * + * @return A read-only reference to the map. + * + * @throw std::out_of_range if @p name is not "" and no plugin named + * @p name is loaded. Strong throw guarantee. + */ + const ModuleMap& get_modules(const name_type& name) const; /** @brief Used to add a new module to the list + * + * The module is added to *this's own map (plugin name ""). * * @tparam ModuleType The type of the module being added. This type must be * constructible with no arguments and must inherit from @@ -111,6 +225,8 @@ class ModuleManager { void add_module(type::key module_key); /** @brief Used to add a new module to the list + * + * The module is added to *this's own map (plugin name ""). * * @param module_key * @param base @@ -120,14 +236,21 @@ class ModuleManager { ///@{ /** @name Module retrievers * - * @param module_key - * @return + * @param[in] module_key The module to retrieve. + * + * @return A reference to the requested module. + * + * @throw std::out_of_range if @p module_key does not refer to a module. + * Strong throw guarantee. + * @throw std::invalid_argument if @p module_key is key-only and more than + * one map has the key. Strong throw + * guarantee. */ - Module& at(const type::key& module_key) { + Module& at(const qualified_key& module_key) { const auto& rv = const_cast(*this).at(module_key); return const_cast(rv); } - const Module& at(const type::key& module_key) const; + const Module& at(const qualified_key& module_key) const; ///@} /** @brief Makes a "deep copy" of a module @@ -138,12 +261,21 @@ class ModuleManager { * that changing @p new_key 's inputs or submodules will not affect the * module stored under @p old_key. * - * @param[in] old_key The key for the module which is being copied. + * The copy is always stored in *this's own map (plugin name ""), even if + * @p old_key is one of a plugin's modules. Plugins' maps only hold the + * modules the plugins provide. + * + * @param[in] old_key The module which is being copied. * @param[in] new_key The key for the resulting copy. * + * @throw std::out_of_range if @p old_key does not refer to a module. + * Strong throw guarantee. + * @throw std::invalid_argument if @p old_key is ambiguous or *this's own + * map already has a module with key + * @p new_key. Strong throw guarantee. * @throw std::bad_alloc if an allocation error arises. */ - void copy_module(const type::key& old_key, type::key new_key); + void copy_module(const qualified_key& old_key, type::key new_key); /** @brief Unloads the specified module. * @@ -152,9 +284,12 @@ class ModuleManager { * NOT clean any data out of the cache. This function is a no-op if @p key * does not exist. * - * @param[in] key The key for the module which should be erased. + * @param[in] key The module which should be erased. + * + * @throw std::invalid_argument if @p key is key-only and more than one map + * has the key. Strong throw guarantee. */ - void erase(const type::key& key); + void erase(const qualified_key& key); /** @brief Changes the key a module is stored under. * @@ -162,12 +297,15 @@ class ModuleManager { * After a call to this function there will be no module stored under * @p old_key. * - * @param[in] old_key The key we are renaming. + * Like copy_module, the renamed module is stored in *this's own map, so + * renaming one of a plugin's modules moves it out of the plugin's map. + * + * @param[in] old_key The module we are renaming. * @param[in] new_key The value for the new key. * - * @throw std::bad_alloc if there is an allocation error. + * @throw ??? Throws if copy_module throws. Same guarantee. */ - void rename_module(const type::key& old_key, type::key new_key); + void rename_module(const qualified_key& old_key, type::key new_key); /** @brief Changes the value of an input bound to a module * @@ -177,7 +315,7 @@ class ModuleManager { * @param new_value */ template - void change_input(const type::key& key, const type::key& option, + void change_input(const qualified_key& key, const type::key& option, T&& new_value) { at(key).change_input(option, std::forward(new_value)); } @@ -190,19 +328,21 @@ class ModuleManager { * by specifying the key of the module, the callback point's key, and the * key for tne new submodule. * - * @param[in] module_key The key for the module whose submodule is being - * changed. + * @param[in] module_key The module whose submodule is being changed. * @param[in] callback_key The name of the callback point in the module * assigned to @p module_key - * @param[in] submod_key The key for the submodule to call. + * @param[in] submod_key The submodule to call. It may be in a different + * map than @p module_key. * * @throw std::out_of_range if any of the keys do not refer to valid, * already present modules or callback points. Weak * throw guarantee. + * @throw std::invalid_argument if @p module_key or @p submod_key is + * ambiguous. Strong throw guarantee. */ - void change_submod(const type::key& module_key, + void change_submod(const qualified_key& module_key, const type::key& callback_key, - const type::key& submod_key); + const qualified_key& submod_key); /** @brief Runs a given module * @@ -213,7 +353,7 @@ class ModuleManager { * @return */ template - auto run_as(const type::key& key, Args&&... args) { + auto run_as(const qualified_key& key, Args&&... args) { return at(key).run_as(std::forward(args)...); } @@ -238,7 +378,8 @@ class ModuleManager { /** @brief Returns the keys of all the modules in the module manager. * - * @return A vector of keys for all the modules in the module manager. + * @return The qualified keys of all the modules, *this's own modules + * first, followed by each plugin's modules in load order. * * @throw std::bad_alloc if an allocation error arises. */ @@ -276,10 +417,11 @@ void ModuleManager::add_module(type::key module_key) { add_module(std::move(module_key), std::make_shared()); } -inline void ModuleManager::rename_module(const type::key& old_key, - type::key new_key) { - copy_module(old_key, std::move(new_key)); - erase(old_key); +template +void ModuleManager::load_plugin() { + static_assert(std::is_base_of_v, + "Plugins must derive from pluginplay::Plugin"); + load_plugin(std::make_unique()); } } // namespace pluginplay diff --git a/cxx/include/pluginplay/module_manager/module_map.hpp b/cxx/include/pluginplay/module_manager/module_map.hpp index 2f8ae2269..63cfbb9ee 100644 --- a/cxx/include/pluginplay/module_manager/module_map.hpp +++ b/cxx/include/pluginplay/module_manager/module_map.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include namespace pluginplay { @@ -87,6 +88,70 @@ class ModuleMap { */ void add_module(key_type key, module_base_ptr base); + /** @brief Stores the already-created module @p module under @p key. + * + * This overload is for adding a module that already exists, *e.g.*, a + * copy of a module. The module's name is set to @p key. + * + * @param[in] key The key to store the module under. + * @param[in] module The module to store. + * + * @throw std::invalid_argument if @p key is already in use. Strong throw + * guarantee. + * @throw std::bad_alloc if there is a problem allocating the module. + * Strong throw guarantee. + */ + void add_module(key_type key, Module module); + + /** @brief Stores an unlocked copy of the module @p old_key under + * @p new_key. + * + * The copy has its own inputs and submodules, so changing them does not + * change the module stored under @p old_key. The copy shares the + * implementation of @p old_key. + * + * @param[in] old_key The key of the module to copy. + * @param[in] new_key The key to store the copy under. + * + * @throw std::out_of_range if *this does not have a module with key + * @p old_key. Strong throw guarantee. + * @throw std::invalid_argument if @p new_key is already in use. Strong + * throw guarantee. + * @throw std::bad_alloc if there is a problem allocating the copy. Strong + * throw guarantee. + */ + void copy_module(const key_type& old_key, key_type new_key); + + /** @brief Removes the module stored under @p key. + * + * Modules that use the removed module as a submodule keep using it. This + * function is a no-op if *this does not have a module with key @p key. + * + * @param[in] key The key of the module to remove. + * + * @throw None No throw guarantee. + */ + void erase(const key_type& key) noexcept { m_modules_.erase(key); } + + /** @brief Changes the value of an input of the module @p key. + * + * @tparam T The type of the new value. + * + * @param[in] key The key of the module whose input is being changed. + * @param[in] option The key of the input to change. + * @param[in] new_value The new value of the input. + * + * @throw std::out_of_range if *this does not have a module with key + * @p key, or if that module has no input + * @p option. Strong throw guarantee. + * @throw ??? Throws if Module::change_input throws. Same guarantee. + */ + template + void change_input(const key_type& key, const key_type& option, + T&& new_value) { + at(key)->change_input(option, std::forward(new_value)); + } + /** @brief Determines if *this contains a module with key @p key. * * @param[in] key The key to look for. Comparisons are case-insensitive. diff --git a/cxx/include/pluginplay/module_manager/qualified_key.hpp b/cxx/include/pluginplay/module_manager/qualified_key.hpp new file mode 100644 index 000000000..68ec86890 --- /dev/null +++ b/cxx/include/pluginplay/module_manager/qualified_key.hpp @@ -0,0 +1,146 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include +#include +#include +#include + +namespace pluginplay { + +/** @brief Identifies a module in a ModuleManager. + * + * A ModuleManager holds one ModuleMap per loaded plugin, plus a ModuleMap of + * its own. Module keys are unique within a map, but two maps may use the same + * key. A QualifiedKey names a module in one of two ways: + * + * - key-only, *e.g.*, `"Overlap"`. The ModuleManager searches every map for + * the key. This is the form created implicitly from a string, so code that + * passes a plain module key keeps working. + * - qualified, *e.g.*, `{"integrals", "Overlap"}`. The ModuleManager only + * looks in the named plugin's map. The empty plugin name, `""`, names the + * ModuleManager's own map. Hence `{"", "Overlap"}` is qualified and is + * not the same as `"Overlap"`: it is the only way to reach the + * ModuleManager's own "Overlap" when a plugin also has one. + * + * Like module keys and plugin names elsewhere in PluginPlay, both the plugin + * name and the module key are compared case-insensitively. + */ +class QualifiedKey { +public: + /// Type used for the names of plugins + using name_type = std::string; + + /// Type used for module keys + using key_type = type::key; + + /** @brief Creates a key-only QualifiedKey. + * + * This constructor is implicit so that a module key can be passed + * anywhere a QualifiedKey is expected. + * + * @param[in] key The module key. + * + * @throw None No throw guarantee. + */ + QualifiedKey(key_type key) noexcept : m_key_(std::move(key)) {} + + /** @brief Creates a key-only QualifiedKey from a string literal. + * + * @param[in] key The module key. + * + * @throw std::bad_alloc if there is a problem copying @p key. Strong throw + * guarantee. + */ + QualifiedKey(const char* key) : QualifiedKey(key_type(key)) {} + + /** @brief Creates a QualifiedKey that names the plugin. + * + * @param[in] plugin The name of the plugin the module is in. The empty + * name means the ModuleManager's own modules. + * @param[in] key The module key. + * + * @throw None No throw guarantee. + */ + QualifiedKey(name_type plugin, key_type key) noexcept : + m_plugin_(std::move(plugin)), + m_key_(std::move(key)), + m_qualified_(true) {} + + /// True if *this names a plugin and false otherwise. No throw guarantee. + bool is_qualified() const noexcept { return m_qualified_; } + + /** @brief The name of the plugin the module is in. + * + * @return The name of the plugin. Empty if *this is key-only or names the + * ModuleManager's own modules. + * + * @throw None No throw guarantee. + */ + const name_type& plugin() const noexcept { return m_plugin_; } + + /// The module key. No throw guarantee. + const key_type& key() const noexcept { return m_key_; } + + /** @brief A string representation of *this. + * + * @return `"::"` if *this is qualified and `""` + * otherwise. + * + * @throw std::bad_alloc if there is a problem allocating the string. + * Strong throw guarantee. + */ + std::string to_string() const { + return m_qualified_ ? m_plugin_ + "::" + m_key_ : m_key_; + } + + /** @brief Determines if *this and @p rhs name the same module. + * + * Two QualifiedKeys are equal if they are both key-only or both qualified, + * and have the same plugin name and module key (both compared + * case-insensitively). + * + * @param[in] rhs The QualifiedKey to compare to. + * + * @return True if *this equals @p rhs and false otherwise. + * + * @throw None No throw guarantee. + */ + bool operator==(const QualifiedKey& rhs) const noexcept { + using utilities::strings::case_insensitive_equal; + if(m_qualified_ != rhs.m_qualified_) return false; + return case_insensitive_equal(m_plugin_, rhs.m_plugin_) && + case_insensitive_equal(m_key_, rhs.m_key_); + } + + /// Negates operator== + bool operator!=(const QualifiedKey& rhs) const noexcept { + return !(*this == rhs); + } + +private: + /// The name of the plugin the module is in + name_type m_plugin_; + + /// The module key + key_type m_key_; + + /// Does *this name a plugin? + bool m_qualified_ = false; +}; + +} // namespace pluginplay diff --git a/cxx/include/pluginplay/plugin/plugin.hpp b/cxx/include/pluginplay/plugin/plugin.hpp index 4dbf4598d..8a19fef70 100644 --- a/cxx/include/pluginplay/plugin/plugin.hpp +++ b/cxx/include/pluginplay/plugin/plugin.hpp @@ -19,27 +19,71 @@ #include #include +/** @brief Declares a plugin class. + * + * The resulting class is named @p plugin_name, derives from + * pluginplay::Plugin, and has a default constructor. The constructor is + * defined with PLUGIN_CTOR. + * + * @param[in] plugin_name The name of the plugin class, *e.g.*, `MyPlugin`. + */ +#define DECLARE_PLUGIN(plugin_name) \ + class plugin_name : public pluginplay::Plugin { \ + public: \ + plugin_name(); \ + } + /** @brief Starts the definition of a plugin's constructor. * * The plugin class must derive from pluginplay::Plugin and declare a default - * constructor. The body of the constructor describes the plugin by calling - * the protected member functions of Plugin, *e.g.*, set_name and add_module. + * constructor, *e.g.*, by being declared with DECLARE_PLUGIN. The body of the + * constructor describes the plugin by calling the protected member functions + * of Plugin, *e.g.*, set_name and add_module. * * @param[in] plugin_name The name of the plugin class. */ #define PLUGIN_CTOR(plugin_name) \ plugin_name::plugin_name() : pluginplay::Plugin() -#define DECLARE_PLUGIN(plugin_name) \ - void load_modules(pluginplay::ModuleManager& mm) - -#define DEFINE_PLUGIN(plugin_name) \ - inline void load_modules(pluginplay::ModuleManager& mm) - -#define EXPORT_PLUGIN(plugin_name, m) \ - void pp_export_plugin_name__(pybind11::module_& m); \ - PYBIND11_MODULE(plugin_name, m) { \ - m.def("load_modules", &load_modules); \ - pp_export_plugin_name__(m); \ - } \ - inline void pp_export_plugin_name__(pybind11::module_& m) +/** @brief Creates the Python module for a plugin. + * + * The Python module is named @p module_name and contains the plugin class, + * exported under the name @p plugin_name, so Python users can load the plugin + * with `mm.load_plugin(module_name.plugin_name())`. The macro is followed by + * the body of a function which can export anything else the Python module + * needs, *e.g.*, property types: + * + * @code{.cpp} + * EXPORT_PLUGIN(my_plugin, MyPlugin, m) { + * export_property_types(m); + * } + * @endcode + * + * Loading a plugin **moves** it into the ModuleManager; so do NOT use the + * Python object afterwards. To make the move work, the plugin class also + * gets a private `_pp_load_into` method, which ModuleManager.load_plugin + * calls; users never call it. The method is compiled into the plugin's + * extension library (the one this macro creates), not into PluginPlay's. + * This is because pybind11 can only take ownership of an object from code in + * the library that created it (the ctor for the plugin will also be in the + * same library), when the object is passed as a pointer to its base class + * (pybind11 3.1, macOS). + * + * @param[in] module_name The name of the Python module. + * @param[in] plugin_name The name of the plugin class. + * @param[in] m The name of the pybind11 module variable, for use in the body. + */ +#define EXPORT_PLUGIN(module_name, plugin_name, m) \ + void pp_export_##module_name##__(pybind11::module_& m); \ + PYBIND11_MODULE(module_name, m) { \ + pybind11::module_::import("pluginplay"); \ + pybind11::class_(m, #plugin_name) \ + .def(pybind11::init<>()) \ + .def("_pp_load_into", [](std::unique_ptr self, \ + pluginplay::ModuleManager& mm) { \ + mm.load_plugin(std::move(self)); \ + }); \ + pp_export_##module_name##__(m); \ + } \ + void pp_export_##module_name##__(pybind11::module_& m) diff --git a/cxx/include/pluginplay/plugin/plugin_class.hpp b/cxx/include/pluginplay/plugin/plugin_class.hpp index 2a579bd5d..59d2509f9 100644 --- a/cxx/include/pluginplay/plugin/plugin_class.hpp +++ b/cxx/include/pluginplay/plugin/plugin_class.hpp @@ -19,10 +19,14 @@ #include #include #include +#include #include #include namespace pluginplay { +namespace detail_ { +struct ModuleManagerPIMPL; +} /** @brief Base class for all plugins. * @@ -127,6 +131,9 @@ class Plugin { Plugin() = default; /** @brief Sets the name of the plugin. + * + * Like module keys, plugin names are case-insensitive. The name is stored + * as given. * * @param[in] name The name of the plugin. * @@ -214,6 +221,9 @@ class Plugin { ModuleMap& modules() noexcept { return m_modules_; } private: + /// The ModuleManager owns loaded plugins and changes their modules + friend struct detail_::ModuleManagerPIMPL; + /// The name of the plugin name_type m_name_; @@ -221,7 +231,7 @@ class Plugin { provider_list_type m_providers_; /// The recorded default submodules, keyed by provider - std::map m_defaults_; + utilities::CaseInsensitiveMap m_defaults_; /// The plugin's modules ModuleMap m_modules_; diff --git a/cxx/include/pluginplay/printing/document_modules.hpp b/cxx/include/pluginplay/printing/document_modules.hpp index e3dc6d212..a8eea2bb5 100644 --- a/cxx/include/pluginplay/printing/document_modules.hpp +++ b/cxx/include/pluginplay/printing/document_modules.hpp @@ -32,6 +32,10 @@ namespace pluginplay::printing { * an index file for the modules to be imported into a documentation generator * like Sphinx. * + * Modules that do not come from a plugin are documented in @p docs_path. A + * plugin's modules are documented in a subdirectory of @p docs_path named after + * the plugin. + * * @param[in] mm Module manager with modules loaded into it. * @param[in] docs_path Path to the directory where documentation is output. */ diff --git a/cxx/include/pluginplay/printing/mermaid.hpp b/cxx/include/pluginplay/printing/mermaid.hpp index 1e7578abf..e8b813b20 100644 --- a/cxx/include/pluginplay/printing/mermaid.hpp +++ b/cxx/include/pluginplay/printing/mermaid.hpp @@ -22,7 +22,7 @@ namespace pluginplay::printing { std::stringstream create_mermaid_graph(const pluginplay::ModuleManager& mm); -void print_submods(const std::string module_key, +void print_submods(const QualifiedKey& module_key, const pluginplay::ModuleManager& mm, std::stringstream& ss, std::string& module_code); } // namespace pluginplay::printing diff --git a/cxx/src/pluginplay/module/detail_/module_pimpl.hpp b/cxx/src/pluginplay/module/detail_/module_pimpl.hpp index 0ba01ea0b..b0552c293 100644 --- a/cxx/src/pluginplay/module/detail_/module_pimpl.hpp +++ b/cxx/src/pluginplay/module/detail_/module_pimpl.hpp @@ -18,6 +18,7 @@ #include #include // for put_time #include +#include #include #include #include @@ -65,7 +66,7 @@ inline auto time_stamp() { class ModulePIMPL { public: /// Type used to store the developer-provided implementation - using base_ptr = std::shared_ptr; + using base_ptr = std::shared_ptr; /// Type of the object used to store the results computed by the module using cache_type = cache::ModuleCache; @@ -85,6 +86,12 @@ class ModulePIMPL { /// Type of the submodule key to UUID map using submod_uuid_map = std::map; + /// Type of a pointer to the runtime the implementation uses + using runtime_ptr = typename ModuleBase::runtime_ptr; + + /// Type of a pointer to the ModuleManager's caches + using mm_cache_ptr = std::shared_ptr; + /** @brief Makes a module with no implementation. * * The ModulePIMPL instance resulting from this ctor wraps no algorithm, @@ -485,6 +492,29 @@ class ModulePIMPL { */ bool is_cached(const type::input_map& in_inputs); + /** @brief Associates *this with a ModuleManager's runtime and caches. + * + * Modules are created without a runtime, UUID, or caches. When a + * ModuleManager takes ownership of a module, it calls this function to + * give the module the ModuleManager's runtime and caches. The + * implementation is given a UUID if it does not already have one, and its + * user cache is the one associated with that UUID. Implementations shared + * by more than one module (*e.g.*, copies of a module) thus share a UUID + * and a user cache. + * + * @param[in] runtime The runtime the implementation should use. + * @param[in] caches The ModuleManager's caches. If null, *this is not + * given caches. + * @param[in] cache_key The key of the module cache *this should use. + * + * @throw std::runtime_error if *this does not have an implementation. + * Strong throw guarantee. + * @throw std::bad_alloc if there is a problem allocating a cache. Weak + * throw guarantee. + */ + void attach(runtime_ptr runtime, const mm_cache_ptr& caches, + const type::key& cache_key); + /** @brief Resets cache. * * This function will reset cache. diff --git a/cxx/src/pluginplay/module/detail_/module_pimpl.ipp b/cxx/src/pluginplay/module/detail_/module_pimpl.ipp index b7b2a0376..e8c34f497 100644 --- a/cxx/src/pluginplay/module/detail_/module_pimpl.ipp +++ b/cxx/src/pluginplay/module/detail_/module_pimpl.ipp @@ -126,6 +126,16 @@ inline bool ModulePIMPL::is_cached(const type::input_map& in_inputs) { return m_cache_->count(ps); } +inline void ModulePIMPL::attach(runtime_ptr runtime, const mm_cache_ptr& caches, + const type::key& cache_key) { + assert_mod_(); + m_base_->set_runtime(runtime); + if(!m_base_->has_uuid()) m_base_->set_uuid(utility::generate_uuid()); + if(!caches) return; + m_base_->set_cache(caches->get_or_make_user_cache(m_base_->uuid())); + m_cache_ = caches->get_or_make_module_cache(cache_key); +} + inline void ModulePIMPL::reset_cache() { if(m_cache_) m_cache_->clear(); } @@ -204,7 +214,8 @@ inline bool ModulePIMPL::operator==(const ModulePIMPL& rhs) const { if(locked() != rhs.locked()) return false; if(!has_module()) return true; - if(std::tie(inputs(), submods(), property_types(), python_property_types()) != + if(std::tie(inputs(), submods(), property_types(), + python_property_types()) != std::tie(rhs.inputs(), rhs.submods(), rhs.property_types(), rhs.python_property_types())) return false; diff --git a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp index 13f2446ce..bad8afeeb 100644 --- a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp +++ b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp @@ -21,7 +21,11 @@ #include #include #include +#include #include +#include +#include +#include namespace pluginplay::detail_ { @@ -32,6 +36,10 @@ namespace pluginplay::detail_ { * When using this class calls should go through the member functions as much * as possible so that error checking occurs. That said members are public so * that it is easier to test the implementation. + * + * The modules are stored in one ModuleMap per loaded plugin, plus a ModuleMap + * for the modules that do not come from a plugin (the "own" map). The own map + * is identified by the empty plugin name. */ struct ModuleManagerPIMPL { /// Type *this implements @@ -41,18 +49,34 @@ struct ModuleManagerPIMPL { /// Type of a pointer to a module's implemenation using module_base_ptr = module_manager_type::module_base_ptr; - /// Type of a pointer to a read-only module implementation - using const_module_base_ptr = module_manager_type::const_module_base_ptr; - /// Type of a map from the module implementation's type to the /// implementation - using base_map = std::map; + using base_map = std::map; /// Type of a usable module using shared_module = std::shared_ptr; /// Type of a map holding usable modules - using module_map = module_manager_type::module_map; + using module_map = ModuleMap; + + /// Type used to identify modules + using qualified_key = QualifiedKey; + + /// Type used for the names of plugins + using name_type = typename module_manager_type::name_type; + + /// Type of a container of plugin names + using name_container_type = + typename module_manager_type::name_container_type; + + /// Type of a container of qualified keys + using key_container_type = typename module_manager_type::key_container_type; + + /// Type of a pointer to a loaded plugin + using plugin_pointer = typename module_manager_type::plugin_pointer; + + /// Type of the container holding the loaded plugins + using plugin_container_type = std::vector; /// The type of the runtime using runtime_type = module_manager_type::runtime_type; @@ -68,7 +92,7 @@ struct ModuleManagerPIMPL { /// Type of a map from key to Python implementation // TODO: remove when a more elegant solution is determined - using py_base_map = std::map; + using py_base_map = std::map; ///@} @@ -79,32 +103,22 @@ struct ModuleManagerPIMPL { ModuleManagerPIMPL(runtime_ptr runtime, cache_pointer cache) : m_pcaches(cache), m_runtime_(runtime) {} - /// Makes a deep copy of this instance on the heap - // auto clone() { return std::make_unique(*this); } - - /// Ensures we determine if we have a module consistently - type::size count(const type::key& key) const noexcept { - return m_modules.count(key); - } - - /// Ensures we count the number of modules consistently - type::size size() const noexcept { return m_modules.size(); } - - /** @brief Returns an iterator to the first element of the module map + /** @brief The number of modules @p key refers to. * - * @return Iterator to the first element of the map + * For a key-only @p key this is the number of maps that contain the key. + * For a qualified @p key this is 1 if the plugin is loaded and has the + * module and 0 otherwise. */ - module_map::iterator begin() noexcept { return m_modules.begin(); }; + type::size count(const qualified_key& key) const noexcept; - /** @brief Returns an iterator to the past-the-end element of the module map - * - * @return Iterator to the past-the-end element of the map - */ - module_map::iterator end() noexcept { return m_modules.end(); }; + /// The total number of modules in all maps + type::size size() const noexcept; /** @brief This function actually adds a module to the list of available * modules. * + * The module is added to the own map. + * * @param key The key under which the module will be registered. * @param base The instance containing the algorithm */ @@ -112,34 +126,83 @@ struct ModuleManagerPIMPL { /** @brief Unloads the specified module. * - * This function unloads the module with the specified key. After this - * operation the key is free to be used again. Calling this function does - * NOT clean any data out of the cache. This function is a no-op if @p key - * does not exist. + * This function unloads the module @p key refers to. After this operation + * the key is free to be used again in that map. Calling this function + * does NOT clean any data out of the cache. This function is a no-op if + * @p key does not refer to a module. * - * @param[in] key The key for the module which should be erased. + * @param[in] key The module which should be erased. * - * @throw None No throw guarantee. + * @throw std::invalid_argument if @p key is key-only and more than one + * map has the key. Strong throw guarantee. */ - void erase(const type::key& key) { m_modules.erase(key); } + void erase(const qualified_key& key); /** @brief Makes a deep copy of a module * * This function makes a deep copy of a module. The new module is unlocked * regardless of whether the old module was locked or not. The user can call * lock on the resulting module to make an exact copy - * @param old_key The key for the module to copy - * @param new_key The key under which the new module will live + * + * @param old_key The module to copy + * @param new_key The key under which the new module will live. The copy + * always goes in the own map. */ - void copy_module(const type::key& old_key, type::key new_key); + void copy_module(const qualified_key& old_key, type::key new_key); - /** @brief Returns a module, filling in all non-set submodules with defaults - * if a ready default exists. + /// Copies @p old_key to @p new_key, then erases @p old_key + void rename_module(const qualified_key& old_key, type::key new_key); + + /** @brief Returns the module @p key refers to. + * + * @param key The module you want + * @return A shared_ptr to the requested module * - * @param key The module you want - * @return A shared_ptr to the requested module + * @throw std::out_of_range if @p key does not refer to a module. Strong + * throw guarantee. + * @throw std::invalid_argument if @p key is key-only and more than one + * map has the key. Strong throw guarantee. */ - shared_module at(const type::key& key); + shared_module at(const qualified_key& key) const; + + /** @brief Takes ownership of @p plugin and its modules. + * + * @param[in] plugin The plugin to load. + * + * @throw std::invalid_argument if @p plugin is null, has no name, or has + * the same name as a plugin that is already + * loaded. Strong throw guarantee for *this. + */ + void load_plugin(plugin_pointer plugin); + + /// Applies the default submodules the loaded plugins recorded + void wire_defaults(); + + /// Is a plugin named @p name loaded? + bool has_plugin(const name_type& name) const noexcept; + + /// The names of the maps, "" (the own map) first, then plugins in load + /// order + name_container_type plugin_names() const; + + /** @brief Returns the loaded plugin named @p name. + * + * @throw std::out_of_range if no plugin named @p name is loaded. Strong + * throw guarantee. + */ + const Plugin& get_plugin(const name_type& name) const; + + ///@{ + /** @brief Returns the map named @p name. + * + * @param[in] name The name of a loaded plugin, or "" for the own map. + * + * @throw std::out_of_range if @p name is not "" and no plugin named + * @p name is loaded. Strong throw guarantee. + */ + module_map& get_modules(const name_type& name); + const module_map& get_modules(const name_type& name) const; + ///@} ///@{ /** @name Comparison operators @@ -152,15 +215,16 @@ struct ModuleManagerPIMPL { bool operator!=(const ModuleManagerPIMPL& rhs) const { return !((*this) == rhs); } + ///@} void set_runtime(runtime_ptr runtime) noexcept { m_runtime_ = runtime; } runtime_type& get_runtime() const { return *m_runtime_; } - ModuleManager::key_container_type keys() const; + /// The qualified keys of every module, in plugin_names() order + key_container_type keys() const; bool has_cache() const noexcept { return static_cast(m_pcaches); } - ///@} ///@{ /** @name ModuleManager state @@ -170,7 +234,8 @@ struct ModuleManagerPIMPL { // These are the Modules in the states provided to us by the developer base_map m_bases; - // These are the Modules in the state set by the user + // These are the Modules that do not come from a plugin, in the state set + // by the user module_map m_modules; // Part of the hacky patch to make multiple python modules work @@ -178,6 +243,10 @@ struct ModuleManagerPIMPL { // These are the Python Modules in their developer state py_base_map m_py_bases; + // These are the loaded plugins, in load order. Each plugin's ModuleMap + // holds the plugin's modules in the state set by the user. + plugin_container_type m_plugins; + // These are the results of the modules running in the user's states cache_pointer m_pcaches; @@ -185,10 +254,19 @@ struct ModuleManagerPIMPL { runtime_ptr m_runtime_; ///@} private: - /// Wraps the check for making sure @p key is not in use. - void assert_unique_key_(const type::key& key) const { - if(count(key)) throw std::invalid_argument("Key is in use"); - } + /// Returns the loaded plugin named @p name, or null if there isn't one + Plugin* find_plugin_(const name_type& name) const noexcept; + + /// Returns the map named @p name, or null if there isn't one + const module_map* find_map_(const name_type& name) const noexcept; + + /// Returns the name of the map holding @p key, throwing if there isn't + /// exactly one + name_type resolve_(const qualified_key& key) const; + + /// Gives @p mod the runtime and caches, using @p cache_key for the module + /// cache + void attach_(const type::key& cache_key, Module& mod) const; }; } // namespace pluginplay::detail_ diff --git a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp index a7bd4b250..9bd1c31ac 100644 --- a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp +++ b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp @@ -20,54 +20,154 @@ namespace pluginplay::detail_ { +inline type::size ModuleManagerPIMPL::count( + const qualified_key& key) const noexcept { + if(key.is_qualified()) { + const auto* map = find_map_(key.plugin()); + return map ? map->count(key.key()) : 0; + } + auto rv = m_modules.count(key.key()); + for(const auto& plugin : m_plugins) + rv += plugin->m_modules_.count(key.key()); + return rv; +} + +inline type::size ModuleManagerPIMPL::size() const noexcept { + auto rv = m_modules.size(); + for(const auto& plugin : m_plugins) rv += plugin->m_modules_.size(); + return rv; +} + inline void ModuleManagerPIMPL::add_module(type::key key, module_base_ptr base) { - assert_unique_key_(key); - auto uuid = utility::generate_uuid(); - base->set_runtime(m_runtime_); - base->set_uuid(uuid); - - cache::ModuleManagerCache::module_cache_pointer module_cache; - if(m_pcaches) { - auto internal_cache = m_pcaches->get_or_make_user_cache(uuid); - base->set_cache(internal_cache); - module_cache = m_pcaches->get_or_make_module_cache(key); - } + if(m_modules.count(key)) throw std::invalid_argument("Key is in use"); + if(!base) + throw std::invalid_argument("Module implementation for key: '" + key + + "' is null"); - std::unique_ptr pimpl; + module_base_ptr stored = base; if(base->is_python()) { // This is a hacky patch to allow multiple python modules to be // added while avoiding the type_index collisions. // TODO: remove when a more elegant solution is determined m_py_bases[key] = base; - pimpl = std::make_unique(m_py_bases[key], module_cache); } else { std::type_index type(base->type()); if(!m_bases.count(type)) m_bases[type] = base; - pimpl = std::make_unique(m_bases[type], module_cache); + stored = m_bases[type]; } - auto ptr = std::make_shared(std::move(pimpl)); - ptr->set_name(key); - m_modules.emplace(std::move(key), ptr); + + Module mod(std::make_unique(stored)); + attach_(key, mod); + m_modules.add_module(std::move(key), std::move(mod)); +} + +inline void ModuleManagerPIMPL::erase(const qualified_key& key) { + if(!count(key)) return; + get_modules(resolve_(key)).erase(key.key()); } -inline void ModuleManagerPIMPL::copy_module(const type::key& old_key, +inline void ModuleManagerPIMPL::copy_module(const qualified_key& old_key, type::key new_key) { - assert_unique_key_(new_key); - Module mod = m_modules.at(old_key)->unlocked_copy(); - auto ptr = std::make_shared(std::move(mod)); - ptr->set_name(new_key); - m_modules.emplace(std::move(new_key), ptr); + m_modules.add_module(std::move(new_key), at(old_key)->unlocked_copy()); +} + +inline void ModuleManagerPIMPL::rename_module(const qualified_key& old_key, + type::key new_key) { + // Pin down which module is renamed before the copy can make old_key + // ambiguous + const qualified_key resolved(resolve_(old_key), old_key.key()); + copy_module(resolved, std::move(new_key)); + erase(resolved); } inline ModuleManagerPIMPL::shared_module ModuleManagerPIMPL::at( - const type::key& key) { - if(!count(key)) { - const std::string msg = - "ModuleManager has no module with key: '" + key + "'"; - throw std::out_of_range(msg); + const qualified_key& key) const { + return get_modules(resolve_(key)).at(key.key()); +} + +inline void ModuleManagerPIMPL::load_plugin(plugin_pointer plugin) { + if(!plugin) throw std::invalid_argument("Can not load a null plugin"); + + const auto& name = plugin->name(); + if(name.empty()) + throw std::invalid_argument( + "Plugins must have a name. Call set_name in the plugin's " + "constructor."); + if(has_plugin(name)) + throw std::invalid_argument("A plugin named '" + name + + "' is already loaded"); + + for(auto& [key, mod] : plugin->m_modules_) + attach_(qualified_key(name, key).to_string(), *mod); + + m_plugins.push_back(std::move(plugin)); +} + +inline void ModuleManagerPIMPL::wire_defaults() { + for(const auto& plugin : m_plugins) { + auto& modules = plugin->m_modules_; + for(const auto& provider_name : plugin->preferred_providers()) { + const auto* provider = find_plugin_(provider_name); + if(provider == nullptr) continue; + + const auto& provided = provider->m_modules_; + for(const auto& record : + plugin->default_submods_from(provider_name)) { + // The user may have erased the module after loading + if(!modules.count(record.module_key)) continue; + + if(!provided.count(record.provider_key)) { + if(m_runtime_) + m_runtime_->logger().warn( + "Plugin '" + plugin->name() + "' asked for module '" + + record.provider_key + "' from plugin '" + + provider_name + "', which does not have it."); + continue; + } + + modules.default_submod(record.module_key, record.callback_key, + provided.at(record.provider_key)); + } + } } - return m_modules.at(key); +} + +inline bool ModuleManagerPIMPL::has_plugin( + const name_type& name) const noexcept { + return find_plugin_(name) != nullptr; +} + +inline ModuleManagerPIMPL::name_container_type +ModuleManagerPIMPL::plugin_names() const { + name_container_type rv{""}; + rv.reserve(m_plugins.size() + 1); + for(const auto& plugin : m_plugins) rv.push_back(plugin->name()); + return rv; +} + +inline const Plugin& ModuleManagerPIMPL::get_plugin( + const name_type& name) const { + const auto* plugin = find_plugin_(name); + if(plugin == nullptr) + throw std::out_of_range("ModuleManager has no plugin named: '" + name + + "'"); + return *plugin; +} + +inline ModuleManagerPIMPL::module_map& ModuleManagerPIMPL::get_modules( + const name_type& name) { + const auto& const_this = const_cast(*this); + return const_cast(const_this.get_modules(name)); +} + +inline const ModuleManagerPIMPL::module_map& ModuleManagerPIMPL::get_modules( + const name_type& name) const { + const auto* map = find_map_(name); + if(map == nullptr) + throw std::out_of_range("ModuleManager has no plugin named: '" + name + + "'"); + return *map; } inline bool ModuleManagerPIMPL::operator==( @@ -75,6 +175,7 @@ inline bool ModuleManagerPIMPL::operator==( // Try to get out early if(m_bases.size() != rhs.m_bases.size()) return false; if(m_modules.size() != rhs.m_modules.size()) return false; + if(m_plugins.size() != rhs.m_plugins.size()) return false; // TODO: Remove with the rest of the python hack if(m_py_bases.size() != rhs.m_py_bases.size()) return false; @@ -89,19 +190,80 @@ inline bool ModuleManagerPIMPL::operator==( } // Need to check the values b/c user may have switched options - for(const auto& [k, v] : rhs.m_modules) { - if(!m_modules.count(k)) return false; - if(*m_modules.at(k) != *v) return false; + if(m_modules != rhs.m_modules) return false; + + // Load order does not matter, so plugins are matched by name + for(const auto& plugin : rhs.m_plugins) { + const auto* mine = find_plugin_(plugin->name()); + if(mine == nullptr || *mine != *plugin) return false; } return true; } -inline ModuleManager::key_container_type ModuleManagerPIMPL::keys() const { - ModuleManager::key_container_type keys; - keys.reserve(m_modules.size()); - for(const auto& [k, v] : m_modules) keys.push_back(k); - return keys; +inline ModuleManagerPIMPL::key_container_type ModuleManagerPIMPL::keys() const { + key_container_type rv; + rv.reserve(size()); + for(const auto& name : plugin_names()) + for(const auto& key : get_modules(name).keys()) + rv.emplace_back(name, key); + return rv; +} + +// ----------------------------------------------------------------------------- +// -- Private Methods +// ----------------------------------------------------------------------------- + +inline Plugin* ModuleManagerPIMPL::find_plugin_( + const name_type& name) const noexcept { + using utilities::strings::case_insensitive_equal; + for(const auto& plugin : m_plugins) + if(case_insensitive_equal(plugin->name(), name)) return plugin.get(); + return nullptr; +} + +inline const ModuleManagerPIMPL::module_map* ModuleManagerPIMPL::find_map_( + const name_type& name) const noexcept { + if(name.empty()) return &m_modules; + const auto* plugin = find_plugin_(name); + return plugin ? &plugin->m_modules_ : nullptr; +} + +inline ModuleManagerPIMPL::name_type ModuleManagerPIMPL::resolve_( + const qualified_key& key) const { + const std::string no_module = + "ModuleManager has no module with key: '" + key.to_string() + "'"; + + if(key.is_qualified()) { + const auto* map = find_map_(key.plugin()); + if(map == nullptr) + throw std::out_of_range("ModuleManager has no plugin named: '" + + key.plugin() + "'"); + if(!map->count(key.key())) throw std::out_of_range(no_module); + return key.plugin(); + } + + name_container_type holders; + for(const auto& name : plugin_names()) + if(find_map_(name)->count(key.key())) holders.push_back(name); + + if(holders.empty()) throw std::out_of_range(no_module); + if(holders.size() > 1) { + std::string msg = "Module key '" + key.key() + + "' is ambiguous. It is used by the plugins:"; + for(const auto& name : holders) + msg += name.empty() ? " '' (the ModuleManager's own modules)" : + " '" + name + "'"; + msg += ". Pass the plugin name too, e.g., {\"" + holders.back() + + "\", \"" + key.key() + "\"}."; + throw std::invalid_argument(msg); + } + return holders.front(); +} + +inline void ModuleManagerPIMPL::attach_(const type::key& cache_key, + Module& mod) const { + mod.m_pimpl_->attach(m_runtime_, m_pcaches, cache_key); } } // namespace pluginplay::detail_ diff --git a/cxx/src/pluginplay/module_manager/export_module_manager.hpp b/cxx/src/pluginplay/module_manager/export_module_manager.hpp index 13cb6f673..a64fca83a 100644 --- a/cxx/src/pluginplay/module_manager/export_module_manager.hpp +++ b/cxx/src/pluginplay/module_manager/export_module_manager.hpp @@ -21,8 +21,10 @@ namespace pluginplay { void export_module_manager_class(py_module_reference m); void export_module_map(py_module_reference m); +void export_qualified_key(py_module_reference m); inline void export_module_manager(py_module_reference m) { + export_qualified_key(m); export_module_map(m); export_module_manager_class(m); } diff --git a/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp b/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp index 0c9745179..6cce5116b 100644 --- a/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp +++ b/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp @@ -24,7 +24,10 @@ namespace pluginplay { void export_module_manager_class(py_module_reference m) { - using at_fxn = Module& (ModuleManager::*)(const type::key&); + using qualified_key = typename ModuleManager::qualified_key; + using at_fxn = Module& (ModuleManager::*)(const qualified_key&); + using plugin_ptr = typename ModuleManager::plugin_pointer; + using load_fxn = void (ModuleManager::*)(plugin_ptr); using runtime_type = typename ModuleManager::runtime_type; using cache_type = typename ModuleManager::cache_type; using cache_pointer = typename ModuleManager::cache_pointer; @@ -43,6 +46,24 @@ void export_module_manager_class(py_module_reference m) { py::arg("rv"), py::arg("cache") = std::make_shared()) .def("count", &ModuleManager::count) .def("size", &ModuleManager::size) + .def("load_plugin", + [](py_obj self, py_obj plugin) { + // C++ plugins from other extension modules release ownership + // in their own module (see EXPORT_PLUGIN) + if(py::hasattr(plugin, "_pp_load_into")) { + plugin.attr("_pp_load_into")(self); + return; + } + auto& mm = self.cast(); + mm.load_plugin(plugin.cast()); + }) + .def("wire_defaults", &ModuleManager::wire_defaults) + .def("has_plugin", &ModuleManager::has_plugin) + .def("plugin_names", &ModuleManager::plugin_names) + .def("get_plugin", &ModuleManager::get_plugin, + py::return_value_policy::reference_internal) + .def("get_modules", &ModuleManager::get_modules, + py::return_value_policy::reference_internal) .def( "add_module", [](ModuleManager& mm, std::string key, @@ -54,8 +75,8 @@ void export_module_manager_class(py_module_reference m) { .def("erase", &ModuleManager::erase) .def("rename_module", &ModuleManager::rename_module) .def("change_input", - [](ModuleManager& self, std::string mod_key, std::string opt_key, - py_obj in) { + [](ModuleManager& self, const qualified_key& mod_key, + std::string opt_key, py_obj in) { self.change_input(mod_key, opt_key, any::make_any_field(in)); }) @@ -72,7 +93,7 @@ void export_module_manager_class(py_module_reference m) { py::return_value_policy::reference_internal) .def("keys", &ModuleManager::keys) .def("has_cache", &ModuleManager::has_cache) - .def("__getitem__", [](ModuleManager& self, const type::key& key) { + .def("__getitem__", [](ModuleManager& self, const qualified_key& key) { return self.at(key); }); m.def("defaulted_mm", []() { return ModuleManager(nullptr); }); diff --git a/cxx/src/pluginplay/module_manager/export_module_map.cpp b/cxx/src/pluginplay/module_manager/export_module_map.cpp index 2502af13f..64d8aac18 100644 --- a/cxx/src/pluginplay/module_manager/export_module_map.cpp +++ b/cxx/src/pluginplay/module_manager/export_module_map.cpp @@ -16,7 +16,9 @@ #include "../module/py_module_base.hpp" #include "export_module_manager.hpp" +#include #include +#include namespace pluginplay { @@ -50,6 +52,15 @@ void export_module_map(py_module_reference m) { self.add_module(std::move(key), p); }, py::keep_alive<1, 3>()) + .def("copy_module", &ModuleMap::copy_module) + .def("erase", &ModuleMap::erase) + .def("change_input", + [](ModuleMap& self, const key_type& key, const key_type& option, + py::object value) { + using python::PythonWrapper; + self.change_input(key, option, + any::make_any_field(value)); + }) .def("change_submod", static_cast(&ModuleMap::change_submod)) .def("has_submod", &ModuleMap::has_submod) diff --git a/cxx/src/pluginplay/module_manager/export_qualified_key.cpp b/cxx/src/pluginplay/module_manager/export_qualified_key.cpp new file mode 100644 index 000000000..adaeae6ab --- /dev/null +++ b/cxx/src/pluginplay/module_manager/export_qualified_key.cpp @@ -0,0 +1,54 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "export_module_manager.hpp" +#include +#include + +namespace pluginplay { + +void export_qualified_key(py_module_reference m) { + using name_type = QualifiedKey::name_type; + using key_type = QualifiedKey::key_type; + + py_class_type(m, "QualifiedKey") + .def(py::init()) + .def(py::init()) + .def(py::init([](const py::tuple& t) { + if(t.size() != 2) + throw std::invalid_argument( + "A QualifiedKey tuple must be (plugin name, module key)"); + return QualifiedKey(t[0].cast(), t[1].cast()); + })) + .def("is_qualified", &QualifiedKey::is_qualified) + .def("plugin", &QualifiedKey::plugin) + .def("key", &QualifiedKey::key) + .def("to_string", &QualifiedKey::to_string) + .def("__str__", &QualifiedKey::to_string) + .def("__repr__", + [](const QualifiedKey& self) { + return "QualifiedKey('" + self.to_string() + "')"; + }) + .def(py::self == py::self) + .def(py::self != py::self); + + // Lets Python pass "key" or ("plugin", "key") wherever a QualifiedKey is + // expected + py::implicitly_convertible(); + py::implicitly_convertible(); +} + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/module_manager/module_manager_class.cpp b/cxx/src/pluginplay/module_manager/module_manager_class.cpp index 75dbef2ee..64eeed07f 100644 --- a/cxx/src/pluginplay/module_manager/module_manager_class.cpp +++ b/cxx/src/pluginplay/module_manager/module_manager_class.cpp @@ -20,13 +20,9 @@ namespace pluginplay { -using module_map = typename ModuleManager::module_map; using module_base_ptr = typename ModuleManager::module_base_ptr; -using cache_type = typename detail_::ModulePIMPL::cache_type; using runtime_type = typename ModuleManager::runtime_type; using runtime_ptr = typename ModuleManager::runtime_ptr; -template -using CIM = utilities::CaseInsensitiveMap; ModuleManager::ModuleManager() : pimpl_(std::make_unique()) {} @@ -36,36 +32,61 @@ ModuleManager::ModuleManager(ModuleManager&& rhs) noexcept = default; ModuleManager& ModuleManager::operator=(ModuleManager&& rhs) noexcept = default; ModuleManager::~ModuleManager() noexcept = default; -type::size ModuleManager::count(type::key key) const noexcept { +type::size ModuleManager::count(const qualified_key& key) const noexcept { return pimpl_->count(key); } +type::size ModuleManager::size() const noexcept { return pimpl_->size(); } + +void ModuleManager::load_plugin(plugin_pointer plugin) { + pimpl_->load_plugin(std::move(plugin)); +} + +void ModuleManager::wire_defaults() { pimpl_->wire_defaults(); } + +bool ModuleManager::has_plugin(const name_type& name) const noexcept { + return pimpl_->has_plugin(name); +} + +ModuleManager::name_container_type ModuleManager::plugin_names() const { + return pimpl_->plugin_names(); +} + +const Plugin& ModuleManager::get_plugin(const name_type& name) const { + return pimpl_->get_plugin(name); +} + +const ModuleMap& ModuleManager::get_modules(const name_type& name) const { + return const_cast(*pimpl_).get_modules( + name); +} + void ModuleManager::add_module(type::key key, module_base_ptr base) { pimpl_->add_module(std::move(key), base); } -void ModuleManager::erase(const type::key& key) { pimpl_->erase(key); } +void ModuleManager::erase(const qualified_key& key) { pimpl_->erase(key); } -void ModuleManager::copy_module(const type::key& old_key, type::key new_key) { +void ModuleManager::copy_module(const qualified_key& old_key, + type::key new_key) { pimpl_->copy_module(old_key, std::move(new_key)); } -void ModuleManager::change_submod(const type::key& module_key, +void ModuleManager::rename_module(const qualified_key& old_key, + type::key new_key) { + pimpl_->rename_module(old_key, std::move(new_key)); +} + +void ModuleManager::change_submod(const qualified_key& module_key, const type::key& callback_key, - const type::key& submod_key) { + const qualified_key& submod_key) { pimpl_->at(module_key)->change_submod(callback_key, pimpl_->at(submod_key)); } -const Module& ModuleManager::at(const type::key& module_key) const { +const Module& ModuleManager::at(const qualified_key& module_key) const { return *pimpl_->at(module_key); } -module_map::iterator ModuleManager::begin() noexcept { return pimpl_->begin(); } - -module_map::iterator ModuleManager::end() noexcept { return pimpl_->end(); } - -type::size ModuleManager::size() const noexcept { return pimpl_->size(); } - void ModuleManager::set_runtime(runtime_ptr runtime) noexcept { pimpl_->set_runtime(runtime); }; diff --git a/cxx/src/pluginplay/module_manager/module_map.cpp b/cxx/src/pluginplay/module_manager/module_map.cpp index 09fc4bac2..ae2ac2971 100644 --- a/cxx/src/pluginplay/module_manager/module_map.cpp +++ b/cxx/src/pluginplay/module_manager/module_map.cpp @@ -29,11 +29,23 @@ void ModuleMap::add_module(key_type key, module_base_ptr base) { "' is null"); auto pimpl = std::make_unique(base); - auto ptr = std::make_shared(std::move(pimpl)); + add_module(std::move(key), Module(std::move(pimpl))); +} + +void ModuleMap::add_module(key_type key, Module module) { + if(count(key)) + throw std::invalid_argument( + "ModuleMap already has a module with key: '" + key + "'"); + + auto ptr = std::make_shared(std::move(module)); ptr->set_name(key); m_modules_.emplace(std::move(key), std::move(ptr)); } +void ModuleMap::copy_module(const key_type& old_key, key_type new_key) { + add_module(std::move(new_key), at(old_key)->unlocked_copy()); +} + ModuleMap::module_pointer ModuleMap::at(const key_type& key) const { if(!count(key)) throw std::out_of_range("ModuleMap has no module with key: '" + key + diff --git a/cxx/src/pluginplay/plugin/export_plugin_class.cpp b/cxx/src/pluginplay/plugin/export_plugin_class.cpp index 649d4abfb..a93089e7d 100644 --- a/cxx/src/pluginplay/plugin/export_plugin_class.cpp +++ b/cxx/src/pluginplay/plugin/export_plugin_class.cpp @@ -38,7 +38,7 @@ void export_plugin_class(py_module_reference m) { auto modules = static_cast(&PluginPublicist::modules); auto py_plugin = - py_class_type>(m, "Plugin"); + py_class_type(m, "Plugin"); py_class_type(py_plugin, "DefaultSubmod") .def_readonly("module_key", &default_submod::module_key) diff --git a/cxx/src/pluginplay/plugin/plugin_class.cpp b/cxx/src/pluginplay/plugin/plugin_class.cpp index c2d4e792d..3ad7c0388 100644 --- a/cxx/src/pluginplay/plugin/plugin_class.cpp +++ b/cxx/src/pluginplay/plugin/plugin_class.cpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace pluginplay { @@ -44,7 +45,9 @@ void Plugin::set_name(name_type name) { } void Plugin::set_preferred_providers(provider_list_type names) { - std::set seen; + // Plugin names are case-insensitive + using name_less = utilities::CaseInsensitiveMap::key_compare; + std::set seen; for(const auto& name : names) { if(name.empty()) throw std::invalid_argument("Provider names can not be empty"); @@ -63,9 +66,12 @@ void Plugin::set_preferred_providers(provider_list_type names) { void Plugin::default_submod_from(const name_type& provider, key_type module_key, key_type callback_key, key_type provider_key) { - auto begin = m_providers_.begin(); - auto end = m_providers_.end(); - if(std::find(begin, end, provider) == end) + auto begin = m_providers_.begin(); + auto end = m_providers_.end(); + auto matches = [&provider](const name_type& name) { + return utilities::strings::case_insensitive_equal(name, provider); + }; + if(std::find_if(begin, end, matches) == end) throw std::invalid_argument( "'" + provider + "' is not a preferred provider. Call set_preferred_providers first."); diff --git a/cxx/src/pluginplay/plugin/py_plugin.hpp b/cxx/src/pluginplay/plugin/py_plugin.hpp index 5bcd7e108..339f6725b 100644 --- a/cxx/src/pluginplay/plugin/py_plugin.hpp +++ b/cxx/src/pluginplay/plugin/py_plugin.hpp @@ -16,6 +16,7 @@ #pragma once #include +#include namespace pluginplay { @@ -23,9 +24,14 @@ namespace pluginplay { * * Plugin's constructor is protected, so pybind11 cannot create a Plugin * directly. PyPlugin makes the constructor public. Plugin has no virtual - * functions to forward to Python, so PyPlugin adds nothing else. + * functions to forward to Python. + * + * ModuleManager::load_plugin takes ownership of the plugin away from Python. + * Deriving from trampoline_self_life_support keeps the Python part of a + * plugin written in Python (*e.g.*, its Python modules) alive for as long as + * the ModuleManager owns the plugin. */ -class PyPlugin : public Plugin { +class PyPlugin : public Plugin, public pybind11::trampoline_self_life_support { public: PyPlugin() = default; }; diff --git a/cxx/src/pluginplay/printing/document_modules.cpp b/cxx/src/pluginplay/printing/document_modules.cpp index 71331e752..49e04ad01 100644 --- a/cxx/src/pluginplay/printing/document_modules.cpp +++ b/cxx/src/pluginplay/printing/document_modules.cpp @@ -32,27 +32,41 @@ void document_modules(pluginplay::ModuleManager& mm, // List of files for index file std::vector toc; - // Loop over key-value pairs of module names and module objects for docs - for(const auto& [name, module] : mm) { - std::string clean_name = - replace_invalid_chars(name, INVALID_CHARS, REPLACEMENT_CHAR); - - // Gather file name in the toc for the index file - toc.push_back(clean_name); - - // Generate documentation - std::string docs = document_module(name, *module); - - // Create the file path - std::filesystem::path file_path = docs_path; - file_path /= clean_name + ".rst"; - - fout.open(file_path); - - // Write to documentation file - fout << docs; - - fout.close(); + // Loop over the maps, then the key-value pairs of module names and module + // objects for docs. The ModuleManager's own modules go in docs_path; each + // plugin's modules go in a subdirectory named after the plugin. + for(const auto& plugin : mm.plugin_names()) { + std::filesystem::path dir = docs_path; + std::string toc_prefix; + if(!plugin.empty()) { + const auto clean_plugin = + replace_invalid_chars(plugin, INVALID_CHARS, REPLACEMENT_CHAR); + dir /= clean_plugin; + std::filesystem::create_directories(dir); + toc_prefix = clean_plugin + "/"; + } + + for(const auto& [name, module] : mm.get_modules(plugin)) { + std::string clean_name = + replace_invalid_chars(name, INVALID_CHARS, REPLACEMENT_CHAR); + + // Gather file name in the toc for the index file + toc.push_back(toc_prefix + clean_name); + + // Generate documentation + std::string docs = document_module(name, *module); + + // Create the file path + std::filesystem::path file_path = dir; + file_path /= clean_name + ".rst"; + + fout.open(file_path); + + // Write to documentation file + fout << docs; + + fout.close(); + } } // Create index file diff --git a/cxx/src/pluginplay/printing/mermaid.cpp b/cxx/src/pluginplay/printing/mermaid.cpp index 7a1e734ff..bbc2554e2 100644 --- a/cxx/src/pluginplay/printing/mermaid.cpp +++ b/cxx/src/pluginplay/printing/mermaid.cpp @@ -32,15 +32,17 @@ void add_letter(std::string& code) { } } -void print_submods(const std::string module_key, - const pluginplay::ModuleManager& mm, std::stringstream& ss, - std::string& module_code) { - const auto& mm_module = mm.at(module_key); // Results in a PluginPlay Module - const auto& submods = mm_module.submods(); // Results in list of Submodules - auto main_mod_code = module_code; +namespace { + +// Recurses over the submodules of mod. Submodules are followed through the +// Module objects, not looked up by key, because a submodule's key may be used +// by more than one plugin. +void print_submods_(const Module& mod, std::stringstream& ss, + std::string& module_code) { + auto main_mod_code = module_code; // Key is the ID/Tag, Value is the reference to the Module - for(const auto& [key, value] : submods) { + for(const auto& [key, value] : mod.submods()) { ss << "\n\t" << main_mod_code << "-->" << "|" << key << "| "; if(value.has_module() == false) { @@ -49,23 +51,33 @@ void print_submods(const std::string module_key, } else { add_letter(module_code); ss << module_code << "[" << value.get_name() << "]"; - print_submods(value.get_name(), mm, ss, module_code); + print_submods_(value.value(), ss, module_code); } } } +} // namespace + +void print_submods(const QualifiedKey& module_key, + const pluginplay::ModuleManager& mm, std::stringstream& ss, + std::string& module_code) { + print_submods_(mm.at(module_key), ss, module_code); +} + std::stringstream create_mermaid_graph(const pluginplay::ModuleManager& mm) { - auto n_modules = mm.size(); std::stringstream ss; std::string module_code = "AAA"; - if(n_modules == 0) { + const auto keys = mm.keys(); + if(keys.empty()) { ss << "No modules are loaded, load some modules!"; } else { - for(decltype(n_modules) i = 0; i < n_modules; i++) { + for(const auto& mod : keys) { + // The ModuleManager's own modules are labeled by key alone + const auto label = + mod.plugin().empty() ? mod.key() : mod.to_string(); ss << "\n```mermaid"; ss << "\nflowchart LR\n"; - auto mod = mm.keys()[i]; - ss << "\t" << module_code << "[" << mod << "]"; + ss << "\t" << module_code << "[" << label << "]"; print_submods(mod, mm, ss, module_code); ss << "\n```"; add_letter(module_code); diff --git a/docs/source/developer/design/pluginplay_initialization.rst b/docs/source/developer/design/pluginplay_initialization.rst index 8f612822b..691e41ca5 100644 --- a/docs/source/developer/design/pluginplay_initialization.rst +++ b/docs/source/developer/design/pluginplay_initialization.rst @@ -105,7 +105,10 @@ its ``ModuleMap`` (see :ref:`plugin_design`). So the ``ModuleManager`` has one ``ModuleMap`` per loaded plugin, plus a ``ModuleMap`` of its own. The ``ModuleManager``'s own map holds modules that do not come from a plugin, such as modules added directly by the user, lambda modules, and facade -modules. The maps are **not** merged into one pool. A submodule may still be +modules. Copies the user makes through the ``ModuleManager`` (``copy_module`` +and ``rename_module``) also go in its own map, even when the original is one +of a plugin's modules, so a plugin's map only holds the modules the plugin +provides. The maps are **not** merged into one pool. A submodule may still be any module in any map, so links can cross maps. The empty plugin name, ``""``, is reserved for the ``ModuleManager``'s own @@ -116,7 +119,9 @@ Uniqueness and Lookup - ``ModuleMap`` enforces unique module keys within a plugin. - The ``ModuleManager`` enforces unique plugin names. Loading a second plugin - with the same name throws. + with the same name throws. Like module keys, plugin names are compared + case-insensitively, everywhere they are used: when loading, in lookups, and + when matching preferred providers. Together these guarantee that the pair (plugin name, module key) is unique. The ``ModuleManager`` therefore offers two ways to look up a module, and @@ -127,22 +132,71 @@ every function that takes a module key (``at``, ``change_input``, name means the ``ModuleManager``'s own map. #. With only a module key. This works as long as the key appears in exactly one map. If it appears in more than one map the call throws and the error - lists the plugins that contain the key. To support this, the - ``ModuleManager`` keeps track of which keys appear in more than one map. + lists the plugins that contain the key. + +Both forms are spelled with a ``QualifiedKey``, a small class holding an +optional plugin name and a module key. A ``QualifiedKey`` is implicitly +created from a string, which gives the key-only form, so the functions keep +a single signature and existing calls keep working: + +.. code-block:: c++ + + mm.at("SCF Driver"); // key-only + mm.at({"scf", "SCF Driver"}); // plugin name and module key + mm.change_submod({"scf", "SCF Driver"}, "Hamiltonian", + {"nux", "Born-Oppenheimer approximation"}); + +In Python, a string or a ``(plugin name, module key)`` tuple can be passed +wherever a ``QualifiedKey`` is expected. + +Note that ``{"", key}`` is a qualified key, not a key-only one. The two forms +look up different things: a key-only key searches every map, while ``{"", +key}`` only looks in the ``ModuleManager``'s own map. They find the same +module only when no plugin uses ``key``. The qualified form is the only way to +reach a module in the ``ModuleManager``'s own map when a plugin also uses its +key: + +.. code-block:: c++ + + mm.add_module("Shared"); // own map + mm.load_plugin(); // also has a "Shared" module + + mm.at("Shared"); // throws: ambiguous + mm.at({"", "Shared"}); // the own map's module + mm.at({"provider_a", "Shared"}); // ProviderA's module The key-only form keeps current code working, since today's module keys are effectively unique across the NWChemEx plugins. +The ``ModuleManager`` does not store which keys appear in more than one map. +Instead, a key-only lookup searches every map. There are only a handful of +plugins, so this is cheap, and a stored record could go out of date when the +user copies or erases a module after loading. + +``count(key)`` returns the number of maps with the key, ``size()`` returns +the total number of modules, and ``keys()`` returns the qualified key of +every module. The ``ModuleManager`` no longer offers ``begin()`` and +``end()``; code that needs the modules loops over ``plugin_names()`` and +calls ``get_modules(name)``, which returns that map. + +Caches +====== + +Memoized results are stored in module caches, which are looked up by name. +Two plugins may use the same module key, so a plugin's module uses the cache +named ``"::"``. Modules in the ``ModuleManager``'s +own map use the module key, as before. + Loading Plugins =============== .. code-block:: c++ pluginplay::ModuleManager mm; - mm.load_plugin(); - mm.load_plugin(); - mm.load_plugin(); - mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); ``load_plugin()``: @@ -150,11 +204,12 @@ Loading Plugins constructible). ``T``'s constructor sets the plugin's name and preferred providers and builds its ``ModuleMap``, including its internal wiring, #. throws if a plugin with the same ``name()`` is already loaded, +#. gives each of the plugin's modules the ``ModuleManager``'s runtime and + caches, and #. takes ownership of the plugin object. The plugin's ``ModuleMap`` is now - the ``ModuleManager``'s map for that plugin; nothing is copied, and -#. updates the record of which keys appear in more than one map. + the ``ModuleManager``'s map for that plugin; nothing is copied. -Python has an equivalent, ``mm.load_plugin(scf.scf())``, which takes an +Python has an equivalent, ``mm.load_plugin(scf.SCF())``, which takes an instance because Python has no template parameters. Because the plugin holds live modules, the same instance must not be loaded into two ``ModuleManager`` objects. @@ -180,10 +235,12 @@ plugin ``P``: #. For each default triple: - - If ``P``'s module ``module_key`` has a submodule with key + - If the user erased ``P``'s module ``module_key``, move to the next + triple. + - If ``P``'s module ``module_key`` has a submodule with key ``callback_key`` that is already set, move to the next triple. - Else, if the provider does not have a module with key ``provider_key``, - move to the next triple. + log a warning and move to the next triple. - Otherwise set ``callback_key`` to the provider's module ``provider_key``. These changes are done to ``P``'s live map, so it includes any changes the user diff --git a/docs/source/developer/design/plugins.rst b/docs/source/developer/design/plugins.rst index 947f08576..d0c77e116 100644 --- a/docs/source/developer/design/plugins.rst +++ b/docs/source/developer/design/plugins.rst @@ -45,8 +45,8 @@ A plugin is loaded into a ``ModuleManager`` as a single unit. Why Do We Need a Plugin Class? ****************************** -The original approach was a set of free functions. ``DECLARE_PLUGIN`` and -``DEFINE_PLUGIN`` (in ``pluginplay/plugin/plugin.hpp``) declare and define a +The original approach was a set of free functions. The original +``DECLARE_PLUGIN`` and ``DEFINE_PLUGIN`` macros declared and defined a ``load_modules(ModuleManager&)`` function. By convention, most plugins also wrote a ``set_defaults(ModuleManager&)`` function for wiring that depended on other plugins. This approach has several problems: @@ -75,6 +75,8 @@ Plugin Considerations - Each plugin has a name, *e.g.*, ``"my_plugin"``. - Plugin names must be unique within a ``ModuleManager``. + - Like module keys, plugin names are case-insensitive, *e.g.*, + ``"my_plugin"`` and ``"My_Plugin"`` are the same name. #. Module creation is separate from the ``ModuleManager`` @@ -256,7 +258,9 @@ Notes on the members: - **One instance per ModuleManager.** Since a plugin holds live modules, a plugin object must not be loaded into two ``ModuleManager`` objects. ``load_plugin()`` creates a new ``T`` each time, so this only matters - for the Python form, which takes an instance. + for the Python form, which takes an instance. ``load_plugin`` takes the + plugin by ``std::unique_ptr``, and in Python it takes ownership from the + Python object, so loading the same instance again throws. - **Provenance.** Setters for version and citations would follow the same convention, *e.g.*, ``set_version`` and ``add_citation`` in the constructor, mirroring ``ModuleBase::citation``. @@ -282,6 +286,12 @@ type as the current ``ModuleManager::module_map``. ``ModuleMap`` provides: - ``default_submod(...)``, a convenience function with the same overloads as ``change_submod``. It sets the submodule only if it is unset. The ``ModuleManager`` uses it to apply ``default_submod_from`` records. +- ``copy_module(old_key, new_key)``, ``change_input(key, option, value)``, + and ``erase(key)``, which mirror the ``ModuleManager`` functions. They let + a plugin set up variants of its own modules, *e.g.*, a copy of a module + with a tighter threshold. +- ``add_module(key, Module)``, which stores an already-created module, *e.g.*, + a copy of a module. ``ModuleMap`` enforces that module keys are unique within the map, which means they are unique within a plugin. Uniqueness across plugins is the @@ -291,11 +301,15 @@ Declaring a Plugin ================== The plugin macros mirror the module macros (``DECLARE_MODULE``, -``MODULE_CTOR``, and ``MODULE_RUN``): +``MODULE_CTOR``, and ``MODULE_RUN``). They are declared in +``pluginplay/plugin/plugin.hpp``: - ``DECLARE_PLUGIN(plugin_name)`` declares a class ``plugin_name`` that - derives from ``Plugin`` and has a default constructor. + derives from ``Plugin`` and has a default constructor. Like any other class, + ``plugin_name`` is CamelCase, *e.g.*, ``MyPlugin``. - ``PLUGIN_CTOR(plugin_name)`` starts the definition of the constructor. +- ``EXPORT_PLUGIN(module_name, plugin_name, m)`` creates the Python module + for the plugin (see `Python`_). A plugin's constructor can also pass ``modules()`` to helper functions that take a ``ModuleMap&``. This lets a large plugin split module registration @@ -361,9 +375,20 @@ Python support follows the pattern used for modules: ``set_name``, ``add_module``, ``change_submod``, ``set_preferred_providers``, and ``default_submod_from``, just as a Python module's ``__init__`` calls ``description`` and ``add_input``. -- ``EXPORT_PLUGIN(plugin_name, m)`` exports the C++ plugin class to Python +- ``EXPORT_PLUGIN(module_name, plugin_name, m)`` creates the Python module + ``module_name`` and exports the C++ plugin class to it as ``plugin_name`` (instead of a ``load_modules`` function), so a Python user can pass an - instance to ``ModuleManager.load_plugin``. + instance to ``ModuleManager.load_plugin``. The Python module and the plugin + class have different names because the module name is lowercase, while the + class name follows the CamelCase convention for classes. The macro is + followed by a function body that can export anything else the module needs, + *e.g.*, property types: + + .. code-block:: c++ + + EXPORT_PLUGIN(my_plugin, MyPlugin, m) { + export_property_types(m); + } - ``ModuleMap`` is exported so Python plugins can use ``default_submod`` and related functions. diff --git a/docs/source/tutorials/plugins/document_modules.rst b/docs/source/tutorials/plugins/document_modules.rst index 8902b89d7..56a1686c0 100644 --- a/docs/source/tutorials/plugins/document_modules.rst +++ b/docs/source/tutorials/plugins/document_modules.rst @@ -18,7 +18,7 @@ Documenting Modules in a Collection This page shows how to document the modules in your module collection using the ``document_modules`` function in the SDE. This page assumes that you can create -a module collection according to :doc:`./overview` and :doc:`load_modules`. +a module collection according to :doc:`./overview` and :doc:`writing_a_plugin`. Generate the Documentation ========================== @@ -29,22 +29,21 @@ documentation program code below in :ref:`example-module-doc-gen`. Currently, documentation generation is expected to be performed in its own binary, which we will refer to as ``build_docs`` here. This program must -include both the ``sde::printing::document_modules`` function and the -``load_modules`` function defined for your modules. +include both the ``pluginplay::printing::document_modules`` function and the +header declaring your plugin. .. literalinclude:: ../../../../tests/cxx/doc_snippets/generate_module_documentation.cpp :language: c++ :lines: 17, 19-21 -Next, set up a ``sde::ModuleManager`` and load your modules with the -``load_modules`` you wrote. +Next, set up a ``pluginplay::ModuleManager`` and load your plugin. .. literalinclude:: ../../../../tests/cxx/doc_snippets/generate_module_documentation.cpp :language: c++ :lines: 33-37 The final step is to generate documentation through -``sde::printing::document_modules``, by providing your ``ModuleManager`` +``pluginplay::printing::document_modules``, by providing your ``ModuleManager`` instance (``mm`` here) and the ``std::filesystem::path`` to an existing directory where the documentation should be output (``doc_path`` here). How this path is generated is a detail left to the developer's discretion. For simplicity @@ -61,7 +60,8 @@ Example Program =============== The following code is a minimal example program generating documentation for -the test modules in SDE. Modify the code according to the TODO comments to +the test modules in PluginPlay. A plugin's modules are documented in a +subdirectory of the output directory, named after the plugin. Modify the code according to the TODO comments to generate documentation for your modules. .. literalinclude:: ../../../../tests/cxx/doc_snippets/generate_module_documentation.cpp diff --git a/docs/source/tutorials/plugins/index.rst b/docs/source/tutorials/plugins/index.rst index fd8c8c1c2..7da203f14 100644 --- a/docs/source/tutorials/plugins/index.rst +++ b/docs/source/tutorials/plugins/index.rst @@ -32,5 +32,5 @@ collection. :caption: Contents: overview - load_modules + writing_a_plugin document_modules diff --git a/docs/source/tutorials/plugins/load_modules.rst b/docs/source/tutorials/plugins/load_modules.rst deleted file mode 100644 index 360f9c066..000000000 --- a/docs/source/tutorials/plugins/load_modules.rst +++ /dev/null @@ -1,101 +0,0 @@ -.. Copyright 2022 NWChemEx-Project -.. -.. Licensed under the Apache License, Version 2.0 (the "License"); -.. you may not use this file except in compliance with the License. -.. You may obtain a copy of the License at -.. -.. http://www.apache.org/licenses/LICENSE-2.0 -.. -.. Unless required by applicable law or agreed to in writing, software -.. distributed under the License is distributed on an "AS IS" BASIS, -.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -.. See the License for the specific language governing permissions and -.. limitations under the License. - -*************************** -Loading a Module Collection -*************************** - -The key piece of a module collection is the ``load_modules`` function. When a -user starts a run with PluginPlay one of the first things they do is initialize -a ``ModuleManager`` instance. The code to do this looks something like: - -.. code-block:: c++ - - // Initialize ModuleManager - ModuleManager mm; - library1::load_modules(mm); - library2::load_modules(mm); - -where ``load_modules(mm)`` calls load the module collections provided by the -various libraries. - -Public API -========== - -As far as the public API for ``load_modules`` goes, the declaration is simple: - -.. literalinclude:: ../../../../tests/cxx/doc_snippets/load_modules.hpp - :language: c++ - -In particular note that by forward declaring the ``load_modules`` function it is -not necessary for the user to know the types of your modules and more -importantly any libraries you use to implement them. - -.. note:: - - If your modules use property types defined elsewhere (such as in centralized - community resources like NWChemEx/SimDE) and your - module-specific inputs are standard C++ types (or types found in the - property types) this is your entire public API!!! - -Implementation -============== - -The actual implementation of ``load_modules`` typically consists of two parts: -loading the modules and setting the default submodules. - -Modules are loaded into the ``ModuleManager`` by calling ``add_module``. For -your convenience the ``ModuleManager`` has an overload of ``add_module`` which -takes a template type parameter that is the type of your module (as in the name -of the class which contains the algorithm) and a module key. The module key is -how users will refer to your module, *e.g.*, when they want to run your module -they will call ``ModuleManager::run_as`` providing your module's key. If your -module requires more detailed set-up other overloads exist that will bind to -existing instances. - -Continuing with our electric field examples, adding our modules to the -``ModuleManager`` looks like: - -.. literalinclude:: ../../../../tests/cxx/doc_snippets/load_modules.cpp - :language: c++ - :lines: 17-27,30-32 - - -Once you have added all your modules the next step is to set the default -submodules. In our examples, the ``ClassicalForce`` module needed a submodule -which can compute electric fields. Presently we have already loaded three such -modules; their keys are: - -- "Coulomb's Law" -- "Coulomb's Law with screening" -- "Single-precision Coulomb's law" - -If by default we want the ``ClassicalForce`` module we loaded under the key -"Force" to use a ``CoulombsLaw`` module we can accomplish this by: - -.. literalinclude:: ../../../../tests/cxx/doc_snippets/load_modules.cpp - :language: c++ - :lines: 29 - -.. note:: - - It is not necessary to set all submodules in the ``load_modules`` function. - In particular if your module depends on a property that none of your modules - can compute you would not set that submodule. In this case, it will be the - responsability of the caller to set the submodule. - -The entire implementation of our ``load_modules`` function looks like: - -.. literalinclude:: ../../../../tests/cxx/doc_snippets/load_modules.cpp - :language: c++ diff --git a/docs/source/tutorials/plugins/overview.rst b/docs/source/tutorials/plugins/overview.rst index f6fd20ea1..77a059961 100644 --- a/docs/source/tutorials/plugins/overview.rst +++ b/docs/source/tutorials/plugins/overview.rst @@ -44,12 +44,12 @@ code will look something like: where: - the ``include/`` directory contains the public API, -- ``our_module.hpp`` includes the ``load_modules`` function (TODO: add link) and - any other headers needed to use the collection, +- ``our_module.hpp`` declares the plugin (see :doc:`writing_a_plugin`) and + includes any other headers needed to use the collection, - the ``src/`` directory contains the implementation of the collection, - ``module1.cpp`` and ``module2.cpp`` are the implementation of the modules, - ``modules.hpp`` contains the declaration of the modules, and -- ``our_module.cpp`` contains the definition of the ``load_modules`` function. +- ``our_module.cpp`` contains the definition of the plugin's constructor. In practice you will also have build system files, readmes, licenses, unit tests (hopefully), etc, but from the perspective of the pluginplay those are the key parts. diff --git a/docs/source/tutorials/plugins/writing_a_plugin.rst b/docs/source/tutorials/plugins/writing_a_plugin.rst new file mode 100644 index 000000000..e90114401 --- /dev/null +++ b/docs/source/tutorials/plugins/writing_a_plugin.rst @@ -0,0 +1,160 @@ +.. Copyright 2022 NWChemEx-Project +.. +.. Licensed under the Apache License, Version 2.0 (the "License"); +.. you may not use this file except in compliance with the License. +.. You may obtain a copy of the License at +.. +.. http://www.apache.org/licenses/LICENSE-2.0 +.. +.. Unless required by applicable law or agreed to in writing, software +.. distributed under the License is distributed on an "AS IS" BASIS, +.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +.. See the License for the specific language governing permissions and +.. limitations under the License. + +################ +Writing a Plugin +################ + +A module collection is distributed as a plugin. When a user starts a run with +PluginPlay one of the first things they do is initialize a ``ModuleManager`` +instance and load the plugins they want to use. The code to do this looks +something like: + +.. code-block:: c++ + + // Initialize ModuleManager + ModuleManager mm; + mm.load_plugin(); + mm.load_plugin(); + mm.wire_defaults(); + +where the ``load_plugin`` calls load the plugins provided by the various +libraries and ``wire_defaults`` sets the default submodules the plugins want +from each other (see `Defaults From Other Plugins`_). + +Public API +========== + +As far as the public API for a plugin goes, the declaration is simple: + +.. literalinclude:: ../../../../tests/cxx/doc_snippets/pluginplay_examples.hpp + :language: c++ + :lines: 17- + +``DECLARE_PLUGIN`` declares a class (here ``PluginplayExamples``) that derives +from ``pluginplay::Plugin`` and has a default constructor. Note that it is not +necessary for the user to know the types of your modules and more importantly +any libraries you use to implement them. + +.. note:: + + If your modules use property types defined elsewhere (such as in centralized + community resources like NWChemEx/SimDE) and your + module-specific inputs are standard C++ types (or types found in the + property types) this is your entire public API!!! + +Implementation +============== + +Plugins are written like modules: everything is set up in the plugin's +constructor, which is started with the ``PLUGIN_CTOR`` macro. The constructor +typically does three things: names the plugin, adds the plugin's modules, and +sets the default submodules. + +Every plugin has a name, set by calling ``set_name``. Plugin names must be +unique within a ``ModuleManager``. Like module keys, plugin names are +case-insensitive. + +Modules are added to the plugin by calling ``add_module``. For your +convenience ``add_module`` has an overload which takes a template type +parameter that is the type of your module (as in the name of the class which +contains the algorithm) and a module key. The module key is how users will +refer to your module, *e.g.*, when they want to run your module they will call +``ModuleManager::run_as`` providing your module's key. Module keys must be +unique within your plugin. If your module requires more detailed set-up other +overloads exist that will bind to existing instances. + +Continuing with our electric field examples, naming our plugin and adding our +modules looks like: + +.. literalinclude:: ../../../../tests/cxx/doc_snippets/pluginplay_examples.cpp + :language: c++ + :lines: 17-29 + +Once you have added all your modules the next step is to set the default +submodules. In our examples, the ``ClassicalForce`` module needed a submodule +which can compute electric fields. Presently we have already loaded three such +modules; their keys are: + +- "Coulomb's Law" +- "Coulomb's Law with screening" +- "Single-precision Coulomb's law" + +If by default we want the ``ClassicalForce`` module we loaded under the key +"Classical Force" to use a ``CoulombsLaw`` module we can accomplish this by: + +.. literalinclude:: ../../../../tests/cxx/doc_snippets/pluginplay_examples.cpp + :language: c++ + :lines: 31 + +.. note:: + + It is not necessary to set all submodules in the plugin's constructor. + In particular if your module depends on a property that none of your modules + can compute you would not set that submodule. In this case, the submodule + can come from another plugin (see `Defaults From Other Plugins`_), or it + will be the responsibility of the caller to set the submodule. + +The entire implementation of our plugin looks like: + +.. literalinclude:: ../../../../tests/cxx/doc_snippets/pluginplay_examples.cpp + :language: c++ + :lines: 17- + +Defaults From Other Plugins +=========================== + +A plugin can not set a submodule to a module from another plugin in its +constructor, because the other plugin may not be loaded. Instead, the plugin +lists the plugins it would like defaults from, highest priority first, and +records which of their modules it would like to use: + +.. code-block:: c++ + + PLUGIN_CTOR(MyPlugin) { + set_name("my_plugin"); + set_preferred_providers("provider_a", "provider_b"); + add_module("My driver"); + + default_submod_from("provider_a", "My driver", "Data", "A data"); + default_submod_from("provider_b", "My driver", "Data", "B data"); + } + +When the user calls ``ModuleManager::wire_defaults``, the ``"Data"`` +submodule of ``"My driver"`` is set to ``"A data"`` if ``provider_a`` is +loaded, otherwise to ``"B data"`` if ``provider_b`` is loaded. Submodules the +user has already set are never changed. + +Python +====== + +To make your plugin usable from Python, create its Python module with the +``EXPORT_PLUGIN`` macro. The macro takes the name of the Python module, the +name of the plugin class, and the name of the pybind11 module variable. It is +followed by the body of a function which can export anything else the Python +module needs, *e.g.*, property types: + +.. literalinclude:: ../../../../tests/python/doc_snippets/export_pluginplay_examples.cpp + :language: c++ + :lines: 24-39 + +Python users then load the plugin with: + +.. code-block:: python + + import pluginplay as pp + import pluginplay_examples as ppe + + mm = pp.ModuleManager() + mm.load_plugin(ppe.PluginplayExamples()) diff --git a/tests/cxx/doc_snippets/generate_module_documentation.cpp b/tests/cxx/doc_snippets/generate_module_documentation.cpp index 90ba945bc..3df5c80ba 100644 --- a/tests/cxx/doc_snippets/generate_module_documentation.cpp +++ b/tests/cxx/doc_snippets/generate_module_documentation.cpp @@ -17,8 +17,8 @@ #include #include -// TODO: Replace with the header containing your ``load_modules`` definition -#include "load_modules.hpp" +// TODO: Replace with the header declaring your plugin +#include "pluginplay_examples.hpp" namespace fs = std::filesystem; @@ -33,8 +33,8 @@ int main(int argc, char** argv) { // Set up module manager pluginplay::ModuleManager mm; - // TODO: Load your modules - pluginplay_examples::load_modules(mm); + // TODO: Load your plugin + mm.load_plugin(); // Generate module documentation at the given path pluginplay::printing::document_modules(mm, docs_path); diff --git a/tests/cxx/doc_snippets/load_modules.cpp b/tests/cxx/doc_snippets/pluginplay_examples.cpp similarity index 64% rename from tests/cxx/doc_snippets/load_modules.cpp rename to tests/cxx/doc_snippets/pluginplay_examples.cpp index f165bb61d..b75fcdb87 100644 --- a/tests/cxx/doc_snippets/load_modules.cpp +++ b/tests/cxx/doc_snippets/pluginplay_examples.cpp @@ -14,19 +14,21 @@ * limitations under the License. */ -#include "load_modules.hpp" #include "modules.hpp" +#include "pluginplay_examples.hpp" namespace pluginplay_examples { -void load_modules(pluginplay::ModuleManager& mm) { +PLUGIN_CTOR(PluginplayExamples) { + set_name("pluginplay_examples"); + using coulombs_law_float = TemplatedCoulombsLaw; - mm.add_module("Classical Force"); - mm.add_module("Coulomb's Law"); - mm.add_module("Coulomb's Law with screening"); - mm.add_module("Single-precision Coulomb's law"); + add_module("Classical Force"); + add_module("Coulomb's Law"); + add_module("Coulomb's Law with screening"); + add_module("Single-precision Coulomb's law"); - mm.change_submod("Classical Force", "electric field", "Coulomb's Law"); + change_submod("Classical Force", "electric field", "Coulomb's Law"); } } // namespace pluginplay_examples diff --git a/tests/cxx/doc_snippets/load_modules.hpp b/tests/cxx/doc_snippets/pluginplay_examples.hpp similarity index 93% rename from tests/cxx/doc_snippets/load_modules.hpp rename to tests/cxx/doc_snippets/pluginplay_examples.hpp index 110630eda..b4d1480bf 100644 --- a/tests/cxx/doc_snippets/load_modules.hpp +++ b/tests/cxx/doc_snippets/pluginplay_examples.hpp @@ -19,6 +19,6 @@ namespace pluginplay_examples { -void load_modules(pluginplay::ModuleManager& mm); +DECLARE_PLUGIN(PluginplayExamples); } diff --git a/tests/cxx/doc_snippets/test_modules.cpp b/tests/cxx/doc_snippets/test_modules.cpp index 26e3ddd30..77673f27e 100644 --- a/tests/cxx/doc_snippets/test_modules.cpp +++ b/tests/cxx/doc_snippets/test_modules.cpp @@ -16,8 +16,8 @@ #include "electric_field.hpp" #include "force.hpp" -#include "load_modules.hpp" #include "modules.hpp" +#include "pluginplay_examples.hpp" #include #include #include @@ -26,8 +26,8 @@ using namespace pluginplay_examples; TEST_CASE("Tutorial modules") { // Set up module manager pluginplay::ModuleManager mm; - // Load your modules - load_modules(mm); + // Load your plugin + mm.load_plugin(); REQUIRE(mm.count("Classical Force") == 1); REQUIRE(mm.count("Coulomb's Law") == 1); REQUIRE(mm.count("Coulomb's Law with screening") == 1); diff --git a/tests/cxx/doc_snippets/test_tutorial_module_documents.cpp b/tests/cxx/doc_snippets/test_tutorial_module_documents.cpp index 8a153feeb..e972b05e9 100644 --- a/tests/cxx/doc_snippets/test_tutorial_module_documents.cpp +++ b/tests/cxx/doc_snippets/test_tutorial_module_documents.cpp @@ -21,7 +21,7 @@ #include #include -#include "load_modules.hpp" +#include "pluginplay_examples.hpp" TEST_CASE("tutorial_modules_docs") { // Need a docs path @@ -45,15 +45,23 @@ TEST_CASE("tutorial_modules_docs") { pluginplay::ModuleManager mm; SECTION("Modules Loaded") { - // Load your modules - pluginplay_examples::load_modules(mm); + // Load your plugin + mm.load_plugin(); // Generate module documentation at the given path pluginplay::printing::document_modules(mm, docs_path); + // docs_path holds index.rst and the plugin's directory + auto plugin_path = docs_path / "pluginplay_examples"; + REQUIRE(std::filesystem::exists(docs_path / "index.rst")); + REQUIRE(std::filesystem::is_directory(plugin_path)); + REQUIRE(std::distance(std::filesystem::directory_iterator(docs_path), + std::filesystem::directory_iterator{}) == 2); + + // The plugin's directory holds one file per module decltype(mm.size()) distance( - std::distance(std::filesystem::directory_iterator(docs_path), + std::distance(std::filesystem::directory_iterator(plugin_path), std::filesystem::directory_iterator{})); - REQUIRE(distance == mm.size() + 1); // +1 for index.rst + REQUIRE(distance == mm.size()); } } diff --git a/tests/cxx/unit_tests/pluginplay/examples/lambda_module.cpp b/tests/cxx/unit_tests/pluginplay/examples/lambda_module.cpp index d5a71edd0..7d2db4920 100644 --- a/tests/cxx/unit_tests/pluginplay/examples/lambda_module.cpp +++ b/tests/cxx/unit_tests/pluginplay/examples/lambda_module.cpp @@ -48,7 +48,7 @@ // For the purposes of this tutorial we reuse the property types from our // property type tutorial and the modules from our module tutorial. The next // two lines include the relevant header files -#include "load_modules.hpp" +#include "writing_a_plugin.hpp" #include // TUTORIAL_START_SKIP @@ -96,7 +96,7 @@ TEST_CASE("How to use a lambda module") { // instance and then retrieves the relevant modules for this tutorial: pluginplay::ModuleManager mm; - load_modules(mm); + mm.load_plugin(); // At this point volume_mod's "area" submodule is set to Rectangle auto volume_mod = mm.at("Prism"); diff --git a/tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp b/tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp deleted file mode 100644 index f3d6bf001..000000000 --- a/tests/cxx/unit_tests/pluginplay/examples/load_modules.hpp +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2022 NWChemEx-Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* The purpose of this tutorial is to showcase the packaging of module groups. - * - * For all intents an purposes the ``ModuleManager`` class can be thought of as - * the code embodiment of the pluginplay. It is responsible for storing all of - * the available modules, assembling the call graph, integrating the modules - * into the actual runtime, and checkpointing the progress of the program. To - * get the ``ModuleManager`` class one includes ``ModuleManager.hpp``. The - * remainder of the following header files are needed for this tutorial. - */ -#pragma once -#include "writing_a_module.hpp" -#include "writing_a_property_type.hpp" -#include - -/* Populating the ModuleManager - * ---------------------------- - * - * Before the ``ModuleManager`` can manage some modules, it needs modules. The - * primary means of providing the ``ModuleManager`` with modules is manually. - * While this sounds tedious, and it is, it only needs to be done once. To this - * end libraries meant for use with the pluginplay will provide a function - * ``load_modules`` which takes a ``ModuleManager`` instance and adds to it - * the modules contained within that library. It is also typically during this - * function call that default modules are assigned. - * - * The following function indicates how this would be done assuming that our - * ``Area``, ``PrismVolume``, ``Rectangle`` and ``Prism`` classes are part of a - * library meant for use with the pluginplay. - */ -inline void load_modules(pluginplay::ModuleManager& mm) { - /* The function starts off by registering the two modules provided by our - * library. Registration involves two things: - * - * - creation of an instance of the module - * - association of that instance with a module key - * - * By taking an already instantiated object, the pluginplay remains - * decoupled from the procedure for initializing the module. It remains - * decoupled from the modules' C++ types (here ``Rectangle`` and ``Prism``) - * by taking the objects via pointers to the ``ModuleBase`` class it - * necessarily inherits from. To avoid gotchas related to handling - * polymorphic data types the pluginplay requires that the user provide the - * module as a ``shared_ptr``. The provided key can be used to retrieve the - * module (*vide infra*). The key must be unique (if it's not an error will - * be raised). - */ - mm.add_module("Rectangle", std::make_shared()); - mm.add_module("Prism", std::make_shared()); - - /* Our ``Prism`` module needs a submodule which computes an ``Area``. Since - * our library provides one, we set it as the default submodule. Users can - * still choose a different submodule later. - */ - mm.change_submod("Prism", "area", "Rectangle"); -} // end load_modules() diff --git a/tests/cxx/unit_tests/pluginplay/examples/workflow.cpp b/tests/cxx/unit_tests/pluginplay/examples/workflow.cpp index d7ccc6374..4f9407db7 100644 --- a/tests/cxx/unit_tests/pluginplay/examples/workflow.cpp +++ b/tests/cxx/unit_tests/pluginplay/examples/workflow.cpp @@ -18,9 +18,9 @@ #include "workflow.hpp" #include -TEST_CASE("load_modules") { +TEST_CASE("load_plugin") { pluginplay::ModuleManager mm; - load_modules(mm); + mm.load_plugin(); REQUIRE(mm.count("Rectangle") == 1); REQUIRE(mm.count("Prism") == 1); } diff --git a/tests/cxx/unit_tests/pluginplay/examples/workflow.hpp b/tests/cxx/unit_tests/pluginplay/examples/workflow.hpp index 07591adfe..b35fd0df4 100644 --- a/tests/cxx/unit_tests/pluginplay/examples/workflow.hpp +++ b/tests/cxx/unit_tests/pluginplay/examples/workflow.hpp @@ -25,25 +25,27 @@ * remainder of the following header files are needed for this tutorial. */ #pragma once -#include "load_modules.hpp" +#include "writing_a_plugin.hpp" #include /* Running a Computation * --------------------- * - * The ``load_modules()`` functions are provided by libraries designed to - * interact with the pluginplay. You typically will not be instantiating them, - * rather you will simply be consuming them in your workflow. The following + * Plugins are provided by libraries designed to interact with pluginplay. + * You typically will not be writing them, rather you will simply be consuming + * them in your workflow. The following * function is an example workflow that a end-user would actually do. Typically * it would be run from Python (the contents being nearly identical). */ static void example_main() { /* Ultimately the run starts by creating and initializing a ``ModuleManger`` - * instance. This is where we leverage the ``load_modules()`` functions - * provided by libraries . + * instance. This is where we load the plugins provided by libraries. Once + * all plugins are loaded, ``wire_defaults`` sets the default submodules + * the plugins want from each other (our single plugin has none). */ pluginplay::ModuleManager mm; - load_modules(mm); + mm.load_plugin(); + mm.wire_defaults(); /* With the modules loaded it's time to set the options up so that they * suite our specific run. For sake of example we'll change the name of diff --git a/tests/cxx/unit_tests/pluginplay/examples/writing_a_plugin.hpp b/tests/cxx/unit_tests/pluginplay/examples/writing_a_plugin.hpp new file mode 100644 index 000000000..17f687c2f --- /dev/null +++ b/tests/cxx/unit_tests/pluginplay/examples/writing_a_plugin.hpp @@ -0,0 +1,84 @@ +/* + * Copyright 2022 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* The purpose of this tutorial is to showcase the packaging of modules into a + * plugin. + * + * For all intents an purposes the ``ModuleManager`` class can be thought of as + * the code embodiment of pluginplay. It is responsible for storing all of + * the available modules, assembling the call graph, integrating the modules + * into the actual runtime, and checkpointing the progress of the program. + * Libraries meant for use with pluginplay distribute their modules as a + * plugin, which the user loads into a ``ModuleManager``. To declare a plugin + * one includes ``pluginplay/plugin/plugin.hpp`` (included by + * ``pluginplay/pluginplay.hpp``). The remainder of the following header files + * are needed for this tutorial. + */ +#pragma once +#include "writing_a_module.hpp" +#include "writing_a_property_type.hpp" +#include + +/* Declaring the Plugin + * -------------------- + * + * A plugin is a class that derives from ``pluginplay::Plugin``. The + * ``DECLARE_PLUGIN`` macro declares such a class, with a default constructor. + * In a real library this declaration is the library's public API; users only + * need it to load the plugin, not the types of your modules. + */ +DECLARE_PLUGIN(ExamplePlugin); + +/* Defining the Plugin + * ------------------- + * + * Plugins are written like modules: everything is set up in the constructor, + * which is started with the ``PLUGIN_CTOR`` macro. Normally the constructor is + * defined in a source file; since this tutorial is a header, it is marked + * ``inline``. + * + * The following constructor assumes that our ``Area``, ``PrismVolume``, + * ``Rectangle`` and ``Prism`` classes are part of a library meant for use with + * pluginplay. + */ +inline PLUGIN_CTOR(ExamplePlugin) { + /* Every plugin has a name. Plugin names must be unique within a + * ``ModuleManager``. + */ + set_name("example_plugin"); + + /* Next, the plugin adds its modules. Adding a module involves two things: + * + * - creation of an instance of the module + * - association of that instance with a module key + * + * The module key is how users refer to the module (*vide infra*). The key + * must be unique within the plugin (if it's not an error will be raised). + */ + add_module("Rectangle"); + add_module("Prism"); + + /* Our ``Prism`` module needs a submodule which computes an ``Area``. Since + * our plugin provides one, we set it as the default submodule. Users can + * still choose a different submodule later. + * + * If a default submodule should come from a different plugin, the plugin + * instead lists that plugin with ``set_preferred_providers`` and records + * the default with ``default_submod_from``. The ``ModuleManager`` applies + * those defaults when the user calls ``wire_defaults``. + */ + change_submod("Prism", "area", "Rectangle"); +} // end ExamplePlugin ctor diff --git a/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp b/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp index 1c10c971b..d292ededf 100644 --- a/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp +++ b/tests/cxx/unit_tests/pluginplay/module_manager/detail_/module_manager_pimpl.cpp @@ -17,6 +17,7 @@ #include "../../catch.hpp" #include "../../examples/writing_a_module.hpp" #include "pluginplay/module_manager/detail_/module_manager_pimpl.hpp" +#include using namespace pluginplay; using namespace pluginplay::detail_; @@ -24,12 +25,24 @@ using namespace pluginplay::detail_; using base_map = typename ModuleManagerPIMPL::base_map; using module_map = typename ModuleManagerPIMPL::module_map; +namespace { + +DECLARE_PLUGIN(PimplTestPlugin); + +PLUGIN_CTOR(PimplTestPlugin) { + set_name("test_plugin"); + add_module("Rectangle"); +} + +} // namespace + TEST_CASE("ModuleManagerPIMPL : Default ctor") { ModuleManagerPIMPL pimpl; SECTION("State") { REQUIRE(pimpl.size() == 0); REQUIRE(pimpl.m_bases == base_map{}); REQUIRE(pimpl.m_modules == module_map{}); + REQUIRE(pimpl.m_plugins.empty()); } } @@ -85,17 +98,15 @@ TEST_CASE("ModuleManagerPIMPL : comparison") { } SECTION("Different modules") { SECTION("Different instances") { - auto ptr1 = std::make_shared(); - auto ptr2 = std::make_shared(); SECTION("Same key") { - pimpl1.m_modules["key"] = ptr1; - pimpl2.m_modules["key"] = ptr2; + pimpl1.m_modules.add_module("key", Module{}); + pimpl2.m_modules.add_module("key", Module{}); REQUIRE(pimpl1 == pimpl2); REQUIRE(!(pimpl1 != pimpl2)); } SECTION("Different key") { - pimpl1.m_modules["key1"] = ptr1; - pimpl2.m_modules["key2"] = ptr2; + pimpl1.m_modules.add_module("key1", Module{}); + pimpl2.m_modules.add_module("key2", Module{}); REQUIRE(pimpl1 != pimpl2); REQUIRE(!(pimpl1 == pimpl2)); } @@ -107,11 +118,18 @@ TEST_CASE("ModuleManagerPIMPL : add_module") { auto ptr1 = std::make_shared(); ModuleManagerPIMPL pimpl1, pimpl2; pimpl2.m_bases[ptr1->type()] = ptr1; - auto pimpl = std::make_unique(ptr1); - pimpl2.m_modules["key1"] = std::make_shared(std::move(pimpl)); - pimpl2.m_modules["key1"]->set_name("key1"); + pimpl2.m_modules.add_module("key1", ptr1); pimpl1.add_module("key1", ptr1); REQUIRE(pimpl1 == pimpl2); + SECTION("Module is given the runtime and caches") { + REQUIRE(&ptr1->get_runtime() == &pimpl1.get_runtime()); + REQUIRE(ptr1->has_uuid()); + REQUIRE(pimpl1.m_modules.at("key1")->is_memoizable()); + } + SECTION("Can't add a null module") { + REQUIRE_THROWS_AS(pimpl1.add_module("key2", nullptr), + std::invalid_argument); + } SECTION("Can't use the key") { REQUIRE_THROWS_AS(pimpl1.add_module("key1", ptr1), std::invalid_argument); @@ -126,10 +144,7 @@ TEST_CASE("ModuleManagerPIMPL : copy_module") { SECTION("Copies non-locked module") { pimpl1.copy_module("key", "new_key"); - auto pimpl = std::make_unique(ptr1); - pimpl2.m_modules["new_key"] = - std::make_shared(std::move(pimpl)); - pimpl2.m_modules["new_key"]->set_name("new_key"); + pimpl2.m_modules.add_module("new_key", ptr1); REQUIRE(pimpl1 == pimpl2); } SECTION("Copy locked module") { @@ -194,12 +209,49 @@ TEST_CASE("ModuleManagerPIMPL : keys") { auto ptr1 = std::make_shared(); pimpl1.add_module("rectangle", ptr1); REQUIRE(pimpl1.keys().size() == 1); - REQUIRE(pimpl1.keys().at(0) == "rectangle"); + REQUIRE(pimpl1.keys().at(0) == QualifiedKey("", "rectangle")); auto ptr2 = std::make_shared(); pimpl1.add_module("prism", ptr2); REQUIRE(pimpl1.keys().size() == 2); - REQUIRE(pimpl1.keys().at(0) == "prism"); - REQUIRE(pimpl1.keys().at(1) == "rectangle"); + REQUIRE(pimpl1.keys().at(0) == QualifiedKey("", "prism")); + REQUIRE(pimpl1.keys().at(1) == QualifiedKey("", "rectangle")); + } +} + +TEST_CASE("ModuleManagerPIMPL : plugins") { + ModuleManagerPIMPL pimpl1, pimpl2; + auto plugin = std::make_unique(); + const auto* pplugin = plugin.get(); + pimpl1.load_plugin(std::move(plugin)); + + SECTION("State") { + REQUIRE(pimpl1.m_plugins.size() == 1); + REQUIRE(pimpl1.m_plugins[0].get() == pplugin); + REQUIRE(pimpl1.m_modules.empty()); + } + + SECTION("Plugin modules are given the runtime and caches") { + auto mod = pplugin->get_modules().at("Rectangle"); + REQUIRE(mod->is_memoizable()); + REQUIRE(mod->run_as(2.0, 3.0) == 6.0); + } + + SECTION("Comparison") { + REQUIRE(pimpl1 != pimpl2); + pimpl2.load_plugin(std::make_unique()); + REQUIRE(pimpl1 == pimpl2); + pimpl2.get_modules("test_plugin") + .change_input("Rectangle", "Dimension 1", 1.0); + REQUIRE(pimpl1 != pimpl2); + } + + SECTION("count, size, and keys span all maps") { + pimpl1.add_module("Rectangle", std::make_shared()); + REQUIRE(pimpl1.count("Rectangle") == 2); + REQUIRE(pimpl1.size() == 2); + REQUIRE(pimpl1.keys() == ModuleManagerPIMPL::key_container_type{ + QualifiedKey("", "Rectangle"), + QualifiedKey("test_plugin", "Rectangle")}); } } diff --git a/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp b/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp index 7b00c2d32..7acb64bd4 100644 --- a/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp +++ b/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp @@ -15,11 +15,91 @@ */ #include "../catch.hpp" +#include "../examples/writing_a_module.hpp" #include "test_common.hpp" #include +#include + +using pluginplay::ModuleManager; +using pluginplay::QualifiedKey; + +namespace { + +// Computes an area and counts how many times it actually ran. Unlike a lambda +// module it is memoizable, so the count shows whether a result came from the +// cache. +struct CountingArea : pluginplay::ModuleBase { + static inline int n_runs = 0; + + CountingArea() : pluginplay::ModuleBase(this) { + satisfies_property_type(); + } + + pluginplay::type::result_map run_( + pluginplay::type::input_map inputs, + pluginplay::type::submodule_map) const override { + ++n_runs; + const auto [base, height] = Area::unwrap_inputs(inputs); + auto rv = results(); + return Area::wrap_results(rv, base * height); + } +}; + +DECLARE_PLUGIN(ProviderA); +DECLARE_PLUGIN(ProviderB); +DECLARE_PLUGIN(Consumer); +DECLARE_PLUGIN(Unnamed); + +PLUGIN_CTOR(ProviderA) { + set_name("provider_a"); + add_module("A area"); + add_module("Shared"); +} + +PLUGIN_CTOR(ProviderB) { + set_name("provider_b"); + add_module("B area"); + add_module("Shared"); +} + +// Prism prefers provider_a's area. Prism 2 prefers provider_b's area, and +// asks provider_a for a module it does not have. +PLUGIN_CTOR(Consumer) { + set_name("consumer"); + set_preferred_providers("provider_a", "provider_b"); + add_module("Prism"); + add_module("Prism 2"); + default_submod_from("provider_a", "Prism", "area", "A area"); + default_submod_from("provider_b", "Prism", "area", "B area"); + default_submod_from("provider_a", "Prism 2", "area", "Not a module"); + default_submod_from("provider_b", "Prism 2", "area", "B area"); +} + +PLUGIN_CTOR(Unnamed) {} + +// Same name as ProviderA, in a different case +DECLARE_PLUGIN(ShoutingProviderA); +PLUGIN_CTOR(ShoutingProviderA) { set_name("PROVIDER_A"); } + +// Like Consumer, but spells the provider's name in a different case +DECLARE_PLUGIN(CaseConsumer); +PLUGIN_CTOR(CaseConsumer) { + set_name("case_consumer"); + set_preferred_providers("Provider_A"); + add_module("Case Prism"); + default_submod_from("PROVIDER_A", "Case Prism", "area", "A area"); +} + +// The module a submodule of a module is set to +const pluginplay::Module& submod(ModuleManager& mm, const QualifiedKey& key, + const std::string& callback) { + return mm.at(key).submods().at(callback).value(); +} + +} // namespace TEST_CASE("ModuleManager") { - pluginplay::ModuleManager mm; + ModuleManager mm; SECTION("add_module(string)") { using mod_t = testing::NoPTModule; // Type of the Module we're adding @@ -27,6 +107,7 @@ TEST_CASE("ModuleManager") { mm.add_module("a mod"); auto corr = testing::make_module("a mod"); REQUIRE(mm.at("a mod") == *corr); + REQUIRE(mm.get_modules("").count("a mod")); } SECTION("add_module(string, shared_ptr") { @@ -37,20 +118,6 @@ TEST_CASE("ModuleManager") { REQUIRE(mm.at("a mod") == *corr); } - SECTION("iterator") { - using mod_t = testing::NoPTModule; // Type of the Module we're adding - - mm.add_module("a key", std::make_shared()); - mm.add_module("b key", std::make_shared()); - decltype(mm.size()) count = 0; - for(auto& [key, mod] : mm) { - count += 1; - REQUIRE("key" == key.substr(key.size() - 3)); - REQUIRE_FALSE(mod->has_description()); - } - REQUIRE(count == mm.size()); - } - SECTION("set_runtime") { auto prv = std::make_shared(); mm.set_runtime(prv); @@ -66,15 +133,292 @@ TEST_CASE("ModuleManager") { mm.add_module("a key", std::make_shared()); mm.add_module("b key", std::make_shared()); + mm.load_plugin(); auto keys = mm.keys(); REQUIRE(keys.size() == mm.size()); - REQUIRE(keys[0] == "a key"); - REQUIRE(keys[1] == "b key"); + REQUIRE(keys[0] == QualifiedKey("", "a key")); + REQUIRE(keys[1] == QualifiedKey("", "b key")); + REQUIRE(keys[2] == QualifiedKey("provider_a", "A area")); + REQUIRE(keys[3] == QualifiedKey("provider_a", "Shared")); } SECTION("has_cache") { REQUIRE(mm.has_cache()); - pluginplay::ModuleManager no_cache(nullptr, nullptr); + ModuleManager no_cache(nullptr, nullptr); REQUIRE_FALSE(no_cache.has_cache()); } } + +TEST_CASE("ModuleManager : plugins") { + ModuleManager mm; + + SECTION("No plugins") { + REQUIRE_FALSE(mm.has_plugin("provider_a")); + REQUIRE(mm.plugin_names() == ModuleManager::name_container_type{""}); + REQUIRE(mm.get_modules("").empty()); + REQUIRE_THROWS_AS(mm.get_plugin("provider_a"), std::out_of_range); + REQUIRE_THROWS_AS(mm.get_modules("provider_a"), std::out_of_range); + } + + SECTION("load_plugin()") { + mm.load_plugin(); + REQUIRE(mm.has_plugin("provider_a")); + REQUIRE(mm.plugin_names() == + ModuleManager::name_container_type{"", "provider_a"}); + REQUIRE(mm.get_plugin("provider_a") == ProviderA{}); + REQUIRE(&mm.get_modules("provider_a") == + &mm.get_plugin("provider_a").get_modules()); + REQUIRE(mm.size() == 2); + REQUIRE(mm.count("A area") == 1); + + // The modules can be run and are memoizable + REQUIRE(mm.at("A area").is_memoizable()); + REQUIRE(mm.run_as("A area", 2.0, 3.0) == 6.0); + } + + SECTION("load_plugin(unique_ptr)") { + auto plugin = std::make_unique(); + const auto* pplugin = plugin.get(); + mm.load_plugin(std::move(plugin)); + + // *this owns the plugin; nothing was copied + REQUIRE(&mm.get_plugin("provider_a") == pplugin); + } + + SECTION("Can't load two plugins with the same name") { + mm.load_plugin(); + REQUIRE_THROWS_AS(mm.load_plugin(), std::invalid_argument); + REQUIRE(mm.plugin_names().size() == 2); + } + + SECTION("Plugin names are case-insensitive") { + mm.load_plugin(); + REQUIRE(mm.has_plugin("Provider_A")); + REQUIRE(&mm.get_plugin("PROVIDER_A") == &mm.get_plugin("provider_a")); + REQUIRE(mm.get_modules("Provider_A").count("A area") == 1); + REQUIRE(mm.count(QualifiedKey("PROVIDER_A", "A area")) == 1); + REQUIRE(&mm.at(QualifiedKey("Provider_A", "A area")) == + &mm.at("A area")); + + // The name keeps the plugin's spelling + REQUIRE(mm.plugin_names() == + ModuleManager::name_container_type{"", "provider_a"}); + + // Same name in a different case is still a duplicate + REQUIRE_THROWS_AS(mm.load_plugin(), + std::invalid_argument); + } + + SECTION("Can't load unnamed or null plugins") { + REQUIRE_THROWS_AS(mm.load_plugin(), std::invalid_argument); + REQUIRE_THROWS_AS(mm.load_plugin(nullptr), std::invalid_argument); + REQUIRE(mm.plugin_names().size() == 1); + } + + SECTION("Modules with the same key get different caches") { + mm.load_plugin(); + mm.load_plugin(); + CountingArea::n_runs = 0; + + REQUIRE(mm.run_as(QualifiedKey("provider_a", "Shared"), 2.0, + 3.0) == 6.0); + REQUIRE(CountingArea::n_runs == 1); + + // Same inputs, but a different module, so it must actually run + REQUIRE(mm.run_as(QualifiedKey("provider_b", "Shared"), 2.0, + 3.0) == 6.0); + REQUIRE(CountingArea::n_runs == 2); + + // Same module and inputs, so the result comes from the cache + REQUIRE(mm.run_as(QualifiedKey("provider_a", "Shared"), 2.0, + 3.0) == 6.0); + REQUIRE(CountingArea::n_runs == 2); + } +} + +TEST_CASE("ModuleManager : QualifiedKey lookup") { + ModuleManager mm; + mm.add_module("Shared"); + mm.load_plugin(); + mm.load_plugin(); + + const QualifiedKey a_shared("provider_a", "Shared"); + const QualifiedKey b_shared("provider_b", "Shared"); + const QualifiedKey own_shared("", "Shared"); + + SECTION("count") { + REQUIRE(mm.count("Shared") == 3); + REQUIRE(mm.count("A area") == 1); + REQUIRE(mm.count("not a key") == 0); + REQUIRE(mm.count(a_shared) == 1); + REQUIRE(mm.count(QualifiedKey("provider_a", "B area")) == 0); + REQUIRE(mm.count(QualifiedKey("not a plugin", "Shared")) == 0); + } + + SECTION("size") { REQUIRE(mm.size() == 5); } + + SECTION("at") { + // Key-only lookups work when the key is unique + REQUIRE(&mm.at("A area") == + &mm.at(QualifiedKey("provider_a", "A area"))); + + // Qualified lookups pick the map + REQUIRE(&mm.at(a_shared) == + mm.get_modules("provider_a").at("Shared").get()); + REQUIRE(&mm.at(own_shared) == mm.get_modules("").at("Shared").get()); + + REQUIRE_THROWS_AS(mm.at("Shared"), std::invalid_argument); + REQUIRE_THROWS_AS(mm.at("not a key"), std::out_of_range); + REQUIRE_THROWS_AS(mm.at(QualifiedKey("provider_a", "B area")), + std::out_of_range); + REQUIRE_THROWS_AS(mm.at(QualifiedKey("not a plugin", "Shared")), + std::out_of_range); + } + + SECTION("Ambiguity error names the plugins") { + try { + mm.at("Shared"); + FAIL("Expected an exception"); + } catch(const std::invalid_argument& e) { + const std::string msg = e.what(); + REQUIRE(msg.find("'provider_a'") != std::string::npos); + REQUIRE(msg.find("'provider_b'") != std::string::npos); + REQUIRE(msg.find("own modules") != std::string::npos); + } + } + + SECTION("change_input") { + mm.change_input(a_shared, "Dimension 1", 2.0); + const auto& inputs = mm.at(a_shared).inputs(); + REQUIRE(inputs.at("Dimension 1").value() == 2.0); + REQUIRE_FALSE(mm.at(b_shared).inputs().at("Dimension 1").has_value()); + REQUIRE_THROWS_AS(mm.change_input("Shared", "Dimension 1", 2.0), + std::invalid_argument); + } + + SECTION("change_submod across maps") { + mm.load_plugin(); + mm.change_submod("Prism", "area", b_shared); + REQUIRE(submod(mm, "Prism", "area") == mm.at(b_shared)); + REQUIRE_THROWS_AS(mm.change_submod("Prism", "area", "Shared"), + std::invalid_argument); + } + + SECTION("copy_module") { + SECTION("Copies of plugin modules go in the own map") { + mm.copy_module(a_shared, "Copy"); + REQUIRE(mm.count("Copy") == 1); + REQUIRE(mm.count(QualifiedKey("", "Copy")) == 1); + REQUIRE(mm.count(QualifiedKey("provider_a", "Copy")) == 0); + REQUIRE_FALSE(mm.at("Copy").locked()); + } + SECTION("The copy may reuse a key a plugin uses") { + // Only the own map has to be free of the key + mm.copy_module(a_shared, "A area"); + REQUIRE(mm.count("A area") == 2); + REQUIRE(mm.count(QualifiedKey("", "A area")) == 1); + } + SECTION("Errors") { + REQUIRE_THROWS_AS(mm.copy_module("Shared", "Copy"), + std::invalid_argument); + REQUIRE_THROWS_AS(mm.copy_module(a_shared, "Shared"), + std::invalid_argument); + REQUIRE_THROWS_AS(mm.copy_module("not a key", "Copy"), + std::out_of_range); + } + } + + SECTION("erase") { + mm.erase(a_shared); + REQUIRE(mm.count("Shared") == 2); + REQUIRE(mm.count(a_shared) == 0); + + mm.erase("not a key"); // No-op + REQUIRE(mm.size() == 4); + + REQUIRE_THROWS_AS(mm.erase("Shared"), std::invalid_argument); + } + + SECTION("rename_module") { + SECTION("A plugin's module moves to the own map") { + mm.rename_module(a_shared, "Renamed"); + REQUIRE(mm.count(a_shared) == 0); + REQUIRE(mm.count(QualifiedKey("", "Renamed")) == 1); + } + SECTION("Same key, different map") { + // Moving A area to the own map under the same key must not trip + // the ambiguity check when the original is erased + mm.rename_module("A area", "A area"); + REQUIRE(mm.count(QualifiedKey("", "A area")) == 1); + REQUIRE(mm.count(QualifiedKey("provider_a", "A area")) == 0); + } + } +} + +TEST_CASE("ModuleManager : wire_defaults") { + ModuleManager mm; + + SECTION("Highest priority loaded provider wins") { + mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); + mm.wire_defaults(); + REQUIRE(submod(mm, "Prism", "area") == mm.at("A area")); + // provider_a lacks "Not a module", so provider_b fills Prism 2 + REQUIRE(submod(mm, "Prism 2", "area") == mm.at("B area")); + } + + SECTION("Lower priority provider is used if higher is not loaded") { + mm.load_plugin(); + mm.load_plugin(); + mm.wire_defaults(); + REQUIRE(submod(mm, "Prism", "area") == mm.at("B area")); + } + + SECTION("No providers leaves submodules unset") { + mm.load_plugin(); + mm.wire_defaults(); + REQUIRE_FALSE(mm.at("Prism").submods().at("area").has_module()); + } + + SECTION("Load order does not matter") { + mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); + mm.wire_defaults(); + REQUIRE(submod(mm, "Prism", "area") == mm.at("A area")); + } + + SECTION("User choices are kept") { + mm.load_plugin(); + mm.load_plugin(); + mm.load_plugin(); + mm.change_submod("Prism", "area", "B area"); + mm.wire_defaults(); + REQUIRE(submod(mm, "Prism", "area") == mm.at("B area")); + } + + SECTION("Can be called again after loading another plugin") { + mm.load_plugin(); + mm.load_plugin(); + mm.wire_defaults(); + mm.load_plugin(); + mm.wire_defaults(); + // Already set from provider_b, so not overwritten by provider_a + REQUIRE(submod(mm, "Prism", "area") == mm.at("B area")); + } + + SECTION("Provider names are matched case-insensitively") { + mm.load_plugin(); + mm.load_plugin(); + mm.wire_defaults(); + REQUIRE(submod(mm, "Case Prism", "area") == mm.at("A area")); + } + + SECTION("Erased modules are skipped") { + mm.load_plugin(); + mm.load_plugin(); + mm.erase("Prism"); + REQUIRE_NOTHROW(mm.wire_defaults()); + } +} diff --git a/tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp b/tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp index 331f6b2e6..96ea196e5 100644 --- a/tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp +++ b/tests/cxx/unit_tests/pluginplay/module_manager/module_map.cpp @@ -181,6 +181,52 @@ TEST_CASE("ModuleMap") { REQUIRE(same != has_mods); } + SECTION("add_module(key, Module)") { + defaulted.add_module("Copy", has_mods.at("Rectangle")->unlocked_copy()); + auto mod = defaulted.at("Copy"); + REQUIRE(mod->get_name() == "Copy"); + REQUIRE(mod != has_mods.at("Rectangle")); + REQUIRE_THROWS_AS(defaulted.add_module("Copy", Module{}), + std::invalid_argument); + } + + SECTION("copy_module") { + has_mods.at("Rectangle")->lock(); + has_mods.copy_module("Rectangle", "Copy"); + auto copy = has_mods.at("Copy"); + REQUIRE(copy->get_name() == "Copy"); + REQUIRE_FALSE(copy->locked()); + + // The copy has its own inputs + copy->change_input("Dimension 1", 1.23); + REQUIRE(*copy != *has_mods.at("Rectangle")); + + REQUIRE_THROWS_AS(has_mods.copy_module("not a key", "Copy 2"), + std::out_of_range); + REQUIRE_THROWS_AS(has_mods.copy_module("Rectangle", "Prism"), + std::invalid_argument); + } + + SECTION("erase") { + has_mods.erase("Rectangle"); + REQUIRE(has_mods.count("Rectangle") == 0); + REQUIRE(has_mods.size() == 1); + has_mods.erase("not a key"); // No-op + REQUIRE(has_mods.size() == 1); + } + + SECTION("change_input") { + has_mods.change_input("Rectangle", "Dimension 1", 1.23); + const auto& inputs = has_mods.at("Rectangle")->inputs(); + REQUIRE(inputs.at("Dimension 1").value() == 1.23); + REQUIRE_THROWS_AS( + has_mods.change_input("not a key", "Dimension 1", 1.0), + std::out_of_range); + REQUIRE_THROWS_AS( + has_mods.change_input("Rectangle", "not an input", 1.0), + std::out_of_range); + } + SECTION("copies share modules") { ModuleMap copy(has_mods); REQUIRE(copy.at("Prism") == has_mods.at("Prism")); diff --git a/tests/cxx/unit_tests/pluginplay/module_manager/qualified_key.cpp b/tests/cxx/unit_tests/pluginplay/module_manager/qualified_key.cpp new file mode 100644 index 000000000..5b539814c --- /dev/null +++ b/tests/cxx/unit_tests/pluginplay/module_manager/qualified_key.cpp @@ -0,0 +1,62 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../catch.hpp" +#include + +using pluginplay::QualifiedKey; + +TEST_CASE("QualifiedKey") { + QualifiedKey key_only("Overlap"); + QualifiedKey qualified("integrals", "Overlap"); + QualifiedKey own("", "Overlap"); + + SECTION("Key-only") { + REQUIRE_FALSE(key_only.is_qualified()); + REQUIRE(key_only.plugin().empty()); + REQUIRE(key_only.key() == "Overlap"); + REQUIRE(key_only.to_string() == "Overlap"); + + // Implicit from std::string too + QualifiedKey from_string = std::string("Overlap"); + REQUIRE(from_string == key_only); + } + + SECTION("Qualified") { + REQUIRE(qualified.is_qualified()); + REQUIRE(qualified.plugin() == "integrals"); + REQUIRE(qualified.key() == "Overlap"); + REQUIRE(qualified.to_string() == "integrals::Overlap"); + + // The empty plugin name still makes a qualified key + REQUIRE(own.is_qualified()); + REQUIRE(own.plugin().empty()); + } + + SECTION("Comparisons") { + // Module keys are case-insensitive + REQUIRE(qualified == QualifiedKey("integrals", "OVERLAP")); + REQUIRE(key_only == QualifiedKey("overlap")); + + // Plugin names are case-insensitive too + REQUIRE(qualified == QualifiedKey("Integrals", "Overlap")); + REQUIRE(qualified != QualifiedKey("nux", "Overlap")); + + REQUIRE(qualified != key_only); + REQUIRE(own != key_only); + REQUIRE(qualified != QualifiedKey("integrals", "Overlaps")); + } +} diff --git a/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp b/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp index 18446fc1a..da26ae6bf 100644 --- a/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp +++ b/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp @@ -29,10 +29,7 @@ void load_modules(ModuleMap& modules) { } // A plugin written the way the design documentation describes -class MyPlugin : public Plugin { -public: - MyPlugin(); -}; +DECLARE_PLUGIN(MyPlugin); PLUGIN_CTOR(MyPlugin) { set_name("my_plugin"); @@ -134,6 +131,9 @@ TEST_CASE("Plugin") { std::invalid_argument); REQUIRE_THROWS_AS(defaulted.set_preferred_providers("b", "b"), std::invalid_argument); + // Names are case-insensitive, so this is a duplicate too + REQUIRE_THROWS_AS(defaulted.set_preferred_providers("b", "B"), + std::invalid_argument); REQUIRE(defaulted.preferred_providers() == provider_list{"a"}); // Can't drop a provider that has recorded defaults @@ -143,9 +143,9 @@ TEST_CASE("Plugin") { std::invalid_argument); REQUIRE(defaulted.preferred_providers() == provider_list{"a"}); - // Keeping it, and adding others, is fine - defaulted.set_preferred_providers("b", "a"); - REQUIRE(defaulted.preferred_providers() == provider_list{"b", "a"}); + // Keeping it (in any case), and adding others, is fine + defaulted.set_preferred_providers("b", "A"); + REQUIRE(defaulted.preferred_providers() == provider_list{"b", "A"}); } SECTION("default_submod_from") { @@ -157,6 +157,13 @@ TEST_CASE("Plugin") { // Recording a default doesn't change the module REQUIRE_FALSE(defaulted.get_modules().has_submod("Prism", "area")); + + // Provider names are case-insensitive + defaulted.default_submod_from("A", "Prism", "area", "key 2"); + default_list corr{default_submod{"Prism", "area", "key"}, + default_submod{"Prism", "area", "key 2"}}; + REQUIRE(defaulted.default_submods_from("a") == corr); + REQUIRE(defaulted.default_submods_from("A") == corr); } SECTION("default_submod_from throws") { diff --git a/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp b/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp index f58e69a5b..54986a20b 100644 --- a/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp +++ b/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp @@ -15,9 +15,31 @@ */ #include "../catch.hpp" +#include "../examples/writing_a_module.hpp" #include +#include #include +#include #include +#include + +namespace { + +DECLARE_PLUGIN(DocumentedPlugin); + +PLUGIN_CTOR(DocumentedPlugin) { + set_name("documented plugin"); + add_module("Rectangle"); + add_module("Prism"); +} + +// Number of entries in the directory dir +auto n_entries(const std::filesystem::path& dir) { + return std::distance(std::filesystem::directory_iterator(dir), + std::filesystem::directory_iterator{}); +} + +} // namespace TEST_CASE("document_modules") { // Need a docs path @@ -50,4 +72,28 @@ TEST_CASE("document_modules") { std::filesystem::directory_iterator{}) == correct); } + + SECTION("Own modules and a plugin") { + mm.add_module("Own Rectangle"); + mm.load_plugin(); + + pluginplay::printing::document_modules(mm, docs_path); + + // index.rst, Own_Rectangle.rst, and the plugin's directory (the space + // in the plugin name is replaced) + const auto plugin_path = docs_path / "documented_plugin"; + REQUIRE(n_entries(docs_path) == 3); + REQUIRE(std::filesystem::exists(docs_path / "Own_Rectangle.rst")); + REQUIRE(std::filesystem::is_directory(plugin_path)); + REQUIRE(n_entries(plugin_path) == 2); + REQUIRE(std::filesystem::exists(plugin_path / "Rectangle.rst")); + REQUIRE(std::filesystem::exists(plugin_path / "Prism.rst")); + + // The index points into the plugin's directory + std::ifstream index(docs_path / "index.rst"); + std::stringstream contents; + contents << index.rdbuf(); + REQUIRE(contents.str().find("documented_plugin/Prism") != + std::string::npos); + } } diff --git a/tests/python/doc_snippets/export_pluginplay_examples.cpp b/tests/python/doc_snippets/export_pluginplay_examples.cpp index 4c40e12a6..c9346c97c 100644 --- a/tests/python/doc_snippets/export_pluginplay_examples.cpp +++ b/tests/python/doc_snippets/export_pluginplay_examples.cpp @@ -16,14 +16,14 @@ #include "../../cxx/doc_snippets/electric_field.hpp" #include "../../cxx/doc_snippets/force.hpp" -#include "../../cxx/doc_snippets/load_modules.hpp" +#include "../../cxx/doc_snippets/pluginplay_examples.hpp" #include "../../cxx/doc_snippets/point_charge.hpp" #include #include namespace pluginplay_examples { -EXPORT_PLUGIN(pluginplay_examples, m) { +EXPORT_PLUGIN(pluginplay_examples, PluginplayExamples, m) { pybind11::class_(m, "PointCharge") .def(pybind11::init<>()) .def(pybind11::init()) diff --git a/tests/python/doc_snippets/test_modules.py b/tests/python/doc_snippets/test_modules.py index 2410f8638..dac6c6f52 100644 --- a/tests/python/doc_snippets/test_modules.py +++ b/tests/python/doc_snippets/test_modules.py @@ -22,9 +22,14 @@ class TestTutorialModules(unittest.TestCase): def test_modules(self): mm = pp.ModuleManager() - ppe.load_modules(mm) + plugin = ppe.PluginplayExamples() + mm.load_plugin(plugin) self.assertEqual(mm.size(), 4) + # The ModuleManager took ownership of the C++ plugin + self.assertRaises(ValueError, plugin.name) + self.assertRaises(ValueError, pp.ModuleManager().load_plugin, plugin) + module_names = [ "Coulomb's Law", "Classical Force", diff --git a/tests/python/unit_tests/module_manager/test_module_manager_class.py b/tests/python/unit_tests/module_manager/test_module_manager_class.py index ab8470a6b..42b676dc3 100644 --- a/tests/python/unit_tests/module_manager/test_module_manager_class.py +++ b/tests/python/unit_tests/module_manager/test_module_manager_class.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import gc import unittest import parallelzone @@ -31,6 +32,37 @@ def run_(self, inputs, submods): return pt.wrap_results(rv, i0) # +class AConsumer(pp.ModuleBase): + def __init__(self): + pp.ModuleBase.__init__(self) + self.satisfies_property_type(test_pp.OneInOneOut()) + self.add_submodule(test_pp.OneInOneOut(), "sub") + + def run_(self, inputs, submods): + pt = test_pp.OneInOneOut() + (i0,) = pt.unwrap_inputs(inputs) + rv = submods["sub"].run_as(pt, i0) + return pt.wrap_results(self.results(), rv) + + +class ProviderPlugin(pp.Plugin): + def __init__(self): + pp.Plugin.__init__(self) + self.set_name("provider") + self.add_module("Provider", APythonModule()) + self.add_module("Shared", APythonModule()) + + +class ConsumerPlugin(pp.Plugin): + def __init__(self): + pp.Plugin.__init__(self) + self.set_name("consumer") + self.set_preferred_providers("provider") + self.add_module("Consumer", AConsumer()) + self.add_module("Shared", APythonModule()) + self.default_submod_from("provider", "Consumer", "sub", "Provider") + + class TestModuleManager(unittest.TestCase): def test_ctor(self): rv = parallelzone.runtime.RuntimeView() @@ -226,7 +258,72 @@ def test_get_runtime(self): def test_keys(self): self.assertEqual(len(self.defaulted.keys()), self.defaulted.size()) self.assertEqual(len(self.has_mods.keys()), self.has_mods.size()) - self.assertTrue("C++ Null PT" in self.has_mods.keys()) + key = pp.QualifiedKey("", "C++ Null PT") + self.assertTrue(key in self.has_mods.keys()) + self.assertFalse( + pp.QualifiedKey("C++ Null PT") in self.has_mods.keys() + ) + + def test_load_plugin(self): + mm = pp.ModuleManager() + self.assertFalse(mm.has_plugin("provider")) + + mm.load_plugin(ProviderPlugin()) + self.assertTrue(mm.has_plugin("provider")) + self.assertEqual(mm.plugin_names(), ["", "provider"]) + self.assertEqual(mm.get_plugin("provider").name(), "provider") + self.assertEqual(mm.get_modules("provider").count("Provider"), 1) + self.assertEqual(mm.size(), 2) + + # Can't load a plugin with the same name twice + self.assertRaises(ValueError, mm.load_plugin, ProviderPlugin()) + + # load_plugin takes ownership, so the plugin can't be loaded again. + # The Python part of a Python plugin stays alive (and readable) for as + # long as the ModuleManager owns the plugin. + plugin = ConsumerPlugin() + mm.load_plugin(plugin) + self.assertTrue(mm.has_plugin("consumer")) + self.assertEqual(plugin.name(), "consumer") + self.assertRaises(RuntimeError, pp.ModuleManager().load_plugin, plugin) + + def test_load_plugin_keeps_python_alive(self): + # Like test_pluginplay_309, but the Python modules are owned by a + # plugin that only the ModuleManager references + mm = pp.ModuleManager() + mm.load_plugin(ProviderPlugin()) + gc.collect() + rv = mm.run_as(test_pp.OneInOneOut(), "Provider", 1) + self.assertEqual(rv, 1) + + def test_qualified_keys(self): + mm = pp.ModuleManager() + mm.load_plugin(ProviderPlugin()) + mm.load_plugin(ConsumerPlugin()) + pt = test_pp.OneInOneOut() + + # Strings, tuples, and QualifiedKeys are accepted + self.assertEqual(mm.count("Shared"), 2) + self.assertEqual(mm.count(("provider", "Shared")), 1) + self.assertEqual(mm.count(pp.QualifiedKey("consumer", "Shared")), 1) + self.assertEqual(mm.run_as(pt, ("provider", "Shared"), 1), 1) + mm.change_submod("Consumer", "sub", ("consumer", "Shared")) + + # A key used by more than one plugin is ambiguous + self.assertRaises(ValueError, mm.at, "Shared") + self.assertRaises(IndexError, mm.at, ("provider", "Consumer")) + + def test_wire_defaults(self): + mm = pp.ModuleManager() + mm.load_plugin(ConsumerPlugin()) + mm.load_plugin(ProviderPlugin()) + submods = mm.at("Consumer").submods() + self.assertFalse(submods["sub"].has_module()) + + mm.wire_defaults() + sub = mm.at("Consumer").submods()["sub"].value() + self.assertEqual(sub, mm.at("Provider")) + self.assertEqual(mm.run_as(test_pp.OneInOneOut(), "Consumer", 2), 2) def setUp(self): self.defaulted = pp.defaulted_mm() diff --git a/tests/python/unit_tests/module_manager/test_module_map.py b/tests/python/unit_tests/module_manager/test_module_map.py index b22ed44ba..5b333706a 100644 --- a/tests/python/unit_tests/module_manager/test_module_map.py +++ b/tests/python/unit_tests/module_manager/test_module_map.py @@ -77,6 +77,30 @@ def test_keys(self): sorted(self.has_mods.keys()), ["Consumer", "Provider"] ) + def test_copy_module(self): + self.has_mods.copy_module("Provider", "Copy") + self.assertEqual(self.has_mods.at("Copy").get_name(), "Copy") + self.assertRaises( + IndexError, self.has_mods.copy_module, "not a key", "Copy 2" + ) + self.assertRaises( + ValueError, self.has_mods.copy_module, "Provider", "Consumer" + ) + + def test_erase(self): + self.has_mods.erase("Provider") + self.assertEqual(self.has_mods.count("Provider"), 0) + self.has_mods.erase("not a key") + self.assertEqual(self.has_mods.size(), 1) + + def test_change_input(self): + self.has_mods.change_input("Provider", "Option 1", 1) + inputs = self.has_mods.at("Provider").inputs() + self.assertEqual(inputs["Option 1"].value(), 1) + self.assertRaises( + IndexError, self.has_mods.change_input, "not a key", "Option 1", 1 + ) + def test_change_submod_by_key(self): self.assertFalse(self.has_mods.has_submod("Consumer", "sub")) self.has_mods.change_submod("Consumer", "sub", "Provider") diff --git a/tests/python/unit_tests/module_manager/test_qualified_key.py b/tests/python/unit_tests/module_manager/test_qualified_key.py new file mode 100644 index 000000000..eb3852f9d --- /dev/null +++ b/tests/python/unit_tests/module_manager/test_qualified_key.py @@ -0,0 +1,46 @@ +# Copyright 2026 NWChemEx-Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import pluginplay as pp + + +class TestQualifiedKey(unittest.TestCase): + def test_key_only(self): + key = pp.QualifiedKey("Overlap") + self.assertFalse(key.is_qualified()) + self.assertEqual(key.plugin(), "") + self.assertEqual(key.key(), "Overlap") + self.assertEqual(str(key), "Overlap") + + def test_qualified(self): + key = pp.QualifiedKey("integrals", "Overlap") + self.assertTrue(key.is_qualified()) + self.assertEqual(key.plugin(), "integrals") + self.assertEqual(key.key(), "Overlap") + self.assertEqual(str(key), "integrals::Overlap") + + def test_from_tuple(self): + key = pp.QualifiedKey(("integrals", "Overlap")) + self.assertEqual(key, pp.QualifiedKey("integrals", "Overlap")) + self.assertRaises(ValueError, pp.QualifiedKey, ("a", "b", "c")) + + def test_comparisons(self): + key = pp.QualifiedKey("integrals", "Overlap") + self.assertEqual(key, pp.QualifiedKey("integrals", "OVERLAP")) + self.assertEqual(key, pp.QualifiedKey("Integrals", "Overlap")) + self.assertNotEqual(key, pp.QualifiedKey("nux", "Overlap")) + self.assertNotEqual(key, pp.QualifiedKey("Overlap")) + self.assertNotEqual(pp.QualifiedKey("", "a"), pp.QualifiedKey("a")) From aa515633c42c41cc79fac8e78e55c1d39878bfc6 Mon Sep 17 00:00:00 2001 From: "Ryan M. Richard" Date: Thu, 15 Oct 2026 09:26:24 -0500 Subject: [PATCH 7/8] address copilot comments --- .../pluginplay/module/module_class.hpp | 8 +- .../module_manager/module_manager_class.hpp | 6 +- .../pluginplay/plugin/plugin_class.hpp | 25 +++++- .../pluginplay/export_submodule_request.cpp | 7 +- .../pluginplay/module/export_module_class.cpp | 10 ++- .../detail_/module_manager_pimpl.hpp | 1 + .../detail_/module_manager_pimpl.ipp | 27 +++++- .../export_module_manager_class.cpp | 29 ++++-- .../module_manager/export_module_map.cpp | 25 ++++-- .../module_manager/module_map_view.hpp | 88 +++++++++++++++++++ .../pluginplay/plugin/export_plugin_class.cpp | 15 +++- cxx/src/pluginplay/plugin/plugin_class.cpp | 22 ++++- cxx/src/pluginplay/plugin/plugin_view.hpp | 80 +++++++++++++++++ .../pluginplay/printing/document_modules.cpp | 10 +-- .../design/pluginplay_initialization.rst | 13 +++ docs/source/developer/design/plugins.rst | 11 +++ .../tutorials/plugins/writing_a_plugin.rst | 15 +++- .../module_manager/module_manager_class.cpp | 20 +++++ .../pluginplay/plugin/plugin_class.cpp | 18 +++- .../pluginplay/printing/document_modules.cpp | 5 +- tests/python/unit_tests/module/test_module.py | 5 ++ .../test_module_manager_class.py | 58 ++++++++++++ .../module_manager/test_module_map.py | 3 + 23 files changed, 455 insertions(+), 46 deletions(-) create mode 100644 cxx/src/pluginplay/module_manager/module_map_view.hpp create mode 100644 cxx/src/pluginplay/plugin/plugin_view.hpp diff --git a/cxx/include/pluginplay/module/module_class.hpp b/cxx/include/pluginplay/module/module_class.hpp index 44f133a8c..e321b857f 100644 --- a/cxx/include/pluginplay/module/module_class.hpp +++ b/cxx/include/pluginplay/module/module_class.hpp @@ -16,6 +16,7 @@ #pragma once #include "pluginplay/types.hpp" +#include #include #include #include @@ -49,8 +50,13 @@ struct ModuleManagerPIMPL; * and is needed by the pluginplay framework to correctly use the module. This * state is not accessible (nor relevant) once the module is running. For the * most part users will only be interested in algorithm state. + * + * Modules stored in a ModuleManager, ModuleMap, or SubmoduleRequest are owned + * by shared_ptrs. Module derives from enable_shared_from_this so the Python + * bindings can hand out shared ownership of those modules; a copy of a + * Module is still an independent module. */ -class Module { +class Module : public std::enable_shared_from_this { public: /// Type of a pointer to a PIMPL using pimpl_ptr = std::unique_ptr; diff --git a/cxx/include/pluginplay/module_manager/module_manager_class.hpp b/cxx/include/pluginplay/module_manager/module_manager_class.hpp index 74bfa8a9b..96bd6d2d6 100644 --- a/cxx/include/pluginplay/module_manager/module_manager_class.hpp +++ b/cxx/include/pluginplay/module_manager/module_manager_class.hpp @@ -134,7 +134,11 @@ class ModuleManager { * *this takes ownership of @p plugin and its modules. Nothing is copied: * the plugin's ModuleMap is the map *this uses for the plugin's modules. * Each module is given *this's runtime and caches. A plugin's modules use - * the module cache named `"::"`. + * the module cache named `"::"`. Modules that + * are reachable through submodules, but no longer in the plugin's map + * (*e.g.*, the plugin erased them after using them as submodules), are + * given the runtime and caches too, using the key they were last stored + * under. * * Loading does not set default submodules between plugins; call * wire_defaults after all plugins are loaded. diff --git a/cxx/include/pluginplay/plugin/plugin_class.hpp b/cxx/include/pluginplay/plugin/plugin_class.hpp index 59d2509f9..b16e86307 100644 --- a/cxx/include/pluginplay/plugin/plugin_class.hpp +++ b/cxx/include/pluginplay/plugin/plugin_class.hpp @@ -126,6 +126,22 @@ class Plugin { /// Negates operator== bool operator!=(const Plugin& rhs) const { return !(*this == rhs); } + /** @brief Determines if @p name can be used as a plugin name. + * + * Plugin names must be identifiers: a letter or an underscore, followed + * by letters, digits, and underscores, *e.g.*, "my_plugin". Plugin names + * are used as directory names (see printing::document_modules) and in + * module cache names, so they can not contain spaces, path separators, + * or "::". + * + * @param[in] name The name to check. + * + * @return True if @p name is a valid plugin name and false otherwise. + * + * @throw None No throw guarantee. + */ + static bool is_valid_name(const name_type& name) noexcept; + protected: /// Plugins are only created through derived classes Plugin() = default; @@ -135,9 +151,11 @@ class Plugin { * Like module keys, plugin names are case-insensitive. The name is stored * as given. * - * @param[in] name The name of the plugin. + * @param[in] name The name of the plugin. Must be an identifier (see + * is_valid_name). * - * @throw std::invalid_argument if @p name is empty. The empty name is + * @throw std::invalid_argument if @p name is not a valid plugin name. + * This includes the empty name, which is * reserved for the ModuleManager's own * modules. Strong throw guarantee. */ @@ -189,7 +207,8 @@ class Plugin { * * @param[in] names The names of the providers, highest priority first. * - * @throw std::invalid_argument if a name is empty, if a name appears more + * @throw std::invalid_argument if a name is not a valid plugin name (see + * is_valid_name), if a name appears more * than once, or if a provider that already * has recorded defaults is not in @p names. * Strong throw guarantee. diff --git a/cxx/src/pluginplay/export_submodule_request.cpp b/cxx/src/pluginplay/export_submodule_request.cpp index 544ed1e3b..5906165e1 100644 --- a/cxx/src/pluginplay/export_submodule_request.cpp +++ b/cxx/src/pluginplay/export_submodule_request.cpp @@ -39,8 +39,6 @@ auto py_sr_run_as(py::object sr, py::object pt, py::args args) { } void export_submodule_request(py_module_reference m) { - using value_fxn = Module& (SubmoduleRequest::*)(); - py_class_type(m, "SubmoduleRequest") .def(py::init<>()) .def("has_type", &SubmoduleRequest::has_type) @@ -53,7 +51,10 @@ void export_submodule_request(py_module_reference m) { .def("set_description", &SubmoduleRequest::set_description) .def("submod_uuids", &SubmoduleRequest::submod_uuids) .def("uuid", &SubmoduleRequest::uuid) - .def("value", static_cast(&SubmoduleRequest::value)) + // Returns the submodule itself, with shared ownership, not a copy + .def( + "value", + [](SubmoduleRequest& self) { return self.value().shared_from_this(); }) .def("description", &SubmoduleRequest::description) .def("get_name", [](SubmoduleRequest& self) { diff --git a/cxx/src/pluginplay/module/export_module_class.cpp b/cxx/src/pluginplay/module/export_module_class.cpp index 99fa3afa2..5ff4fb078 100644 --- a/cxx/src/pluginplay/module/export_module_class.cpp +++ b/cxx/src/pluginplay/module/export_module_class.cpp @@ -47,10 +47,14 @@ pybind11::object py_module_run_as_python_only( } void export_module_class(py_module_reference m) { - using ready_fxn = bool (Module::*)(const type::input_map&) const; - using change_submod_fxn = void (Module::*)(type::key, Module); + using ready_fxn = bool (Module::*)(const type::input_map&) const; + // Links the submodule (shares it) rather than copying it, as in C++ + using change_submod_fxn = + void (Module::*)(type::key, std::shared_ptr); - py_class_type(m, "Module") + // Python handles share ownership of modules, so a handle stays valid if + // the module is erased from its map or replaced as a submodule + py_class_type>(m, "Module") .def(pybind11::init<>()) .def("unlocked_copy", &Module::unlocked_copy) .def("has_module", &Module::has_module) diff --git a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp index bad8afeeb..478fd42ac 100644 --- a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp +++ b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include diff --git a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp index 9bd1c31ac..37603c71e 100644 --- a/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp +++ b/cxx/src/pluginplay/module_manager/detail_/module_manager_pimpl.ipp @@ -98,8 +98,33 @@ inline void ModuleManagerPIMPL::load_plugin(plugin_pointer plugin) { throw std::invalid_argument("A plugin named '" + name + "' is already loaded"); - for(auto& [key, mod] : plugin->m_modules_) + // Attach the plugin's modules, then every module reachable from them + // through submodules. A plugin may set a submodule and then erase that + // module's key; the module is still used, so it needs the runtime and + // caches too. + std::set visited; + std::vector to_visit; + for(auto& [key, mod] : plugin->m_modules_) { attach_(qualified_key(name, key).to_string(), *mod); + visited.insert(mod.get()); + to_visit.push_back(mod.get()); + } + while(!to_visit.empty()) { + auto* mod = to_visit.back(); + to_visit.pop_back(); + for(auto& [callback, request] : mod->m_pimpl_->submods()) { + if(!request.has_module()) continue; + auto& submod = request.value(); + if(!visited.insert(&submod).second) continue; + + // The module is no longer in the plugin's map, so its cache is + // named after the key it was last stored under + const auto& last_key = + submod.has_name() ? submod.get_name() : callback; + attach_(qualified_key(name, last_key).to_string(), submod); + to_visit.push_back(&submod); + } + } m_plugins.push_back(std::move(plugin)); } diff --git a/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp b/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp index 6cce5116b..14e4a85a0 100644 --- a/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp +++ b/cxx/src/pluginplay/module_manager/export_module_manager_class.cpp @@ -15,6 +15,7 @@ */ #include "../module/py_module_base.hpp" +#include "../plugin/plugin_view.hpp" #include "export_module_manager.hpp" #include #include @@ -25,7 +26,6 @@ namespace pluginplay { void export_module_manager_class(py_module_reference m) { using qualified_key = typename ModuleManager::qualified_key; - using at_fxn = Module& (ModuleManager::*)(const qualified_key&); using plugin_ptr = typename ModuleManager::plugin_pointer; using load_fxn = void (ModuleManager::*)(plugin_ptr); using runtime_type = typename ModuleManager::runtime_type; @@ -60,17 +60,30 @@ void export_module_manager_class(py_module_reference m) { .def("wire_defaults", &ModuleManager::wire_defaults) .def("has_plugin", &ModuleManager::has_plugin) .def("plugin_names", &ModuleManager::plugin_names) - .def("get_plugin", &ModuleManager::get_plugin, - py::return_value_policy::reference_internal) - .def("get_modules", &ModuleManager::get_modules, - py::return_value_policy::reference_internal) + // Python has no const, so read-only views are returned instead of the + // plugin and map themselves + .def( + "get_plugin", + [](const ModuleManager& self, const std::string& name) { + return PluginView(self.get_plugin(name)); + }, + py::keep_alive<0, 1>()) + .def( + "get_modules", + [](const ModuleManager& self, const std::string& name) { + return ModuleMapView(self.get_modules(name)); + }, + py::keep_alive<0, 1>()) .def( "add_module", [](ModuleManager& mm, std::string key, std::shared_ptr p) { mm.add_module(key, p); }, py::keep_alive<1, 3>()) - .def("at", static_cast(&ModuleManager::at), - py::return_value_policy::reference_internal) + // Modules are returned with shared ownership (see the Module bindings) + .def("at", + [](ModuleManager& self, const qualified_key& key) { + return self.at(key).shared_from_this(); + }) .def("copy_module", &ModuleManager::copy_module) .def("erase", &ModuleManager::erase) .def("rename_module", &ModuleManager::rename_module) @@ -94,7 +107,7 @@ void export_module_manager_class(py_module_reference m) { .def("keys", &ModuleManager::keys) .def("has_cache", &ModuleManager::has_cache) .def("__getitem__", [](ModuleManager& self, const qualified_key& key) { - return self.at(key); + return self.at(key).shared_from_this(); }); m.def("defaulted_mm", []() { return ModuleManager(nullptr); }); } diff --git a/cxx/src/pluginplay/module_manager/export_module_map.cpp b/cxx/src/pluginplay/module_manager/export_module_map.cpp index 64d8aac18..5a52c612b 100644 --- a/cxx/src/pluginplay/module_manager/export_module_map.cpp +++ b/cxx/src/pluginplay/module_manager/export_module_map.cpp @@ -16,6 +16,7 @@ #include "../module/py_module_base.hpp" #include "export_module_manager.hpp" +#include "module_map_view.hpp" #include #include #include @@ -25,14 +26,25 @@ namespace pluginplay { void export_module_map(py_module_reference m) { using key_type = ModuleMap::key_type; - // Module is bound with the default (unique_ptr) holder, so Python can't - // share ownership of a Module. Hence, only the key-based change_submod and - // default_submod overloads are exposed, and at returns a reference. + // Only the key-based change_submod and default_submod overloads are + // exposed. at returns the module with shared ownership. using change_by_key = void (ModuleMap::*)(const key_type&, const key_type&, const key_type&); using default_by_key = bool (ModuleMap::*)(const key_type&, const key_type&, const key_type&); + py_class_type(m, "ModuleMapView") + .def("count", &ModuleMapView::count) + .def("__contains__", &ModuleMapView::count) + .def("size", &ModuleMapView::size) + .def("__len__", &ModuleMapView::size) + .def("empty", &ModuleMapView::empty) + .def("keys", &ModuleMapView::keys) + .def("at", &ModuleMapView::at) + .def("has_submod", &ModuleMapView::has_submod) + .def(py::self == py::self) + .def(py::self != py::self); + py_class_type(m, "ModuleMap") .def(py::init<>()) .def("count", &ModuleMap::count) @@ -40,12 +52,7 @@ void export_module_map(py_module_reference m) { .def("__len__", &ModuleMap::size) .def("empty", &ModuleMap::empty) .def("keys", &ModuleMap::keys) - .def( - "at", - [](ModuleMap& self, const key_type& key) -> Module& { - return *self.at(key); - }, - py::return_value_policy::reference_internal) + .def("at", &ModuleMap::at) .def( "add_module", [](ModuleMap& self, key_type key, std::shared_ptr p) { diff --git a/cxx/src/pluginplay/module_manager/module_map_view.hpp b/cxx/src/pluginplay/module_manager/module_map_view.hpp new file mode 100644 index 000000000..48f8d154d --- /dev/null +++ b/cxx/src/pluginplay/module_manager/module_map_view.hpp @@ -0,0 +1,88 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include + +namespace pluginplay { + +/** @brief A read-only view of a ModuleMap, for the Python bindings. + * + * C++ can hand out a `const ModuleMap&`, but Python has no const, so a + * ModuleMap returned to Python could be changed through ModuleMap's + * bindings, *e.g.*, have modules added that never receive the + * ModuleManager's runtime and caches. Functions that return a read-only + * ModuleMap to Python return a ModuleMapView instead, which only exposes the + * read-only members. + * + * The view does not own the map; the bindings keep the map's owner alive for + * as long as the view exists. The modules themselves can still be changed + * (*e.g.*, their inputs), just as with ModuleManager.at, and at returns them + * with shared ownership. + */ +class ModuleMapView { +public: + /// Type used for module keys + using key_type = ModuleMap::key_type; + + /// Type of a container of module keys + using key_container_type = ModuleMap::key_container_type; + + /// Creates a view of @p map. No throw guarantee. + explicit ModuleMapView(const ModuleMap& map) noexcept : m_map_(&map) {} + + /// The map *this views. No throw guarantee. + const ModuleMap& map() const noexcept { return *m_map_; } + + /// Forwards to ModuleMap::count + type::size count(const key_type& key) const noexcept { + return m_map_->count(key); + } + + /// Forwards to ModuleMap::size + type::size size() const noexcept { return m_map_->size(); } + + /// Forwards to ModuleMap::empty + bool empty() const noexcept { return m_map_->empty(); } + + /// Forwards to ModuleMap::keys + key_container_type keys() const { return m_map_->keys(); } + + /// Forwards to ModuleMap::at + ModuleMap::module_pointer at(const key_type& key) const { + return m_map_->at(key); + } + + /// Forwards to ModuleMap::has_submod + bool has_submod(const key_type& module_key, + const key_type& callback_key) const { + return m_map_->has_submod(module_key, callback_key); + } + + /// True if the viewed maps are equal + bool operator==(const ModuleMapView& rhs) const { + return *m_map_ == *rhs.m_map_; + } + + /// Negates operator== + bool operator!=(const ModuleMapView& rhs) const { return !(*this == rhs); } + +private: + /// The map *this views + const ModuleMap* m_map_; +}; + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/plugin/export_plugin_class.cpp b/cxx/src/pluginplay/plugin/export_plugin_class.cpp index a93089e7d..a273de916 100644 --- a/cxx/src/pluginplay/plugin/export_plugin_class.cpp +++ b/cxx/src/pluginplay/plugin/export_plugin_class.cpp @@ -16,6 +16,7 @@ #include "../module/py_module_base.hpp" #include "export_plugin.hpp" +#include "plugin_view.hpp" #include "py_plugin.hpp" namespace pluginplay { @@ -37,6 +38,14 @@ void export_plugin_class(py_module_reference m) { static_cast(&PluginPublicist::set_preferred_providers); auto modules = static_cast(&PluginPublicist::modules); + py_class_type(m, "PluginView") + .def("name", &PluginView::name) + .def("preferred_providers", &PluginView::preferred_providers) + .def("default_submods_from", &PluginView::default_submods_from) + .def("get_modules", &PluginView::get_modules, py::keep_alive<0, 1>()) + .def(py::self == py::self) + .def(py::self != py::self); + auto py_plugin = py_class_type(m, "Plugin"); @@ -51,8 +60,10 @@ void export_plugin_class(py_module_reference m) { .def("name", &Plugin::name) .def("preferred_providers", &Plugin::preferred_providers) .def("default_submods_from", &Plugin::default_submods_from) - .def("get_modules", &Plugin::get_modules, - py::return_value_policy::reference_internal) + .def( + "get_modules", + [](const Plugin& self) { return ModuleMapView(self.get_modules()); }, + py::keep_alive<0, 1>()) .def("set_name", &PluginPublicist::set_name) .def( "add_module", diff --git a/cxx/src/pluginplay/plugin/plugin_class.cpp b/cxx/src/pluginplay/plugin/plugin_class.cpp index 3ad7c0388..62d9aafda 100644 --- a/cxx/src/pluginplay/plugin/plugin_class.cpp +++ b/cxx/src/pluginplay/plugin/plugin_class.cpp @@ -15,6 +15,7 @@ */ #include +#include #include #include #include @@ -36,11 +37,25 @@ bool Plugin::operator==(const Plugin& rhs) const { rhs.m_modules_); } +bool Plugin::is_valid_name(const name_type& name) noexcept { + if(name.empty()) return false; + const auto first = static_cast(name.front()); + if(!std::isalpha(first) && first != '_') return false; + return std::all_of(name.begin(), name.end(), [](unsigned char c) { + return std::isalnum(c) || c == '_'; + }); +} + void Plugin::set_name(name_type name) { if(name.empty()) throw std::invalid_argument( "Plugin names can not be empty. The empty name is reserved for the " "ModuleManager's own modules."); + if(!is_valid_name(name)) + throw std::invalid_argument( + "'" + name + + "' is not a valid plugin name. Plugin names must be identifiers: " + "letters, digits, and underscores, not starting with a digit."); m_name_ = std::move(name); } @@ -49,8 +64,11 @@ void Plugin::set_preferred_providers(provider_list_type names) { using name_less = utilities::CaseInsensitiveMap::key_compare; std::set seen; for(const auto& name : names) { - if(name.empty()) - throw std::invalid_argument("Provider names can not be empty"); + if(!is_valid_name(name)) + throw std::invalid_argument( + "'" + name + + "' is not a valid plugin name, so it can not be a provider. " + "Plugin names must be identifiers."); if(!seen.insert(name).second) throw std::invalid_argument("Provider '" + name + "' is listed more than once"); diff --git a/cxx/src/pluginplay/plugin/plugin_view.hpp b/cxx/src/pluginplay/plugin/plugin_view.hpp new file mode 100644 index 000000000..8fb89824d --- /dev/null +++ b/cxx/src/pluginplay/plugin/plugin_view.hpp @@ -0,0 +1,80 @@ +/* + * Copyright 2026 NWChemEx-Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once +#include "../module_manager/module_map_view.hpp" +#include + +namespace pluginplay { + +/** @brief A read-only view of a Plugin, for the Python bindings. + * + * ModuleManager::get_plugin returns a `const Plugin&`. Python has no const, + * and the Plugin bindings include the setters a plugin written in Python + * uses in its constructor, so a loaded plugin returned to Python as a + * Plugin could be changed. ModuleManager.get_plugin returns a PluginView + * instead, which only exposes Plugin's read-only members. + * + * The view does not own the plugin; the bindings keep the plugin's owner + * alive for as long as the view exists. + */ +class PluginView { +public: + /// Type used for plugin names + using name_type = Plugin::name_type; + + /// Type of a list of plugin names + using provider_list_type = Plugin::provider_list_type; + + /// Type of a list of recorded default submodules + using default_list_type = Plugin::default_list_type; + + /// Creates a view of @p plugin. No throw guarantee. + explicit PluginView(const Plugin& plugin) noexcept : m_plugin_(&plugin) {} + + /// Forwards to Plugin::name + const name_type& name() const noexcept { return m_plugin_->name(); } + + /// Forwards to Plugin::preferred_providers + const provider_list_type& preferred_providers() const noexcept { + return m_plugin_->preferred_providers(); + } + + /// Forwards to Plugin::default_submods_from + const default_list_type& default_submods_from( + const name_type& provider) const noexcept { + return m_plugin_->default_submods_from(provider); + } + + /// A read-only view of the plugin's modules + ModuleMapView get_modules() const noexcept { + return ModuleMapView(m_plugin_->get_modules()); + } + + /// True if the viewed plugins are equal + bool operator==(const PluginView& rhs) const { + return *m_plugin_ == *rhs.m_plugin_; + } + + /// Negates operator== + bool operator!=(const PluginView& rhs) const { return !(*this == rhs); } + +private: + /// The plugin *this views + const Plugin* m_plugin_; +}; + +} // namespace pluginplay diff --git a/cxx/src/pluginplay/printing/document_modules.cpp b/cxx/src/pluginplay/printing/document_modules.cpp index 49e04ad01..d2fbe23d3 100644 --- a/cxx/src/pluginplay/printing/document_modules.cpp +++ b/cxx/src/pluginplay/printing/document_modules.cpp @@ -34,16 +34,16 @@ void document_modules(pluginplay::ModuleManager& mm, // Loop over the maps, then the key-value pairs of module names and module // objects for docs. The ModuleManager's own modules go in docs_path; each - // plugin's modules go in a subdirectory named after the plugin. + // plugin's modules go in a subdirectory named after the plugin. Plugin + // names are identifiers (see Plugin::is_valid_name), so they are already + // safe, distinct directory names. for(const auto& plugin : mm.plugin_names()) { std::filesystem::path dir = docs_path; std::string toc_prefix; if(!plugin.empty()) { - const auto clean_plugin = - replace_invalid_chars(plugin, INVALID_CHARS, REPLACEMENT_CHAR); - dir /= clean_plugin; + dir /= plugin; std::filesystem::create_directories(dir); - toc_prefix = clean_plugin + "/"; + toc_prefix = plugin + "/"; } for(const auto& [name, module] : mm.get_modules(plugin)) { diff --git a/docs/source/developer/design/pluginplay_initialization.rst b/docs/source/developer/design/pluginplay_initialization.rst index 691e41ca5..0ad619523 100644 --- a/docs/source/developer/design/pluginplay_initialization.rst +++ b/docs/source/developer/design/pluginplay_initialization.rst @@ -187,6 +187,19 @@ Two plugins may use the same module key, so a plugin's module uses the cache named ``"::"``. Modules in the ``ModuleManager``'s own map use the module key, as before. +This naming is not collision-free: a module in the ``ModuleManager``'s own +map with the key ``"my_plugin::Area"`` shares a cache with the module +``"Area"`` of the plugin ``"my_plugin"``. Since plugin names must be +identifiers, plugin names can not collide this way, only module keys +containing ``"::"`` can. Rather than change the cache names of the +``ModuleManager``'s own modules, the user documentation asks developers to +avoid ``"::"`` in module keys. + +A plugin may set a submodule and then erase that module's key. The module is +still used, so ``load_plugin`` also gives every module reachable through +submodules the runtime and caches, naming its cache after the key it was last +stored under. + Loading Plugins =============== diff --git a/docs/source/developer/design/plugins.rst b/docs/source/developer/design/plugins.rst index d0c77e116..8e9af072c 100644 --- a/docs/source/developer/design/plugins.rst +++ b/docs/source/developer/design/plugins.rst @@ -74,6 +74,10 @@ Plugin Considerations #. Identity - Each plugin has a name, *e.g.*, ``"my_plugin"``. + - Plugin names must be identifiers (letters, digits, and underscores, not + starting with a digit). Plugin names are used as directory names when + documenting modules and in the names of module caches, so they can not + contain spaces, path separators, ``"."``, or ``"::"``. - Plugin names must be unique within a ``ModuleManager``. - Like module keys, plugin names are case-insensitive, *e.g.*, ``"my_plugin"`` and ``"My_Plugin"`` are the same name. @@ -391,6 +395,13 @@ Python support follows the pattern used for modules: } - ``ModuleMap`` is exported so Python plugins can use ``default_submod`` and related functions. +- Python has no ``const``. Functions that return a read-only plugin or map in + C++ (``ModuleManager::get_plugin``, ``ModuleManager::get_modules``, and + ``Plugin::get_modules``) return a ``PluginView`` or ``ModuleMapView`` in + Python instead. The views only expose the read-only members, so a loaded + plugin can not be changed through them, *e.g.*, by adding a module that + never receives the ``ModuleManager``'s runtime and caches. The modules + themselves can still be changed, as with ``ModuleManager.at``. ********************** Further Considerations diff --git a/docs/source/tutorials/plugins/writing_a_plugin.rst b/docs/source/tutorials/plugins/writing_a_plugin.rst index e90114401..68330715d 100644 --- a/docs/source/tutorials/plugins/writing_a_plugin.rst +++ b/docs/source/tutorials/plugins/writing_a_plugin.rst @@ -63,8 +63,9 @@ typically does three things: names the plugin, adds the plugin's modules, and sets the default submodules. Every plugin has a name, set by calling ``set_name``. Plugin names must be -unique within a ``ModuleManager``. Like module keys, plugin names are -case-insensitive. +identifiers: letters, digits, and underscores, not starting with a digit, +*e.g.*, ``"pluginplay_examples"``. Plugin names must be unique within a +``ModuleManager``. Like module keys, plugin names are case-insensitive. Modules are added to the plugin by calling ``add_module``. For your convenience ``add_module`` has an overload which takes a template type @@ -75,6 +76,16 @@ refer to your module, *e.g.*, when they want to run your module they will call unique within your plugin. If your module requires more detailed set-up other overloads exist that will bind to existing instances. +.. note:: + + Avoid using ``"::"`` in module keys. PluginPlay names the caches of a + plugin's modules ``"::"``, and caches of modules + added directly to the ``ModuleManager`` after the module key alone. A + module key containing ``"::"`` can therefore end up sharing a cache with a + different module, *e.g.*, a module added to the ``ModuleManager`` under + ``"my_plugin::Area"`` and the module ``"Area"`` of the plugin + ``"my_plugin"``. + Continuing with our electric field examples, naming our plugin and adding our modules looks like: diff --git a/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp b/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp index 7acb64bd4..b7f392174 100644 --- a/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp +++ b/tests/cxx/unit_tests/pluginplay/module_manager/module_manager_class.cpp @@ -77,6 +77,16 @@ PLUGIN_CTOR(Consumer) { PLUGIN_CTOR(Unnamed) {} +// Uses a module as a submodule, then erases that module's key +DECLARE_PLUGIN(ErasesSubmod); +PLUGIN_CTOR(ErasesSubmod) { + set_name("erases_submod"); + add_module("Rectangle"); + add_module("Prism"); + change_submod("Prism", "area", "Rectangle"); + modules().erase("Rectangle"); +} + // Same name as ProviderA, in a different case DECLARE_PLUGIN(ShoutingProviderA); PLUGIN_CTOR(ShoutingProviderA) { set_name("PROVIDER_A"); } @@ -209,6 +219,16 @@ TEST_CASE("ModuleManager : plugins") { std::invalid_argument); } + SECTION("Erased submodules are given the runtime and caches") { + mm.load_plugin(); + REQUIRE(mm.count("Rectangle") == 0); + + // Prism is only memoizable if its submodule has a UUID, which it only + // gets by being attached + REQUIRE(mm.at("Prism").is_memoizable()); + REQUIRE(mm.at("Prism").submods().at("area").value().is_memoizable()); + } + SECTION("Can't load unnamed or null plugins") { REQUIRE_THROWS_AS(mm.load_plugin(), std::invalid_argument); REQUIRE_THROWS_AS(mm.load_plugin(nullptr), std::invalid_argument); diff --git a/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp b/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp index da26ae6bf..7c2a274c5 100644 --- a/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp +++ b/tests/cxx/unit_tests/pluginplay/plugin/plugin_class.cpp @@ -88,10 +88,20 @@ TEST_CASE("Plugin") { } SECTION("set_name") { - defaulted.set_name("a name"); - REQUIRE(defaulted.name() == "a name"); + defaulted.set_name("a_name"); + REQUIRE(defaulted.name() == "a_name"); REQUIRE_THROWS_AS(defaulted.set_name(""), std::invalid_argument); - REQUIRE(defaulted.name() == "a name"); + // Names must be identifiers + for(const auto* bad : {"a name", "..", "a::b", "1abc", "a/b", "a-b"}) + REQUIRE_THROWS_AS(defaulted.set_name(bad), std::invalid_argument); + REQUIRE(defaulted.name() == "a_name"); + } + + SECTION("is_valid_name") { + for(const auto* good : {"a", "_a", "my_plugin", "Plugin2", "SCF"}) + REQUIRE(Plugin::is_valid_name(good)); + for(const auto* bad : {"", ".", "..", "a name", "a::b", "1abc", "a-b"}) + REQUIRE_FALSE(Plugin::is_valid_name(bad)); } SECTION("add_module") { @@ -129,6 +139,8 @@ TEST_CASE("Plugin") { defaulted.set_preferred_providers("a"); REQUIRE_THROWS_AS(defaulted.set_preferred_providers(""), std::invalid_argument); + REQUIRE_THROWS_AS(defaulted.set_preferred_providers("not valid"), + std::invalid_argument); REQUIRE_THROWS_AS(defaulted.set_preferred_providers("b", "b"), std::invalid_argument); // Names are case-insensitive, so this is a duplicate too diff --git a/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp b/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp index 54986a20b..f99c90d96 100644 --- a/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp +++ b/tests/cxx/unit_tests/pluginplay/printing/document_modules.cpp @@ -28,7 +28,7 @@ namespace { DECLARE_PLUGIN(DocumentedPlugin); PLUGIN_CTOR(DocumentedPlugin) { - set_name("documented plugin"); + set_name("documented_plugin"); add_module("Rectangle"); add_module("Prism"); } @@ -79,8 +79,7 @@ TEST_CASE("document_modules") { pluginplay::printing::document_modules(mm, docs_path); - // index.rst, Own_Rectangle.rst, and the plugin's directory (the space - // in the plugin name is replaced) + // index.rst, Own_Rectangle.rst, and the plugin's directory const auto plugin_path = docs_path / "documented_plugin"; REQUIRE(n_entries(docs_path) == 3); REQUIRE(std::filesystem::exists(docs_path / "Own_Rectangle.rst")); diff --git a/tests/python/unit_tests/module/test_module.py b/tests/python/unit_tests/module/test_module.py index 273d0b69e..d938dc29d 100644 --- a/tests/python/unit_tests/module/test_module.py +++ b/tests/python/unit_tests/module/test_module.py @@ -285,6 +285,11 @@ def test_change_submod(self): has_submod.change_submod(key, submod) self.assertEqual(has_submod.submods()[key].value(), submod) + # The submodule is linked, not copied + self.assertIs(has_submod.submods()[key].value(), submod) + submod.turn_off_memoization() + self.assertFalse(has_submod.submods()[key].value().is_memoizable()) + def test_run_as(self): # Can't run a module with no implementation self.assertRaises(Exception, self.defaulted.run_as, test_pp.NullPT()) diff --git a/tests/python/unit_tests/module_manager/test_module_manager_class.py b/tests/python/unit_tests/module_manager/test_module_manager_class.py index 42b676dc3..00d4cf86b 100644 --- a/tests/python/unit_tests/module_manager/test_module_manager_class.py +++ b/tests/python/unit_tests/module_manager/test_module_manager_class.py @@ -247,6 +247,31 @@ def test_get_item(self): self.assertTrue(module_with_description.has_description()) self.assertNotEqual(self.has_mods["C++ no PT"], None) + def test_module_handles_share_ownership(self): + key = "C++ no PT" + + # Repeated lookups give the same object, which is the live module + self.assertIs(self.has_mods.at(key), self.has_mods.at(key)) + self.has_mods[key].turn_off_memoization() + self.assertFalse(self.has_mods.at(key).is_memoizable()) + + # A handle stays valid after the module is erased + mod = self.has_mods.at(key) + self.has_mods.erase(key) + gc.collect() + self.assertFalse(mod.is_memoizable()) + self.assertTrue(mod.has_module()) + + def test_submodule_value_is_the_submodule(self): + mod_key = "C++ module needing a submodule" + self.has_mods.change_submod(mod_key, "Submodule 1", "C++ Null PT") + submod = self.has_mods.at(mod_key).submods()["Submodule 1"].value() + + # Changes made through value() reach the actual submodule + self.assertIs(submod, self.has_mods.at("C++ Null PT")) + submod.turn_off_memoization() + self.assertFalse(self.has_mods.at("C++ Null PT").is_memoizable()) + def test_set_runtime(self): new_rv = parallelzone.runtime.RuntimeView() self.defaulted.set_runtime(new_rv) @@ -287,6 +312,39 @@ def test_load_plugin(self): self.assertEqual(plugin.name(), "consumer") self.assertRaises(RuntimeError, pp.ModuleManager().load_plugin, plugin) + def test_read_only_views(self): + mm = pp.ModuleManager() + mm.load_plugin(ProviderPlugin()) + + # get_modules returns a read-only view of the live map + modules = mm.get_modules("provider") + self.assertIsInstance(modules, pp.ModuleMapView) + self.assertEqual(modules.size(), 2) + self.assertTrue("Provider" in modules) + self.assertEqual(sorted(modules.keys()), ["Provider", "Shared"]) + for name in ("add_module", "copy_module", "erase", "change_submod"): + self.assertFalse(hasattr(modules, name)) + + # The view's modules are the live modules + modules.at("Provider").turn_off_memoization() + self.assertFalse(mm.at("Provider").is_memoizable()) + + # get_plugin returns a read-only view of the loaded plugin + plugin = mm.get_plugin("provider") + self.assertIsInstance(plugin, pp.PluginView) + self.assertEqual(plugin.name(), "provider") + self.assertEqual(plugin.get_modules().size(), 2) + for name in ("set_name", "add_module", "modules", "change_submod"): + self.assertFalse(hasattr(plugin, name)) + + def test_views_keep_owner_alive(self): + mm = pp.ModuleManager() + mm.load_plugin(ProviderPlugin()) + modules = mm.get_plugin("provider").get_modules() + del mm + gc.collect() + self.assertEqual(modules.size(), 2) + def test_load_plugin_keeps_python_alive(self): # Like test_pluginplay_309, but the Python modules are owned by a # plugin that only the ModuleManager references diff --git a/tests/python/unit_tests/module_manager/test_module_map.py b/tests/python/unit_tests/module_manager/test_module_map.py index 5b333706a..55bbb6d8b 100644 --- a/tests/python/unit_tests/module_manager/test_module_map.py +++ b/tests/python/unit_tests/module_manager/test_module_map.py @@ -88,7 +88,10 @@ def test_copy_module(self): ) def test_erase(self): + # Handles share ownership, so they stay valid after the erase + mod = self.has_mods.at("Provider") self.has_mods.erase("Provider") + self.assertEqual(mod.get_name(), "Provider") self.assertEqual(self.has_mods.count("Provider"), 0) self.has_mods.erase("not a key") self.assertEqual(self.has_mods.size(), 1) From b0dc2c10c378210dbd96e9958e4c772308289414 Mon Sep 17 00:00:00 2001 From: "Ryan M. Richard" Date: Thu, 15 Oct 2026 11:20:31 -0500 Subject: [PATCH 8/8] bump tag --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f64492a53..9836eadc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ requires = [ # [project].dependencies; leave the floors unbounded so the newest # release is always resolved. "nwchemex-nwxcmake>=0.1.0", - "nwchemex-utilities>=0.1.45", + "nwchemex-utilities>=0.1.49", "nwchemex-parallelzone>=0.1.43", ] build-backend = "scikit_build_core.build" @@ -39,7 +39,7 @@ dependencies = [ # registries and breaks cross-module type exchange. "pybind11==3.1.0", "nwchemex-nwxcmake>=0.1.0", - "nwchemex-utilities>=0.1.45", + "nwchemex-utilities>=0.1.49", "nwchemex-parallelzone>=0.1.43", ]