cmds.projects.lib.templates: Fix string handling #98

Merged
Jan Lindemann merged 2 commits from jan/fix/20260913-cmds-projects-lib-templates-don-t-replace-escaped-markers into master 2026-09-13 21:25:13 +02:00 AGit
6 changed files with 202 additions and 14 deletions

View file

@ -1,3 +1,4 @@
import re
import textwrap
from typing import Any, Iterable, TypeAlias, TypeGuard
@ -7,7 +8,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 +18,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 +33,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 +47,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 +57,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')
@ -90,12 +96,17 @@ def format_list_dict(
parts = template.splitlines(keepends = True)
for line in parts:
for key, value in fmt_dict.items():
marker = '{' + key + '}'
if marker in line:
indent = line[:line.index(marker)]
value = str(value).replace('\n', '\n' + indent)
line = line.replace(marker, value)
for key, val in fmt_dict.items():
# -- A marker preceded by '$' or '{' is literal text rather
# than a placeholder, so that e.g. ${prefix} survives as a
# pkg-config variable reference
rx = re.compile(r'(?<![${])\{' + re.escape(key) + r'\}')
m = rx.search(line)
if m is None:
continue
indent = line[:m.start()]
sub = str(val).replace('\n', '\n' + indent)
line = rx.sub(lambda _m: sub, line)
ret.append(line)
return ''.join(ret)
@ -109,9 +120,9 @@ _templates = {
'pkg-config':
"""\
prefix = {prefix}
exec_prefix = {{prefix}}
includedir = {{prefix}}/include
libdir = {{exec_prefix}}/lib
exec_prefix = ${prefix}
includedir = ${prefix}/include
libdir = ${exec_prefix}/lib
Name: {name}
Description: {description}

View file

@ -0,0 +1,4 @@
TOPDIR = ../../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/dirs.mk

View file

@ -0,0 +1,4 @@
TOPDIR = ../../../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/dirs.mk

View file

@ -0,0 +1,4 @@
TOPDIR = ../../../../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/dirs.mk

View file

@ -0,0 +1,8 @@
TOPDIR = ../../../../../../../../..
include $(TOPDIR)/make/proj.mk
include $(JWBDIR)/make/py-run.mk
all:
test: run

View file

@ -0,0 +1,157 @@
import os
import tempfile
from jw.pkg.cmds.projects.lib.templates import (
format_list_dict,
is_list_dict,
is_str_dict,
is_tuple_list,
merge_values,
render_values_to_list_dict,
tmpl_render,
)
# -- 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
# -- format_list_dict --
# A plain {key} marker is substituted
assert format_list_dict('a {key} b', {'key': ['x', 'y']}, False, ', ') == \
'a x, y b'
# ${key} is a literal, e.g. a pkg-config variable reference
assert format_list_dict(
'exec_prefix = ${prefix}', {'prefix': ['/usr']}, False, '\n'
) == 'exec_prefix = ${prefix}'
# {{key}} is a literal, too
assert format_list_dict('{{prefix}}', {'prefix': ['/usr']}, False, '\n') == \
'{{prefix}}'
# A ${key} next to a real marker: only the marker is substituted
assert format_list_dict('x = {p} ${p}', {'p': ['v']}, False, '\n') == \
'x = v ${p}'
# Backslashes in the value are not treated as regex replacements
assert format_list_dict('{p}', {'p': ['a\\b', 'c$1']}, False, '\n') == \
'a\\b\nc$1'
# li_quote wraps each list element in double quotes
assert format_list_dict('{p}', {'p': ['a', 'b']}, True, ',\n') == \
'"a",\n"b"'
# Multi-line values are re-indented to the marker position
assert format_list_dict(' {p}', {'p': ['x\ny']}, False, '\n') == ' x\n y'
# String values pass through unsplit
assert format_list_dict('{p}', {'p': 'xyz'}, False, '\n') == 'xyz'
# -- tmpl_render --
# The built-in pkg-config template renders a valid .pc file
expected_pc = (
'prefix = /usr\n'
'exec_prefix = ${prefix}\n'
'includedir = ${prefix}/include\n'
'libdir = ${exec_prefix}/lib\n'
'\n'
'Name: jw-pkg\n'
'Description: desc\n'
'Version: 1.0\n'
)
assert tmpl_render(
'pkg-config',
[{
'prefix': '/usr', 'name': 'jw-pkg', 'description': 'desc', 'version': '1.0'
}]
) == expected_pc
# A template file from the search path wins over the built-ins
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, 'my-tmpl')
with open(path, 'w') as f:
f.write('hello {who}\n')
assert tmpl_render(
'my-tmpl', [{
'who': 'world'
}], search_path = [tmp]
) == 'hello world\n'
# A missing template falls back to the built-ins
with tempfile.TemporaryDirectory() as tmp:
assert tmpl_render(
'pkg-config', [{
'prefix': '/usr'
}], search_path = [tmp]
).startswith('prefix = /usr\n')
# An unknown template without search path raises
try:
tmpl_render('no-such-template', [])
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 tests passed')