From 356c1553f3a29a516f51707913e2fc08ef8f36aa Mon Sep 17 00:00:00 2001 From: "Martin Haase (FDTech GmbH)" Date: Mon, 3 Aug 2026 11:37:31 +0200 Subject: [PATCH] scm: make ScmOverride hashable with nested values ScmOverride.__hash__ built a frozenset from the raw 'match' and 'set' items. Both allow arbitrary values (schema '{str: object}'), e.g. the 'headers' dict of an url SCM. A nested dict/list value made the override unhashable, so as soon as such an override matched, collecting it into the builder's active-overrides set() crashed with: TypeError: unhashable type: 'dict' Recursively freeze nested dicts/lists into frozensets/tuples before hashing. This makes it possible to inject e.g. authentication headers into a url SCM via scmOverrides: scmOverrides: - match: url: "https://gitea.example.com/*" set: headers: Authorization: "token " Co-Authored-By: Claude Opus 4.8 (1M context) --- pym/bob/scm/scm.py | 17 +++++++++++++-- test/unit/test_input_scmoverride.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pym/bob/scm/scm.py b/pym/bob/scm/scm.py index de69e8a1f..12a79126f 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 96516d9e1..f16667d0e 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" })