From d45954a4c19d9d07cd41e33ed46804fdf9cffcc2 Mon Sep 17 00:00:00 2001
From: corey <corey@nxp.com>
Date: Sun, 29 Dec 2024 13:59:49 -0600
Subject: [PATCH] Continue ParamStr dev

---
 .../tests/utils/test_param_str.py             | 423 +++++++++++++++++-
 rust/origen_metal/src/utils/param_str.rs      | 160 ++++++-
 rust/pyapi/Cargo.lock                         |  10 +
 rust/pyapi_metal/src/utils/param_str.rs       |  98 +++-
 4 files changed, 664 insertions(+), 27 deletions(-)

diff --git a/python/origen_metal/tests/utils/test_param_str.py b/python/origen_metal/tests/utils/test_param_str.py
index 702de213..d2979145 100644
--- a/python/origen_metal/tests/utils/test_param_str.py
+++ b/python/origen_metal/tests/utils/test_param_str.py
@@ -22,6 +22,10 @@ def a4(self):
     def a5(self):
         return "arg5"
 
+    @pytest.fixture
+    def a6(self):
+        return "arg6"
+
     @property
     def m(self):
         return "missing"
@@ -30,6 +34,36 @@ def m(self):
     def missing(self):
         return self.m
 
+    @pytest.fixture
+    def leading(self):
+        return "leading"
+
+    @pytest.fixture
+    def reparse_error(self):
+        return r"ParamStr has already been parsed. Please use 'param_str.clear\(\)', before parsing new input"
+
+    @pytest.fixture
+    def leading_str_not_allowed_msg(self):
+        return "Attempted to set leading value but 'allows_leading_str' is not allowed"
+
+    @pytest.fixture
+    def set_leading_str_after_parse_error_msg(self):
+        return "Attempted to change ParamStr's 'allows_leading_str' setting after parsing, which is not allowed"
+
+    def removing_missing_def_err_msg(self, default):
+        return f"No parameter '{default}' to remove from ParamStr's defaults"
+
+    def not_allowed_param_msg(self, param):
+        return f"ParamStr encountered parameter '{param}', which is not an expected parameter"
+
+    @pytest.fixture
+    def defs_update_after_parse_err_msg(self):
+        return "Attempted to update ParamStr's default values after parsing, which is not allowed"
+
+    @pytest.fixture
+    def update_allows_non_defaults_after_parse_err_msg(self):
+        return "Cannot set ParamStr's allows_non_defaults with no default parameters"
+
 class TestStandaloneParamStr(Common):
     @classmethod
     def to_vals(cls, vals):
@@ -230,32 +264,31 @@ def assert_param_str(cls, p, as_str, as_dict=None, leading=None, raw=None):
             assert p[k]
         assert p.get(k) is None
 
-
     def test_leading_str(self, a1, a2):
         in_str = "blah"
         p = ParamStr(True)
         p.parse(in_str)
-        self.assert_param_str(p, "", None, leading=in_str, raw=in_str)
+        self.assert_param_str(p, in_str, None, leading=in_str, raw=in_str)
 
         in_str = "blah~"
         p = ParamStr(allows_leading_str=True)
         p.parse(in_str)
-        self.assert_param_str(p, "", None, "blah", in_str)
+        self.assert_param_str(p, "blah", None, "blah", in_str)
 
         in_str = "blah~arg1"
         p = ParamStr(allows_leading_str=True)
         p.parse(in_str)
-        self.assert_param_str(p, a1, {a1: []}, "blah", in_str)
+        self.assert_param_str(p, in_str, {a1: []}, "blah", in_str)
 
         in_str = f"blah blah~{a1}:v1:v2~{a2}:w2"
         p = ParamStr(allows_leading_str=True)
         p.parse(in_str)
-        self.assert_param_str(p, f"{a1}:v1:v2~{a2}:w2", {a1: ["v1", "v2"], a2: ["w2"]}, "blah blah", in_str)
+        self.assert_param_str(p, in_str, {a1: ["v1", "v2"], a2: ["w2"]}, "blah blah", in_str)
 
         in_str = "~blah"
         p = ParamStr(allows_leading_str=True)
         p.parse(in_str)
-        self.assert_param_str(p, "blah", {"blah": []}, "", in_str)
+        self.assert_param_str(p, "~blah", {"blah": []}, "", in_str)
 
     def test_key_order(self, a3, a2, a1):
         in_str = f"{a3}~{a2}:val~{a1}:v1:v2"
@@ -354,26 +387,27 @@ def test_default_keys(self, a1, a2):
         assert str(p) == f"{a2}:default"
 
         # Try with some values
+        p = ParamStr(allows_leading_str=False, defaults=defs)
         in_str = f"{a1}~{a2}:not:default"
         p.parse(in_str)
         assert p.parsed == {a1: [], a2: ['not', 'default']}
         assert str(p) == f"{a1}~{a2}:not:default"
-        p.parse(in_str)
 
     def test_defaults_with_array(self, a1, a2, a3, a4):
         defs = {a1: ['default_1', 'default_2'], a2: ['default'], a3: [], a4: None}
         p = ParamStr(allows_leading_str=False, defaults=defs)
-        p = ParamStr(allows_leading_str=False, defaults=defs)
         assert p.defaults == defs
         assert p.parsed is None
         assert p.allows_non_defaults is False
 
         in_str = ""
+        p = ParamStr(allows_leading_str=False, defaults=defs)
         p.parse(in_str)
         assert p.parsed == {a1: ['default_1', 'default_2'], a2: ['default'], a3: []}
         assert str(p) == f"{a1}:default_1:default_2~{a2}:default~{a3}"
 
         in_str = f"{a2}~{a3}:a:b:c:d"
+        p = ParamStr(allows_leading_str=False, defaults=defs)
         p.parse(in_str)
         assert p.parsed == {a1: ['default_1', 'default_2'], a2: [], a3: ['a', 'b', 'c', 'd']}
         assert str(p) == f"{a2}~{a3}:a:b:c:d~{a1}:default_1:default_2"
@@ -413,10 +447,383 @@ def test_allowing_non_defaults(self, a1, a2, missing):
         assert p.parsed == {a1: ['default'], a2: ['val'], missing: []}
         assert str(p) == f"{a2}:val~{missing}~{a1}:default"
 
+        p = ParamStr(allows_leading_str=False, defaults=defs, allows_non_defaults=True)
         p.parse(f"{a2}:val~{missing}:m:val")
         assert p.parsed == {a1: ['default'], a2: ['val'], missing: ['m', 'val']}
         assert str(p) == f"{a2}:val~{missing}:m:val~{a1}:default"
 
+    def test_try_parse(self, a1, a2, a3):
+        p = ParamStr()
+        err = p.try_parse("~")
+        self.assert_unparsed(p)
+        assert isinstance(err, RuntimeError)
+        assert str(err) == "ParamStr encountered a parameter with an empty key, which is not allowed"
+
+        p = ParamStr(defaults={a1: None, a2: []})
+        err = p.try_parse(f"{a3}:fail")
+        self.assert_unparsed(p)
+        assert isinstance(err, RuntimeError)
+        assert str(err) == self.not_allowed_param_msg(a3)
+
+        p = ParamStr(defaults={a1: None, a2: []})
+        parsed = p.try_parse(f"{a1}:should:pass")
+        assert isinstance(parsed, ParamStr)
+        assert p.parsed == {a1: ['should', 'pass'], a2: []}
+        assert str(p) == f"{a1}:should:pass~{a2}"
+
+    def test_clear(self, a1, a2):
+        defs = {a1: ['default'], a2: []}
+        p = ParamStr(allows_leading_str=True, defaults=defs, allows_non_defaults=True)
+        self.assert_unparsed(p)
+        assert p.defaults == defs
+        assert p.allows_leading_str == True
+        assert p.allows_non_defaults == True
+
+        # No change to setup upon clear
+        assert isinstance(p.clear(), ParamStr)
+        self.assert_unparsed(p)
+        assert p.defaults == defs
+        assert p.allows_leading_str == True
+        assert p.allows_non_defaults == True
+
+        leading = "leading"
+        in_str = f"{leading}~{a2}:not:default"
+        p.parse(in_str)
+        assert p.parsed == {a1: ['default'], a2: ['not', 'default']}
+        assert p.raw == in_str
+        assert p.leading == leading
+
+        assert isinstance(p.clear(), ParamStr)
+        self.assert_unparsed(p)
+        assert p.defaults == defs
+        assert p.allows_leading_str == True
+        assert p.allows_non_defaults == True
+
+    def test_duplicating_unparsed_instance(self, a1, a2):
+        defs = {a1: ['default'], a2: []}
+        leading = "leading"
+        p = ParamStr(allows_leading_str=True, defaults=defs)
+        self.assert_unparsed(p)
+        assert p.defaults == defs
+        assert p.allows_leading_str == True
+        assert p.allows_non_defaults == False
+
+        p2 = p.duplicate()
+        self.assert_unparsed(p2)
+        assert p2.defaults == defs
+        assert p2.allows_leading_str == True
+        assert p2.allows_non_defaults == False
+        in_str_p2 = f"{leading}~{a1}~{a2}:val"
+        p2.parse(in_str_p2)
+        assert p2.parsed == {a1: [], a2: ['val']}
+        assert str(p2) == in_str_p2
+        assert p2.raw == in_str_p2
+        assert p2.leading == leading
+
+        p3 = p.dup()
+        self.assert_unparsed(p3)
+        assert p3.defaults == defs
+        assert p3.allows_leading_str == True
+        assert p3.allows_non_defaults == False
+
+        in_str = f"p3~{a1}:new"
+        p3.parse(in_str)
+        assert p3.parsed == {a1: ['new'], a2: []}
+        assert str(p3) == f"{in_str}~{a2}"
+        assert p3.raw == in_str
+        assert p3.leading == "p3"
+
+        # p and p2 should remain unchanged
+        self.assert_unparsed(p)
+        assert p2.parsed == {a1: [], a2: ['val']}
+        assert str(p2) == f"{leading}~{a1}~{a2}:val"
+        assert p2.raw == in_str_p2
+        assert p2.leading == leading
+
+    def test_duplicating_parsed_instance(self, a1, a2, a3):
+        defs = {a1: [], a2: []}
+        p = ParamStr.and_parse(f"{a3}:v1", defaults=defs, allows_non_defaults=True)
+        assert p.parsed == {a1: [], a2: [], a3: ['v1']}
+
+        p2 = p.duplicate()
+        assert p2.parsed == {a1: [], a2: [], a3: ['v1']}
+
+        p3 = p2.dup()
+        assert p3.parsed == {a1: [], a2: [], a3: ['v1']}
+
+        p.clear()
+        self.assert_unparsed(p)
+        assert p2.parsed == {a1: [], a2: [], a3: ['v1']}
+        assert p3.parsed == {a1: [], a2: [], a3: ['v1']}
+
+        p2.clear()
+        self.assert_unparsed(p2)
+        assert p3.parsed == {a1: [], a2: [], a3: ['v1']}
+
+    def test_error_on_reparse(self, a1, a2, a3, reparse_error):
+        parsed = {a1: [], a2: ['val']}
+        leading = 'leading'
+        in_str = f"{leading}~{a1}~{a2}:val"
+        p = ParamStr(allows_leading_str=True)
+        p.parse(in_str)
+        assert p.parsed == parsed
+        assert p.raw == in_str
+        assert p.leading == leading
+
+        with pytest.raises(RuntimeError, match=reparse_error):
+            p.parse(f"{a1}~{a3}")
+        assert p.parsed == parsed
+        assert p.raw == in_str
+        assert p.leading == leading
+
+    def test_setting_parameters(self, a1, a2, a3, reparse_error):
+        p = ParamStr()
+        assert p.set(a1, 'val') == False
+        assert p.parsed == {a1: ['val']}
+        assert p.raw == None
+        assert p.leading == None
+
+        assert p.set(a2, []) == False
+        assert p.set(a3, ['v', '3']) == False
+        assert p.parsed == {a1: ['val'], a2: [], a3: ['v', '3']}
+        assert p.raw == None
+        assert p.leading == None
+
+        assert p.set(a2, None) == True
+        assert p.parsed == {a1: ['val'], a3: ['v', '3']}
+
+        with pytest.raises(RuntimeError, match=reparse_error):
+            p.parse("")
+
+    def test_setting_parameters_after_parse(self, a1, a2, a3):
+        p = ParamStr()
+        in_str = f"{a1}:v1~{a2}"
+        p.parse(in_str)
+        assert p.parsed == {a1: ['v1'], a2: []}
+        assert p.leading == None
+        assert p.raw == in_str
+
+        assert p.set(a3, []) == False
+        assert p.parsed == {a1: ['v1'], a2: [], a3: []}
+        assert p.raw == in_str
+
+        assert p.set(a2, ['v', '2']) == True
+        assert p.set(a1, None) == True
+        assert p.parsed == {a2: ['v', '2'], a3: []}
+        assert p.raw == in_str
+
+    def test_setting_parameters_with_defaults(self, a1, a2, a3, a4):
+        defs = {a1: 'v1', a2: [], a3: ['v', '3']}
+        p = ParamStr(defaults=defs)
+        assert p.set(a1, 'val') == True
+        assert p.parsed == {a1: ['val'], a2: [], a3: ['v', '3']}
+
+        p = ParamStr(defaults=defs)
+        with pytest.raises(RuntimeError, match=self.not_allowed_param_msg(a4)):
+            p.set(a4, None)
+        # This will still initialize the ParamStr
+        assert p.parsed == {a1: ['v1'], a2: [], a3: ['v', '3']}
+
+        p = ParamStr(defaults=defs, allows_non_defaults=True)
+        assert p.set(a2, None) == True
+        assert p.set(a4, []) == False
+        assert p.parsed == {a1: ['v1'], a3: ['v', '3'], a4: []}
+
+    @pytest.mark.skip
+    def test_setting_with_dict(self):
+        fail
+
+    @pytest.mark.skip
+    def test_duplicate_param(self):
+        fail
+
+    def test_setting_leading_str(self, a1):
+        p = ParamStr(allows_leading_str=True)
+
+        lead_str = "test_setting"
+        assert p.set_leading(lead_str) == False
+        assert p.leading == lead_str
+        assert str(p) == lead_str
+
+        lead_str2 = "test setting 2"
+        assert p.set_leading(lead_str2) == True
+        assert p.leading == lead_str2
+        p.set(a1, "val")
+        assert str(p) == f"{lead_str2}~{a1}:val"
+
+    def test_setting_leading_str_after_parse(self, a1, a2):
+        p = ParamStr(allows_leading_str=True)
+        in_str = f"~{a1}~{a2}:v"
+        p.parse(in_str)
+        assert p.leading == ''
+        assert p.raw == in_str
+
+        leading = "leading"
+        assert p.set_leading(leading) == True
+        assert p.leading == leading
+        assert p.raw == in_str
+        assert str(p) == f"{leading}{in_str}"
+
+    def test_error_setting_leading_str_if_not_allowed(self, leading_str_not_allowed_msg):
+        p = ParamStr()
+        with pytest.raises(RuntimeError, match=leading_str_not_allowed_msg):
+            p.set_leading("leading")
+
+        p.parse("")
+        assert p.parsed == {}
+        assert p.leading == None
+        with pytest.raises(RuntimeError, match=leading_str_not_allowed_msg):
+            p.set_leading("leading")
+
+    def test_setting_allows_leading_str(self, a1, leading, leading_str_not_allowed_msg):
+        p = ParamStr()
+        assert p.allows_leading_str == False
+
+        assert p.set_allows_leading_str(True) == None
+        assert p.allows_leading_str == True
+        self.assert_unparsed(p)
+        p.set_leading(leading) == True
+
+        p.clear()
+        assert p.allows_leading_str == True
+        p.parse(f"{leading}~{a1}")
+        assert p.leading == leading
+        assert p.parsed == {a1: []}
+
+        p.clear()
+        assert p.set_allows_leading_str(False) == None
+        assert p.allows_leading_str == False
+        with pytest.raises(RuntimeError, match=leading_str_not_allowed_msg):
+            p.set_leading("leading")
+
+    def test_error_setting_allows_leading_str_after_parse(self, leading_str_not_allowed_msg, set_leading_str_after_parse_error_msg):
+        p = ParamStr()
+        assert p.allows_leading_str == False
+        p.parse("")
+
+        with pytest.raises(RuntimeError, match=set_leading_str_after_parse_error_msg):
+            p.set_allows_leading_str(True)
+
+        # Allows leading should still be False
+        assert p.allows_leading_str == False
+        with pytest.raises(RuntimeError, match=leading_str_not_allowed_msg):
+            p.set_leading("leading")
+
+        with pytest.raises(RuntimeError, match=set_leading_str_after_parse_error_msg):
+            p.set_allows_leading_str(False)
+        assert p.allows_leading_str == False
+
+    def test_setting_defaults(self, a1, a2, a3, a4, a5, a6):
+        p = ParamStr()
+        assert p.defaults == None
+        assert p.allows_non_defaults == None
+
+        assert p.add_default(a1, None) == False
+        assert p.add_default(a2, 'v1') == False
+        assert p.add_default(a3, ['v', '2']) == False
+        assert p.allows_non_defaults == False
+        assert p.defaults == {a1: None, a2: ['v1'], a3: ['v', '2']}
+        p.parse("")
+        assert p.parsed == {a2: ['v1'], a3: ['v', '2']}
+
+        defs = {a1: ['v1'], a2: None}
+        p = ParamStr(defaults=defs)
+        assert p.defaults == defs
+        assert p.add_default(a3, None) == False
+        assert p.add_default(a1, None) == True
+        assert p.defaults == {a1: None, a2: None, a3: None}
+
+        assert p.add_defaults({a4: None, a5: ['v' , '5']}) == [False, False]
+        assert p.defaults == {a1: None, a2: None, a3: None, a4: None, a5: ['v', '5']}
+
+        assert p.add_defaults({a2: None, a3: ['v3'], a6: 'v6'}) == [True, True, False]
+        assert p.defaults == {a1: None, a2: None, a3: ['v3'], a4: None, a5: ['v', '5'], a6: ['v6']}
+
+    def test_removing_defaults(self, a1, a2, a3, a4, a5):
+        defs = {a1: None, a2: 'val', a3: ['v', '3'], a4: [], a5: 'v5'}
+        defs_added = {a1: None, a2: ['val'], a3: ['v', '3'], a4: [], a5: ['v5']}
+        p = ParamStr(defaults=defs)
+        assert p.defaults == defs_added
+
+        assert p.remove_default(a1) == None
+        defs_added.pop(a1)
+        assert p.defaults == defs_added
+
+        assert p.remove_default(a2) == ['val']
+        assert p.remove_default(a3) == ['v', '3']
+        assert p.remove_default(a4) == []
+        defs_added.pop(a2)
+        defs_added.pop(a3)
+        defs_added.pop(a4)
+        assert p.defaults == defs_added
+        p.parse("")
+        assert str(p) == f"{a5}:v5"
+
+        p = ParamStr(defaults=defs)
+        assert p.remove_defaults([a1, a3, a4, a5]) == [None, ['v', '3'], [], ['v5']]
+        assert p.defaults == {a2: ['val']}
+
+        with pytest.raises(RuntimeError, match=self.not_allowed_param_msg(a1)):
+            p.parse(f'{a1}:try')
+
+        p.parse(f"{a2}:override")
+        assert str(p) == f"{a2}:override"
+
+    def test_error_removing_missing_default(self, a1, a2, a3):
+        defs = {a1: ['v1'], a2: []}
+        p = ParamStr(defaults=defs)
+        with pytest.raises(RuntimeError, match=self.removing_missing_def_err_msg(a3)):
+            p.remove_default(a3)
+        with pytest.raises(RuntimeError, match=self.removing_missing_def_err_msg(a3)):
+            p.remove_defaults([a1, a3])
+        assert p.defaults == defs
+
+    def test_error_changing_defaults_after_parse(self, a1, a2, a3, a4, defs_update_after_parse_err_msg):
+        defs = {a1: [], a2: None}
+        p = ParamStr(defaults=defs)
+        p.parse("")
+        with pytest.raises(RuntimeError, match=defs_update_after_parse_err_msg):
+            p.add_default(a3, [])
+        assert p.defaults == defs
+
+        with pytest.raises(RuntimeError, match=defs_update_after_parse_err_msg):
+            p.add_defaults({a3: 'val', a4: 'val'})
+        assert p.defaults == defs
+
+        with pytest.raises(RuntimeError, match=defs_update_after_parse_err_msg):
+            p.add_defaults({a1: 'v', a3: 'v3'})
+        assert p.defaults == defs
+
+        with pytest.raises(RuntimeError, match=defs_update_after_parse_err_msg):
+            p.remove_default(a3)
+        assert p.defaults == defs
+
+        with pytest.raises(RuntimeError, match=defs_update_after_parse_err_msg):
+            p.remove_defaults([a3, a4])
+        assert p.defaults == defs
+
+        with pytest.raises(RuntimeError, match=defs_update_after_parse_err_msg):
+            p.remove_defaults([a1, a4])
+        assert p.defaults == defs
+
+    def test_setting_allows_non_defaults(self, a1, a2, a3, update_allows_non_defaults_after_parse_err_msg):
+        p = ParamStr(defaults={a1: [], a2: None})
+        assert p.allows_non_defaults == False
+        assert p.set_allows_non_defaults(True) == None
+        assert p.allows_non_defaults == True
+        p.parse(a3)
+        assert p.parsed == {a1: [], a3: []}
+
+        with pytest.raises(RuntimeError, match=update_allows_non_defaults_after_parse_err_msg):
+            p.set_allows_non_defaults(False)
+        assert p.allows_non_defaults == True
+
+        p = ParamStr(defaults={a1: [], a2: None}, allows_non_defaults=True)
+        assert p.allows_non_defaults == True
+        assert p.set_allows_non_defaults(False) == None
+        with pytest.raises(RuntimeError, match=self.not_allowed_param_msg(a3)):
+            p.parse(a3)
+
 class TestMultiParamStr(Common):
     @classmethod
     def assert_pre_parsed(cls, mps):
diff --git a/rust/origen_metal/src/utils/param_str.rs b/rust/origen_metal/src/utils/param_str.rs
index e35a9f7a..c0507614 100644
--- a/rust/origen_metal/src/utils/param_str.rs
+++ b/rust/origen_metal/src/utils/param_str.rs
@@ -29,7 +29,30 @@ impl ParamStr {
         }
     }
 
+    pub fn init_parsed(&mut self, allow_parsed: bool) -> Result<bool> {
+        if self.parsed.is_some() {
+            if allow_parsed {
+                return Ok(false);
+            } else {
+                bail!("ParamStr has already been parsed. Please use 'param_str.clear()', before parsing new input");
+            }
+        }
+        let mut parsed = IndexMap::new();
+        if let Some(defs) = self.defaults.as_ref() {
+            for (name, def_val) in &defs.1 {
+                if let Some(v) = def_val {
+                    parsed.insert(name.to_owned(), v.to_owned());
+                }
+            }
+        }
+        self.parsed = Some(parsed);
+        Ok(true)
+    }
+
     pub fn parse(&mut self, input: String) -> Result<&Self> {
+        if self.parsed.is_some() {
+            bail!("ParamStr has already been parsed. Please use 'param_str.clear()', before parsing new input");
+        }
         let input_inter;
         let leading;
         let mut parsed = IndexMap::new();
@@ -139,6 +162,35 @@ impl ParamStr {
         Ok(self)
     }
 
+    fn param_set(&mut self, param: String, val: Option<Vec<String>>) -> Result<bool> {
+        match self.parsed.as_mut() {
+            Some(params) => {
+                let retn = params.contains_key(&param);
+                if let Some(defs) = self.defaults.as_ref() {
+                    if (!defs.0) && (!defs.1.contains_key(&param)) {
+                        bail!("ParamStr encountered parameter '{}', which is not an expected parameter", &param);
+                    }
+                }
+                if let Some(v) = val {
+                    params.insert(param, v);
+                } else {
+                    params.remove(&param);
+                }
+                Ok(retn)
+            },
+            None => bail!(*NOT_PARSED_MSG)
+        }
+    }
+
+    pub fn set_param(&mut self, param: String, val: Option<Vec<String>>) -> Result<bool> {
+        if self.parsed.is_some() {
+            self.param_set(param, val)
+        } else {
+            self.init_parsed(true)?;
+            self.param_set(param, val)
+        }
+    }
+
     pub fn defaults(&self) -> Option<&IndexMap<String, Option<Vec<String>>>> {
         if let Some(defs) = self.defaults.as_ref() {
             Some(&defs.1)
@@ -147,6 +199,64 @@ impl ParamStr {
         }
     }
 
+    pub fn with_mut_defs<T, F>(&mut self, f: F) -> Result<T>
+    where
+        F: FnOnce(&mut (bool, IndexMap<String, Option<Vec<String>>>)) -> Result<T>,
+    {
+        if self.parsed.is_some() {
+            bail!("Attempted to update ParamStr's default values after parsing, which is not allowed")
+        }
+        if self.defaults.is_none() {
+            self.defaults = Some((false, IndexMap::new()));
+        }
+        f(self.defaults.as_mut().unwrap())
+    }
+
+    pub fn add_default(&mut self, def: String, value: Option<Vec<String>>) -> Result<bool> {
+        self.with_mut_defs( |defs| {
+            let retn = defs.1.contains_key(&def);
+            defs.1.insert(def, value);
+            Ok(retn)
+        })
+    }
+
+    pub fn add_defaults(&mut self, to_add: IndexMap<String, Option<Vec<String>>>) -> Result<Vec<bool>> {
+        self.with_mut_defs( |defs| {
+            let mut retn = vec!();
+            for (name, val) in to_add {
+                retn.push(defs.1.contains_key(&name));
+                defs.1.insert(name, val);
+            }
+            Ok(retn)
+        })
+    }
+
+    pub fn remove_default(&mut self, to_remove: &str) -> Result<Option<Vec<String>>> {
+        self.with_mut_defs( |defs| {
+            match defs.1.shift_remove(to_remove) {
+                Some(value) => Ok(value),
+                None => bail!("No parameter '{}' to remove from ParamStr's defaults", to_remove)
+            }
+        })
+    }
+
+    pub fn remove_defaults(&mut self, to_remove: &Vec<String>) -> Result<Vec<Option<Vec<String>>>> {
+        self.with_mut_defs( |defs| {
+            // Check that all keys are valid first
+            for name in to_remove {
+                if !defs.1.contains_key(name) {
+                    bail!("No parameter '{}' to remove from ParamStr's defaults", name)
+                }
+            }
+
+            let mut retn = vec!();
+            for name in to_remove {
+                retn.push(defs.1.shift_remove(name).unwrap());
+            }
+            Ok(retn)
+        })
+    }
+
     pub fn allows_non_defaults(&self) -> Option<bool> {
         if let Some(setup) = self.defaults.as_ref() {
             Some(setup.0)
@@ -155,6 +265,16 @@ impl ParamStr {
         }
     }
 
+    pub fn set_allows_non_defaults(&mut self, new_val: bool) -> Result<()> {
+        if self.parsed.is_some() {
+            bail!("Cannot set ParamStr's allows_non_defaults with no default parameters");
+        }
+        self.with_mut_defs( |defs| {
+            defs.0 = new_val;
+            Ok(())
+        })
+    }
+
     pub fn parsed(&self) -> &Option<IndexMap<String, Vec<String>>> {
         &self.parsed
     }
@@ -167,6 +287,13 @@ impl ParamStr {
         }
     }
 
+    pub fn clear(&mut self) -> Result<&mut Self> {
+        self.parsed = None;
+        self.leading = None;
+        self.raw = None;
+        Ok(self)
+    }
+
     pub fn raw(&self) -> Result<&Option<String>> {
         if self.parsed.is_some() {
             Ok(&self.raw)
@@ -183,10 +310,28 @@ impl ParamStr {
         }
     }
 
+    pub fn set_leading(&mut self, new_leading: Option<String>) -> Result<bool> {
+        if !self.allows_leading_str {
+            bail!("Attempted to set leading value but 'allows_leading_str' is not allowed")
+        }
+        self.init_parsed(true)?;
+        let retn = self.leading.is_some();
+        self.leading = new_leading;
+        Ok(retn)
+    }
+
     pub fn allows_leading_str(&self) -> bool {
         self.allows_leading_str
     }
-    
+
+    pub fn set_allows_leading_str(&mut self, new_val: bool) -> Result<()> {
+        if self.parsed.is_some() {
+            bail!("Attempted to change ParamStr's 'allows_leading_str' setting after parsing, which is not allowed");
+        }
+        self.allows_leading_str = new_val;
+        Ok(())
+    }
+
     pub fn get(&self, key: &str) -> Result<Option<&Vec<String>>> {
         if let Some(args) = self.parsed.as_ref() {
             Ok(args.get(key))
@@ -197,13 +342,22 @@ impl ParamStr {
 
     pub fn to_string(&self) -> Result<String> {
         if let Some(args) = self.parsed.as_ref() {
-            Ok(args.iter().map(|(k, v)| {
+            let params = args.iter().map(|(k, v)| {
                 if v.is_empty() {
                     k.clone()
                 } else {
                     format!("{}:{}", k, v.join(":"))
                 }
-            }).collect::<Vec<String>>().join("~"))
+            }).collect::<Vec<String>>().join("~");
+            if let Some(leading) = self.leading.as_ref() {
+                if params.is_empty() {
+                    Ok(leading.clone())
+                } else {
+                    Ok(format!("{}~{}", leading, params))
+                }
+            } else {
+                Ok(params)
+            }
         } else {
             bail!(*NOT_PARSED_MSG);
         }
diff --git a/rust/pyapi/Cargo.lock b/rust/pyapi/Cargo.lock
index ab5b2f6b..6410b497 100644
--- a/rust/pyapi/Cargo.lock
+++ b/rust/pyapi/Cargo.lock
@@ -2132,6 +2132,15 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
+[[package]]
+name = "minijinja"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c37e1b517d1dcd0e51dc36c4567b9d5a29262b3ec8da6cb5d35e27a8fb529b5"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "minimal-lexical"
 version = "0.2.1"
@@ -2636,6 +2645,7 @@ dependencies = [
  "ldap3",
  "lettre",
  "md-5",
+ "minijinja",
  "num-bigint 0.4.5",
  "num-traits",
  "octocrab",
diff --git a/rust/pyapi_metal/src/utils/param_str.rs b/rust/pyapi_metal/src/utils/param_str.rs
index d2a2dda6..6f9e10fc 100644
--- a/rust/pyapi_metal/src/utils/param_str.rs
+++ b/rust/pyapi_metal/src/utils/param_str.rs
@@ -62,6 +62,10 @@ impl ParamStr {
         }
     }
 
+    pub fn set_allows_non_defaults(&mut self, new_allows_non_defaults: bool) -> PyResult<()> {
+        Ok(self.om.set_allows_non_defaults(new_allows_non_defaults)?)
+    }
+
     #[getter]
     pub fn defaults<'py>(&self, py: Python<'py>) -> PyResult<Option<&'py PyDict>> {
         if let Some(defs) = self.om.defaults() {
@@ -75,11 +79,41 @@ impl ParamStr {
         }
     }
 
+    pub fn add_default(&mut self, name: String, value: &PyAny) -> PyResult<bool> {
+        Ok(self.om.add_default(name, Self::extract_param_value(value)?)?)
+    }
+
+    pub fn add_defaults(&mut self, to_add: &PyDict) -> PyResult<Vec<bool>> {
+        Ok(self.om.add_defaults(Self::extract_defaults(to_add)?)?)
+    }
+
+    pub fn remove_default(&mut self, to_remove: &str) -> PyResult<Option<Vec<String>>> {
+        Ok(self.om.remove_default(to_remove)?)
+    }
+
+    pub fn remove_defaults(&mut self, to_remove: Vec<String>) -> PyResult<Vec<Option<Vec<String>>>> {
+        Ok(self.om.remove_defaults(&to_remove)?)
+    }
+
     pub fn parse(mut slf: PyRefMut<Self>, input: String) -> PyResult<PyRefMut<Self>> {
         slf.om.parse(input)?;
         Ok(slf)
     }
 
+    /// If the ParamStr fails to parse, returns the exception instead of raising one.
+    /// Non-ParamStr parse exceptions will still be raised (such is missing the input argument)
+    pub fn try_parse<'py>(mut slf: PyRefMut<Self>, py: Python<'py>, input: String) -> PyResult<PyObject> {
+        Ok(match slf.om.parse(input) {
+            Ok(_) => slf.into_py(py),
+            Err(e) => runtime_exception!(e.msg).to_object(py)
+        })
+    }
+
+    pub fn clear(mut slf: PyRefMut<Self>) -> PyResult<PyRefMut<Self>> {
+        slf.om.clear()?;
+        Ok(slf)
+    }
+
     #[getter]
     pub fn raw(&self) -> PyResult<Option<String>> {
         Ok(self.om.raw()?.to_owned())
@@ -90,11 +124,19 @@ impl ParamStr {
         Ok(self.om.leading()?.to_owned())
     }
 
+    pub fn set_leading(&mut self, new_leading: Option<String>) -> PyResult<bool> {
+        Ok(self.om.set_leading(new_leading)?)
+    }
+
     #[getter]
     pub fn allows_leading_str(&self) -> PyResult<bool> {
         Ok(self.om.allows_leading_str())
     }
 
+    pub fn set_allows_leading_str(&mut self, new_leading: bool) -> PyResult<()> {
+        Ok(self.om.set_allows_leading_str(new_leading)?)
+    }
+
     fn to_str(&self) -> PyResult<String> {
         Ok(self.om.to_string()?)
     }
@@ -137,7 +179,7 @@ impl ParamStr {
 
     fn __iter__(slf: PyRefMut<Self>) -> PyResult<ParamStrIter> {
         Ok(ParamStrIter {
-            keys: slf.keys().unwrap(),
+            keys: slf.keys()?,
             i: 0,
         })
     }
@@ -154,6 +196,24 @@ impl ParamStr {
             _ => Ok(py.NotImplemented()),
         })
     }
+
+    fn dup(&self) -> PyResult<Self> {
+        Ok(Self {
+            om: self.om.clone()
+        })
+    }
+
+    fn duplicate(&self) -> PyResult<Self> {
+        self.dup()
+    }
+
+    fn set_param(&mut self, param: String, value: &PyAny) -> PyResult<bool> {
+        Ok(self.om.set_param(param, Self::extract_param_value(value)?)?)
+    }
+
+    fn set(&mut self, param: String, value: &PyAny) -> PyResult<bool> {
+        self.set_param(param, value)
+    }
 }
 
 impl ParamStr {
@@ -166,26 +226,32 @@ impl ParamStr {
     fn new_om(allows_leading_str: bool, defaults: Option<&PyDict>, allows_non_defaults: Option<bool>) -> Result<OmParamStr> {
         let om_defaults;
         if let Some(defs) = defaults {
-            let mut om_defs = IndexMap::new();
-            for (key, default) in defs {
-                let def;
-                if let Ok(s) = default.extract::<String>() {
-                    def = Some(vec!(s));
-                } else if let Ok(v) = default.extract::<Vec<String>>() {
-                    def = Some(v);
-                } else if default.is_none() {
-                    def = None
-                } else {
-                    bail!("ParamStr default value must be either None, a str, or list of strs");
-                }
-                om_defs.insert(key.extract::<String>()?, def);
-            }
-            om_defaults = Some((allows_non_defaults.unwrap_or(false), om_defs))
+            om_defaults = Some((allows_non_defaults.unwrap_or(false), Self::extract_defaults(defs)?));
         } else {
             om_defaults = None
         }
         Ok(OmParamStr::new(allows_leading_str, om_defaults))
     }
+
+    fn extract_defaults(defs: &PyDict) -> PyResult<IndexMap<String, Option<Vec<String>>>> {
+        let mut om_defs = IndexMap::new();
+        for (key, default) in defs {
+            om_defs.insert(key.extract::<String>()?, Self::extract_param_value(default)?);
+        }
+        Ok(om_defs)
+    }
+
+    fn extract_param_value(val: &PyAny) -> Result<Option<Vec<String>>> {
+        Ok(if let Ok(s) = val.extract::<String>() {
+            Some(vec!(s))
+        } else if let Ok(v) = val.extract::<Vec<String>>() {
+            Some(v)
+        } else if val.is_none() {
+            None
+        } else {
+            bail!("ParamStr value must be either None, a str, or list of strs");
+        })
+    }
 }
 
 #[pyclass]