Improve Python config file template substitution #8

Merged
Jan Lindemann merged 10 commits from jan/feature/20260609-pyproject-toml-add-mypypath into master 2026-06-09 08:13:09 +02:00 AGit
7 changed files with 219 additions and 64 deletions

4
conf/templates/Makefile Normal file
View file

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

View file

@ -1,3 +1,7 @@
[tool.mypy]
{mypypath}
[tool.isort] [tool.isort]
lines_between_sections = 1 lines_between_sections = 1

View file

@ -264,6 +264,7 @@ CVS_RSH ?= /usr/bin/ssh
JW_PKG_DIR = $(JWBDIR) JW_PKG_DIR = $(JWBDIR)
JW_PKG_CONF_BASE_DIR ?= $(firstword $(wildcard $(JW_PKG_DIR)/conf /etc/opt/jw-pkg)) JW_PKG_CONF_BASE_DIR ?= $(firstword $(wildcard $(JW_PKG_DIR)/conf /etc/opt/jw-pkg))
JW_PKG_CONF_DIR ?= $(firstword $(wildcard $(JW_PKG_CONF_BASE_DIR)/project $(JW_PKG_CONF_BASE_DIR))) JW_PKG_CONF_DIR ?= $(firstword $(wildcard $(JW_PKG_CONF_BASE_DIR)/project $(JW_PKG_CONF_BASE_DIR)))
JW_PKG_TMPL_DIR ?= $(JW_PKG_CONF_BASE_DIR)/templates
PROJECT_STEM = $(PROJECT)-$(DIST_VERSION) PROJECT_STEM = $(PROJECT)-$(DIST_VERSION)
# only works if checked out true to CVS, i.e. below proj # only works if checked out true to CVS, i.e. below proj

View file

@ -1,10 +1,21 @@
TD_COPY_FILES += pyproject.toml include $(JWBDIR)/make/ldlibpath.mk
TD_GENERATE_FILES += pyproject.toml
PY_CHECK_EXCLUDE ?= PY_CHECK_EXCLUDE ?=
PY_SRC_ROOT += $(wildcard $(TOPDIR)/src $(TOPDIR)/tools)
MYPY_CONFIG_PATH = $(subst :,:$$MYPY_CONFIG_FILE_DIR/,:$(PYTHONPATH))
MYPY_PATH_DIRECTIVE = mypy_path = "$(MYPY_CONFIG_PATH)"
ifndef PY_CHECK_ROOTS
PY_CHECK_ROOTS += $(wildcard $(TOPDIR)/src $(TOPDIR)/tools)
endif
ifndef PY_CHECK_RUFF ifndef PY_CHECK_RUFF
PY_CHECK_RUFF := $(shell which ruff 2>/dev/null) PY_CHECK_RUFF := $(shell which ruff 2>/dev/null)
ifneq ($(PY_CHECK_RUFF),)
PY_CHECK_RUFF += --config pyproject.toml
endif
endif endif
ifndef PY_CHECK_YAPF ifndef PY_CHECK_YAPF
@ -25,27 +36,27 @@ check-format: py-check-format
py-check: py-check-syntax py-check-format py-check: py-check-syntax py-check-format
py-check-syntax: py-check-syntax:
ifneq ($(PY_CHECK_RUFF),) ifneq ($(PY_CHECK_RUFF),)
$(PY_CHECK_RUFF) check $(addprefix --exclude ,$(PY_CHECK_EXCLUDE)) $(PY_SRC_ROOT) $(PY_CHECK_RUFF) check $(addprefix --exclude ,$(PY_CHECK_EXCLUDE)) $(PY_CHECK_ROOTS)
endif endif
mypy $(addprefix --exclude ,$(PY_CHECK_EXCLUDE)) $(PY_SRC_ROOT) mypy $(addprefix --exclude ,$(PY_CHECK_EXCLUDE)) $(PY_CHECK_ROOTS)
ifneq ($(PY_CHECK_PYRIGHT),) ifneq ($(PY_CHECK_PYRIGHT),)
pyright pyright $(PY_CHECK_ROOTS)
endif endif
py-check-format: py-check-format:
ifneq ($(PY_CHECK_YAPF),) ifneq ($(PY_CHECK_YAPF),)
$(PY_CHECK_YAPF) --diff --recursive . $(PY_CHECK_YAPF) --diff --recursive $(PY_CHECK_ROOTS)
endif endif
py-format: py-format:
find . -type f -name '*.py' -print0 | \ find . -type f -name '*.py' -print0 | \
xargs -0 sed -i -E '1{/^# -\*- coding: utf-8 -\*-$$/{:a;N;/\n[[:space:]]*$$/ba;s/^# -\*- coding: utf-8 -\*-\n([[:space:]]*\n)*/ /;s/^ //}}' xargs -0 sed -i -E '1{/^# -\*- coding: utf-8 -\*-$$/{:a;N;/\n[[:space:]]*$$/ba;s/^# -\*- coding: utf-8 -\*-\n([[:space:]]*\n)*/ /;s/^ //}}'
ifneq ($(PY_CHECK_YAPF),) ifneq ($(PY_CHECK_YAPF),)
$(PY_CHECK_YAPF) --in-place --recursive . $(PY_CHECK_YAPF) --in-place --recursive $(PY_CHECK_ROOTS)
endif endif
py-format-assignments: py-format-assignments:
find . \ find $(PY_CHECK_ROOTS) \
-path './.git' -prune -o \ -path './.git' -prune -o \
-type f -name '*.py' \ -type f -name '*.py' \
-execdir /usr/bin/sed -i 's/^\(\s\+[a-zA-Z0-9_]\+\)=\([^,[:space:]]\+\)\([,(]\)*\s*$$/\1 = \2\3/g' {} '+' -execdir /usr/bin/sed -i 's/^\(\s\+[a-zA-Z0-9_]\+\)=\([^,[:space:]]\+\)\([,(]\)*\s*$$/\1 = \2\3/g' {} '+'
@ -53,19 +64,24 @@ py-format-assignments:
py-check-annotation-imports: py-check-annotation-imports:
ifneq ($(PY_CHECK_RUFF),) ifneq ($(PY_CHECK_RUFF),)
$(PY_CHECK_RUFF) check --select TC,FA --diff --unsafe-fixes . $(PY_CHECK_RUFF) check --select TC,FA --diff --unsafe-fixes $(PY_CHECK_ROOTS)
endif endif
py-format-annotation-imports: py-format-annotation-imports:
ifneq ($(PY_CHECK_RUFF),) ifneq ($(PY_CHECK_RUFF),)
$(PY_CHECK_RUFF) check --select TC,FA --fix --unsafe-fixes . $(PY_CHECK_RUFF) check --select TC,FA --fix --unsafe-fixes $(PY_CHECK_ROOTS)
endif endif
clean.topdir: clean.py-check clean.topdir: clean.py-check
clean.py-check: clean.py-check:
rm -rf .mypy_cache rm -rf .mypy_cache
pyproject.toml:
$(PYTHON) $(JWB_SCRIPT_DIR)/jw-pkg.py -p $(PROJECTS_DIR) -t $(TOPDIR) --topdir-format unaltered projects create-file --format tmpl \
--template-name $@ --search-path $(JW_PKG_CONF_BASE_DIR)/templates --field mypypath='$(MYPY_PATH_DIRECTIVE)' $(PROJECT) > $@.tmp
mv $@.tmp $@
pyrightconfig.json: pyrightconfig.json:
$(PYTHON) $(JWB_SCRIPT_DIR)/jw-pkg.py -p $(PROJECTS_DIR) -t $(TOPDIR) --topdir-format unaltered projects create-file --format pyright \ $(PYTHON) $(JWB_SCRIPT_DIR)/jw-pkg.py -p $(PROJECTS_DIR) -t $(TOPDIR) --topdir-format unaltered projects create-file --format pyright \
$(PROJECT) --field base=$(JW_PKG_CONF_BASE_DIR)/project/pyrightconfig-base.json $(addprefix --field include=,$(wildcard src/python tools/python)) > $@.tmp --field base=$(JW_PKG_CONF_BASE_DIR)/project/pyrightconfig-base.json $(addprefix --field include=,$(wildcard src/python tools/python)) $(PROJECT) > $@.tmp
mv $@.tmp $@ mv $@.tmp $@

View file

@ -1,11 +1,10 @@
from argparse import ArgumentParser, ArgumentTypeError, Namespace from argparse import ArgumentParser, ArgumentTypeError, Namespace
from enum import Enum, auto from enum import Enum, auto
from typing import Iterable, TypeAlias
from ...lib.log import WARNING, log from ...lib.log import WARNING, log
from .Cmd import Cmd, Parent from .Cmd import Cmd, Parent
from .lib.pkg_relations import VersionSyntax, pkg_relations from .lib.pkg_relations import VersionSyntax, pkg_relations
from .lib.templates import tmpl_render from .lib.templates import ListDict, RenderValues, tmpl_render
def key_value(s): def key_value(s):
try: try:
@ -18,27 +17,14 @@ def key_value(s):
class Fmt(Enum): class Fmt(Enum):
Pyright = auto() Pyright = auto()
TupleList: TypeAlias = Iterable[tuple[str, str]]
ListDict: TypeAlias = dict[str, list[str]]
class CmdCreateFile(Cmd): # export class CmdCreateFile(Cmd): # export
def __tuple_list_to_dict(self, src: TupleList) -> ListDict: def __jw_required(self, module: str | None = None) -> list[str]:
ret: ListDict = {} if module is None:
for key, val in src: module = self.app.args.module
entry = ret.setdefault(key, []) if module is None:
entry.append(val) raise Exception('Can\'t get required packages without module name')
return ret return pkg_relations(
def __update_dict(self, dst: ListDict, src: TupleList) -> ListDict:
ret = dst
for key, val in src:
entry = ret.setdefault(key, [])
entry.append(val)
return ret
def render_pyright(self, module: str, extra_fields: TupleList) -> str:
prereq = pkg_relations(
self.app, self.app,
rel_type = 'requires', rel_type = 'requires',
flavours = ['run'], flavours = ['run'],
@ -52,9 +38,35 @@ class CmdCreateFile(Cmd): # export
hide_jw_pkg = False, hide_jw_pkg = False,
) )
def __render(
self,
template_name: str,
values: list[RenderValues],
li_quote = False,
li_delimiter = '\n',
) -> str:
return tmpl_render(
template_name,
values,
li_quote = li_quote,
li_delimiter = li_delimiter,
search_path = self.app.args.search_path.split(':'),
)
def render_tmpl(self, module: str, extra_fields: RenderValues) -> str:
template_name = self.app.args.template_name
if template_name is None:
raise Exception('Can\'t render template without name')
return self.__render(
template_name, [extra_fields],
li_quote = self.app.args.quote,
li_delimiter = ',\n'
)
def render_pyright(self, module: str, extra_fields: RenderValues) -> str:
extra_paths = [] extra_paths = []
for m in prereq: for m in self.__jw_required():
path = self.app.find_dir(m) path = self.app.find_dir(m, search_subdirs = ['src/python', 'tools/python'])
if path is None: if path is None:
log(WARNING, f'No project directory for module "{m}"') log(WARNING, f'No project directory for module "{m}"')
continue continue
@ -62,10 +74,10 @@ class CmdCreateFile(Cmd): # export
values: ListDict = { values: ListDict = {
'extra_paths': extra_paths, 'extra_paths': extra_paths,
} }
self.__update_dict(values, extra_fields) return self.__render(
'pyrightconfig.json', [values, extra_fields],
return tmpl_render( li_quote = True,
'pyrightconfig.json', values, li_quote = True, li_delimiter = ',\n' li_delimiter = ',\n'
) )
def __init__(self, parent: Parent) -> None: def __init__(self, parent: Parent) -> None:
@ -77,8 +89,21 @@ class CmdCreateFile(Cmd): # export
super().add_arguments(parser) super().add_arguments(parser)
parser.add_argument( parser.add_argument(
'--format', '--format',
choices = [fmt.name.lower() for fmt in Fmt], help = (
help = 'Output format' 'Output format, for example: '
', '.join([fmt.name.lower() for fmt in Fmt])
)
)
parser.add_argument(
'--search-path',
default = '/etc/opt/jw-pkg/templates',
help = 'Template search path, colon separated',
)
parser.add_argument('--template-name', help = 'Template file name')
parser.add_argument(
'--quote',
action = 'store_true',
help = 'Enclose variable values in double quotes before substituting'
) )
parser.add_argument( parser.add_argument(
'-f', '-f',

View file

@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from .Cmd import Cmd, Parent from .Cmd import Cmd, Parent
from .lib.templates import tmpl_render from .lib.templates import tmpl_render
from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from argparse import ArgumentParser, Namespace from argparse import ArgumentParser, Namespace
@ -61,24 +62,26 @@ class CmdCreatePkgConfig(Cmd): # export
contents = tmpl_render( contents = tmpl_render(
'pkg-config', 'pkg-config',
{ [
'prefix': args.prefix, {
'name': args.name, 'prefix': args.prefix,
'description': merged['summary'], 'name': args.name,
'version': args.version, 'description': merged['summary'] or '',
}, 'version': args.version,
}
]
) )
if args.cflags is not None: if args.cflags is not None:
contents += f'Cflags: {args.cflags}\n' contents += f'Cflags: {args.cflags}\n'
if args.libflags is not None: if args.libflags is not None:
contents += f'Libs: {args.libflags}\n' contents += f'Libs: {args.libflags}\n'
if merged['requires_run'] is not None: val = merged.get('requires_run')
contents += f'Requires: {self.__cleanup_requires(merged["requires_run"])}' if val is not None:
if merged['requires_build'] is not None: contents += f'Requires: {self.__cleanup_requires(val)}'
contents += ( val = merged.get('requires_build')
f'Requires.private: {self.__cleanup_requires(merged["requires_build"])}' if val is not None:
) contents += (f'Requires.private: {self.__cleanup_requires(val)}')
# not sure what to do with requires_devel # not sure what to do with requires_devel
print(contents) print(contents)

View file

@ -1,21 +1,98 @@
import textwrap import textwrap
from typing import Iterable, TypeAlias, TypeGuard
def format_lines(template, values, li_quote, li_delimiter): 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: RenderValues) -> 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: RenderValues) -> 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: RenderValues) -> 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: RenderValues) -> 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):
return values
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, [])
if isinstance(val, list):
entry += val
else:
entry.append(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): def __format_value(val):
if not li_quote: if not li_quote:
return str(val) return str(val)
return f'"{val}"' return f'"{val}"'
fmt_dict: dict[str, str] = {}
for key, value in values.items(): for key, value in values.items():
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
values[key] = li_delimiter.join(map(__format_value, value)) fmt_dict[key] = li_delimiter.join(map(__format_value, value))
elif isinstance(value, str):
fmt_dict[key] = value
ret = [] ret: list[str] = []
parts = template.splitlines(keepends = True) parts = template.splitlines(keepends = True)
for line in parts: for line in parts:
for key, value in values.items(): for key, value in fmt_dict.items():
marker = '{' + key + '}' marker = '{' + key + '}'
if marker in line: if marker in line:
indent = line[:line.index(marker)] indent = line[:line.index(marker)]
@ -25,6 +102,11 @@ def format_lines(template, values, li_quote, li_delimiter):
return ''.join(ret) 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 = { _templates = {
'pkg-config': 'pkg-config':
"""\ """\
@ -63,11 +145,31 @@ _templates = {
} }
def tmpl_render( def tmpl_render(
template_name: str, values, li_quote = False, li_delimiter = '\n' template_name: str,
values: list[RenderValues],
li_quote = False,
li_delimiter = '\n',
search_path: list[str] = []
) -> str: ) -> str:
return format_lines(
textwrap.dedent(_templates[template_name]), def __format(template: str) -> str:
values, return format_lines(
li_quote = li_quote, template,
li_delimiter = li_delimiter, values,
) li_quote = li_quote,
li_delimiter = li_delimiter,
)
for d in search_path:
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))