diff options
author | Peter Pentchev <roam@ringlet.net> | 2018-12-23 16:56:14 +0200 |
---|---|---|
committer | Peter Pentchev <roam@ringlet.net> | 2019-01-02 20:39:29 +0200 |
commit | 2f5e78512c7814578db992c0fe2ce060f9e95d00 (patch) | |
tree | bf47fb0fb5cd586cb3273600e720203b4cac59aa | |
parent | e632e229358105dd3010006f3fda2c0712230cf9 (diff) |
Implement confget as a Python module.
Also implement the command-line interface in Python.
-rw-r--r-- | python/confget/__init__.py | 0 | ||||
-rwxr-xr-x | python/confget/__main__.py | 297 | ||||
-rw-r--r-- | python/confget/backend/__init__.py | 16 | ||||
-rw-r--r-- | python/confget/backend/abstract.py | 29 | ||||
-rw-r--r-- | python/confget/backend/http_get.py | 77 | ||||
-rw-r--r-- | python/confget/backend/ini.py | 144 | ||||
-rw-r--r-- | python/confget/defs.py | 23 | ||||
-rw-r--r-- | python/confget/format.py | 164 | ||||
-rw-r--r-- | python/tox.ini | 31 | ||||
-rw-r--r-- | t/14-too-many.t | 7 |
10 files changed, 788 insertions, 0 deletions
diff --git a/python/confget/__init__.py b/python/confget/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/python/confget/__init__.py diff --git a/python/confget/__main__.py b/python/confget/__main__.py new file mode 100755 index 0000000..4064989 --- /dev/null +++ b/python/confget/__main__.py @@ -0,0 +1,297 @@ +# Copyright (c) 2018 Peter Pentchev +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +""" +The command-line interface to the confget module: specify some parameters +through command-line options, then display variable values or names. +""" + +from __future__ import print_function + +import argparse +import sys + +from typing import List, Optional + +from . import backend +from . import defs +from . import format as fmt + + +_TYPING_USED = (defs, List, Optional) + +VERSION_STRING = '2.2.0' +FEATURES = [ + ('BASE', VERSION_STRING), +] + + +class MainConfig(fmt.FormatConfig): + # pylint: disable=too-few-public-methods,too-many-instance-attributes + """ + Extend the format config class with some output settings. + + Add the following settings: + - check_only (boolean): only check whether a variable is defined + - query_sections (boolean): only display the section names + """ + + def __init__(self, # type: MainConfig + check_only, # type: bool + filename, # type: Optional[str] + list_all, # type: bool + match_regex, # type: bool + match_var_names, # type: bool + match_var_values, # type: Optional[str] + name_prefix, # type: Optional[str] + query_sections, # type: bool + section, # type: str + section_override, # type: bool + section_specified, # type: bool + show_var_name, # type: bool + shell_escape, # type: bool + varnames # type: List[str] + ): # type: (...) -> None + # pylint: disable=too-many-arguments,too-many-locals + """ Store the specified configuration values. """ + super(MainConfig, self).__init__( + filename=filename, + list_all=list_all, + match_regex=match_regex, + match_var_names=match_var_names, + match_var_values=match_var_values, + name_prefix=name_prefix, + section=section, + section_specified=section_specified, + section_override=section_override, + show_var_name=show_var_name, + shell_escape=shell_escape, + varnames=varnames, + ) + self.check_only = check_only + self.query_sections = query_sections + + +def version(): + # type: () -> None + """ + Display program version information. + """ + print('confget ' + VERSION_STRING) + + +def features(name): + # type: (Optional[str]) -> None + """ + Display a list of the features supported by the program. + """ + if name is None: + print(' '.join([ + '{name}={version}'.format(name=item[0], version=item[1]) + for item in FEATURES + ])) + else: + ver = dict(FEATURES).get(name, None) + if ver is None: + sys.exit(1) + print(ver) + + +def output_check_only(cfg, data): + # type: (MainConfig, defs.ConfigData) -> None + """ Check whether the variable is present. """ + if cfg.section not in data: + sys.exit(1) + elif cfg.varnames[0] not in data[cfg.section]: + sys.exit(1) + sys.exit(0) + + +def output_vars(cfg, data): + # type: (MainConfig, defs.ConfigData) -> None + """ Output the variable values. """ + for vitem in fmt.filter_vars(cfg, data): + print(vitem.output_full) + + +def output_sections(data): + # type: (defs.ConfigData) -> None + """ Output the section names. """ + for name in sorted(data.keys()): + if name != '': + print(name) + + +def validate_options(args, backend_name): + # type: (argparse.Namespace, str) -> None + """ + Make sure the command-line options are not used in an invalid combination. + """ + query_sections = args.query == 'sections' + + if args.list_all or query_sections: + if args.varnames: + sys.exit('Only a single query at a time, please!') + elif args.match_var_names: + if not args.varnames: + sys.exit('No patterns to match against') + elif args.check_only and len(args.varnames) > 1: + sys.exit('Only a single query at a time, please!') + elif not args.varnames: + sys.exit('No variables specified to query') + + if query_sections and backend_name != 'ini': + sys.exit("The query for sections is only supported for " + "the 'ini' backend for the present") + + +def check_option_conflicts(args): + # type: (argparse.Namespace) -> None + """ Make sure that the command-line options do not conflict. """ + total = int(args.query is not None) + int(args.match_var_names) + \ + int(args.list_all) + \ + int(bool(args.varnames) and + not (args.match_var_names or args.query == 'feature')) + if total > 1: + sys.exit('Only a single query at a time, please!') + + +def main(): + # type: () -> None + """ + The main program: parse arguments, do things. + """ + parser = argparse.ArgumentParser( + prog='confget', + usage=''' + confget [-t ini] -f filename [-s section] varname... + confget -V | -h | --help | --version + confget -q features''') + parser.add_argument('-c', action='store_true', dest='check_only', + help='check whether the variables are defined in ' + 'the file') + parser.add_argument('-f', type=str, dest='filename', + help='specify the configuration file name') + parser.add_argument('-L', action='store_true', dest='match_var_names', + help='specify which variables to display') + parser.add_argument('-l', action='store_true', dest='list_all', + help='list all variables in the specified section') + parser.add_argument('-m', type=str, dest='match_var_values', + help='only display variables with values that match ' + 'the specified pattern') + parser.add_argument('-N', action='store_true', dest='show_var_name', + help='always display the variable name') + parser.add_argument('-n', action='store_true', dest='hide_var_name', + help='never display the variable name') + parser.add_argument('-O', action='store_true', dest='section_override', + help='allow variables in the specified section to ' + 'override those placed before any ' + 'section definitions') + parser.add_argument('-p', type=str, dest='name_prefix', + help='display this string before the variable name') + parser.add_argument('-q', type=str, dest='query', choices=[ + 'feature', + 'features', + 'sections' + ], help='query for a specific type of information, e.g. the list of ' + 'sections defined in ' 'the configuration file') + parser.add_argument('-S', action='store_true', dest='shell_quote', + help='quote the values suitably for the Bourne shell') + parser.add_argument('-s', type=str, dest='section', + help='specify the configuration file section') + parser.add_argument('-t', type=str, default='ini', dest='backend', + help='specify the configuration file type') + parser.add_argument('-V', '--version', action='store_true', + help='display program version information and exit') + parser.add_argument('-x', action='store_true', dest='match_regex', + help='treat the match patterns as regular expressions') + parser.add_argument('varnames', nargs='*', + help='the variable names to query') + + args = parser.parse_args() + if args.version: + version() + return + + check_option_conflicts(args) + + if args.query == 'features': + if args.varnames: + sys.exit('No arguments to -q features') + features(None) + return + if args.query == 'feature': + if len(args.varnames) != 1: + sys.exit('Only a single feature name expected') + features(args.varnames[0]) + return + + query_sections = args.query == 'sections' + + cfg = MainConfig( + check_only=args.check_only, + filename=args.filename, + list_all=args.list_all, + match_regex=args.match_regex, + match_var_names=args.match_var_names, + match_var_values=args.match_var_values, + name_prefix=args.name_prefix, + query_sections=query_sections, + section=args.section if args.section is not None else '', + section_override=args.section_override, + section_specified=args.section is not None, + shell_escape=args.shell_quote, + show_var_name=args.show_var_name or + ((args.match_var_names or args.list_all or len(args.varnames) > 1) and + not args.hide_var_name), + varnames=args.varnames, + ) + + matched_backends = [name for name in sorted(backend.BACKENDS.keys()) + if name.startswith(args.backend)] + if not matched_backends: + sys.exit('Unknown backend "{name}", use "list" for a list' + .format(name=args.backend)) + elif len(matched_backends) > 1: + sys.exit('Ambiguous backend "{name}": {lst}' + .format(name=args.backend, lst=' '.join(matched_backends))) + back = backend.BACKENDS[matched_backends[0]] + + validate_options(args, matched_backends[0]) + + try: + cfgp = back(cfg) + except Exception as exc: # pylint: disable=broad-except + sys.exit(str(exc)) + data = cfgp.read_file() + + if cfg.check_only: + output_check_only(cfg, data) + elif query_sections: + output_sections(data) + else: + output_vars(cfg, data) + + +if __name__ == '__main__': + main() diff --git a/python/confget/backend/__init__.py b/python/confget/backend/__init__.py new file mode 100644 index 0000000..25cdb75 --- /dev/null +++ b/python/confget/backend/__init__.py @@ -0,0 +1,16 @@ +""" +Provide an interface to all the configuration format backends. +""" + +from typing import Dict, Type + +from . import abstract, http_get, ini + + +_TYPING_USED = (Dict, Type, abstract) + + +BACKENDS = { + 'http_get': http_get.HTTPGetBackend, + 'ini': ini.INIBackend, +} # type: Dict[str, Type[abstract.Backend]] diff --git a/python/confget/backend/abstract.py b/python/confget/backend/abstract.py new file mode 100644 index 0000000..af7c3e6 --- /dev/null +++ b/python/confget/backend/abstract.py @@ -0,0 +1,29 @@ +""" +An abstract metaclass for confget backends. +""" + +import abc + +from typing import Dict + +import six + +from .. import defs + + +_TYPING_USED = (defs, Dict) + + +@six.add_metaclass(abc.ABCMeta) +class Backend(object): + # pylint: disable=too-few-public-methods + """ An abstract confget parser backend. """ + def __init__(self, cfg): + # type: (Backend, defs.Config) -> None + self._cfg = cfg + + @abc.abstractmethod + def read_file(self): + # type: (Backend) -> defs.ConfigData + """ Read and parse the configuration file, invoke the callbacks. """ + raise NotImplementedError('Backend.read_file') diff --git a/python/confget/backend/http_get.py b/python/confget/backend/http_get.py new file mode 100644 index 0000000..de32c66 --- /dev/null +++ b/python/confget/backend/http_get.py @@ -0,0 +1,77 @@ +""" +A confget backend for reading INI-style files. +""" + +import os +import re +import urllib + +from typing import Dict, List + +from .. import defs + +from . import abstract + + +_TYPING_USED = (defs, Dict, List) + +RE_ENTITY = re.compile(r'^ (?P<full> [a-zA-Z0-9_]+ ; )', re.X) + + +class HTTPGetBackend(abstract.Backend): + # pylint: disable=too-few-public-methods + """ Parse INI-style configuration files. """ + + def __init__(self, cfg): + # type: (HTTPGetBackend, defs.Config) -> None + super(HTTPGetBackend, self).__init__(cfg) + + if self._cfg.filename is not None: + raise ValueError('No config filename expected') + + qname = 'QUERY_STRING' if self._cfg.section == '' \ + else self._cfg.section + qval = os.environ.get(qname, None) + if qval is None: + raise ValueError('No "{qname}" variable in the environment' + .format(qname=qname)) + self.query_string = qval + + def read_file(self): + # type: (HTTPGetBackend) -> defs.ConfigData + unquote = getattr(urllib, 'parse', urllib).unquote + + def split_by_amp(line): + # type: (str) -> List[str] + """ Split a line by "&" or "&" tokens. """ + if not line: + return [] + + start = end = 0 + while True: + pos = line[start:].find('&') + if pos == -1: + return [line] + if line[pos + 1:].startswith('amp;'): + end = pos + 4 + break + entity = RE_ENTITY.match(line[pos + 1:]) + if entity is None: + end = pos + break + start = pos + len(entity.group('full')) + + return [line[:pos]] + split_by_amp(line[end + 1:]) + + data = {} # type: Dict[str, str] + fragments = split_by_amp(self.query_string) + for varval in fragments: + fields = varval.split('=') + if len(fields) == 1: + fields.append('') + elif len(fields) != 2: + raise ValueError('Invalid query string component: "{varval}"' + .format(varval=varval)) + data[unquote(fields[0])] = unquote(fields[1]) + + return {self._cfg.section: data} diff --git a/python/confget/backend/ini.py b/python/confget/backend/ini.py new file mode 100644 index 0000000..a409e85 --- /dev/null +++ b/python/confget/backend/ini.py @@ -0,0 +1,144 @@ +""" +A confget backend for reading INI-style files. +""" + +import re +import sys + +from typing import Callable, Dict, Match, NamedTuple, Pattern + +from .. import defs + +from . import abstract + + +_TYPING_USED = (defs, Dict) + + +class INIBackend(abstract.Backend): + # pylint: disable=too-few-public-methods + """ Parse INI-style configuration files. """ + + def __init__(self, cfg, encoding='UTF-8'): + # type: (INIBackend, defs.Config, str) -> None + super(INIBackend, self).__init__(cfg) + + if self._cfg.filename is None: + raise ValueError('No config filename specified') + elif self._cfg.filename == '-': + infile = sys.stdin + else: + infile = open(self._cfg.filename, mode='r') + + reconfigure = getattr(infile, 'reconfigure', None) + if reconfigure is not None: + reconfigure(encoding=encoding) + + self.infile = infile + + def read_file(self): + # type: (INIBackend) -> defs.ConfigData + state = { + 'section': '', + 'name': '', + 'value': '', + 'cont': '', + 'found': '', + } + res = {'': {}} # type: defs.ConfigData + + def handle_section(match, # type: Match[str] + state, # type: Dict[str, str] + cfg, # type: defs.Config + res # type: defs.ConfigData + ): # type: (...) -> None + """ Handle a section heading: store the name. """ + state['section'] = match.group('name') + if state['section'] not in res: + res[state['section']] = {} + if not (cfg.section_specified or cfg.section or state['found']): + cfg.section = state['section'] + state['found'] = state['section'] + + def handle_comment(_match, # type: Match[str] + _state, # type: Dict[str, str] + _cfg, # type: defs.Config + _res # type: defs.ConfigData + ): # type: (...) -> None + """ Handle a comment line: ignore it. """ + pass # pylint: disable=unnecessary-pass + + def handle_variable(match, # type: Match[str] + state, # type: Dict[str, str] + _cfg, # type: defs.Config + res # type: defs.ConfigData + ): # type: (...) -> None + """ Handle an assignment: store, check for a continuation. """ + state['name'] = match.group('name') + state['value'] = match.group('value') + state['cont'] = match.group('cont') + state['found'] = state['name'] + if not state['cont']: + res[state['section']][state['name']] = state['value'] + + MatcherType = NamedTuple('MatcherType', [ + ('regex', Pattern[str]), + ('handle', Callable[[ + Match[str], + Dict[str, str], + defs.Config, + defs.ConfigData, + ], None]), + ]) + matches = [ + MatcherType( + regex=re.compile(r'^ \s* (?: [#;] .* )? $', re.X), + handle=handle_comment, + ), + MatcherType( + regex=re.compile(r''' + ^ + \s* \[ \s* + (?P<name> [^\]]+? ) + \s* \] \s* + $''', + re.X), + handle=handle_section, + ), + MatcherType( + regex=re.compile(r''' + ^ + \s* + (?P<name> \S+ ) + \s* = \s* + (?P<value> .*? ) + \s* + (?P<cont> [\\] )? + $''', re.X), + handle=handle_variable, + ), + ] + + for line in self.infile.readlines(): + line = line.rstrip('\r\n') + if state['cont']: + if line.endswith('\\'): + line, state['cont'] = line[:-1], line[-1] + else: + state['cont'] = '' + state['value'] += line + if not state['cont']: + res[state['section']][state['name']] = state['value'] + continue + + for data in matches: + match = data.regex.match(line) + if match is None: + continue + data.handle(match, state, self._cfg, res) + break + else: + raise ValueError('Unexpected line in {fname}: {line}' + .format(fname=self._cfg.filename, line=line)) + + return res diff --git a/python/confget/defs.py b/python/confget/defs.py new file mode 100644 index 0000000..d098e25 --- /dev/null +++ b/python/confget/defs.py @@ -0,0 +1,23 @@ +""" +Common definitions for the confget configuration parsing library. +""" + +from typing import Dict, List, Optional + + +_TYPING_USED = (List, Optional) + +ConfigData = Dict[str, Dict[str, str]] + + +class Config(object): + # pylint: disable=too-few-public-methods + """ Base class for the internal confget configuration. """ + + def __init__(self, filename, section, section_specified, varnames): + # type: (Config, Optional[str], str, bool, List[str]) -> None + """ Store the specified configuration values. """ + self.filename = filename + self.section = section + self.section_specified = section_specified + self.varnames = varnames diff --git a/python/confget/format.py b/python/confget/format.py new file mode 100644 index 0000000..fc2a012 --- /dev/null +++ b/python/confget/format.py @@ -0,0 +1,164 @@ +""" +Filter and format a subset of the configuration variables. +""" + +import fnmatch +import re + +from typing import Callable, Dict, Iterable, NamedTuple, List, Optional + +from . import defs + + +_TYPING_USED = (Callable, Dict, Iterable, List, Optional) + +FormatOutput = NamedTuple('FormatOutput', [ + ('name', str), + ('value', str), + ('output_name', str), + ('output_value', str), + ('output_full', str), +]) + + +class FormatConfig(defs.Config): + # pylint: disable=too-few-public-methods,too-many-instance-attributes + """ + Extend the config class with some output settings. + + Add the following settings: + - list_all (boolean): list all variables, not just a subset + - match_regex (boolean): for match_var_names and match_var_values, + perform regular expression matches instead of filename pattern ones + - match_var_names (boolean): treat the variable names specified as + patterns and display all variables that match those + - match_var_values (string): display only the variables with values + that match this pattern + - name_prefix (string): when displaying variable names, prefix them + with this string + - show_var_name (boolean): display the variable names, not just + the values + - shell_escape (boolean): format the values in a manner suitable for + the Bourne shell + """ + + def __init__(self, # type: FormatConfig + filename, # type: Optional[str] + list_all, # type: bool + match_regex, # type: bool + match_var_names, # type: bool + match_var_values, # type: Optional[str] + name_prefix, # type: Optional[str] + section, # type: str + section_override, # type: bool + section_specified, # type: bool + show_var_name, # type: bool + shell_escape, # type: bool + varnames # type: List[str] + ): # type: (...) -> None + # pylint: disable=too-many-arguments + """ Store the specified configuration values. """ + super(FormatConfig, self).__init__( + filename=filename, + section=section, + section_specified=section_specified, + varnames=varnames, + ) + self.list_all = list_all + self.match_regex = match_regex + self.match_var_names = match_var_names + self.match_var_values = match_var_values + self.name_prefix = name_prefix + self.section_override = section_override + self.shell_escape = shell_escape + self.show_var_name = show_var_name + + +def get_check_function(cfg, patterns): + # type: (FormatConfig, List[str]) -> Callable[[str], bool] + """ + Get a function that determines whether a variable name should be + included in the displayed subset. + """ + if cfg.match_regex: + re_vars = [re.compile(name) for name in patterns] + + def check_re_vars(key): + # type: (str) -> bool + """ Check that the key matches any of the specified regexes. """ + return any(rex.search(key) for rex in re_vars) + + return check_re_vars + + def check_fn_vars(key): + # type: (str) -> bool + """ Check that the key matches any of the specified patterns. """ + return any(fnmatch.fnmatch(key, pattern) + for pattern in patterns) + + return check_fn_vars + + +def get_varnames(cfg, sect_data): + # type: (FormatConfig, Dict[str, str]) -> Iterable[str] + """ Get the variable names that match the configuration requirements. """ + if cfg.list_all: + varnames = sect_data.keys() # type: Iterable[str] + elif cfg.match_var_names: + check_var = get_check_function(cfg, cfg.varnames) + varnames = [name for name in sect_data.keys() if check_var(name)] + else: + varnames = [name for name in cfg.varnames if name in sect_data] + + if not cfg.match_var_values: + return varnames + + check_value = get_check_function(cfg, [cfg.match_var_values]) + return [name for name in varnames if check_value(sect_data[name])] + + +def filter_vars(cfg, data): + # type: (FormatConfig, defs.ConfigData) -> Iterable[FormatOutput] + """ + Filter the variables in the configuration file according to + the various criteria specified in the configuration. + Return an iterable of FormatOutput structures allowing the caller to + process the variable names and values in various ways. + """ + if cfg.section_override: + sect_data = data[''] + else: + sect_data = {} + if cfg.section in data: + sect_data.update(data[cfg.section]) + + varnames = get_varnames(cfg, sect_data) + res = [] # type: List[FormatOutput] + for name in sorted(varnames): + if cfg.name_prefix: + output_name = cfg.name_prefix + name + else: + output_name = name + + value = sect_data[name] + if cfg.shell_escape: + output_value = "'{esc}'".format( + esc="'\"'\"'".join(value.split("'"))) + else: + output_value = value + + if cfg.show_var_name: + output_full = '{name}={value}'.format(name=output_name, + value=output_value) + else: + output_full = output_value + + res.append(FormatOutput( + name=name, + value=value, + output_name=output_name, + output_value=output_value, + output_full=output_full, + )) + + return res diff --git a/python/tox.ini b/python/tox.ini new file mode 100644 index 0000000..a33bf53 --- /dev/null +++ b/python/tox.ini @@ -0,0 +1,31 @@ +[tox] +envlist = pep8,mypy2,mypy3,pylint +skipsdist = True + +[testenv:pep8] +basepython = python3 +deps = + flake8 +commands = + flake8 confget + +[testenv:mypy2] +basepython = python3 +deps = + mypy +commands = + mypy --strict --py2 confget + +[testenv:mypy3] +basepython = python3 +deps = + mypy +commands = + mypy --strict confget + +[testenv:pylint] +basepython = python3 +deps = + pylint +commands = + pylint --disable=useless-object-inheritance,duplicate-code confget diff --git a/t/14-too-many.t b/t/14-too-many.t index 5949fe3..5c1be63 100644 --- a/t/14-too-many.t +++ b/t/14-too-many.t @@ -56,6 +56,13 @@ for args in \ '-q sections key1' \ '-q features -q feature BASE' \ '-q features key1'; do + if [ "${args#-q sections -q}" != "$args" ] || [ "${args#-q features -q}" != "$args" ]; then + if [ "${CONFGET#*python}" != "$CONFGET" ]; then + echo "ok $idx $args - skipped, Python argparse" + idx="$((idx + 1))" + continue + fi + fi v=`$CONFGET -f "$T2" $args 2>&1` if expr "x$v" : 'x.*Only a single ' > /dev/null; then echo "ok $idx $args"; else echo "not ok $idx args $args v is $v"; fi idx="$((idx + 1))" |