Repository: projectsyn/reclass-rs (language: python)

Issue:
Add support for loading config from a file
Kapitan allows users to provide additional reclass config options in a file `reclass-config.yml` in the inventory root directory. We add this feature to reclass-rs, but allow users to specify an arbitrary YAML file from which config options are loaded.

Additionally, we update the `inventory_tests::test_render()` Rust and the `test_inventory()` Python test cases to load the config from the already existing `reclass-config.yml` for the test inventory.

This PR is split out from #84 

## Checklist
<!--
Remove items that do not apply. For completed items, change [ ] to [x].
-->

- [x] The PR has a meaningful title. The title will be used to auto generate the changelog
- [x] PR contains a single logical change (to build a better changelog).
- [x] Update the documentation.
- [x] Update tests.
- [x] Categorize the PR by setting a good title and adding one of the labels:
      `bug`, `enhancement`, `documentation`, `change`, `breaking`, `dependency`, `internal`
      as they show up in the changelog
- [x] Link this PR to related PRs or issues.

<!--
NOTE: these things are not required to open a PR and can be done afterwards,
while the PR is open.
-->



Discussion / hints:
## Benchmark for cb0c952
  <details>
    <summary>Click to view benchmark</summary>

| Test | Base         | PR               | % |
|------|--------------|------------------|---|
| Reclass::inventory() multi-threaded | 1609.8±108.40µs | 1612.4±134.00µs | +0.16% |
| Reclass::inventory() single-threaded | 3.6±0.11ms | **3.5±0.04ms** | **-2.78%** |

  </details>
  
## Benchmark for f58ee74
  <details>
    <summary>Click to view benchmark</summary>

| Test | Base         | PR               | % |
|------|--------------|------------------|---|
| Reclass::inventory() multi-threaded | 1652.3±116.28µs | 1596.8±59.52µs | -3.36% |
| Reclass::inventory() single-threaded | 3.7±0.21ms | **3.6±0.09ms** | **-2.70%** |

  </details>
  
## Benchmark for 0a35f51
  <details>
    <summary>Click to view benchmark</summary>

| Test | Base         | PR               | % |
|------|--------------|------------------|---|
| Reclass::inventory() multi-threaded | 1600.7±117.09µs | 1588.5±90.37µs | -0.76% |
| Reclass::inventory() single-threaded | 3.6±0.09ms | 3.6±0.03ms | 0.00% |

  </details>
  

Fix patch:
diff --git a/src/lib.rs b/src/lib.rs
index db8a7d5..891d275 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -287,13 +287,26 @@ impl Reclass {
         Ok(r)
     }
 
+    /// Creates a `Reclass` instance for the provided `inventory_path` and loads config options
+    /// from the provided config file. The value of `config_file` is interpreted relative to
+    /// `inventory_path`.
+    ///
+    /// Returns a `Reclass` instance or raises a `ValueError`
     #[classmethod]
-    fn from_config(_cls: &PyType, inventory_path: &str, config_file: &str) -> PyResult<Self> {
+    fn from_config_file(cls: &PyType, inventory_path: &str, config_file: &str) -> PyResult<Self> {
         let mut c = Config::new(Some(inventory_path), None, None, None)
             .map_err(|e| PyValueError::new_err(format!("{e}")))?;
         c.load_from_file(config_file)
             .map_err(|e| PyValueError::new_err(format!("{e}")))?;
-        let r = Self::new_from_config(c).map_err(|e| PyValueError::new_err(format!("{e}")))?;
+        Self::from_config(cls, c)
+    }
+
+    /// Creates a `Reclass` instance from the provided `Config` instance.
+    ///
+    /// Returns a `Reclass` instance or raises a `ValueError`
+    #[classmethod]
+    fn from_config(_cls: &PyType, config: Config) -> PyResult<Self> {
+        let r = Self::new_from_config(config).map_err(|e| PyValueError::new_err(format!("{e}")))?;
         Ok(r)
     }
 


Test patch:
diff --git a/tests/test_compose_node_name.py b/tests/test_compose_node_name.py
index c3fe164..d336eca 100644
--- a/tests/test_compose_node_name.py
+++ b/tests/test_compose_node_name.py
@@ -35,7 +35,7 @@ def test_no_compose_node_name():
 
 
 def test_compose_node_name_compat():
-    r = reclass_rs.Reclass.from_config(
+    r = reclass_rs.Reclass.from_config_file(
         "./tests/inventory-compose-node-name", "reclass-config.yml"
     )
     r.set_compat_flag(reclass_rs.CompatFlag.ComposeNodeNameLiteralDots)
@@ -63,7 +63,7 @@ def test_compose_node_name_compat():
 
 
 def test_compose_node_name():
-    r = reclass_rs.Reclass.from_config(
+    r = reclass_rs.Reclass.from_config_file(
         "./tests/inventory-compose-node-name", "reclass-config.yml"
     )
     assert r.config.compose_node_name
diff --git a/tests/test_ignore_class_notfound_regexp.py b/tests/test_ignore_class_notfound_regexp.py
index 9ef1b83..b619e21 100644
--- a/tests/test_ignore_class_notfound_regexp.py
+++ b/tests/test_ignore_class_notfound_regexp.py
@@ -4,7 +4,7 @@
 
 
 def test_ignore_regexp_render_n1():
-    r = reclass_rs.Reclass.from_config(
+    r = reclass_rs.Reclass.from_config_file(
         "./tests/inventory-class-notfound-regexp", "reclass-config.yml"
     )
     assert r.config.ignore_class_notfound_regexp == ["service\\..*", ".*missing.*"]
@@ -15,7 +15,7 @@ def test_ignore_regexp_render_n1():
 
 
 def test_ignore_regexp_render_n2():
-    r = reclass_rs.Reclass.from_config(
+    r = reclass_rs.Reclass.from_config_file(
         "./tests/inventory-class-notfound-regexp", "reclass-config.yml"
     )
     assert r.config.ignore_class_notfound_regexp == ["service\\..*", ".*missing.*"]
@@ -27,7 +27,7 @@ def test_ignore_regexp_render_n2():
 
 
 def test_ignore_regexp_update_config_render_n2():
-    r = reclass_rs.Reclass.from_config(
+    r = reclass_rs.Reclass.from_config_file(
         "./tests/inventory-class-notfound-regexp", "reclass-config.yml"
     )
     r.set_ignore_class_notfound_regexp([".*"])
diff --git a/tests/test_inventory.py b/tests/test_inventory.py
index 1c3205c..abae671 100644
--- a/tests/test_inventory.py
+++ b/tests/test_inventory.py
@@ -66,7 +66,7 @@
 
 
 def test_inventory():
-    r = reclass_rs.Reclass.from_config("./tests/inventory", "reclass-config.yml")
+    r = reclass_rs.Reclass.from_config_file("./tests/inventory", "reclass-config.yml")
 
     assert set(r.nodes.keys()) == expected_nodes
     included_classes = set(expected_classes.keys())
@@ -112,3 +112,37 @@ def test_inventory_as_dict():
 
     assert "__reclass__" in inv
     assert set(inv["__reclass__"].keys()) == set(["timestamp"])
+
+
+def test_reclass_from_config():
+    config_options = {
+        "nodes_uri": "targets",
+        "classes_uri": "classes",
+        "ignore_class_notfound": True,
+        "compose_node_name": True,
+    }
+    c = reclass_rs.Config.from_dict("./tests/inventory", config_options)
+    assert c is not None
+
+    r = reclass_rs.Reclass.from_config(c)
+    assert r is not None
+
+    assert set(r.nodes.keys()) == expected_nodes
+    included_classes = set(expected_classes.keys())
+    all_classes = set(r.classes.keys())
+    # discovered classes which aren't shown with their resolved names in the output:
+    assert all_classes - included_classes == {
+        "${baz}",  # appears as `\\${baz}`
+        "cluster.foo",  # appears as `cluster.${dist}`
+        "config_symlink",  # doesn't appear
+        "foo.bar",  # appears as `${tenant}.${cluster}`
+    }
+    # class includes which aren't shown in their resolved form:
+    assert included_classes - all_classes == {
+        "${cls9}",  # resolved as `cls9` for n15
+        "${qux}",  # resolved as `cls1` for n4
+        "${tenant}.${cluster}",  # resolved as `foo.bar` for n16
+        "\\${baz}",  # resolved as `${baz}` for n17
+        "cluster.${dist}",  # resolved as `cluster.foo` for n19
+        "nonexisting",  # skipped because ignore_class_notfound=True
+    }