From 10e4b95d89731962dd9210dd3d7bcd9e391b6874 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 4 Aug 2026 10:34:44 -0600 Subject: [PATCH 1/7] fix(python): load dynamic plugin specs from TOML Signed-off-by: Bryan Bednarski --- docs/about-nemo-relay/release-notes/index.mdx | 6 + .../plugin-configuration-files.mdx | 24 +++ python/nemo_relay/plugin.py | 113 ++++++++++++- python/nemo_relay/plugin.pyi | 8 +- python/tests/test_dynamic_plugin_host.py | 156 +++++++++++++++++- 5 files changed, 303 insertions(+), 4 deletions(-) diff --git a/docs/about-nemo-relay/release-notes/index.mdx b/docs/about-nemo-relay/release-notes/index.mdx index 408d20151..6d1ebef2a 100644 --- a/docs/about-nemo-relay/release-notes/index.mdx +++ b/docs/about-nemo-relay/release-notes/index.mdx @@ -98,6 +98,12 @@ their values cannot be isolated between endpoints. ### Fixed Known Issues in 0.7 +- Embedded Python hosts can use + `plugin.load_dynamic_plugin_activation_specs(path)` to convert the standard + `[[plugins.dynamic]]` records in one explicitly selected `plugins.toml` into + the activation specs accepted by `initialize_with_dynamic_plugins()`. This + scoped 0.7 compatibility helper removes host-side TOML and manifest parsing; + a future unified file-backed initializer is expected to replace it. - Programmatically declared plugin components now apply their `enabled` value over discovered file configuration. When code re-enables a component that a discovered file disabled, initialization reports a warning that names the diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index e5a486c9f..b9de59171 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -163,6 +163,30 @@ manifest’s optional static JSON Schema before you enable or run the plugin. Us dynamic-plugin lifecycle. Refer to [Configure Discoverable Plugins](/configure-plugins/discoverable-plugins) for manifest, trust, and policy requirements. +### Embedded Python Compatibility Helper + +Python hosts that already own plugin activation can convert the dynamic records +from one explicitly selected file into the activation specs accepted by the +0.7 host API: + +```python +from nemo_relay import plugin + +dynamic_plugins = plugin.load_dynamic_plugin_activation_specs("path/to/plugins.toml") +activation = await plugin.initialize_with_dynamic_plugins({}, dynamic_plugins) +``` + +The helper resolves each manifest relative to `plugins.toml` and reads the +plugin ID and execution lane from the manifest. It does not perform discovery, +consult CLI lifecycle state, provision a Python worker environment, or change +enablement. Every `[[plugins.dynamic]]` declaration in the selected file is +returned, so passing the result to `initialize_with_dynamic_plugins()` is +explicit consent to load those trusted native libraries or worker processes. + +This helper is a 0.7 compatibility surface for embedded integrations and is +planned for deprecation after Relay provides a unified file-backed +initializer. Keep its use localized so migration is straightforward. + The runtime reads only files named `plugins.toml` during default discovery. ## Runtime Discovery diff --git a/python/nemo_relay/plugin.py b/python/nemo_relay/plugin.py index dbd807083..2596b0915 100644 --- a/python/nemo_relay/plugin.py +++ b/python/nemo_relay/plugin.py @@ -11,8 +11,13 @@ from __future__ import annotations import asyncio +import json +import os +import tomllib +from collections.abc import Sequence from contextlib import asynccontextmanager from dataclasses import dataclass, field, fields, is_dataclass +from pathlib import Path from typing import TYPE_CHECKING, AsyncIterator, Callable, Literal, Protocol, Self, TypedDict, cast from nemo_relay import ( @@ -417,6 +422,111 @@ async def __aexit__( await self.close() +def load_dynamic_plugin_activation_specs( + plugin_config_path: str | os.PathLike[str], +) -> list[DynamicPluginActivationSpec]: + """Load dynamic activation specs from one standard ``plugins.toml``. + + Args: + plugin_config_path: Explicit path to the ``plugins.toml`` file. + + Returns: + Activation specs for every ``[[plugins.dynamic]]`` record, in file + order. Manifest paths are resolved relative to ``plugins.toml`` and + each plugin's identifier and execution lane come from its manifest. + + Behavior: + This 0.7 compatibility helper parses one explicit file only. It does + not perform standard discovery, inspect CLI lifecycle state, provision + worker environments, change enablement, or activate plugins. It is + planned for deprecation after a unified file-backed initializer is + available. Keep its use localized and pass the result to + :func:`initialize_with_dynamic_plugins`. + """ + source = Path(os.fspath(plugin_config_path)).resolve() + document = _load_plugin_toml(source, "plugin TOML") + plugins = document.get("plugins", {}) + if not isinstance(plugins, dict): + raise ValueError(f"invalid dynamic plugin config in {source}: 'plugins' must be a table") + plugins = cast(dict[str, object], plugins) + dynamic_plugins = plugins.get("dynamic", []) + if not isinstance(dynamic_plugins, list): + raise ValueError(f"invalid dynamic plugin config in {source}: 'plugins.dynamic' must be an array of tables") + + specs: list[DynamicPluginActivationSpec] = [] + seen_plugin_ids: set[str] = set() + for index, entry in enumerate(dynamic_plugins): + if not isinstance(entry, dict): + raise ValueError(f"invalid dynamic plugin config in {source}: plugins.dynamic[{index}] must be a table") + entry = cast(dict[str, object], entry) + unknown_fields = sorted(set(entry) - {"manifest", "config"}) + if unknown_fields: + raise ValueError( + f"invalid dynamic plugin config in {source}: plugins.dynamic[{index}] has unknown fields: " + + ", ".join(unknown_fields) + ) + manifest_ref = entry.get("manifest") + if not isinstance(manifest_ref, str) or not manifest_ref.strip(): + raise ValueError( + f"invalid dynamic plugin config in {source}: " + f"plugins.dynamic[{index}].manifest must be a non-empty string" + ) + manifest_path = Path(manifest_ref) + if not manifest_path.is_absolute(): + manifest_path = source.parent / manifest_path + manifest_path = manifest_path.resolve() + + manifest = _load_plugin_toml(manifest_path, "dynamic plugin manifest") + identity = manifest.get("plugin") + if not isinstance(identity, dict): + raise ValueError(f"invalid dynamic plugin manifest in {manifest_path}: 'plugin' must be a table") + identity = cast(dict[str, object], identity) + plugin_id = identity.get("id") + if not isinstance(plugin_id, str) or not plugin_id.strip(): + raise ValueError( + f"invalid dynamic plugin manifest in {manifest_path}: 'plugin.id' must be a non-empty string" + ) + plugin_id = plugin_id.strip() + kind = identity.get("kind") + if kind not in ("rust_dynamic", "worker"): + raise ValueError( + f"invalid dynamic plugin manifest in {manifest_path}: 'plugin.kind' must be 'rust_dynamic' or 'worker'" + ) + if plugin_id in seen_plugin_ids: + raise ValueError(f"duplicate dynamic plugin id {plugin_id!r} in {source}") + seen_plugin_ids.add(plugin_id) + + config = entry.get("config", {}) + if not isinstance(config, dict): + raise ValueError( + f"invalid dynamic plugin config in {source}: plugins.dynamic[{index}].config must be a table" + ) + try: + normalized_config = cast(JsonObject, json.loads(json.dumps(config, allow_nan=False))) + except (TypeError, ValueError) as error: + raise ValueError( + f"invalid dynamic plugin config in {source}: " + f"plugins.dynamic[{index}].config must contain JSON values: {error}" + ) from error + specs.append( + DynamicPluginActivationSpec( + plugin_id=plugin_id, + kind=cast(DynamicPluginKind, kind), + manifest_ref=str(manifest_path), + config=normalized_config, + ) + ) + return specs + + +def _load_plugin_toml(path: Path, description: str) -> dict[str, object]: + try: + with path.open("rb") as file: + return cast(dict[str, object], tomllib.load(file)) + except tomllib.TOMLDecodeError as error: + raise ValueError(f"invalid {description} in {path}: {error}") from error + + def validate(config: PluginConfig | JsonObject) -> ConfigReport: """Validate a plugin configuration without changing runtime state. @@ -452,7 +562,7 @@ async def initialize(config: PluginConfig | JsonObject) -> ConfigReport: async def initialize_with_dynamic_plugins( config: PluginConfig | JsonObject, - dynamic_plugins: list[DynamicPluginActivationSpec | JsonObject], + dynamic_plugins: Sequence[DynamicPluginActivationSpec | JsonObject], ) -> PluginHostActivation: """Initialize registered components with dynamic plugins as one owned host. @@ -607,6 +717,7 @@ def deregister(plugin_kind: str) -> bool: "PluginContext", "PluginHostActivation", "Plugin", + "load_dynamic_plugin_activation_specs", "initialize_with_dynamic_plugins", "clear", "clear_async", diff --git a/python/nemo_relay/plugin.pyi b/python/nemo_relay/plugin.pyi index 39bc464d5..ee8f165dd 100644 --- a/python/nemo_relay/plugin.pyi +++ b/python/nemo_relay/plugin.pyi @@ -1,7 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from collections.abc import Callable +import os +from collections.abc import Callable, Sequence from types import TracebackType from typing import AsyncContextManager, Literal, Protocol, Self, TypedDict @@ -160,11 +161,14 @@ class PluginHostActivation: traceback: TracebackType | None, ) -> None: ... +def load_dynamic_plugin_activation_specs( + plugin_config_path: str | os.PathLike[str], +) -> list[DynamicPluginActivationSpec]: ... def validate(config: PluginConfig | JsonObject) -> ConfigReport: ... async def initialize(config: PluginConfig | JsonObject) -> ConfigReport: ... async def initialize_with_dynamic_plugins( config: PluginConfig | JsonObject, - dynamic_plugins: list[DynamicPluginActivationSpec | JsonObject], + dynamic_plugins: Sequence[DynamicPluginActivationSpec | JsonObject], ) -> PluginHostActivation: ... def clear() -> None: ... async def clear_async() -> None: ... diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index 97340cec5..1a0d14edd 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -197,6 +197,156 @@ def test_dynamic_plugin_activation_spec_preserves_nested_json_nulls(): } +def test_load_dynamic_plugin_activation_specs_resolves_standard_toml(tmp_path: Path): + manifests = tmp_path / "plugins" + manifests.mkdir() + native_manifest = manifests / "native.toml" + native_manifest.write_text( + textwrap.dedent( + """ + manifest_version = 1 + + [plugin] + id = "fixture.native" + kind = "rust_dynamic" + """ + ) + ) + worker_manifest = manifests / "worker.toml" + worker_manifest.write_text( + textwrap.dedent( + """ + manifest_version = 1 + + [plugin] + id = "fixture.worker" + kind = "worker" + """ + ) + ) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + textwrap.dedent( + f""" + version = 1 + + [[plugins.dynamic]] + manifest = "plugins/native.toml" + + [plugins.dynamic.config] + mode = "strict" + nested = {{ enabled = true }} + + [[plugins.dynamic]] + manifest = {str(worker_manifest)!r} + """ + ) + ) + + specs = plugin.load_dynamic_plugin_activation_specs(plugins_toml) + + assert [spec.to_dict() for spec in specs] == [ + { + "plugin_id": "fixture.native", + "kind": "rust_dynamic", + "manifest_ref": str(native_manifest.resolve()), + "config": {"mode": "strict", "nested": {"enabled": True}}, + }, + { + "plugin_id": "fixture.worker", + "kind": "worker", + "manifest_ref": str(worker_manifest.resolve()), + "config": {}, + }, + ] + + +def test_load_dynamic_plugin_activation_specs_rejects_duplicate_ids(tmp_path: Path): + manifest = tmp_path / "relay-plugin.toml" + manifest.write_text("[plugin]\nid = 'duplicate'\nkind = 'rust_dynamic'\n") + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + "[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\n[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\n" + ) + + with pytest.raises(ValueError, match="duplicate dynamic plugin id 'duplicate'"): + plugin.load_dynamic_plugin_activation_specs(plugins_toml) + + +@pytest.mark.parametrize( + ("plugins_toml", "manifest", "message"), + [ + ("plugins = []\n", None, "'plugins' must be a table"), + ("[plugins]\ndynamic = 'invalid'\n", None, "'plugins.dynamic' must be an array of tables"), + ("[plugins]\ndynamic = ['invalid']\n", None, r"plugins.dynamic\[0\] must be a table"), + ( + "[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\nunsupported = true\n", + "[plugin]\nid = 'fixture'\nkind = 'rust_dynamic'\n", + "has unknown fields: unsupported", + ), + ("[[plugins.dynamic]]\nmanifest = ''\n", None, "manifest must be a non-empty string"), + ( + "[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\n", + "plugin = 'invalid'\n", + "'plugin' must be a table", + ), + ( + "[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\n", + "[plugin]\nkind = 'rust_dynamic'\n", + "'plugin.id' must be a non-empty string", + ), + ( + "[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\n", + "[plugin]\nid = 'fixture'\nkind = 'invalid'\n", + "'plugin.kind' must be 'rust_dynamic' or 'worker'", + ), + ( + "[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\nconfig = 'invalid'\n", + "[plugin]\nid = 'fixture'\nkind = 'rust_dynamic'\n", + "config must be a table", + ), + ( + "[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\n" + "[plugins.dynamic.config]\nwhen = 1979-05-27T07:32:00Z\n", + "[plugin]\nid = 'fixture'\nkind = 'rust_dynamic'\n", + "config must contain JSON values", + ), + ], +) +def test_load_dynamic_plugin_activation_specs_rejects_invalid_records( + tmp_path: Path, + plugins_toml: str, + manifest: str | None, + message: str, +): + config_path = tmp_path / "plugins.toml" + config_path.write_text(plugins_toml) + if manifest is not None: + (tmp_path / "relay-plugin.toml").write_text(manifest) + + with pytest.raises(ValueError, match=message): + plugin.load_dynamic_plugin_activation_specs(config_path) + + +@pytest.mark.parametrize("filename", ["plugins.toml", "relay-plugin.toml"]) +def test_load_dynamic_plugin_activation_specs_rejects_invalid_toml(tmp_path: Path, filename: str): + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text("[[plugins.dynamic]]\nmanifest = 'relay-plugin.toml'\n") + (tmp_path / "relay-plugin.toml").write_text("[plugin]\nid = 'fixture'\nkind = 'rust_dynamic'\n") + (tmp_path / filename).write_text("invalid = [\n") + + with pytest.raises(ValueError, match="invalid .* in"): + plugin.load_dynamic_plugin_activation_specs(plugins_toml) + + +def test_load_dynamic_plugin_activation_specs_reports_missing_manifest(tmp_path: Path): + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text("[[plugins.dynamic]]\nmanifest = 'missing/relay-plugin.toml'\n") + + with pytest.raises(FileNotFoundError, match="missing/relay-plugin.toml"): + plugin.load_dynamic_plugin_activation_specs(plugins_toml) + + def test_validate_omits_raw_plugin_config_nulls_but_preserves_component_config_nulls( monkeypatch: pytest.MonkeyPatch, ): @@ -332,6 +482,9 @@ def register(self, _plugin_config, context): [[components]] kind = {static_kind!r} enabled = true + + [[plugins.dynamic]] + manifest = {str(native_dynamic_plugin.manifest)!r} """ ) ) @@ -343,7 +496,8 @@ def register(self, _plugin_config, context): plugin.register(static_kind, cast(plugin.Plugin, FileStaticPlugin())) activation = None try: - activation = await plugin.initialize_with_dynamic_plugins(plugin.PluginConfig(), [native_dynamic_plugin.spec()]) + dynamic_plugins = plugin.load_dynamic_plugin_activation_specs(plugins_toml) + activation = await plugin.initialize_with_dynamic_plugins(plugin.PluginConfig(), dynamic_plugins) assert activation.report == { "diagnostics": [ { From 3ee7dfd71a2ae9435fc318e270f132857b0b6b77 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 4 Aug 2026 11:52:44 -0600 Subject: [PATCH 2/7] docs: fix async plugin configuration example Signed-off-by: Bryan Bednarski --- .../plugin-configuration-files.mdx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index b9de59171..fd0a96441 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -170,10 +170,22 @@ from one explicitly selected file into the activation specs accepted by the 0.7 host API: ```python +import asyncio + from nemo_relay import plugin -dynamic_plugins = plugin.load_dynamic_plugin_activation_specs("path/to/plugins.toml") -activation = await plugin.initialize_with_dynamic_plugins({}, dynamic_plugins) + +async def main() -> None: + dynamic_plugins = plugin.load_dynamic_plugin_activation_specs( + "path/to/plugins.toml" + ) + activation = await plugin.initialize_with_dynamic_plugins({}, dynamic_plugins) + async with activation: + # Run your host application while dynamic plugins are active. + ... + + +asyncio.run(main()) ``` The helper resolves each manifest relative to `plugins.toml` and reads the From deea40f36179ef05efb4bb734bf0b6b9c718837e Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 4 Aug 2026 11:58:50 -0600 Subject: [PATCH 3/7] docs: clarify dynamic plugin loader errors Signed-off-by: Bryan Bednarski --- .../plugin-configuration-files.mdx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index fd0a96441..203360961 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -191,9 +191,17 @@ asyncio.run(main()) The helper resolves each manifest relative to `plugins.toml` and reads the plugin ID and execution lane from the manifest. It does not perform discovery, consult CLI lifecycle state, provision a Python worker environment, or change -enablement. Every `[[plugins.dynamic]]` declaration in the selected file is -returned, so passing the result to `initialize_with_dynamic_plugins()` is -explicit consent to load those trusted native libraries or worker processes. +enablement. + +On success, the helper returns every `[[plugins.dynamic]]` declaration in file +order; it does not skip invalid entries. A missing `plugins.toml` or referenced +manifest raises `FileNotFoundError`. Malformed TOML, invalid records or required +manifest fields, duplicate plugin IDs, and non-JSON configuration raise +`ValueError`. The helper does not apply an optional manifest-declared static +JSON Schema. + +Passing the result to `initialize_with_dynamic_plugins()` is explicit consent +to load those trusted native libraries or worker processes. This helper is a 0.7 compatibility surface for embedded integrations and is planned for deprecation after Relay provides a unified file-backed From 91332e1eeaaafc77134f6d52a4edad204aae04ac Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 4 Aug 2026 12:03:55 -0600 Subject: [PATCH 4/7] test: cover tuple dynamic plugin specs Signed-off-by: Bryan Bednarski --- python/tests/test_dynamic_plugin_host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index 1a0d14edd..d349aebb2 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -497,7 +497,7 @@ def register(self, _plugin_config, context): activation = None try: dynamic_plugins = plugin.load_dynamic_plugin_activation_specs(plugins_toml) - activation = await plugin.initialize_with_dynamic_plugins(plugin.PluginConfig(), dynamic_plugins) + activation = await plugin.initialize_with_dynamic_plugins(plugin.PluginConfig(), tuple(dynamic_plugins)) assert activation.report == { "diagnostics": [ { From 622963a53f8f8009c1eef067003d60d1743d29ad Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 4 Aug 2026 12:25:11 -0600 Subject: [PATCH 5/7] fix: reject unsupported plugin config versions Signed-off-by: Bryan Bednarski --- python/nemo_relay/plugin.py | 3 +++ python/tests/test_dynamic_plugin_host.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/python/nemo_relay/plugin.py b/python/nemo_relay/plugin.py index 2596b0915..478c30bce 100644 --- a/python/nemo_relay/plugin.py +++ b/python/nemo_relay/plugin.py @@ -445,6 +445,9 @@ def load_dynamic_plugin_activation_specs( """ source = Path(os.fspath(plugin_config_path)).resolve() document = _load_plugin_toml(source, "plugin TOML") + version = document.get("version", 1) + if not isinstance(version, int) or isinstance(version, bool) or version != 1: + raise ValueError(f"plugin config version {version!r} in {source} is unsupported; expected 1") plugins = document.get("plugins", {}) if not isinstance(plugins, dict): raise ValueError(f"invalid dynamic plugin config in {source}: 'plugins' must be a table") diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index d349aebb2..bd3823359 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -261,6 +261,15 @@ def test_load_dynamic_plugin_activation_specs_resolves_standard_toml(tmp_path: P ] +def test_load_dynamic_plugin_activation_specs_rejects_unsupported_version(tmp_path: Path): + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text("version = 2\n") + + with pytest.raises(ValueError) as error: + plugin.load_dynamic_plugin_activation_specs(plugins_toml) + assert str(error.value) == f"plugin config version 2 in {plugins_toml.resolve()} is unsupported; expected 1" + + def test_load_dynamic_plugin_activation_specs_rejects_duplicate_ids(tmp_path: Path): manifest = tmp_path / "relay-plugin.toml" manifest.write_text("[plugin]\nid = 'duplicate'\nkind = 'rust_dynamic'\n") From f6a5346704691b282f632e16a86458b3b6073f18 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 4 Aug 2026 12:31:32 -0600 Subject: [PATCH 6/7] test: cover invalid plugin config versions Signed-off-by: Bryan Bednarski --- python/tests/test_dynamic_plugin_host.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index bd3823359..ccf1cdc52 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -261,13 +261,21 @@ def test_load_dynamic_plugin_activation_specs_resolves_standard_toml(tmp_path: P ] -def test_load_dynamic_plugin_activation_specs_rejects_unsupported_version(tmp_path: Path): +@pytest.mark.parametrize( + ("toml_version", "version"), + [("true", True), ("0", 0), ("2", 2), ('"1"', "1")], +) +def test_load_dynamic_plugin_activation_specs_rejects_unsupported_version( + tmp_path: Path, toml_version: str, version: object +): plugins_toml = tmp_path / "plugins.toml" - plugins_toml.write_text("version = 2\n") + plugins_toml.write_text(f"version = {toml_version}\n") with pytest.raises(ValueError) as error: plugin.load_dynamic_plugin_activation_specs(plugins_toml) - assert str(error.value) == f"plugin config version 2 in {plugins_toml.resolve()} is unsupported; expected 1" + assert str(error.value) == ( + f"plugin config version {version!r} in {plugins_toml.resolve()} is unsupported; expected 1" + ) def test_load_dynamic_plugin_activation_specs_rejects_duplicate_ids(tmp_path: Path): From 4e416caed52423bb3dc8597edfb757bd3ec91590 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 4 Aug 2026 15:33:23 -0600 Subject: [PATCH 7/7] test: make missing manifest assertion portable Signed-off-by: Bryan Bednarski --- python/tests/test_dynamic_plugin_host.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/tests/test_dynamic_plugin_host.py b/python/tests/test_dynamic_plugin_host.py index ccf1cdc52..f06673032 100644 --- a/python/tests/test_dynamic_plugin_host.py +++ b/python/tests/test_dynamic_plugin_host.py @@ -359,9 +359,11 @@ def test_load_dynamic_plugin_activation_specs_rejects_invalid_toml(tmp_path: Pat def test_load_dynamic_plugin_activation_specs_reports_missing_manifest(tmp_path: Path): plugins_toml = tmp_path / "plugins.toml" plugins_toml.write_text("[[plugins.dynamic]]\nmanifest = 'missing/relay-plugin.toml'\n") + missing_manifest = (tmp_path / "missing" / "relay-plugin.toml").resolve() - with pytest.raises(FileNotFoundError, match="missing/relay-plugin.toml"): + with pytest.raises(FileNotFoundError) as error: plugin.load_dynamic_plugin_activation_specs(plugins_toml) + assert Path(error.value.filename) == missing_manifest def test_validate_omits_raw_plugin_config_nulls_but_preserves_component_config_nulls(