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
|
||||
|
||||
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}
|
||||
|
|
|
|||
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