refactor: finalize scheduler and plugin integration

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-09-01 18:36:02 +02:00
parent 1e1fc7975b
commit 3c779b98b8
17 changed files with 262 additions and 94 deletions
@@ -3,6 +3,26 @@
// SPDX-License-Identifier: MIT
// All contributions are certified under the DCO
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// ----------------------------------------------------------------------------
//! Native compatibility pipeline for filesystem-backed awesome navigation.
use anyhow::{bail, Context, Result};
@@ -26,6 +46,21 @@ mod pattern;
mod resolver;
mod sort;
// ----------------------------------------------------------------------------
// Enums
// ----------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Level {
Info,
Warning,
Error,
}
// ----------------------------------------------------------------------------
// Structs
// ----------------------------------------------------------------------------
/// Native awesome-nav pipeline.
#[derive(Clone, Debug)]
pub struct AwesomeNav {
@@ -40,29 +75,22 @@ pub struct Dependencies<'a> {
pub pages: &'a Stream<Id, Page>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Info,
Warning,
Error,
}
#[derive(Clone, Debug)]
pub struct Diagnostic {
pub level: Level,
pub message: String,
struct Diagnostic {
level: Level,
message: String,
}
#[derive(Clone, Copy, Debug)]
pub struct Logs {
pub nav_override: Level,
pub root_title: Level,
pub root_hide: Level,
pub no_matches: Level,
struct Logs {
nav_override: Level,
root_title: Level,
root_hide: Level,
no_matches: Level,
}
#[derive(Clone, Debug)]
pub struct Settings {
struct Settings {
enabled: bool,
docs: String,
filename: String,
@@ -89,6 +117,10 @@ struct Pages(Arc<Vec<Page>>);
impl Value for Pages {}
// ----------------------------------------------------------------------------
// Implementations
// ----------------------------------------------------------------------------
impl AwesomeNav {
/// Resolves immutable settings for one workflow lifetime.
pub fn new(config: &Config, strict: bool) -> Result<Self> {
@@ -183,6 +215,10 @@ impl Logs {
}
}
// ----------------------------------------------------------------------------
// Functions
// ----------------------------------------------------------------------------
fn level(value: Option<&str>, default: Level) -> Result<Level> {
match value {
None => Ok(default),
@@ -219,6 +255,10 @@ fn is_config_file(path: &SourcePath, filename: &str) -> bool {
.is_some_and(|prefix| prefix.ends_with('/'))
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::{is_config_file, level, Level};
@@ -3,6 +3,26 @@
// SPDX-License-Identifier: MIT
// All contributions are certified under the DCO
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// ----------------------------------------------------------------------------
//! Strict `.nav.yml` parsing and configuration models.
use anyhow::{bail, Context, Result};
@@ -3,6 +3,26 @@
// SPDX-License-Identifier: MIT
// All contributions are certified under the DCO
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// ----------------------------------------------------------------------------
//! Replaceable awesome-nav pattern matching boundary.
use anyhow::{bail, Result};
@@ -3,6 +3,26 @@
// SPDX-License-Identifier: MIT
// All contributions are certified under the DCO
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// ----------------------------------------------------------------------------
//! Filesystem-derived awesome-nav resolution.
use anyhow::{bail, Context, Result};
@@ -3,6 +3,26 @@
// SPDX-License-Identifier: MIT
// All contributions are certified under the DCO
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// ----------------------------------------------------------------------------
//! Awesome-nav sorting compatible with upstream's natsort settings.
use std::cmp::Ordering;
@@ -15,7 +15,7 @@
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
@@ -15,7 +15,7 @@
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
@@ -173,7 +173,7 @@ impl Meta {
}
/// Returns the immutable settings shared with resource classification.
pub(crate) fn settings(&self) -> &Settings {
pub fn settings(&self) -> &Settings {
&self.settings
}
}
@@ -89,17 +89,17 @@ impl Minify {
}
/// Returns the normalized plugin configuration for the asset stage.
pub(super) fn config(&self) -> &MinifyPluginConfig {
fn config(&self) -> &MinifyPluginConfig {
&self.config
}
/// Returns the site output directory owned by this pipeline.
pub(super) fn output(&self) -> &OutputRoot {
fn output(&self) -> &OutputRoot {
&self.output
}
/// Returns the template-visible project configuration.
pub(super) fn project(&self) -> &Arc<Project> {
fn project(&self) -> &Arc<Project> {
&self.project
}
@@ -336,7 +336,7 @@ impl Value for Manifest {}
/// Transforms selected resources, writes every effective asset, and publishes
/// the project view whose configured asset paths name the emitted files.
pub(super) fn setup(
pub fn setup(
plugin: &Minify, resources: &Stream<Id, Resource>,
) -> Signal<Id, Manifest> {
let settings = Settings::new(plugin.config());
@@ -83,19 +83,16 @@ impl Mkdocstrings {
module
.call_method1("get_inventory", (cached,))?
.extract::<Vec<u8>>()
});
})?;
if let Ok(data) = data {
let path = pipeline.output.join(
&"objects.inv"
.parse::<SitePath>()
.expect("static site path"),
);
let _ = fs::create_dir_all(path.parent().expect("invariant"));
let _ = fs::write(path, &data);
let _ = fs::create_dir_all(&pipeline.cache);
let _ = fs::write(&cache_path, &data);
}
let path = pipeline.output.join(
&"objects.inv".parse::<SitePath>().expect("static site path"),
);
fs::create_dir_all(path.parent().expect("invariant"))?;
fs::write(path, &data)?;
fs::create_dir_all(&pipeline.cache)?;
fs::write(&cache_path, &data)?;
Ok::<_, anyhow::Error>(())
});
}
}
@@ -89,12 +89,16 @@ impl Resources {
}
/// Classifies sources and resolves docs-over-theme precedence.
pub fn setup(&self, deps: Dependencies<'_>) -> Stream<Id, Resource> {
pub fn setup(
&self, dependencies: Dependencies<'_>,
) -> Stream<Id, Resource> {
let classifier = self.classifier.clone();
let resources =
deps.sources.filter_map(move |id: &Id, source: &Source| {
classifier.classify(id, source)
});
dependencies
.sources
.filter_map(move |id: &Id, source: &Source| {
classifier.classify(id, source)
});
// Settle precedence before consumers transform or write a resource.
// Removing a docs override therefore reveals its theme fallback in
+41 -10
View File
@@ -239,7 +239,7 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult<bool> {
let run = runner
.settle()
.map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
report_failures(&run)?;
report_failures(&run);
// Create channel for reload notifications
let (sender, receiver) = unbounded();
@@ -315,7 +315,7 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult<bool> {
let run = runner
.settle()
.map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
report_failures(&run)?;
report_failures(&run);
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => match mode {
@@ -345,14 +345,21 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult<bool> {
Ok(false)
}
/// Returns the first action failure reported by one settled run.
fn report_failures(run: &zrx::stream::Run<Id>) -> PyResult<()> {
for invocation in run.report().invocations() {
if let Some(failure) = invocation.outcomes.failures().first() {
return Err(PyRuntimeError::new_err(format!("{failure:#}")));
}
/// Prints action failures reported by one settled run.
fn report_failures(run: &zrx::stream::Run<Id>) {
for failure in reported_failures(run) {
eprintln!("Error - {failure}");
}
Ok(())
}
/// Formats action failures reported by one settled run.
fn reported_failures(run: &zrx::stream::Run<Id>) -> Vec<String> {
run.report()
.invocations()
.iter()
.flat_map(|invocation| invocation.outcomes.failures())
.map(|failure| format!("{failure:#}"))
.collect()
}
// ----------------------------------------------------------------------------
@@ -410,8 +417,32 @@ fn zensical(m: &Bound<'_, PyModule>) -> PyResult<()> {
mod tests {
use std::fs;
use tempfile::tempdir;
use zrx::id::Id;
use zrx::stream::{Key, Workflow};
use super::clear_dir;
use super::{clear_dir, reported_failures};
#[test]
fn operator_failures_are_formatted_for_cli_reporting() {
let workflow = Workflow::<Id>::build(|workflow| {
let input = workflow.input::<u64>();
let output = input.map(|_: &u64| -> anyhow::Result<u64> {
anyhow::bail!("broken callback")
});
workflow.output(&output);
});
let mut runner = workflow.runner().unwrap();
let input = runner.input::<u64>().unwrap();
let mut revision = input.begin().unwrap();
revision
.insert(Key::from_iter(std::iter::empty()), 1)
.unwrap();
let _input = revision.seal().unwrap();
let run = runner.settle().unwrap();
assert_eq!(reported_failures(&run), ["broken callback"]);
assert_eq!(runner.errors().len(), 1);
}
#[test]
fn clear_dir_removes_non_hidden_file() {
+1 -1
View File
@@ -15,7 +15,7 @@
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+31 -1
View File
@@ -171,6 +171,18 @@ class TestPluginShimming:
config = self._parse_yaml(tmp_path, plugins={"glightbox": {}})
assert GlightboxExtension.name in config["markdown_extensions"]
@pytest.mark.parametrize(
"entry", ["material/meta", {"material/meta": None}]
)
def test_material_meta_presence_enables_defaults(
self, tmp_path: Path, entry: object
) -> None:
config = self._parse_yaml(tmp_path, plugins=[entry])
assert config["plugins"]["meta"]["config"] == {
"enabled": True,
"meta_file": ".meta.yml",
}
def test_material_meta_plugin_is_normalized(self, tmp_path: Path) -> None:
config = self._parse_yaml(
tmp_path,
@@ -183,6 +195,25 @@ class TestPluginShimming:
}
assert config["plugins_hash"] == cfg_module._hash(config["plugins"])
@pytest.mark.parametrize(
"name",
[
"material/meta",
"redirects",
"minify",
"literate-nav",
"awesome-nav",
],
)
def test_native_plugin_configuration_must_be_a_mapping(
self, tmp_path: Path, name: str
) -> None:
with pytest.raises(
cfg_module.ConfigurationError,
match=rf"{name} configuration must be a mapping",
):
self._parse_yaml(tmp_path, plugins={name: []})
def test_redirects_plugin_is_normalized(self, tmp_path: Path) -> None:
config = self._parse_yaml(
tmp_path,
@@ -292,7 +323,6 @@ class TestPluginShimming:
@pytest.mark.parametrize(
("plugin", "message"),
[
([], "configuration must be a mapping"),
({"unknown": True}, "unknown awesome-nav option"),
({"filename": 42}, "filename must be a string"),
({"filename": ""}, "filename must not be empty"),
+1 -1
View File
@@ -15,7 +15,7 @@
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+28 -42
View File
@@ -1292,8 +1292,8 @@ def _convert_plugin_markdown_extensions(
def _convert_plugins(value: Any, config: dict) -> dict:
"""Convert plugins configuration to something we can work with."""
plugins = {}
tags = []
plugins: dict[str, Any] = {}
tags: list[dict[str, Any]] = []
def add(name: str, data: Any) -> None:
"""Preserve tags instances while retaining legacy map semantics."""
@@ -1334,35 +1334,23 @@ def _convert_plugins(value: Any, config: dict) -> dict:
# Consume Material's public plugin name and normalize it to the internal
# identifier extracted into typed Rust configuration.
material_meta = plugins.pop("material/meta", None)
if material_meta is None:
meta = {"enabled": False, "meta_file": ".meta.yml"}
else:
meta = dict(material_meta or {})
set_default(meta, "enabled", True, bool)
set_default(meta, "meta_file", ".meta.yml", str)
present, meta = _pop_plugin_config(plugins, "material/meta")
set_default(meta, "enabled", present, bool)
set_default(meta, "meta_file", ".meta.yml", str)
plugins["meta"] = meta
# Normalize redirects into typed native configuration. The enabled flag is
# internal; plugin presence retains MkDocs' activation semantics.
if "redirects" not in plugins:
redirects = {"enabled": False, "redirect_maps": {}}
else:
redirects = dict(plugins["redirects"] or {})
set_default(redirects, "enabled", True, bool)
set_default(redirects, "redirect_maps", {}, dict)
present, redirects = _pop_plugin_config(plugins, "redirects")
set_default(redirects, "enabled", present, bool)
set_default(redirects, "redirect_maps", {}, dict)
plugins["redirects"] = redirects
# Normalize the complete mkdocs-minify-plugin configuration surface. Asset
# settings are retained for the dedicated copy/output stage; the inline
# switches are Zensical extensions handled by the final HTML pass.
minify: dict[str, Any]
if "minify" not in plugins:
minify = {"enabled": False}
else:
minify = dict(plugins["minify"] or {})
set_default(minify, "enabled", True, bool)
present, minify = _pop_plugin_config(plugins, "minify")
set_default(minify, "enabled", present, bool)
set_default(minify, "minify_html", False, bool)
set_default(minify, "minify_js", False, bool)
set_default(minify, "minify_css", False, bool)
@@ -1401,13 +1389,8 @@ def _convert_plugins(value: Any, config: dict) -> dict:
# Normalize mkdocs-literate-nav without importing or executing the plugin.
# Python retains extension objects and callables for the narrow Markdown
# rendering boundary; Rust owns discovery and navigation resolution.
literate_nav: dict[str, Any]
if "literate-nav" not in plugins:
literate_nav = {"enabled": False}
else:
literate_nav_config = plugins.pop("literate-nav")
literate_nav = dict(literate_nav_config or {})
set_default(literate_nav, "enabled", True, bool)
present, literate_nav = _pop_plugin_config(plugins, "literate-nav")
set_default(literate_nav, "enabled", present, bool)
set_default(literate_nav, "nav_file", "SUMMARY.md", str)
set_default(literate_nav, "implicit_index", False, bool)
set_default(literate_nav, "tab_length", 4, int)
@@ -1420,19 +1403,8 @@ def _convert_plugins(value: Any, config: dict) -> dict:
# Normalize mkdocs-awesome-nav without importing or executing the plugin.
# Rust owns discovery, YAML parsing, matching and navigation resolution.
awesome_nav: dict[str, Any]
if "awesome-nav" not in plugins:
awesome_nav = {"enabled": False}
else:
awesome_nav_config = plugins.pop("awesome-nav")
if awesome_nav_config is not None and not isinstance(
awesome_nav_config, dict
):
raise ConfigurationError(
"awesome-nav configuration must be a mapping"
)
awesome_nav = dict(awesome_nav_config or {})
set_default(awesome_nav, "enabled", True, bool)
present, awesome_nav = _pop_plugin_config(plugins, "awesome-nav")
set_default(awesome_nav, "enabled", present, bool)
unknown = set(awesome_nav) - {"enabled", "filename", "logs"}
if unknown:
option = sorted(unknown)[0]
@@ -1516,3 +1488,17 @@ def _convert_plugins(value: Any, config: dict) -> dict:
# Return plugins
return plugins
def _pop_plugin_config(
plugins: dict[str, Any], name: str
) -> tuple[bool, dict[str, Any]]:
"""Consume one optional mapping while preserving presence semantics."""
if name not in plugins:
return False, {}
value = plugins.pop(name)
if value is None:
return True, {}
if not isinstance(value, dict):
raise ConfigurationError(f"{name} configuration must be a mapping")
return True, dict(value)