diff --git a/src/python/jw/pkg/cmds/projects/CmdCreateFile.py b/src/python/jw/pkg/cmds/projects/CmdCreateFile.py index 754f42cb..f1d09a17 100644 --- a/src/python/jw/pkg/cmds/projects/CmdCreateFile.py +++ b/src/python/jw/pkg/cmds/projects/CmdCreateFile.py @@ -55,32 +55,56 @@ class CmdCreateFile(Cmd): # export p = 'render_' return [name.removeprefix(p) for name in dir(self) if name.startswith(p)] + def __format_separator(self, separator: str | None, default: str) -> str: + if separator is None: + return default + format_chars = { + '%n': '\n', + } + for src, dst in format_chars.items(): + separator = separator.replace(src, dst) + return separator + def __render( self, template_name: str, values: list[RenderValues], li_quote: bool = False, - li_delimiter: str = '\n', + li_separator: str = '\n', ) -> str: return tmpl_render( template_name, values, li_quote = li_quote, - li_delimiter = li_delimiter, + li_delimiter = li_separator, search_path = self.app.args.search_path.split(':'), ) - def render_tmpl(self, module: str, extra_fields: RenderValues) -> str: + def render_tmpl( + self, + module: str, + extra_fields: RenderValues, + separator: str | None, + ) -> 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], + template_name, + [extra_fields], li_quote = self.app.args.quote, - li_delimiter = ',\n' + li_separator = self.__format_separator(separator, ',\n'), ) - def render_pyright(self, module: str, extra_fields: RenderValues) -> str: + def render_pyright( + self, + module: str, + extra_fields: RenderValues, + separator: str | None, + ) -> str: + separator = self.__format_separator(separator, ',\n') + if separator != ',\n': + raise Exception(f'Unsupported separator for pyright config: "{separator}"') extra_paths = [] for m in self.__jw_required(include_self = True): path = self.app.find_dir(m, search_subdirs = ['src/python', 'tools/python']) @@ -94,7 +118,7 @@ class CmdCreateFile(Cmd): # export return self.__render( 'pyrightconfig.json', [values, extra_fields], li_quote = True, - li_delimiter = ',\n' + li_separator = ',\n' ) def __init__(self, parent: Parent) -> None: @@ -132,11 +156,45 @@ class CmdCreateFile(Cmd): # export metavar = 'KEY=VALUE', help = 'Additional fields to insert into the output file', ) + parser.add_argument( + '--field-keys', + help = ( + 'Possible keys for the --field option, comma-separated. Defaults to ' + 'all passed via the --field option. If this field is specified, ' + 'passing a key in --field that\'s not specified here is an error, ' + 'and not passing one makes the respective field go away from the ' + 'rendering output' + ) + ) + parser.add_argument( + '--field-separator', + help = ( + 'Field separator. %%n expands to newline. Default value depends on ' + '--format' + ) + ) parser.add_argument('module', help = 'The module to generate the file for') @override async def _run(self, args: Namespace) -> None: + + # -- Honour --field-keys option + fields = args.field + if args.field_keys: + passed_keys = set([field[0] for field in fields]) + field_keys = set(args.field_keys.split(',')) + if passed_keys - field_keys: + raise Exception( + 'The following keys in --field are not in --field-keys: ' + + ', '.join(sorted(passed_keys - field_keys)) + ) + for key in field_keys - passed_keys: + fields.append((key, '')) + + # -- Find method for --format method = getattr(self, 'render_' + args.format, None) if method is None: # choices already restricts this; keeps the linter happy raise Exception(f'Unsupported output format {args.format}') - sys.stdout.write(method(args.module, args.field)) + + # -- Render + sys.stdout.write(method(args.module, fields, args.field_separator)) diff --git a/test/integration/jw-pkg/help/test-expected.txt b/test/integration/jw-pkg/help/test-expected.txt index 4ac25287..5bd788a1 100644 --- a/test/integration/jw-pkg/help/test-expected.txt +++ b/test/integration/jw-pkg/help/test-expected.txt @@ -336,6 +336,8 @@ usage: jw-pkg.py projects create-file [-h] --format {pyright,tmpl} [--search-path SEARCH_PATH] [--template-name TEMPLATE_NAME] [--quote] [-f KEY=VALUE] + [--field-keys FIELD_KEYS] + [--field-separator FIELD_SEPARATOR] module Generate a file from project metadata @@ -357,6 +359,16 @@ options: -f, --field KEY=VALUE Additional fields to insert into the output file (default: []) + --field-keys FIELD_KEYS + Possible keys for the --field option, comma-separated. + Defaults to all passed via the --field option. If this + field is specified, passing a key in --field that's + not specified here is an error, and not passing one + makes the respective field go away from the rendering + output (default: None) + --field-separator FIELD_SEPARATOR + Field separator. %n expands to newline. Default value + depends on --format (default: None) ============= Running: jw-pkg.py -t ../../../.. --log-level info projects create-pkg-config --help usage: jw-pkg.py projects create-pkg-config [-h] [-F PROJECT_DESCR_FILE] [-d DESCRIPTION] [-n NAME] diff --git a/test/integration/jw-pkg/projects/create-file/Makefile b/test/integration/jw-pkg/projects/create-file/Makefile new file mode 100644 index 00000000..47a63b62 --- /dev/null +++ b/test/integration/jw-pkg/projects/create-file/Makefile @@ -0,0 +1,23 @@ +TOPDIR = ../../../../.. + +OUTPUT = test-out.txt +REFERENCE = test-expected.txt + +# Relative topdir keeps the rendered project paths relocatable; dropping the +# log position keeps the error output free of volatile line numbers. +TEST_CMD_ARGS = --topdir-format relative --log-flags prio,stderr + +include $(TOPDIR)/make/proj.mk +include $(TOPDIR)/make/test-jw-pkg.mk + +all: + +$(OUTPUT): Makefile test.sh templates/basic.tmpl templates/list.tmpl + bash ./test.sh $(TEST_CMD_LINE) > $(OUTPUT).tmp 2>&1 + diff $(REFERENCE) $(OUTPUT).tmp + mv $(OUTPUT).tmp $(OUTPUT) + +test: $(OUTPUT) +clean: test.integration.in-tree.clean +test.integration.in-tree.clean: + rm -f $(OUTPUT) $(OUTPUT).tmp diff --git a/test/integration/jw-pkg/projects/create-file/templates/basic.tmpl b/test/integration/jw-pkg/projects/create-file/templates/basic.tmpl new file mode 100644 index 00000000..e55fc8af --- /dev/null +++ b/test/integration/jw-pkg/projects/create-file/templates/basic.tmpl @@ -0,0 +1,2 @@ +name={name} +path={path} diff --git a/test/integration/jw-pkg/projects/create-file/templates/list.tmpl b/test/integration/jw-pkg/projects/create-file/templates/list.tmpl new file mode 100644 index 00000000..85d16b63 --- /dev/null +++ b/test/integration/jw-pkg/projects/create-file/templates/list.tmpl @@ -0,0 +1 @@ +start={items}end diff --git a/test/integration/jw-pkg/projects/create-file/test-expected.txt b/test/integration/jw-pkg/projects/create-file/test-expected.txt new file mode 100644 index 00000000..88e91be6 --- /dev/null +++ b/test/integration/jw-pkg/projects/create-file/test-expected.txt @@ -0,0 +1,55 @@ +============= Running: jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name basic.tmpl --search-path templates --field name=foo jw-pkg +name=foo +path={path} +============= Running: jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name list.tmpl --search-path templates --field items=a --field items=b jw-pkg +start=a, +start=bend +============= Running: jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name list.tmpl --search-path templates --field items=a --field items=b --field-separator | jw-pkg +start=a|bend +============= Running: jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name list.tmpl --search-path templates --field items=a --field items=b --field-separator %n jw-pkg +start=a +start=bend +============= Running: jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name list.tmpl --search-path templates --field items=a --field items=b --quote --field-separator | jw-pkg +start="a"|"b"end +============= Running: jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name basic.tmpl --search-path templates --field-keys name,path --field name=foo jw-pkg +name=foo +path= +============= Running: jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format pyright --field base=./conf/project/pyrightconfig-base.json --field include=src/python jw-pkg +{ + "extends": "./conf/project/pyrightconfig-base.json", + "include": [ + "src/python" + ], + "exclude": [ + "**/__pycache__", + "**/.pytest_cache", + "**/.mypy_cache", + "**/.ruff_cache", + "**/.venv", + "**/build", + "**/dist" + ], + "extraPaths": [ + "src/python" + ], + "typeCheckingMode": "basic", + "pythonPlatform": "Linux" +} +============= Running (expect failure): jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl jw-pkg + Failed: Can't render template without name +============= Exit status: 1 +============= Running (expect failure): jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name nosuch.tmpl --search-path templates jw-pkg + Failed: Failed to find template "nosuch.tmpl" +============= Exit status: 1 +============= Running (expect failure): jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name basic.tmpl --search-path templates --field-keys name --field path=x jw-pkg + Failed: The following keys in --field are not in --field-keys: path +============= Exit status: 1 +============= Running (expect failure): jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format pyright --field base=x --field include=y --field-separator | jw-pkg + Failed: Unsupported separator for pyright config: "|" +============= Exit status: 1 +============= Running (expect failure): jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format bogus jw-pkg +jw-pkg.py projects create-file: error: argument --format: invalid choice: 'bogus' (choose from 'pyright', 'tmpl') +============= Exit status: 2 +============= Running (expect failure): jw-pkg.py -t ../../../../.. --log-level info --topdir-format relative --log-flags prio,stderr projects create-file --format tmpl --template-name basic.tmpl --search-path templates --field noval jw-pkg +jw-pkg.py projects create-file: error: argument -f/--field: Expected KEY=VALUE +============= Exit status: 2 diff --git a/test/integration/jw-pkg/projects/create-file/test.sh b/test/integration/jw-pkg/projects/create-file/test.sh new file mode 100644 index 00000000..58fb7a4f --- /dev/null +++ b/test/integration/jw-pkg/projects/create-file/test.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# shellcheck disable=SC2048,SC2086 +# Unquoted $* is intentional — the jw-pkg command line + +export LC_ALL="C" +set -euo pipefail + +jw_pkg_py="$*" + +# Print a header and run a command whose output is part of the expected +# result. Under `set -e` a non-zero exit aborts the test. +run() +{ + local log_cmd + # shellcheck disable=SC2001 + log_cmd=$(echo "$*" | sed 's|.*python3[0-9.]*\s\+\(\.\.\/\)*scripts/||') + printf '============= Running: %s\n' "$log_cmd" + "$@" +} + +# Run a command that must exit non-zero and report the given message. Prints +# the command, the resulting error message, and the observed exit status. +# Fails the test if the command unexpectedly succeeds or the message is +# missing. Only the last output line is shown: for the app-level errors it is +# the error itself, and for argparse rejections it is the error line, which +# keeps the expected file free of the volatile usage text. +expect_fail() +{ + local expected out rc log_cmd + expected=$1 + shift + # shellcheck disable=SC2001 + log_cmd=$(echo "$*" | sed 's|.*python3[0-9.]*\s\+\(\.\.\/\)*scripts/||') + printf '============= Running (expect failure): %s\n' "$log_cmd" + set +e + out=$("$@" 2>&1) + rc=$? + set -e + printf '%s\n' "$(tail -n 1 <<<"$out")" + printf '============= Exit status: %s\n' "$rc" + if [ "$rc" -eq 0 ]; then + echo "ERROR: expected failure, but the command succeeded" >&2 + return 1 + fi + if ! grep -qF -- "$expected" <<<"$out"; then + echo "ERROR: expected message not found: $expected" >&2 + return 1 + fi +} + +cmd_test() +{ + # -- tmpl: substitute a field; a marker without a value stays literal + run $jw_pkg_py projects create-file --format tmpl \ + --template-name basic.tmpl --search-path templates \ + --field name=foo jw-pkg + + # -- tmpl: multi-value field, default separator (comma, newline) + run $jw_pkg_py projects create-file --format tmpl \ + --template-name list.tmpl --search-path templates \ + --field items=a --field items=b jw-pkg + + # -- tmpl: multi-value field, custom separator + run $jw_pkg_py projects create-file --format tmpl \ + --template-name list.tmpl --search-path templates \ + --field items=a --field items=b --field-separator '|' jw-pkg + + # -- tmpl: multi-value field, "%n" separator + run $jw_pkg_py projects create-file --format tmpl \ + --template-name list.tmpl --search-path templates \ + --field items=a --field items=b --field-separator '%n' jw-pkg + + # -- tmpl: multi-value field, quoted values + run $jw_pkg_py projects create-file --format tmpl \ + --template-name list.tmpl --search-path templates \ + --field items=a --field items=b --quote --field-separator '|' jw-pkg + + # -- tmpl: --field-keys adds keys that were not passed as empty fields + run $jw_pkg_py projects create-file --format tmpl \ + --template-name basic.tmpl --search-path templates \ + --field-keys name,path --field name=foo jw-pkg + + # -- pyright: built-in template, extra paths from project metadata + run $jw_pkg_py projects create-file --format pyright \ + --field base=./conf/project/pyrightconfig-base.json \ + --field include=src/python jw-pkg + + # -- errors + expect_fail "Can't render template without name" \ + $jw_pkg_py projects create-file --format tmpl jw-pkg + expect_fail 'Failed to find template "nosuch.tmpl"' \ + $jw_pkg_py projects create-file --format tmpl \ + --template-name nosuch.tmpl --search-path templates jw-pkg + expect_fail "not in --field-keys: path" \ + $jw_pkg_py projects create-file --format tmpl \ + --template-name basic.tmpl --search-path templates \ + --field-keys name --field path=x jw-pkg + expect_fail 'Unsupported separator for pyright config: "|"' \ + $jw_pkg_py projects create-file --format pyright \ + --field base=x --field include=y --field-separator '|' jw-pkg + expect_fail "invalid choice: 'bogus'" \ + $jw_pkg_py projects create-file --format bogus jw-pkg + expect_fail "Expected KEY=VALUE" \ + $jw_pkg_py projects create-file --format tmpl \ + --template-name basic.tmpl --search-path templates --field noval jw-pkg +} + +cmd_test diff --git a/test/unit/python/jw/pkg/cmds/projects/CmdCreateFile/Makefile b/test/unit/python/jw/pkg/cmds/projects/CmdCreateFile/Makefile new file mode 100644 index 00000000..47708b2e --- /dev/null +++ b/test/unit/python/jw/pkg/cmds/projects/CmdCreateFile/Makefile @@ -0,0 +1,8 @@ +TOPDIR = ../../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-run.mk + +all: + +test: run diff --git a/test/unit/python/jw/pkg/cmds/projects/CmdCreateFile/test.py b/test/unit/python/jw/pkg/cmds/projects/CmdCreateFile/test.py new file mode 100644 index 00000000..c5000bd0 --- /dev/null +++ b/test/unit/python/jw/pkg/cmds/projects/CmdCreateFile/test.py @@ -0,0 +1,25 @@ +from jw.pkg.cmds.projects.CmdCreateFile import CmdCreateFile + +# __format_separator() is a private method that does not use self, so an +# uninitialised instance is a sufficient receiver for the bound method +inst = CmdCreateFile.__new__(CmdCreateFile) +fmt = getattr(inst, '_CmdCreateFile__format_separator') + +# -- __format_separator -- + +# A None separator falls back to the format's default +assert fmt(None, ',\n') == ',\n' + +# %n expands to a newline, wherever it appears in the separator +assert fmt('%n', ',\n') == '\n' +assert fmt(',%n', '\n') == ',\n' +assert fmt('a%n b', '') == 'a\n b' + +# Several %n expand independently +assert fmt('%n%n', '') == '\n\n' + +# Anything that is not %n passes through untouched +assert fmt('%', '') == '%' +assert fmt('%x', '') == '%x' + +print('All CmdCreateFile tests passed')