jw-pkg/src/python/jw/pkg/cmds/projects/lib/templates.py
Jan Lindemann 955fb8101b
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 <jan@janware.com>
2026-09-13 21:08:53 +02:00

178 lines
5 KiB
Python

import textwrap
from typing import Any, Iterable, TypeAlias, TypeGuard
TupleList: TypeAlias = Iterable[tuple[str, str]]
ListDict: TypeAlias = dict[str, list[str]]
StrDict: TypeAlias = dict[str, str]
RenderValues: TypeAlias = ListDict | StrDict | TupleList
def is_str_dict(values: object) -> TypeGuard[StrDict]:
if not isinstance(values, dict):
return False
for key, val in values.items():
if not isinstance(key, str):
return False
if not isinstance(val, str):
return False
return True
def is_list_dict(values: object) -> TypeGuard[ListDict]:
if not isinstance(values, dict):
return False
for key, val in values.items():
if not isinstance(key, str):
return False
if isinstance(val, str):
continue
if not isinstance(val, list):
return False
for entry in val:
if not isinstance(entry, str):
return False
return True
def is_tuple_list(values: object) -> TypeGuard[TupleList]:
if not isinstance(values, list):
return False
for item in values:
if not isinstance(item, tuple):
return False
if not len(item) == 2:
return False
if not isinstance(item[0], str):
return False
if not isinstance(item[1], str):
return False
return True
def render_values_to_list_dict(values: object) -> ListDict:
def __tuple_list_to_dict(src: TupleList) -> ListDict:
ret: ListDict = {}
for key, val in src:
entry = ret.setdefault(key, [])
entry.append(val)
return ret
if is_list_dict(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')
def merge_values(*values: RenderValues) -> ListDict:
ret: ListDict = {}
for rhs in values:
rhs_dict = render_values_to_list_dict(rhs)
for key, val in rhs_dict.items():
entry = ret.setdefault(key, [])
entry += val
return ret
def format_list_dict(
template: str, values: ListDict | dict[str, str], li_quote: bool, li_delimiter: str
) -> str:
def __format_value(val: Any) -> str:
if not li_quote:
return str(val)
return f'"{val}"'
fmt_dict: dict[str, str] = {}
for key, value in values.items():
if isinstance(value, (list, tuple)):
fmt_dict[key] = li_delimiter.join(map(__format_value, value))
elif isinstance(value, str):
fmt_dict[key] = value
ret: list[str] = []
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)
ret.append(line)
return ''.join(ret)
def format_lines(
template: str, values: list[RenderValues], li_quote: bool, li_delimiter: str
) -> str:
return format_list_dict(template, merge_values(*values), li_quote, li_delimiter)
_templates = {
'pkg-config':
"""\
prefix = {prefix}
exec_prefix = {{prefix}}
includedir = {{prefix}}/include
libdir = {{exec_prefix}}/lib
Name: {name}
Description: {description}
Version: {version}
""",
'pyrightconfig.json':
"""\
{
"extends": {base},
"include": [
{include}
],
"exclude": [
"**/__pycache__",
"**/.pytest_cache",
"**/.mypy_cache",
"**/.ruff_cache",
"**/.venv",
"**/build",
"**/dist"
],
"extraPaths": [
{extra_paths}
],
"typeCheckingMode": "basic",
"pythonPlatform": "Linux"
}
""",
}
def tmpl_render(
template_name: str,
values: list[RenderValues],
li_quote: bool = False,
li_delimiter: str = '\n',
search_path: list[str] | None = None,
) -> str:
def __format(template: str) -> str:
return format_lines(
template,
values,
li_quote = li_quote,
li_delimiter = li_delimiter,
)
for d in search_path if search_path else []:
path = d + '/' + template_name
try:
with open(path, 'r') as f:
template = f.read()
return __format(template)
except FileNotFoundError:
pass
raw = _templates.get(template_name, None)
if raw is None:
raise Exception(f'Failed to find template "{template_name}"')
return __format(textwrap.dedent(raw))