summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Scheibel <scheibel1@llnl.gov>2024-01-17 17:11:27 -0800
committerGitHub <noreply@github.com>2024-01-17 17:11:27 -0800
commit453ecdb77e5b30b6b42685ea16f3838fb424880c (patch)
treefbce5086e711b7c9e92009a62d6726af49a2800f
parent796d251061b0f222d5b2aa51aada0d5326274df7 (diff)
downloadspack-453ecdb77e5b30b6b42685ea16f3838fb424880c.tar.gz
spack-453ecdb77e5b30b6b42685ea16f3838fb424880c.tar.bz2
spack-453ecdb77e5b30b6b42685ea16f3838fb424880c.tar.xz
spack-453ecdb77e5b30b6b42685ea16f3838fb424880c.zip
Config path quote handling: keys with quotes (#40976)
As observed in #40944, when using `spack config add <path>`, the `path` might contain keys that are enclosed in quotes. This was broken in https://github.com/spack/spack/pull/39831, which assumed that only the value (if present, the final element of the path) would use quotes. This preserves the primary intended behavior of #39931 (allowing ":" in values when using `spack config add`) while also allowing quotes on keys. This has complicated the function `process_config_path`, but: * It is not used outside of `config.py` * The docstring has been updated to account for this * Created an object to formalize the DSL, added a test for that, and refactored parsing to make use of regular expressions as well. * Updated the parsing and also updated the `config_path_dsl` test with an explicit check. At a higher level, split the parsing to check if something is either a key or not: * in the first case, it is covered by a regex * in the second, it may be a YAML value, but in that case it would have to be the last entry of x:y:z, so in that case I attempt to use the YAML handling logic to parse it as such
-rw-r--r--lib/spack/spack/config.py178
-rw-r--r--lib/spack/spack/test/config.py32
2 files changed, 163 insertions, 47 deletions
diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py
index 2ff51e085c..d13cc643a2 100644
--- a/lib/spack/spack/config.py
+++ b/lib/spack/spack/config.py
@@ -638,7 +638,6 @@ class Configuration:
We use ``:`` as the separator, like YAML objects.
"""
- # TODO: Currently only handles maps. Think about lists if needed.
parts = process_config_path(path)
section = parts.pop(0)
@@ -883,7 +882,9 @@ def add(fullpath: str, scope: Optional[str] = None) -> None:
has_existing_value = True
path = ""
override = False
- value = syaml.load_config(components[-1])
+ value = components[-1]
+ if not isinstance(value, syaml.syaml_str):
+ value = syaml.load_config(value)
for idx, name in enumerate(components[:-1]):
# First handle double colons in constructing path
colon = "::" if override else ":" if path else ""
@@ -905,7 +906,7 @@ def add(fullpath: str, scope: Optional[str] = None) -> None:
# construct value from this point down
for component in reversed(components[idx + 1 : -1]):
- value = {component: value}
+ value: Dict[str, str] = {component: value} # type: ignore[no-redef]
break
if override:
@@ -916,7 +917,7 @@ def add(fullpath: str, scope: Optional[str] = None) -> None:
# append values to lists
if isinstance(existing, list) and not isinstance(value, list):
- value = [value]
+ value: List[str] = [value] # type: ignore[no-redef]
# merge value into existing
new = merge_yaml(existing, value)
@@ -1336,56 +1337,141 @@ def merge_yaml(dest, source, prepend=False, append=False):
return copy.copy(source)
+class ConfigPath:
+ quoted_string = "(?:\"[^\"]+\")|(?:'[^']+')"
+ unquoted_string = "[^:'\"]+"
+ element = rf"(?:(?:{quoted_string})|(?:{unquoted_string}))"
+ next_key_pattern = rf"({element}[+-]?)(?:\:|$)"
+
+ @staticmethod
+ def _split_front(string, extract):
+ m = re.match(extract, string)
+ if not m:
+ return None, None
+ token = m.group(1)
+ return token, string[len(token) :]
+
+ @staticmethod
+ def _validate(path):
+ """Example valid config paths:
+
+ x:y:z
+ x:"y":z
+ x:y+:z
+ x:y::z
+ x:y+::z
+ x:y:
+ x:y::
+ """
+ first_key, path = ConfigPath._split_front(path, ConfigPath.next_key_pattern)
+ if not first_key:
+ raise ValueError(f"Config path does not start with a parse-able key: {path}")
+ path_elements = [first_key]
+ path_index = 1
+ while path:
+ separator, path = ConfigPath._split_front(path, r"(\:+)")
+ if not separator:
+ raise ValueError(f"Expected separator for {path}")
+
+ path_elements[path_index - 1] += separator
+ if not path:
+ break
+
+ element, remainder = ConfigPath._split_front(path, ConfigPath.next_key_pattern)
+ if not element:
+ # If we can't parse something as a key, then it must be a
+ # value (if it's valid).
+ try:
+ syaml.load_config(path)
+ except spack.util.spack_yaml.SpackYAMLError as e:
+ raise ValueError(
+ "Remainder of path is not a valid key"
+ f" and does not parse as a value {path}"
+ ) from e
+ element = path
+ path = None # The rest of the path was consumed into the value
+ else:
+ path = remainder
+
+ path_elements.append(element)
+ path_index += 1
+
+ return path_elements
+
+ @staticmethod
+ def process(path):
+ result = []
+ quote = "['\"]"
+ seen_override_in_path = False
+
+ path_elements = ConfigPath._validate(path)
+ last_element_idx = len(path_elements) - 1
+ for i, element in enumerate(path_elements):
+ override = False
+ append = False
+ prepend = False
+ quoted = False
+ if element.endswith("::") or (element.endswith(":") and i == last_element_idx):
+ if seen_override_in_path:
+ raise syaml.SpackYAMLError(
+ "Meaningless second override indicator `::' in path `{0}'".format(path), ""
+ )
+ override = True
+ seen_override_in_path = True
+ element = element.rstrip(":")
+
+ if element.endswith("+"):
+ prepend = True
+ elif element.endswith("-"):
+ append = True
+ element = element.rstrip("+-")
+
+ if re.match(f"^{quote}", element):
+ quoted = True
+ element = element.strip("'\"")
+
+ if any([append, prepend, override, quoted]):
+ element = syaml.syaml_str(element)
+ if append:
+ element.append = True
+ if prepend:
+ element.prepend = True
+ if override:
+ element.override = True
+
+ result.append(element)
+
+ return result
+
+
def process_config_path(path: str) -> List[str]:
"""Process a path argument to config.set() that may contain overrides ('::' or
trailing ':')
- Note: quoted value path components will be processed as a single value (escaping colons)
- quoted path components outside of the value will be considered ill formed and will
- raise.
- e.g. `this:is:a:path:'value:with:colon'` will yield:
+ Colons will be treated as static strings if inside of quotes,
+ e.g. `this:is:a:path:'value:with:colon'` will yield:
- [this, is, a, path, value:with:colon]
- """
- result = []
- if path.startswith(":"):
- raise syaml.SpackYAMLError(f"Illegal leading `:' in path `{path}'", "")
- seen_override_in_path = False
- while path:
- front, sep, path = path.partition(":")
- if (sep and not path) or path.startswith(":"):
- if seen_override_in_path:
- raise syaml.SpackYAMLError(
- f"Meaningless second override indicator `::' in path `{path}'", ""
- )
- path = path.lstrip(":")
- front = syaml.syaml_str(front)
- front.override = True # type: ignore[attr-defined]
- seen_override_in_path = True
-
- elif front.endswith("+"):
- front = front.rstrip("+")
- front = syaml.syaml_str(front)
- front.prepend = True # type: ignore[attr-defined]
-
- elif front.endswith("-"):
- front = front.rstrip("-")
- front = syaml.syaml_str(front)
- front.append = True # type: ignore[attr-defined]
-
- result.append(front)
+ [this, is, a, path, value:with:colon]
- quote = "['\"]"
- not_quote = "[^'\"]"
+ The path may consist only of keys (e.g. for a `get`) or may end in a value.
+ Keys are always strings: if a user encloses a key in quotes, the quotes
+ should be removed. Values with quotes should be treated as strings,
+ but without quotes, may be parsed as a different yaml object (e.g.
+ '{}' is a dict, but '"{}"' is a string).
- if re.match(f"^{quote}", path):
- m = re.match(rf"^({quote}{not_quote}+{quote})$", path)
- if not m:
- raise ValueError("Quotes indicate value, but there are additional path entries")
- result.append(m.group(1))
- break
+ This function does not know whether the final element of the path is a
+ key or value, so:
+
+ * It must strip the quotes, in case it is a key (so we look for "key" and
+ not '"key"'))
+ * It must indicate somehow that the quotes were stripped, in case it is a
+ value (so that we don't process '"{}"' as a YAML dict)
- return result
+ Therefore, all elements with quotes are stripped, and then also converted
+ to ``syaml_str`` (if treating the final element as a value, the caller
+ should not parse it in this case).
+ """
+ return ConfigPath.process(path)
#
diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py
index 491931da20..83a652cd41 100644
--- a/lib/spack/spack/test/config.py
+++ b/lib/spack/spack/test/config.py
@@ -284,6 +284,13 @@ def test_add_config_path(mutable_config):
set_value = spack.config.get("config")["install_tree"]["projections"]["cmake"]
assert set_value == "{architecture}/{compiler.name}-{compiler.version}/{name}-{version}-{hash}"
+ path = 'modules:default:tcl:all:environment:set:"{name}_ROOT":"{prefix}"'
+ spack.config.add(path)
+ set_value = spack.config.get("modules")["default"]["tcl"]["all"]["environment"]["set"]
+ assert r"{name}_ROOT" in set_value
+ assert set_value[r"{name}_ROOT"] == r"{prefix}"
+ assert spack.config.get('modules:default:tcl:all:environment:set:"{name}_ROOT"') == r"{prefix}"
+
# NOTE:
# The config path: "config:install_tree:root:<path>" is unique in that it can accept multiple
# schemas (such as a dropped "root" component) which is atypical and may lead to passing tests
@@ -1188,7 +1195,7 @@ def test_set_dict_override(mock_low_high_config, write_config_file):
def test_set_bad_path(config):
- with pytest.raises(syaml.SpackYAMLError, match="Illegal leading"):
+ with pytest.raises(ValueError):
with spack.config.override(":bad:path", ""):
pass
@@ -1462,3 +1469,26 @@ def test_config_file_read_invalid_yaml(tmpdir, mutable_empty_config):
with pytest.raises(spack.config.ConfigFileError, match="parsing YAML"):
spack.config.read_config_file(filename)
+
+
+@pytest.mark.parametrize(
+ "path,it_should_work,expected_parsed",
+ [
+ ("x:y:z", True, ["x:", "y:", "z"]),
+ ("x+::y:z", True, ["x+::", "y:", "z"]),
+ ('x:y:"{z}"', True, ["x:", "y:", '"{z}"']),
+ ('x:"y"+:z', True, ["x:", '"y"+:', "z"]),
+ ('x:"y"trail:z', False, None),
+ ("x:y:[1.0]", True, ["x:", "y:", "[1.0]"]),
+ ("x:y:['1.0']", True, ["x:", "y:", "['1.0']"]),
+ ("x:{y}:z", True, ["x:", "{y}:", "z"]),
+ ("x:'{y}':z", True, ["x:", "'{y}':", "z"]),
+ ("x:{y}", True, ["x:", "{y}"]),
+ ],
+)
+def test_config_path_dsl(path, it_should_work, expected_parsed):
+ if it_should_work:
+ assert spack.config.ConfigPath._validate(path) == expected_parsed
+ else:
+ with pytest.raises(ValueError):
+ spack.config.ConfigPath._validate(path)