cmds.projects.lib.templates: Fix string handling #98
6 changed files with 202 additions and 14 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from typing import Any, Iterable, TypeAlias, TypeGuard
|
from typing import Any, Iterable, TypeAlias, TypeGuard
|
||||||
|
|
@ -7,7 +8,7 @@ ListDict: TypeAlias = dict[str, list[str]]
|
||||||
StrDict: TypeAlias = dict[str, str]
|
StrDict: TypeAlias = dict[str, str]
|
||||||
RenderValues: TypeAlias = ListDict | StrDict | TupleList
|
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):
|
if not isinstance(values, dict):
|
||||||
return False
|
return False
|
||||||
for key, val in values.items():
|
for key, val in values.items():
|
||||||
|
|
@ -17,7 +18,7 @@ def is_str_dict(values: RenderValues) -> TypeGuard[StrDict]:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_list_dict(values: RenderValues) -> TypeGuard[ListDict]:
|
def is_list_dict(values: object) -> TypeGuard[ListDict]:
|
||||||
if not isinstance(values, dict):
|
if not isinstance(values, dict):
|
||||||
return False
|
return False
|
||||||
for key, val in values.items():
|
for key, val in values.items():
|
||||||
|
|
@ -32,7 +33,7 @@ def is_list_dict(values: RenderValues) -> TypeGuard[ListDict]:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_tuple_list(values: RenderValues) -> TypeGuard[TupleList]:
|
def is_tuple_list(values: object) -> TypeGuard[TupleList]:
|
||||||
if not isinstance(values, list):
|
if not isinstance(values, list):
|
||||||
return False
|
return False
|
||||||
for item in values:
|
for item in values:
|
||||||
|
|
@ -46,7 +47,7 @@ def is_tuple_list(values: RenderValues) -> TypeGuard[TupleList]:
|
||||||
return False
|
return False
|
||||||
return True
|
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:
|
def __tuple_list_to_dict(src: TupleList) -> ListDict:
|
||||||
ret: ListDict = {}
|
ret: ListDict = {}
|
||||||
|
|
@ -56,7 +57,12 @@ def render_values_to_list_dict(values: RenderValues) -> ListDict:
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
if is_list_dict(values):
|
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):
|
if is_tuple_list(values):
|
||||||
return __tuple_list_to_dict(values)
|
return __tuple_list_to_dict(values)
|
||||||
raise Exception('Unsupported template value layout')
|
raise Exception('Unsupported template value layout')
|
||||||
|
|
@ -90,12 +96,17 @@ def format_list_dict(
|
||||||
parts = template.splitlines(keepends = True)
|
parts = template.splitlines(keepends = True)
|
||||||
|
|
||||||
for line in parts:
|
for line in parts:
|
||||||
for key, value in fmt_dict.items():
|
for key, val in fmt_dict.items():
|
||||||
marker = '{' + key + '}'
|
# -- A marker preceded by '$' or '{' is literal text rather
|
||||||
if marker in line:
|
# than a placeholder, so that e.g. ${prefix} survives as a
|
||||||
indent = line[:line.index(marker)]
|
# pkg-config variable reference
|
||||||
value = str(value).replace('\n', '\n' + indent)
|
rx = re.compile(r'(?<![${])\{' + re.escape(key) + r'\}')
|
||||||
line = line.replace(marker, value)
|
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)
|
ret.append(line)
|
||||||
|
|
||||||
return ''.join(ret)
|
return ''.join(ret)
|
||||||
|
|
@ -109,9 +120,9 @@ _templates = {
|
||||||
'pkg-config':
|
'pkg-config':
|
||||||
"""\
|
"""\
|
||||||
prefix = {prefix}
|
prefix = {prefix}
|
||||||
exec_prefix = {{prefix}}
|
exec_prefix = ${prefix}
|
||||||
includedir = {{prefix}}/include
|
includedir = ${prefix}/include
|
||||||
libdir = {{exec_prefix}}/lib
|
libdir = ${exec_prefix}/lib
|
||||||
|
|
||||||
Name: {name}
|
Name: {name}
|
||||||
Description: {description}
|
Description: {description}
|
||||||
|
|
|
||||||
4
test/unit/python/jw/pkg/cmds/Makefile
Normal file
4
test/unit/python/jw/pkg/cmds/Makefile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
TOPDIR = ../../../../../..
|
||||||
|
|
||||||
|
include $(TOPDIR)/make/proj.mk
|
||||||
|
include $(JWBDIR)/make/dirs.mk
|
||||||
4
test/unit/python/jw/pkg/cmds/projects/Makefile
Normal file
4
test/unit/python/jw/pkg/cmds/projects/Makefile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
TOPDIR = ../../../../../../..
|
||||||
|
|
||||||
|
include $(TOPDIR)/make/proj.mk
|
||||||
|
include $(JWBDIR)/make/dirs.mk
|
||||||
4
test/unit/python/jw/pkg/cmds/projects/lib/Makefile
Normal file
4
test/unit/python/jw/pkg/cmds/projects/lib/Makefile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
TOPDIR = ../../../../../../../..
|
||||||
|
|
||||||
|
include $(TOPDIR)/make/proj.mk
|
||||||
|
include $(JWBDIR)/make/dirs.mk
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
TOPDIR = ../../../../../../../../..
|
||||||
|
|
||||||
|
include $(TOPDIR)/make/proj.mk
|
||||||
|
include $(JWBDIR)/make/py-run.mk
|
||||||
|
|
||||||
|
all:
|
||||||
|
|
||||||
|
test: run
|
||||||
157
test/unit/python/jw/pkg/cmds/projects/lib/templates/test.py
Normal file
157
test/unit/python/jw/pkg/cmds/projects/lib/templates/test.py
Normal 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')
|
||||||
Loading…
Reference in a new issue