diff --git a/CHANGELOG.md b/CHANGELOG.md index e9681602..f1255e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- `ConfDict`: added `ConfDict` operations `ConfDict.ops.DELETE`, `ConfDict.ops.BEFORE`, and `ConfDict.ops.AFTER`; `ConfDict.ops.REPLACE` replaces `ConfDict.OVERRIDE`. [#310] +- Config YAML dumps now preserve key order; cache uid checks tolerate key-order-only `uid.yaml` changes for compatibility. [#310] - `ConfDict`: `pop`/`del` no longer prune emptied parent mappings; an empty mapping is a value like any other. [#311] ## 0.5.28 - 26-07-20 diff --git a/exca/confdict.py b/exca/confdict.py index 0c79de90..47e54c1f 100644 --- a/exca/confdict.py +++ b/exca/confdict.py @@ -13,6 +13,7 @@ import re import sys import typing as tp +import warnings from collections import OrderedDict, abc from pathlib import Path, PosixPath, WindowsPath @@ -25,17 +26,79 @@ logger = logging.getLogger(__name__) Mapping = tp.MutableMapping[str, tp.Any] | tp.Iterable[tuple[str, tp.Any]] _sentinel = object() -OVERRIDE = "=replace=" + + +class ConfDictOps: + _PATTERN = re.compile(r"=.+=") + REPLACE = "=replace=" + DELETE = "=delete=" + BEFORE = "=before=" + AFTER = "=after=" + + @classmethod + def is_op(cls, value: tp.Any) -> tp.TypeGuard[str]: + """True for a known op, False for a non-op token, and raises on an + op-shaped string (:code:`=...=`) that is not a known op (typo guard). + """ + if not isinstance(value, str) or cls._PATTERN.fullmatch(value) is None: + return False + if value not in {cls.REPLACE, cls.DELETE, cls.BEFORE, cls.AFTER}: + msg = f"Unknown ConfDict op {value!r} (key or values with format " + msg += "'==' are reserved for operations)" + raise ValueError(msg) + return True + + +class _ConfDictMeta(type): + @property + def OVERRIDE(cls) -> str: # noqa: N802 + warnings.warn( + "ConfDict.OVERRIDE is deprecated, use ConfDict.ops.REPLACE", + DeprecationWarning, + stacklevel=2, + ) + return ConfDictOps.REPLACE def _is_seq(val: tp.Any) -> tp.TypeGuard[tp.Sequence[tp.Any]]: return isinstance(val, abc.Sequence) and not isinstance(val, str) +def _apply_move(obj: dict[str, tp.Any], key: str) -> None: + sub = obj[key] + present = [op for op in (ConfDict.ops.BEFORE, ConfDict.ops.AFTER) if op in sub] + if len(present) > 1: + raise ValueError( + f"Use either {ConfDict.ops.BEFORE!r} or {ConfDict.ops.AFTER!r}, not both" + ) + if not present: + return + op = present[0] + anchor = sub[op] + if not isinstance(anchor, str): + raise TypeError(f"{op!r} expects a key name, got {anchor!r}") + if anchor == key: + raise ValueError(f"Cannot move {key!r} relative to itself") + if anchor not in obj: + return + dict.pop(sub, op, None) + value = dict.pop(obj, key) + items = list(obj.items()) + obj.clear() + for name, item in items: + if op == ConfDict.ops.BEFORE and name == anchor: + dict.__setitem__(obj, key, value) + dict.__setitem__(obj, name, item) + if op == ConfDict.ops.AFTER and name == anchor: + dict.__setitem__(obj, key, value) + + def _propagate_confdict(obj: tp.Any, replace_dicts: bool = False) -> tp.Any: """Recursively cast content of list and ordered dict to to confdicts""" - # Note: avoid replacing native dicts as they may contain OVERRIDE tag + # Note: avoid replacing native dicts as they may contain ConfDict.ops.REPLACE tag # which needs to be processed later on + if isinstance(obj, ConfDict): + return obj if isinstance(obj, OrderedDict): sub = {x: _propagate_confdict(y, replace_dicts=True) for x, y in obj.items()} return OrderedDict(sub) @@ -46,45 +109,67 @@ def _propagate_confdict(obj: tp.Any, replace_dicts: bool = False) -> tp.Any: return Container([_propagate_confdict(v, replace_dicts=True) for v in obj]) # type: ignore if isinstance(obj, dict): return obj - if isinstance(obj, ConfDict): - return obj return obj def _set_item(obj: tp.Any, key: str, val: tp.Any) -> None: """Internal recursive setitem on ConfDict/list""" p, *rest = key.split(".", maxsplit=1) + if not rest: + val = _propagate_confdict(val, replace_dicts=False) + if ConfDict.ops.is_op(val) and val != ConfDict.ops.DELETE: + raise ValueError(f"Unexpected ConfDict op value {val!r}") + if isinstance(obj, dict): + ConfDict.ops.is_op(p) # reject op-shaped typos used as keys if isinstance(obj, dict): + existed = p in obj sub = obj.setdefault(p, ConfDict()) elif _is_seq(obj) and p.isdigit(): p = int(p) # type: ignore + existed = True sub = obj[p] # type: ignore else: raise TypeError(f"Cannot handle key {p!r} on existing container {obj!r}") - # replace sub by dict if not dict or sequence - if not _is_seq(sub) and not isinstance(sub, dict): - sub = ConfDict() - if _is_seq(obj): - obj[p] = sub # type: ignore - else: - dict.__setitem__(obj, p, sub) if rest: + # replace sub by dict if not dict or sequence + if not _is_seq(sub) and not isinstance(sub, dict): + sub = ConfDict() + if _is_seq(obj): + obj[p] = sub # type: ignore + else: + dict.__setitem__(obj, p, sub) _set_item(sub, rest[0], val) return # final part - val = _propagate_confdict(val, replace_dicts=False) + if val == ConfDict.ops.DELETE and existed: + if _is_seq(obj): + raise TypeError(f"Cannot delete key {p!r} on existing sequence {obj!r}") + dict.pop(obj, p, None) + return # list case if _is_seq(obj): obj[p] = val # type: ignore return if isinstance(val, dict) and not isinstance(val, OrderedDict): - obj[p].update(val) + if isinstance(sub, OrderedDict): + patched = ConfDict(sub) + patched.update(val) # degrades to dict + sub.clear() + sub.update(OrderedDict(patched.items())) + elif not isinstance(sub, ConfDict): + sub = ConfDict(sub) if isinstance(sub, dict) else ConfDict() + dict.__setitem__(obj, p, sub) + sub.update(val) + else: + sub.update(val) + _apply_move(obj, p) else: dict.__setitem__(obj, p, val) -class ConfDict(dict[str, tp.Any]): - """Dictionary which breaks into sub-dictionnaries on "." as in a config (see example) +class ConfDict(dict[str, tp.Any], metaclass=_ConfDictMeta): + """Dictionary which breaks into sub-dictionnaries on "." as in a config (see example), + and merges recursively. The data can be specified either through "." keywords or directly through sub-dicts or a mixture of both. Lists of dictionaries are processed as list of ConfDict @@ -97,14 +182,13 @@ class ConfDict(dict[str, tp.Any]): Note ---- - This is designed for configurations, so it probably does not scale well to 100k+ keys - - dicts are merged expect if containing the key :code:`"=replace="`, - in which case they replace the content. On the other hand, non-dicts always - replace the content. + - Updates apply patches: mappings that merge dict values recursively and + may contain operation tokens. See :code:`ConfDict.update`. """ LATEST_UID_VERSION = 3 UID_VERSION = int(os.environ.get("CONFDICT_UID_VERSION", LATEST_UID_VERSION)) - OVERRIDE = OVERRIDE # convenient to have it here + ops = ConfDictOps def __init__(self, mapping: Mapping | None = None, **kwargs: tp.Any) -> None: super().__init__() @@ -184,9 +268,34 @@ def pop(self, key: str, default: tp.Any = _sentinel) -> tp.Any: def update( # type: ignore self, mapping: Mapping | None = None, **kwargs: tp.Any ) -> None: - """Updates recursively the keys of the confdict. - No key is removed unless a sub-dictionary contains :code:`"=replace=": True`, - in this case the existing keys in the sub-dictionary are wiped + """Apply a patch using the ConfDict merge rules. + + Rules + ----- + - dict values merge recursively. + - non-dict values replace existing values. + - operations (see below) apply top to bottom; unresolved ops stay in the config until + applicable. + + Operations + ----------- + - :code:`ConfDict.ops.DELETE` as value to a key deletes the key altogether. + - :code:`ConfDict.ops.REPLACE` as key with True value replaces the whole + existing content with the new one instead of merging recursively. + - :code:`ConfDict.ops.BEFORE` and :code:`ConfDict.ops.AFTER` reorders a key + relative to an existing sibling. + + Example + ------- + >>> cfg = ConfDict({"a": {"x": 1}, "b": {"x": 2}}) + >>> cfg.update( + ... { + ... "a": {ConfDict.ops.REPLACE: True, ConfDict.ops.AFTER: "b", "y": 4}, + ... "b": {"y": 12}, + ... } + ... ) + >>> cfg + {'b': {'x': 2, 'y': 12}, 'a': {'y': 4}} """ if mapping is not None: if not isinstance(mapping, abc.Mapping): @@ -194,10 +303,12 @@ def update( # type: ignore kwargs.update(mapping) if not kwargs: return - if kwargs.pop(OVERRIDE, False): + if self and kwargs.pop(ConfDict.ops.REPLACE, False): self.clear() for key, val in kwargs.items(): - self[key] = val + if not isinstance(key, str): + raise TypeError(f"ConfDict only supports str keys, got {key!r}") + _set_item(self, key, val) def flat(self) -> dict[str, tp.Any]: """Returns a flat dictionary such as @@ -209,7 +320,10 @@ def copy(self) -> tp.Self: return self.__class__(super().copy()) @classmethod - def from_yaml(cls, yaml: str | Path | tp.IO[str] | tp.IO[bytes]) -> "ConfDict": + def from_yaml( + cls, + yaml: str | Path | tp.IO[str] | tp.IO[bytes], + ) -> "ConfDict": """Loads a ConfDict from a yaml string/filepath/file handle.""" input_ = yaml if isinstance(yaml, str): @@ -225,13 +339,15 @@ def from_yaml(cls, yaml: str | Path | tp.IO[str] | tp.IO[bytes]) -> "ConfDict": out = _yaml.safe_load(yaml) if not isinstance(out, dict): raise TypeError(f"Cannot convert non-dict yaml:\n{out}\n(from {input_})") - return ConfDict(out) + conf = ConfDict() + conf.update(out) + return conf def to_yaml(self, filepath: Path | str | None = None) -> str: """Exports the ConfDict to yaml string and optionally to a file if a filepath is provided """ - out: str = _yaml.safe_dump(_to_simplified_dict(self), sort_keys=True) + out: str = _yaml.safe_dump(_to_simplified_dict(self), sort_keys=False) if filepath is not None: Path(filepath).write_text(out, encoding="utf8") return out @@ -360,6 +476,10 @@ def __init__(self, data: tp.Any, version: int | None = None) -> None: self.hash = h typestr = "array" elif isinstance(data, dict): + for k in data: + # _to_simplified_dict may fuse an op into a dotted key (a.=op=) + if any(ConfDict.ops.is_op(p) for p in k.split(".")): + raise ValueError(f"Cannot compute uid on unresolved config {k!r}") udata = {x: UidMaker(y, version=version) for x, y in data.items()} if version > 2: if isinstance(data, OrderedDict): @@ -397,6 +517,8 @@ def __init__(self, data: tp.Any, version: int | None = None) -> None: else: self.string = f"{data:.2e}" elif isinstance(data, (str, int, np.int32, np.int64)) or data is None: + if ConfDict.ops.is_op(data): + raise ValueError(f"Cannot compute uid on unresolved config {data!r}") self.string = str(data) self.hash = self.string typestr = "str" if isinstance(data, str) else "int" diff --git a/exca/helpers.py b/exca/helpers.py index 1c8369b3..67c9bdf9 100644 --- a/exca/helpers.py +++ b/exca/helpers.py @@ -482,7 +482,8 @@ def _inject_type_on_serialization( cls = type(self) key = cls._exca_discriminator_key name = cls.__name__ - result.setdefault(key, name) + if key not in result: + return {key: name, **result} # type first for readability # serialization can be reentrant in some pydantic version (not sure why) # so the field may be prepopulated if result[key] != name: diff --git a/exca/steps/test_backends.py b/exca/steps/test_backends.py index 8afcf648..8259bd18 100644 --- a/exca/steps/test_backends.py +++ b/exca/steps/test_backends.py @@ -210,13 +210,13 @@ def test_config_files_and_consistency(tmp_path: Path) -> None: handle = step.lookup(10.0) step_folder = handle.paths.step_folder - expected_uid = "- coeff: 3.0\n type: Mult\n" + expected_uid = "- type: Mult\n coeff: 3.0\n" assert (step_folder / "uid.yaml").read_text("utf8") == expected_uid assert (step_folder / "full-uid.yaml").read_text("utf8") == expected_uid assert (step_folder / "config.yaml").exists() # Inconsistent uid.yaml raises error - (step_folder / "uid.yaml").write_text("- coeff: 999.0\n type: Mult\n") + (step_folder / "uid.yaml").write_text("- type: Mult\n coeff: 999.0\n") step.lookup(10.0).clear_cache() with pytest.raises(RuntimeError, match="Inconsistent uid config"): step.run(10.0) diff --git a/exca/steps/test_steps.py b/exca/steps/test_steps.py index 3295487b..ba70fdfa 100644 --- a/exca/steps/test_steps.py +++ b/exca/steps/test_steps.py @@ -134,8 +134,8 @@ def test_chain_hash_and_uid(with_infra: bool, tmp_path: Path) -> None: expected_yaml = """steps: - type: Add value: 12.0 -- coeff: 3.0 - type: Mult +- type: Mult + coeff: 3.0 - type: Add value: 12.0 """ diff --git a/exca/test_confdict.py b/exca/test_confdict.py index e85e6b8c..3b28eb77 100644 --- a/exca/test_confdict.py +++ b/exca/test_confdict.py @@ -5,6 +5,7 @@ # LICENSE file in the root directory of this source tree. import dataclasses import decimal +import doctest import fractions import glob import typing as tp @@ -52,28 +53,175 @@ def test_simplified_dict_2() -> None: def test_update_override() -> None: data = ConfDict({"a": 12, "b": 12}) - data.update({ConfDict.OVERRIDE: True, "d": 13}) + data.update({ConfDict.ops.REPLACE: True, "d": 13}) assert data == {"d": 13} +def test_override_deprecation() -> None: + with pytest.warns(DeprecationWarning, match="ConfDict.ops.REPLACE"): + assert ConfDict.OVERRIDE == ConfDict.ops.REPLACE + + def test_update() -> None: data = ConfDict({"a": {"c": 12}, "b": {"c": 12}}) - data.update(a={ConfDict.OVERRIDE: True, "d": 13}, b={"d": 13}) + data.update(a={ConfDict.ops.REPLACE: True, "d": 13}, b={"d": 13}) assert data == {"a": {"d": 13}, "b": {"c": 12, "d": 13}} # more complex data = ConfDict({"a": {"b": {"c": 12}}}) - data.update(a={"b": {"d": 12, ConfDict.OVERRIDE: True}}) + data.update(a={"b": {"d": 12, ConfDict.ops.REPLACE: True}}) assert data == {"a": {"b": {"d": 12}}} # with compressed key - data.update(**{"a.b": {"e": 13, ConfDict.OVERRIDE: True}}) + data.update(**{"a.b": {"e": 13, ConfDict.ops.REPLACE: True}}) assert data == {"a": {"b": {"e": 13}}} # assignment - data["a"] = {"c": 1, "b": {"d": 12, ConfDict.OVERRIDE: True}} + data["a"] = {"c": 1, "b": {"d": 12, ConfDict.ops.REPLACE: True}} assert data == {"a": {"b": {"d": 12}, "c": 1}} - data["a.b"] = {"e": 15, ConfDict.OVERRIDE: True} + data["a.b"] = {"e": 15, ConfDict.ops.REPLACE: True} assert data == {"a": {"b": {"e": 15}, "c": 1}} +def test_update_docstring_examples() -> None: + parser = doctest.DocTestParser() + runner = doctest.DocTestRunner() + docstring = ConfDict.update.__doc__ + assert docstring is not None + test = parser.get_doctest( + docstring, + {"ConfDict": ConfDict}, + "ConfDict.update", + None, + 0, + ) + result = runner.run(test) + assert not result.failed + + +def test_update_delete_and_move() -> None: + base = """ + steps: + read.t: read + clean.t: clean + resample.t: resample + pad.t: pad + item: + name: old + size: 1 + """ + data = ConfDict.from_yaml(base) + patch = ConfDict.from_yaml( + f""" + steps: + clean: {ConfDict.ops.DELETE} + project: + {ConfDict.ops.AFTER}: read + t: project + resample: + {ConfDict.ops.AFTER}: project + frequency: 2 + pad: + {ConfDict.ops.BEFORE}: resample + late: + {ConfDict.ops.AFTER}: missing + """ + ) + assert ( + patch.to_yaml() + == f"""steps: + clean: {ConfDict.ops.DELETE} + project: + {ConfDict.ops.AFTER}: read + t: project + pad: {{}} + resample.frequency: 2 + late.{ConfDict.ops.AFTER}: missing +""" + ), "patch should have resolved eagerly what could be" + + data.update(patch) + + assert ( + data.to_yaml() + == f"""steps: + read.t: read + project.t: project + resample: + t: resample + frequency: 2 + pad.t: pad + late.{ConfDict.ops.AFTER}: missing +item: + name: old + size: 1 +""" + ) + with pytest.raises(ValueError, match="unresolved config"): + data.to_uid() + + data.update({"steps": {"missing": {}, "late": {"t": 1}}}) + assert list(data["steps"]) == [ + "read", + "project", + "resample", + "pad", + "missing", + "late", + ] + assert data["steps.late"] == {"t": 1} + + grid = ConfDict({"item": [{ConfDict.ops.REPLACE: True, "name": "new"}]}) + data.update({"item": grid.flat()["item"][0]}) + assert data["item"] == {"name": "new"} + data.update({"item.name": ConfDict.ops.DELETE}) + assert data["item"] == {} + + +@pytest.mark.parametrize( + "data", + [ + {"missing": ConfDict.ops.DELETE}, + {"item": {ConfDict.ops.REPLACE: True, "name": "new"}}, + {"item": {ConfDict.ops.BEFORE: "anchor"}}, + {"item": {ConfDict.ops.AFTER: "anchor"}}, + ], +) +def test_to_uid_rejects_unresolved_ops(data: dict[str, tp.Any]) -> None: + with pytest.raises(ValueError, match="unresolved config"): + ConfDict(data).to_uid() + + +@pytest.mark.parametrize( + "update", + [ + {"steps": {"a": {ConfDict.ops.BEFORE: "b", ConfDict.ops.AFTER: "b"}}}, + {"steps": {"a": {ConfDict.ops.AFTER: "a"}}}, + {"steps": {"a": ConfDict.ops.REPLACE}}, + {"steps": {"a": ConfDict.ops.BEFORE}}, + {"steps": {"a": ConfDict.ops.AFTER}}, + {"steps": {"=unknown=": "a"}}, + {"steps": {"a": "=unknown="}}, + ], +) +def test_update_op_errors(update: dict[str, tp.Any]) -> None: + data = ConfDict({"steps": {"a": {}, "b": {}}}) + with pytest.raises(ValueError): + data.update(update) + + +@pytest.mark.parametrize( + "update", + [ + {"steps": {"missing": "=unknown="}}, + {"steps": {"=unknown=": "a"}}, + ], +) +def test_update_op_error_does_not_mutate(update: dict[str, tp.Any]) -> None: + data = ConfDict({"steps": {"a": {}, "b": {}}}) + before = data.to_yaml() + with pytest.raises(ValueError): + data.update(update) + assert data.to_yaml() == before + + @pytest.mark.parametrize( "update,expected", [ @@ -290,6 +438,29 @@ def test_dict_hash() -> None: assert maker1.hash == "dict:{x=float:461168601842738689,y=seq:(str:z,int:12)}" +def test_uid_ordering_rules() -> None: + cfg = ConfDict.from_yaml("a: 1\nb: 2\n") + reversed_cfg = ConfDict.from_yaml("b: 2\na: 1\n") + ordered = OrderedDict([("a", 1), ("b", 2)]) + reversed_ordered = OrderedDict([("b", 2), ("a", 1)]) + + assert cfg.to_yaml() != reversed_cfg.to_yaml() + assert cfg.to_uid() == reversed_cfg.to_uid() + assert ( + confdict.UidMaker(ordered).format() + != confdict.UidMaker(reversed_ordered).format() + ) + + +def test_update_preserves_ordered_dict_uid_order() -> None: + cfg = ConfDict({"x": OrderedDict((name, {"value": name}) for name in ("a", "b"))}) + uid = cfg.to_uid() + cfg.update({"x": {"b": {ConfDict.ops.BEFORE: "a"}}}) + assert isinstance(cfg["x"], OrderedDict) + assert list(cfg["x"]) == ["b", "a"] + assert cfg.to_uid() != uid + + def test_set_hash() -> None: data = [str(k) for k in range(6)] np.random.shuffle(data) diff --git a/exca/test_utils.py b/exca/test_utils.py index b36620f8..41b6bc62 100644 --- a/exca/test_utils.py +++ b/exca/test_utils.py @@ -14,10 +14,11 @@ import pydantic import pytest +import yaml import exca -from . import utils +from . import steps, utils from .confdict import ConfDict from .utils import ShortItemUid, to_dict @@ -119,12 +120,12 @@ def test_discriminators(caplog: tp.Any) -> None: seq=[[{"uid": "D2"}, {"uid": "D1"}]], # type: ignore ) expected = """inst.uid: D2 +something_else: 12 seq: - - uid: D2 - - anything: 12 + - uid: D1 + anything: 12 sub.uid: D2 - uid: D1 -something_else: 12 stuff: [] """ # check uid of subinstance (should not have discriminator) @@ -134,10 +135,10 @@ def test_discriminators(caplog: tp.Any) -> None: out = ConfDict.from_model(d).to_yaml() assert out == expected expected = """inst.uid: D2 +something_else: 12 seq: - - uid: D2 - uid: D1 -something_else: 12 """ out = ConfDict.from_model(d, exclude_defaults=True).to_yaml() assert not caplog.records @@ -587,6 +588,26 @@ def test_check_configs(tmp_path: Path) -> None: assert content == "- x: 1\n- x: 2\n" +class OrderYamlStep(steps.Step): + b: int = 2 + a: int = 1 + + def _run(self, value: int = 0) -> int: + return value + self.a + self.b + + +def test_check_configs_accepts_uid_yaml_key_order_change(tmp_path: Path) -> None: + step = OrderYamlStep(a=3, b=5, infra=steps.backends.Cached(folder=tmp_path)) + assert step.run() == 8 + + uid_file = step.lookup().paths.step_folder / "uid.yaml" + current = uid_file.read_text("utf8") + uid_file.write_text(yaml.safe_dump(yaml.safe_load(current), sort_keys=True)) + assert uid_file.read_text("utf8") != current + + assert step.clone().run() == 8 + + def test_check_configs_concurrently(tmp_path: Path) -> None: def check(folder: Path, barrier: threading.Barrier) -> None: barrier.wait() diff --git a/exca/utils.py b/exca/utils.py index 79fc58c4..6a02ab9a 100644 --- a/exca/utils.py +++ b/exca/utils.py @@ -704,7 +704,7 @@ def _to_yaml(self, name: str) -> str: data = getattr(self, name.replace("-", "_")) if hasattr(data, "to_yaml"): return data.to_yaml() # ConfDict with OrderedDict support - return _yaml.safe_dump(data, sort_keys=True) + return _yaml.safe_dump(data, sort_keys=False) def _error(self, msg: str) -> RuntimeError: return RuntimeError(f"{msg}\n\n(this is for object: {self.model!r})") @@ -739,16 +739,23 @@ def read_file(name: str) -> str | None: corrupted.add(name) return None - # uid.yaml must match exactly (cache collision detection) + # uid.yaml must match (cache collision detection) prev_uid = read_file("uid") curr_uid = self._to_yaml("uid") if prev_uid is not None and curr_uid != prev_uid: - diff = "\n".join(difflib.ndiff(curr_uid.splitlines(), prev_uid.splitlines())) - uid_str = as_confdict(self.uid).to_uid() - raise self._error( - f"Inconsistent uid config for {uid_str} in '{folder / 'uid.yaml'}':\n" - f"* got:\n{curr_uid!r}\n\n* but uid file contains:\n{prev_uid!r}\n\n(diff:\n{diff})" - ) + sorted_uids = [ # ordering may differ (support convention change) + _yaml.safe_dump(_yaml.safe_load(uid), sort_keys=True) + for uid in (prev_uid, curr_uid) + ] + if sorted_uids[0] != sorted_uids[1]: + uid_str = as_confdict(self.uid).to_uid() + diff = "\n".join( + difflib.ndiff(curr_uid.splitlines(), prev_uid.splitlines()) + ) + raise self._error( + f"Inconsistent uid config for {uid_str} in '{folder / 'uid.yaml'}':\n" + f"* got:\n{curr_uid!r}\n\n* but uid file contains:\n{prev_uid!r}\n\n(diff:\n{diff})" + ) # Check full-uid for incompatible default changes prev_full = read_file("full-uid")