From 955fb8101bec8f9c9f7847275b01c94ce27df2b4 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 8 Sep 2026 21:41:09 +0200 Subject: [PATCH] cmds.projects.lib.templates: merge_values(): Don't split strings is_list_dict() accepts a string value as a list-dict value, and render_values_to_list_dict() passes such values through unchanged. merge_values() then merges them with list extension, and [] += 'xyz' appends the individual characters, so every string value ends up as a list of its characters. Normalize string values to single-element lists in render_values_to_list_dict(), so that merge_values() appends whole values. jw-pkg projects create-pkg-config is hit by this, because all of its values are strings: the generated file contains one line per character. The code path was just never exercised lately. Add unit tests for the value layout guards, the normalization, and the merging of the three supported layouts. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- .../jw/pkg/cmds/projects/lib/templates.py | 15 ++-- test/unit/python/jw/pkg/cmds/Makefile | 4 + .../unit/python/jw/pkg/cmds/projects/Makefile | 4 + .../python/jw/pkg/cmds/projects/lib/Makefile | 4 + .../pkg/cmds/projects/lib/templates/Makefile | 8 ++ .../pkg/cmds/projects/lib/templates/test.py | 73 +++++++++++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 test/unit/python/jw/pkg/cmds/Makefile create mode 100644 test/unit/python/jw/pkg/cmds/projects/Makefile create mode 100644 test/unit/python/jw/pkg/cmds/projects/lib/Makefile create mode 100644 test/unit/python/jw/pkg/cmds/projects/lib/templates/Makefile create mode 100644 test/unit/python/jw/pkg/cmds/projects/lib/templates/test.py diff --git a/src/python/jw/pkg/cmds/projects/lib/templates.py b/src/python/jw/pkg/cmds/projects/lib/templates.py index 3df12aa5..bc9bf23c 100644 --- a/src/python/jw/pkg/cmds/projects/lib/templates.py +++ b/src/python/jw/pkg/cmds/projects/lib/templates.py @@ -7,7 +7,7 @@ ListDict: TypeAlias = dict[str, list[str]] StrDict: TypeAlias = dict[str, str] RenderValues: TypeAlias = ListDict | StrDict | TupleList -def is_str_dict(values: RenderValues) -> TypeGuard[StrDict]: +def is_str_dict(values: object) -> TypeGuard[StrDict]: if not isinstance(values, dict): return False for key, val in values.items(): @@ -17,7 +17,7 @@ def is_str_dict(values: RenderValues) -> TypeGuard[StrDict]: return False return True -def is_list_dict(values: RenderValues) -> TypeGuard[ListDict]: +def is_list_dict(values: object) -> TypeGuard[ListDict]: if not isinstance(values, dict): return False for key, val in values.items(): @@ -32,7 +32,7 @@ def is_list_dict(values: RenderValues) -> TypeGuard[ListDict]: return False return True -def is_tuple_list(values: RenderValues) -> TypeGuard[TupleList]: +def is_tuple_list(values: object) -> TypeGuard[TupleList]: if not isinstance(values, list): return False for item in values: @@ -46,7 +46,7 @@ def is_tuple_list(values: RenderValues) -> TypeGuard[TupleList]: return False return True -def render_values_to_list_dict(values: RenderValues) -> ListDict: +def render_values_to_list_dict(values: object) -> ListDict: def __tuple_list_to_dict(src: TupleList) -> ListDict: ret: ListDict = {} @@ -56,7 +56,12 @@ def render_values_to_list_dict(values: RenderValues) -> ListDict: return ret if is_list_dict(values): - return values + ret: ListDict = {} + for key, val in values.items(): + # -- A string value is treated as a list with a single element; + # merging it unconverted would split it into characters + ret[key] = val if isinstance(val, list) else [val] + return ret if is_tuple_list(values): return __tuple_list_to_dict(values) raise Exception('Unsupported template value layout') diff --git a/test/unit/python/jw/pkg/cmds/Makefile b/test/unit/python/jw/pkg/cmds/Makefile new file mode 100644 index 00000000..f817ac8c --- /dev/null +++ b/test/unit/python/jw/pkg/cmds/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/dirs.mk diff --git a/test/unit/python/jw/pkg/cmds/projects/Makefile b/test/unit/python/jw/pkg/cmds/projects/Makefile new file mode 100644 index 00000000..c87020cb --- /dev/null +++ b/test/unit/python/jw/pkg/cmds/projects/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/dirs.mk diff --git a/test/unit/python/jw/pkg/cmds/projects/lib/Makefile b/test/unit/python/jw/pkg/cmds/projects/lib/Makefile new file mode 100644 index 00000000..3b96da8e --- /dev/null +++ b/test/unit/python/jw/pkg/cmds/projects/lib/Makefile @@ -0,0 +1,4 @@ +TOPDIR = ../../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/dirs.mk diff --git a/test/unit/python/jw/pkg/cmds/projects/lib/templates/Makefile b/test/unit/python/jw/pkg/cmds/projects/lib/templates/Makefile new file mode 100644 index 00000000..6a0e35b2 --- /dev/null +++ b/test/unit/python/jw/pkg/cmds/projects/lib/templates/Makefile @@ -0,0 +1,8 @@ +TOPDIR = ../../../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-run.mk + +all: + +test: run diff --git a/test/unit/python/jw/pkg/cmds/projects/lib/templates/test.py b/test/unit/python/jw/pkg/cmds/projects/lib/templates/test.py new file mode 100644 index 00000000..46400ddd --- /dev/null +++ b/test/unit/python/jw/pkg/cmds/projects/lib/templates/test.py @@ -0,0 +1,73 @@ +from jw.pkg.cmds.projects.lib.templates import ( + is_list_dict, + is_str_dict, + is_tuple_list, + merge_values, + render_values_to_list_dict, +) + +# -- Type guards -- + +assert is_str_dict({'a': 'x', 'b': 'y'}) +assert not is_str_dict({'a': ['x']}) +assert not is_str_dict({'a': 1}) +assert not is_str_dict([('a', 'x')]) + +assert is_list_dict({'a': ['x'], 'b': ['y']}) +# A string value is a legitimate list-dict value, too +assert is_list_dict({'a': ['x'], 'b': 'y'}) +assert not is_list_dict({'a': 1}) +assert not is_list_dict({'a': ['x', 1]}) +assert not is_list_dict([('a', 'x')]) + +assert is_tuple_list([('a', 'x'), ('b', 'y')]) +assert not is_tuple_list([('a', 'x', 'y')]) +assert not is_tuple_list([('a', 1)]) +assert not is_tuple_list(['a']) + +# -- render_values_to_list_dict -- + +# A list of lists passes through unchanged +assert render_values_to_list_dict({'a': ['x', 'y']}) == {'a': ['x', 'y']} + +# String values are normalized to single-element lists, not split up +assert render_values_to_list_dict({'a': 'xyz'}) == {'a': ['xyz']} +assert render_values_to_list_dict({'a': ['x'], 'b': 'y'}) == {'a': ['x'], 'b': ['y']} + +# Tuple lists are folded into a dict of lists +assert render_values_to_list_dict([('a', 'x'), ('a', 'y'), ('b', 'z')]) == { + 'a': ['x', 'y'], + 'b': ['z'], +} + +# Unsupported layouts raise +try: + render_values_to_list_dict({'a': 1}) + assert False, 'Should have raised' +except Exception: + pass + +# -- merge_values -- + +# String values are merged as whole strings, not character by character +assert merge_values({ + 'prefix': '/usr', 'name': 'jw-pkg' +}) == { + 'prefix': ['/usr'], + 'name': ['jw-pkg'], +} + +# Values for the same key are appended in order +assert merge_values({'a': ['x']}, {'a': ['y']}) == {'a': ['x', 'y']} +assert merge_values({'a': 'x'}, {'a': 'y'}) == {'a': ['x', 'y']} + +# All three layouts can be mixed +assert merge_values( + {'a': ['x']}, + {'a': 'y'}, + [('a', 'z'), ('b', 'w')], +) == { + 'a': ['x', 'y', 'z'], 'b': ['w'] +} + +print('All templates merge tests passed')