diff --git a/pym/bob/scm/scm.py b/pym/bob/scm/scm.py index de69e8a1..12a79126 100644 --- a/pym/bob/scm/scm.py +++ b/pym/bob/scm/scm.py @@ -13,6 +13,19 @@ SYNTHETIC_SCM_PROPS = frozenset(('__source', 'recipe', 'overridden')) +def _freezeValue(value): + """Recursively turn a (possibly nested) value into a hashable one. + + scmOverrides 'match' and 'set' allow arbitrary values (e.g. the 'headers' + dict of an url SCM). Such nested dicts/lists are unhashable, so convert + them into frozensets/tuples to make ScmOverride hashable. + """ + if isinstance(value, dict): + return frozenset((k, _freezeValue(v)) for k, v in value.items()) + if isinstance(value, list): + return tuple(_freezeValue(v) for v in value) + return value + class ScmOverride: def __init__(self, override): self.__match = override.get("match", {}) @@ -54,8 +67,8 @@ def __doesMatch(self, scm, env): return True def __hash__(self): - return hash((frozenset(self.__match.items()), frozenset(self.__del), - frozenset(self.__set.items()), frozenset(self.__replace.items()))) + return hash((_freezeValue(self.__match), frozenset(self.__del), + _freezeValue(self.__set), frozenset(self.__replace.items()))) def __eq__(self, other): return ((self.__match, self.__del, self.__set, self.__replace) == diff --git a/test/unit/test_input_scmoverride.py b/test/unit/test_input_scmoverride.py index 96516d9e..f16667d0 100644 --- a/test/unit/test_input_scmoverride.py +++ b/test/unit/test_input_scmoverride.py @@ -207,3 +207,36 @@ def testDump(self): o = ScmOverride(spec) self.assertEqual(spec, yaml.load(str(o), Loader=yaml.Loader)) + + def testHashableNestedValue(self): + """A scmOverride with a nested dict value (e.g. url SCM headers) must + still be hashable. The builder collects active overrides in a set().""" + o = ScmOverride({ + 'match' : { 'url' : "https://gitea.example.com/*" }, + 'set' : { 'headers' : { 'Authorization' : "token 1234" } }, + }) + # Must not raise "unhashable type: 'dict'". + self.assertIn(o, { o }) + + # Equal overrides hash equally, unequal ones stay distinct. + same = ScmOverride({ + 'match' : { 'url' : "https://gitea.example.com/*" }, + 'set' : { 'headers' : { 'Authorization' : "token 1234" } }, + }) + other = ScmOverride({ + 'match' : { 'url' : "https://gitea.example.com/*" }, + 'set' : { 'headers' : { 'Authorization' : "token 4321" } }, + }) + self.assertEqual(hash(o), hash(same)) + self.assertEqual(len({ o, same, other }), 2) + + def testMangleNestedValue(self): + """A nested dict value is injected verbatim into the matched SCM.""" + o = ScmOverride({ + 'match' : { 'scm' : "url" }, + 'set' : { 'headers' : { 'Authorization' : "token 1234" } }, + }) + scm = { 'scm' : "url", 'url' : "https://gitea.example.com/x.tar" } + match, mangled = o.mangle(scm, Env()) + self.assertTrue(match) + self.assertEqual(mangled['headers'], { 'Authorization' : "token 1234" })