~dricottone/gap

8ce757c2576321df9edb688cd6d68fc2ca5cbb8c — Dominic Ricottone 4 years ago v1.0.0
Initial commit
A  => .gitignore +4 -0
@@ 1,4 @@
**/__pycache__
**/*.pyc
tests/generated_syntax/*


A  => Makefile +31 -0
@@ 1,31 @@

unittest = unittest --color
unittest_discover = unittest --color --working-directory .
# If you don't have my unittest wrapper installed...
#unittest = python -m unittest
#unittest_discover = python -m unittest discover tests --top-level-directory . --start-directory

clean:
	rm -rf **/__pycache__ **/*.pyc

bootstrap:
	./gap/bootstrap.py > gap/cli.py

test:
	mkdir -p tests/generated_syntax
	touch tests/__init__.py tests/generated_syntax/__init__.py
	python -m py_compile gap/*.py
	$(unittest_discover) tests
	python -m py_compile tests/generated_syntax/*.py
	$(unittest) tests/generated_syntax_tests.py

unittest:
	$(unittest_discover) tests --verbose
	$(unittest) tests/generated_syntax_tests.py --verbose

install:
	pipx install --spec . gap

uninstall:
	pipx uninstall gap


A  => README.md +66 -0
@@ 1,66 @@

# generated argument parser

A package that uses external configuration files to generate a static, standalone parser module.


## To-Do

Add parsers aside from `toml`, like `json` and `configparser`.

Allow import of third-party libraries, like `toml`, to fail-just remove that format from the valid list.


## Workflow

Given a configuration like:

```toml
# Quickly define '--help', '-h', and '-x'
[help]
number = 0
alternatives = ['h', 'x']

# Immediately raise an error if too few values given.
[range]
number = 2

# Take variable numbers of values, only raising an error at the minimum.
# Will greedily consume arguments up to the maximum.
[files]
minimum = 1
maximum = 9
```

You could include the following in your build process:

```shell
python -m gap my-project-cli.conf > cli.py
```

Your argument parser then is accessible in Python like:

```python
import sys, cli

options, positionals = cli.main(sys.argv[1:])

if "help" in options.keys():
	print_help_message()

# It's just a built-in dict, so use all the usual methods as you please.
for file in options.get("files", []):
	read_input(file)
else:
	for argument in positionals:
		read_input(file)
```


## Licensing

This package is licensed under GPL.

I claim absolutely no license over any code generated by this package.



A  => gap/__init__.py +0 -0
A  => gap/__main__.py +126 -0
@@ 1,126 @@
#!/usr/bin/env python3

import sys

from . import generator
from . import cli

def help_message():
    message = (
        "Usage: gap [OPTIONS] INPUT\n"
    )
    sys.stdout.write(message)

def version_message():
    message = (
        "gap 1.0.0\n"
    )
    sys.stdout.write(message)

def usage_message():
    message = (
        "Usage: gap [OPTIONS] INPUT\n"
    )
    sys.stderr.write(message)

def format_message(input_format):
    message = (
        f'gap: Invalid input format "{input_format}"\n'
        "Use one of: toml\n"
    )
    sys.stderr.write(message)

def file_message(filename):
    message = (
        f'gap: Cannot access file "{filename}"\n'
    )
    sys.stderr.write(message)

def main():
    config, positionals = cli.main(sys.argv[1:])

    # Check for --help
    if "help" in config.keys():
        help_message()
        sys.exit(0)
    elif "version" in config.keys():
        version_message()
        sys.exit(0)

    # Check for too few arguments
    if len(positionals) == 0:
        usage_message()
        sys.exit(1)
    else:
        filename = positionals[0]

    # Check for valid format, then import corresponding parser
    if "format" in config.keys():
        input_format = config["format"]
        if input_format == "toml":
            from . import toml_parser as parser
        else:
            format_message(input_format)
            sys.exit(0)
    else:
        from . import toml_parser as parser

    # Set verbosity level
    verbose = False
    if "verbose" in config.keys():
        verbose = True
    if "quiet" in config.keys():
        verbose = False
    if "output" in config.keys():
        output_filename = config["output"]
    else:
        output_filename = None

    # Set generator options
    attached_values = generator.DEFAULT_ATTACHED_VALUES
    if "attached-values" in config.keys():
        attached_values = True
    elif "no-attached-values" in config.keys():
        attached_values = False

    debug_mode = generator.DEFAULT_ATTACHED_VALUES
    if "debug-mode" in config.keys():
        debug_mode = True
    elif "no-debug-mode" in config.keys():
        debug_mode = False

    executable = generator.DEFAULT_ATTACHED_VALUES
    if "executable" in config.keys():
        executable = True
    elif "no-executable" in config.keys():
        executable = False

    raise_on_overfull = generator.DEFAULT_ATTACHED_VALUES
    if "raise-on-overfull" in config.keys():
        raise_on_overfull = True
    elif "no-raise-on-overfull" in config.keys():
        raise_on_overfull = False

    # Parse input file and generate output
    data = parser.data_from_file(filename)
    options = generator.Options._from_dict(data, expand_alternatives=True)
    options.attached_values(attached_values)
    options.debug_mode(debug_mode)
    options.executable(executable)
    options.raise_on_overfull(raise_on_overfull)
    syntax = options.build_syntax()

    # Write/print and return
    if output_filename is not None:
        try:
            with open(output_filename, 'w') as f:
                f.write(syntax)
        except OSError:
            file_message(output_filename)
    else:
        sys.stdout.write(syntax)
    sys.exit(0)

if __name__ == "__main__":
    main()


A  => gap/bootstrap.py +63 -0
@@ 1,63 @@
#!/usr/bin/env python3

import toml

import generator

GAP_TOML = """

[format]
number = 1
alternatives = ['f']

[output]
number = 1
alternatives = ['o']

[help]
number = 0
alternatives = ['h', 'x']

[version]
number = 0
alternatives = ['V']

[verbose]
number = 0
alternatives = ['v']

[quiet]
number = 0
alternatives = ['q']

[attached-values]
number = 0

[no-attached-values]
number = 0

[debug-mode]
number = 0

[no-debug-mode]
number = 0

[executable]
number = 0

[no-executable]
number = 0

[raise-on-overfull]
number = 0

[no-raise-on-overfull]
number = 0

"""

if __name__ == "__main__":
    data = toml.loads(GAP_TOML)
    syntax = generator.Options._from_dict(data, expand_alternatives=True).build_syntax()
    print(syntax)


A  => gap/cli.py +204 -0
@@ 1,204 @@
#!/usr/bin/env python3

import re

def main(arguments):
	config=dict()
	positional=[]
	pattern=re.compile(r"(?:-(?:f|o|h|x|V|v|q)|--(?:format|output|help|version|verbose|quiet|attached-values|no-attached-values|debug-mode|no-debug-mode|executable|no-executable|raise-on-overfull|no-raise-on-overfull))(?:=.*)?$")
	consuming,needing,wanting=None,0,0
	attached_value=None
	while len(arguments) and arguments[0]!="--":
		if consuming is not None:
			if config[consuming] is None:
				config[consuming]=arguments.pop(0)
			else:
				config[consuming].append(arguments.pop(0))
			needing-=1
			wanting-=1
			if wanting==0:
				consuming,needing,wanting=None,0,0
		elif pattern.match(arguments[0]):
			option = arguments.pop(0).lstrip('-')
			if '=' in option:
				option,attached_value=option.split('=',1)
			if option=="format":
				if attached_value is not None:
					config["format"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["format"]=None
					consuming,needing,wanting="format",1,1
			elif option=="output":
				if attached_value is not None:
					config["output"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["output"]=None
					consuming,needing,wanting="output",1,1
			elif option=="help":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "help"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["help"]=True
			elif option=="version":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "version"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["version"]=True
			elif option=="verbose":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "verbose"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["verbose"]=True
			elif option=="quiet":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "quiet"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["quiet"]=True
			elif option=="attached-values":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "attached-values"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["attached-values"]=True
			elif option=="no-attached-values":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "no-attached-values"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["no-attached-values"]=True
			elif option=="debug-mode":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "debug-mode"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["debug-mode"]=True
			elif option=="no-debug-mode":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "no-debug-mode"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["no-debug-mode"]=True
			elif option=="executable":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "executable"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["executable"]=True
			elif option=="no-executable":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "no-executable"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["no-executable"]=True
			elif option=="raise-on-overfull":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "raise-on-overfull"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["raise-on-overfull"]=True
			elif option=="no-raise-on-overfull":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "no-raise-on-overfull"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["no-raise-on-overfull"]=True
			elif option=="f":
				if attached_value is not None:
					config["format"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["format"]=None
					consuming,needing,wanting="format",1,1
			elif option=="o":
				if attached_value is not None:
					config["output"]=attached_value
					attached_value=None
					consuming,needing,wanting=None,0,0
				else:
					config["output"]=None
					consuming,needing,wanting="output",1,1
			elif option=="h":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "help"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["help"]=True
			elif option=="x":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "help"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["help"]=True
			elif option=="V":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "version"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["version"]=True
			elif option=="v":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "verbose"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["verbose"]=True
			elif option=="q":
				if attached_value is not None:
					message=(
						'unexpected value while parsing "quiet"'
						' (expected 0 values)'
					)
					raise ValueError(message) from None
				config["quiet"]=True
		else:
			positional.append(arguments.pop(0))
	if needing>0:
		message=(
			f'unexpected end while parsing "{consuming}"'
			f' (expected {needing} values)'
		)
		raise ValueError(message) from None
	for argument in arguments[1:]:
		positional.append(argument)
	return config,positional


A  => gap/generator.py +531 -0
@@ 1,531 @@
#!/usr/bin/env python3

from pprint import pformat
from typing import Dict,List

DEFAULT_ARG_NUM =           1     # assumed number of arguments for an option
DEFAULT_ATTACHED_VALUES =   True  # if True, parser splits attached values (i.e. --verbosity=1)
DEFAULT_DEBUG_MODE =        False # if True, parser generates with logging calls
DEFAULT_EXECUTABLE =        False # if True, parser generates with executable main procedure
DEFAULT_RAISE_ON_OVERFULL = True  # if True, parser complains about flags with attached values (i.e. --quiet=true)

def update_no_collide(
    d1: Dict,
    d2: Dict,
) -> Dict:
    """Update d1 with the keys and values in d2, and raise KeyError instead of
    overwriting a key.
    """
    for key, val in d2.items():
        if key in d1.keys():
            message = "cannot overwrite key '{0}'".format(key)
            raise KeyError(message) from None
        d1[key] = val
    return d1

def indent(
    number: int,
    lines: List[str],
) -> List[str]:
    if number > 0:
        buffer = []
        for line in lines:
            buffer.append(("\t" * number) + line)
        return buffer
    else:
        return lines

class Options(object):
    def __init__(
        self,
        options: Dict[str, 'Option'] = {},
    ) -> None:
        self.options = options
        self._attached_values = DEFAULT_ATTACHED_VALUES
        self._debug_mode = DEFAULT_DEBUG_MODE
        self._executable = DEFAULT_EXECUTABLE

    def __len__(self):
        return len(self.options)

    def __str__(self):
        return pformat(self._as_dict())

    def __eq__(self, other):
        if not isinstance(other, Options):
            return NotImplemented
        elif len(self) != len(other):
            return False
        for key in self.options.keys():
            if key not in other.options.keys():
                return False
            elif self.options[key] != other.options[key]:
                return False
        return True

    def _as_dict(self):
        return {name: opt._as_dict() for name, opt in self.options.items()}

    @classmethod
    def _from_dict(
        cls,
        data: Dict[str,Dict],
        *,
        expand_alternatives: bool = False,
    ) -> 'Options':
        options = cls(
            {name: Option._from_dict(name,option_data)
                for name, option_data in data.items()}
        )
        if expand_alternatives:
            options.expand_alternatives()
        return options

    @classmethod
    def _from_list_object(
        cls,
        data: List['Option'],
        *,
        expand_alternatives: bool = False,
    ) -> 'Options':
        options = cls(
            {option.name: option for option in data}
        )
        if expand_alternatives:
            options.expand_alternatives()
        return options

    def expand_alternatives(self) -> None:
        orig_options = [key for key in self.options.keys()]
        for name in orig_options:
            new_options: Dict[str, 'Option'] = {}
            for alt in self.options[name]._alternatives:
                new_options[alt] = self.options[name].make_alternate(alt)
            self.options = update_no_collide(self.options, new_options)

    def attached_values(
        self,
        on: bool,
    ) -> None:
        self._attached_values = on
        for name in self.options.keys():
            self.options[name]._attached_values = on

    def raise_on_overfull(
        self,
        on: bool,
    ) -> None:
        for name in self.options.keys():
            self.options[name]._raise_on_overfull = on

    def executable(
        self,
        on: bool,
    ) -> None:
        self._executable = on

    def debug_mode(
        self,
        on: bool,
    ) -> None:
        self._debug_mode = on

    def build_syntax(self):
        # Accessible Variables
        # ====================
        # option          parsed option name
        # pattern         pattern for option names ('(?:a|b|c)?=.*')
        # attached_value  if a value came attached to an argument ('--a=b'),
        #                   then it is stored here
        # config          dictionary of options -> values, where value is...
        #                   Optional[bool] if flag option,
        #                   Optional[str] if singleton option,
        #                   Optional[List[str]] if multi-value option, or
        #                   Optional[List[str]] if range of values option
        # positional      list of positional arguments
        # consuming       name of option that the next argument *must* be
        #                   stored into
        # needing         number of arguments that *must* be stored into the
        #                   consuming option
        # wanting         number of arguments that will be stored into the
        #                   consuming option, unless '--' is received

        # build regex and body of parser syntax by looping over options
        flag_single, flag_double = [], []
        body = []
        for option in self.options.values():
            if len(option.name) == 1:
                flag_single.append(option.name)
            else:
                flag_double.append(option.name)
            body.extend(indent(3, option.build_syntax()))
        body[0] = body[0].replace("elif", "if", 1)
        pattern = r"(?:"
        pattern += r"-(?:" + '|'.join(flag_single) + r")"
        pattern += r"|"
        pattern += r"--(?:" + '|'.join(flag_double) + r")"
        pattern += r")"
        if self._attached_values:
            pattern += r"(?:=.*)?"
        pattern += r"$"

        # (mostly) static head/tail
        head = [
            """#!/usr/bin/env python3\n""",
            """import re\n""",
            """def main(arguments):""",
            """\tconfig=dict()""",
            """\tpositional=[]""",
            """\tpattern=re.compile(r"{0}")""".format(pattern),
            """\tconsuming,needing,wanting=None,0,0""",
        ]
        if self._attached_values: head.extend([
            """\tattached_value=None""",
        ])
        if self._debug_mode: head.extend([
            """\tdef log(*values): pass\n""",
            """\tif "--debug-gap-behavior" in arguments:""",
            """\t\tdef log(*values): print(*values)""",
        ])
        head.extend([
            """\twhile len(arguments) and arguments[0]!="--":""",
        ])
        if self._debug_mode: head.extend([
            """\t\tif arguments[0]=="--debug-gap-behavior":""",
            """\t\t\targuments.pop(0)""",
            """\t\t\tcontinue""",
            """\t\tlog(f'processing {arguments[0]}...')""",
        ])
        head.extend([
            """\t\tif consuming is not None:""",
        ])
        if self._debug_mode: head.extend([
            """\t\t\tlog(f'option {consuming} is consuming')""",
        ])
        head.extend([
            """\t\t\tif config[consuming] is None:""",
            """\t\t\t\tconfig[consuming]=arguments.pop(0)""",
        ])
        if self._debug_mode: head.extend([
            """\t\t\t\tlog(f'option {consuming} = {config[consuming]}')""",
        ])
        head.extend([
            """\t\t\telse:""",
            """\t\t\t\tconfig[consuming].append(arguments.pop(0))""",
        ])
        if self._debug_mode: head.extend([
            """\t\t\t\tlog(f'option {consuming} = {config[consuming]}')""",
        ])
        head.extend([
            """\t\t\tneeding-=1""",
            """\t\t\twanting-=1""",
            """\t\t\tif wanting==0:""",
        ])
        if self._debug_mode: head.extend([
            """\t\t\t\tlog(f'option {consuming} is no longer consuming')""",
        ])
        head.extend([
            """\t\t\t\tconsuming,needing,wanting=None,0,0""",
            """\t\telif pattern.match(arguments[0]):""",
        ])
        if self._debug_mode: head.extend([
            """\t\t\tlog(f'{arguments[0]} matched an option')""",
        ])
        head.extend([
            """\t\t\toption = arguments.pop(0).lstrip('-')""",
        ])
        if self._attached_values: head.extend([
            """\t\t\tif '=' in option:""",
        ])
        if self._attached_values and self._debug_mode: head.extend([
            """\t\t\t\tlog(f'{option} has an attached value')""",
        ])
        if self._attached_values: head.extend([
            """\t\t\t\toption,attached_value=option.split('=',1)""",
        ])
        if self._debug_mode: head.extend([
            """\t\t\tlog(f'{option} is an option')""",
        ])

        tail = [
            """\t\telse:""",
            """\t\t\tpositional.append(arguments.pop(0))""",
            """\tif needing>0:""",
            """\t\tmessage=(""",
            """\t\t\tf'unexpected end while parsing "{consuming}"'""",
            """\t\t\tf' (expected {needing} values)'""",
            """\t\t)""",
            """\t\traise ValueError(message) from None""",
            """\tfor argument in arguments[1:]:""",
        ]
        if self._debug_mode: tail.extend([
            """\t\tif argument=="--debug-gap-behavior":""",
            """\t\t\tcontinue""",
        ])
        tail.extend([
            """\t\tpositional.append(argument)""",
            """\treturn config,positional\n""",
        ])
        if self._executable: tail.extend([
            """if __name__=="__main__":""",
            """\timport sys""",
            """\tcfg,pos = main(sys.argv[1:])""",
            """\tcfg = {k:v for k,v in cfg.items() if v is not None}""",
            """\tif len(cfg):""",
            """\t\tprint("Options:")""",
            """\t\tfor k,v in cfg.items():""",
            """\t\t\tprint(f"{k:20} = {v}")""",
            """\tif len(pos):""",
            """\t\tprint("Positional arguments:", ", ".join(pos))\n""",
        ])

        # combine and return
        whole = head + body + tail
        return '\n'.join(whole)

class Option(object):
    def __init__(
        self,
        name: str,
        *,
        alternatives: List[str] = [],
        canon_name: str = "",
        minimum: int = DEFAULT_ARG_NUM,
        maximum: int = DEFAULT_ARG_NUM,
    ) -> None:
        # core attributes
        self.name = name
        self.canon_name = name
        if canon_name != "":
            self.canon_name = canon_name
        self.min = minimum
        self.max = maximum

        # check min and max
        if self.min > self.max:
            message = (
                "expected maximum greater than minimum (got {0} and {1})"
            ).format(self.min, self.max)
            raise ValueError(message) from None

        # hidden attributes
        self._alternatives = alternatives
        self._raise_on_overfull = DEFAULT_RAISE_ON_OVERFULL
        self._attached_values = DEFAULT_ATTACHED_VALUES

    def __eq__(self, other):
        if not isinstance(other, Option):
            print("  -> ???")
            return NotImplemented
        elif self.canon_name != other.canon_name:
            print("  -> 'canon_name' differs in one")
            return False
        elif self.min != other.min:
            print("  -> 'min' differs in one")
            return False
        elif self.max != other.max:
            print("  -> 'max' differs in one")
            return False
        return True

    def _as_dict(self):
        return {
            "name": self.name,
            "canon_name": self.canon_name,
            "minimum": self.min,
            "maximum": self.max,
        }

    @classmethod
    def _from_dict(cls, name, data):
        option = cls(
            name,
            alternatives=data.get("alternatives", []),
            canon_name=name,
            minimum=data.get("minimum", DEFAULT_ARG_NUM),
            maximum=data.get("maximum", DEFAULT_ARG_NUM),
        )
        if "number" in data.keys():
            option.max = data["number"]
            option.min = data["number"]
        return option

    def make_alternate(
        self,
        name: str,
    ) -> 'Option':
        return Option(
            name,
            canon_name=self.canon_name,
            minimum=self.min,
            maximum=self.max,
        )

    def build_syntax(self) -> List[str]:
        buffer = ['elif option=="{0}":'.format(self.name)]
        if self.min == self.max == 0:
            buffer.extend(indent(1, self._build_syntax_flag()))
        elif self.min == self.max == 1:
            buffer.extend(indent(1, self._build_syntax_singleton()))
        elif self.min == 0 and self.max == 1:
            buffer.extend(indent(1, self._build_syntax_optional_singleton()))
        elif self.min <= self.max:
            buffer.extend(indent(1, self._build_syntax_multivalue()))
        else:
            message = (
                "cannot parse option '{0}' with {1} to {2} values"
            ).format(self.name, self.min, self.max)
            raise ValueError(message) from None
        return buffer

    def _build_syntax_flag(self) -> List[str]:
        #Generate syntax to consume a flag (Optional[bool])
        cnn = self.canon_name
        if self._raise_on_overfull and self._attached_values:
            return [
                """if attached_value is not None:""",
                """\tmessage=(""",
                """\t\t'unexpected value while parsing "{0}"'""".format(cnn),
                """\t\t' (expected 0 values)'""",
                """\t)""",
                """\traise ValueError(message) from None""",
                """config["{0}"]=True""".format(cnn),
            ]
        else:
            return [
                    """config["{0}"]=True""".format(self.canon_name),
            ]

    def _build_syntax_singleton(self) -> List[str]:
        #Generate syntax to consume an option (Optional[str])
        cnn = self.canon_name
        if self._attached_values:
            return [
                """if attached_value is not None:""",
                """\tconfig["{0}"]=attached_value""".format(cnn),
                """\tattached_value=None""",
                """\tconsuming,needing,wanting=None,0,0""",
                """else:""",
                """\tconfig["{0}"]=None""".format(cnn),
                """\tconsuming,needing,wanting="{0}",1,1""".format(cnn),
            ]
        else:
            return [
                """config["{0}"]=None""".format(cnn),
                """consuming,needing,wanting="{0}",1,1""".format(cnn),
            ]

    def _build_syntax_optional_singleton(self) -> List[str]:
        #Generate syntax to consume a 0 or 1 value option (Optional[List[str]])
        cnn = self.canon_name
        if self._attached_values:
            return [
                """if attached_value is not None:""",
                """\tconfig["{0}"]=[attached_value]""".format(cnn),
                """\tconsuming,needing,wanting=None,0,0""",
                """\tattached_value=None""",
                """else:""",
                """\tconfig["{0}"]=[]""".format(cnn),
                """\tconsuming,needing,wanting="{0}",1,1""".format(cnn),
            ]
        else:
            return [
                """config["{0}"]=[]""".format(cnn),
                """consuming,needing,wanting="{0}",1,1""".format(cnn),
            ]


    def _build_syntax_multivalue(self) -> List[str]:
        #Generate syntax to consume a multi-value option (Optional[List[str]])
        cnn = self.canon_name
        etc = (cnn, self.min-1, self.max-1, )
        if self._attached_values:
            return [
                """if attached_value is not None:""",
                """\tconfig["{0}"]=[attached_value]""".format(cnn),
                """\tconsuming,needing,wanting="{0}",{1},{2}""".format(*etc),
                """\tattached_value=None""",
                """else:""",
                """\tconfig["{0}"]=[]""".format(cnn),
                """\tconsuming,needing,wanting="{0}",{1},{2}""".format(*etc),
            ]
        else:
            return [
                    """config["{0}"]=[]""".format(cnn),
                    """consuming,needing,wanting="{0}",{1},{2}""".format(*etc),
            ]

def test_expand_alternatives():
    a = Option("a", minimum=0, maximum=1, alternatives=["ab", "abc"])
    ab = Option("ab", canon_name="a", minimum=0, maximum=1)
    abc = Option("abc", canon_name="a", minimum=0, maximum=1)
    a._alternatives = ["ab", "abc"]

    x = Option("x", minimum=2)
    y = Option("y", minimum=3)
    z = Option("z", minimum=4)

    compacted = Options({"a": a, "x": x, "y": y, "z": z})
    expanded = Options({"a": a, "ab": ab, "abc": abc, "x": x, "y": y, "z": z})

    compacted.expand_alternatives()

    #print(compacted)
    #print(expanded)
    assert compacted == expanded

def test_expand_alternatives_bad():
    a = Option("a", minimum=0, maximum=1, alternatives=["ab", "abc"])
    ab = Option("ab", canon_name="a", minimum=0, maximum=1)

    x = Option("x", minimum=2)
    y = Option("y", minimum=3)
    z = Option("z", minimum=4)

    options = Options({"a": a, "ab": ab, "x": x, "y": y, "z": z})

    try:
        options.expand_alternatives()
        raise AssertionFailure
    except KeyError:
        pass

def test_build_syntax_no_attached_values():
    a = Option("alpha", minimum=0, maximum=0, alternatives=['a'])
    b = Option("beta", alternatives=['b'])
    g = Option("gamma", minimum=0, alternatives=['y'])
    d = Option("delta", minimum=1, maximum=4, alternatives=['d'])
    options = Options({"alpha": a, "beta": b, "gamma": g, "delta": d})
    options.expand_alternatives()
    options.attached_values(False)
    print(options.build_syntax())

def test_build_syntax_executable():
    a = Option("alpha", minimum=0, maximum=0, alternatives=['a'])
    b = Option("beta", alternatives=['b'])
    g = Option("gamma", minimum=0, alternatives=['y'])
    d = Option("delta", minimum=1, maximum=4, alternatives=['d'])
    options = Options({"alpha": a, "beta": b, "gamma": g, "delta": d})
    options.expand_alternatives()
    options.executable(True)
    print(options.build_syntax())

def test_build_syntax_executable_debug_mode():
    a = Option("alpha", minimum=0, maximum=0, alternatives=['a'])
    b = Option("beta", alternatives=['b'])
    g = Option("gamma", minimum=0, alternatives=['y'])
    d = Option("delta", minimum=1, maximum=4, alternatives=['d'])
    options = Options({"alpha": a, "beta": b, "gamma": g, "delta": d})
    options.expand_alternatives()
    options.executable(True)
    options.debug_mode(True)
    print(options.build_syntax())

#if __name__=="__main__":
    #test_expand_alternatives()
    #test_expand_alternatives_bad()
    #test_build_syntax()
    #test_build_syntax_no_attached_values()
    #test_build_syntax_executable()
    #test_build_syntax_executable_debug_mode()


A  => gap/toml_parser.py +21 -0
@@ 1,21 @@
#!/usr/bin/env python3

from typing import Dict

import toml

def data_from_file(filename: str) -> Dict:
    try:
        with open(filename, 'r') as f:
            data = toml.load(f)
    except OSError:
        message = 'file "{0}" cannot be found'.format(filename)
        raise FileNotFoundError(message) from None
    except toml.TomlDecodeError:
        message = 'file "{0}" is invalid TOML'.format(filename)
        raise ValueError(message) from None
    return data

if __name__=="__main__":
    pass


A  => requirements.txt +3 -0
@@ 1,3 @@
setuptools>=47.1.1
toml>=0.10.1


A  => setup.py +24 -0
@@ 1,24 @@
#!/usr/bin/env python3

from setuptools import setup

long_description = None
with open('README.md', encoding='utf-8') as f:
    long_description = f.read()

setup(
    name="gap",
    packages=["gap"],
    version="1.0.0",
    license="GPL",
    description="Generated argument parser",
    long_description=long_description,
    long_description_content_type='text/markdown',
    author="Dominic Ricottone",
    author_email="me@dominic-ricottone.com",
    url="git.dominic-ricottone.com/gap",
    entry_points={"console_scripts": ["gap = gap.__main__:main"]},
    install_requires=["toml>=0.10.1"],
    python_requires=">=3.6",
)


A  => tests/__init__.py +0 -0
A  => tests/generated_syntax_tests.py +326 -0
@@ 1,326 @@
#!/usr/bin/env python3

import unittest

import tests.generated_syntax.syntax0 as syntax0
import tests.generated_syntax.syntax1 as syntax1
import tests.generated_syntax.syntax2 as syntax2
import tests.generated_syntax.syntax3 as syntax3
import tests.generated_syntax.syntax4 as syntax4
import tests.generated_syntax.syntax5 as syntax5
import tests.generated_syntax.syntax6 as syntax6
import tests.generated_syntax.syntax7 as syntax7
import tests.generated_syntax.syntax8 as syntax8
import tests.generated_syntax.syntax9 as syntax9
import tests.generated_syntax.syntax10 as syntax10
import tests.generated_syntax.syntax11 as syntax11
import tests.generated_syntax.syntax12 as syntax12
import tests.generated_syntax.syntax13 as syntax13
import tests.generated_syntax.syntax14 as syntax14
import tests.generated_syntax.syntax15 as syntax15

# itertools.product([True,False], repeat=4) ->
# True,  True,  True,  True
# True,  True,  True,  False
# True,  True,  False, True
# True,  True,  False, False
# True,  False, True,  True
# True,  False, True,  False
# True,  False, False, True
# True,  False, False, False
# False, True,  True,  True
# False, True,  True,  False
# False, True,  False, True
# False, True,  False, False
# False, False, True,  True
# False, False, True,  False
# False, False, False, True
# False, False, False, False
# Order: attached_values, raise_on_overfull, executable, debug_mode

class Test_GeneratedSyntax(unittest.TestCase):
    def read(self, filename):
        try:
            with open(filename, 'r') as f:
                return f.readlines()
        except OSError:
            self.skipTest("cannot read file '{0}'".format(filename))

    def test_attached_values(self):
        # The below tests shoudl parse apart "-b=1" and set beta to "1".
        args = ["-b=1"]
        cfg, pos = syntax0.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        args = ["-b=1"]
        cfg, pos = syntax1.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        args = ["-b=1"]
        cfg, pos = syntax2.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        args = ["-b=1"]
        cfg, pos = syntax3.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        args = ["-b=1"]
        cfg, pos = syntax4.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        args = ["-b=1"]
        cfg, pos = syntax5.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        args = ["-b=1"]
        cfg, pos = syntax6.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        args = ["-b=1"]
        cfg, pos = syntax7.main(args)
        self.assertEqual(pos, [])
        self.assertEqual(cfg, {"beta": "1"})

        # The below tests should set "-b=1" into positionals.
        args = ["-b=1"]
        cfg, pos = syntax8.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

        args = ["-b=1"]
        cfg, pos = syntax9.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

        args = ["-b=1"]
        cfg, pos = syntax10.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

        args = ["-b=1"]
        cfg, pos = syntax11.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

        args = ["-b=1"]
        cfg, pos = syntax12.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

        args = ["-b=1"]
        cfg, pos = syntax13.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

        args = ["-b=1"]
        cfg, pos = syntax14.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

        args = ["-b=1"]
        cfg, pos = syntax15.main(args)
        self.assertEqual(pos, ["-b=1"])
        self.assertEqual(cfg, {})

    def test_attached_values_raise_on_overfull(self):
        # The below tests should raise on finding the "=1".
        args = ["-a=1"]
        with self.assertRaises(ValueError):
            cfg, pos = syntax0.main(args)

        args = ["-a=1"]
        with self.assertRaises(ValueError):
            cfg, pos = syntax1.main(args)

        args = ["-a=1"]
        with self.assertRaises(ValueError):
            cfg, pos = syntax2.main(args)

        args = ["-a=1"]
        with self.assertRaises(ValueError):
            cfg, pos = syntax3.main(args)

        # The below tests should set alpha to True and ignore the "=1".
        args = ["-a=1"]
        cfg, pos = syntax4.main(args)
        self.assertEqual(cfg, {"alpha": True})
        self.assertEqual(pos, [])

        args = ["-a=1"]
        cfg, pos = syntax5.main(args)
        self.assertEqual(cfg, {"alpha": True})
        self.assertEqual(pos, [])

        args = ["-a=1"]
        cfg, pos = syntax6.main(args)
        self.assertEqual(cfg, {"alpha": True})
        self.assertEqual(pos, [])

        args = ["-a=1"]
        cfg, pos = syntax7.main(args)
        self.assertEqual(cfg, {"alpha": True})
        self.assertEqual(pos, [])

        # The below tests should set "-a=1" into positionals.
        # raise_on_overfull only matters for options that take 0 values but
        #   have an attached value. Therefore, this option is only effective
        #   if attached_values is also True.
        args = ["-a=1"]
        cfg, pos = syntax8.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

        args = ["-a=1"]
        cfg, pos = syntax9.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

        args = ["-a=1"]
        cfg, pos = syntax10.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

        args = ["-a=1"]
        cfg, pos = syntax11.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

        args = ["-a=1"]
        cfg, pos = syntax12.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

        args = ["-a=1"]
        cfg, pos = syntax13.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

        args = ["-a=1"]
        cfg, pos = syntax14.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

        args = ["-a=1"]
        cfg, pos = syntax15.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["-a=1"])

    def test_executable(self):
        for fn in enumerate([
            "tests/generated_syntax/syntax0.py",
            "tests/generated_syntax/syntax1.py",
            "tests/generated_syntax/syntax2.py",
            "tests/generated_syntax/syntax3.py",
            "tests/generated_syntax/syntax4.py",
            "tests/generated_syntax/syntax5.py",
            "tests/generated_syntax/syntax6.py",
            "tests/generated_syntax/syntax7.py",
            "tests/generated_syntax/syntax8.py",
            "tests/generated_syntax/syntax9.py",
            "tests/generated_syntax/syntax10.py",
            "tests/generated_syntax/syntax11.py",
            "tests/generated_syntax/syntax12.py",
            "tests/generated_syntax/syntax13.py",
            "tests/generated_syntax/syntax14.py",
            "tests/generated_syntax/syntax15.py",
        ]):
            with self.subTest(fn=fn):
                number, filename = fn
                lines = self.read(filename)
                # These files should have a line 'if __name__=="__main__":'
                if number in (0,1,4,5,8,9,12,13,):
                    self.assertIn('if __name__=="__main__":\n', lines)

    def test_debugmode_catch_option(self):
        # The below tests should pull "--debug-gap-behavior" from the arguments
        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax0.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax2.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax4.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax6.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax8.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax10.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax12.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax14.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, [])

        # The below tests should set "--debug-gap-behavior" into positionals
        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax1.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax3.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax5.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax7.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax9.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax11.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax13.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

        args = ["--", "--debug-gap-behavior"]
        cfg, pos = syntax15.main(args)
        self.assertEqual(cfg, {})
        self.assertEqual(pos, ["--debug-gap-behavior"])

if __name__ == "__main__":
    unittest.main()


A  => tests/test_generator.py +141 -0
@@ 1,141 @@
#!/usr/bin/env python3

import itertools
import unittest

from gap import generator

class Test_Option(unittest.TestCase):
    def test_object(self):
        a = generator.Option("alpha", minimum=0, maximum=0, alternatives=['a'])
        b = generator.Option("beta", alternatives=['b'])
        y = generator.Option("gamma", minimum=0, alternatives=['y'])
        d = generator.Option("delta", minimum=1, maximum=4, alternatives=['d'])

        self.assertEqual(a.min, 0)
        self.assertEqual(a.max, 0)
        self.assertEqual(a._alternatives, ['a'])
        self.assertEqual(a.canon_name, "alpha")

        self.assertEqual(b.min, 1)
        self.assertEqual(b.max, 1)
        self.assertEqual(b._alternatives, ['b'])
        self.assertEqual(b.canon_name, "beta")

        self.assertEqual(y.min, 0)
        self.assertEqual(y.max, 1)
        self.assertEqual(y._alternatives, ['y'])
        self.assertEqual(y.canon_name, "gamma")

        self.assertEqual(d.min, 1)
        self.assertEqual(d.max, 4)
        self.assertEqual(d._alternatives, ['d'])
        self.assertEqual(d.canon_name, "delta")

    def test_bad_object(self):
        with self.assertRaises(ValueError):
            z = generator.Option("zeta", minimum=1, maximum=0, alternatives=['z'])

class Test_Options(unittest.TestCase):
    def test_object(self):
        a = generator.Option("alpha", minimum=0, maximum=0, alternatives=['a'])
        b = generator.Option("beta", alternatives=['b'])
        y = generator.Option("gamma", minimum=0, alternatives=['y'])
        d = generator.Option("delta", minimum=1, maximum=4, alternatives=['d'])

        options = generator.Options._from_list_object([a,b,y,d])

        self.assertEqual(options.options["alpha"].min, 0)
        self.assertEqual(options.options["alpha"].max, 0)
        self.assertEqual(options.options["alpha"]._alternatives, ['a'])
        self.assertEqual(options.options["alpha"].canon_name, "alpha")

        self.assertEqual(options.options["beta"].min, 1)
        self.assertEqual(options.options["beta"].max, 1)
        self.assertEqual(options.options["beta"]._alternatives, ['b'])
        self.assertEqual(options.options["beta"].canon_name, "beta")

        self.assertEqual(options.options["gamma"].min, 0)
        self.assertEqual(options.options["gamma"].max, 1)
        self.assertEqual(options.options["gamma"]._alternatives, ['y'])
        self.assertEqual(options.options["gamma"].canon_name, "gamma")

        self.assertEqual(options.options["delta"].min, 1)
        self.assertEqual(options.options["delta"].max, 4)
        self.assertEqual(options.options["delta"]._alternatives, ['d'])
        self.assertEqual(options.options["delta"].canon_name, "delta")

    def test_expand_alternatives(self):
        a = generator.Option("alpha", minimum=0, maximum=0, alternatives=['a'])
        b = generator.Option("beta", alternatives=['b'])
        y = generator.Option("gamma", minimum=0, alternatives=['y'])
        d = generator.Option("delta", minimum=1, maximum=4, alternatives=['d'])

        options = generator.Options._from_list_object(
            [a,b,y,d],
            expand_alternatives=True,
        )

        self.assertEqual(options.options["a"].min, 0)
        self.assertEqual(options.options["a"].max, 0)
        self.assertEqual(options.options["a"].canon_name, "alpha")

        self.assertEqual(options.options["b"].min, 1)
        self.assertEqual(options.options["b"].max, 1)
        self.assertEqual(options.options["b"].canon_name, "beta")

        self.assertEqual(options.options["y"].min, 0)
        self.assertEqual(options.options["y"].max, 1)
        self.assertEqual(options.options["y"].canon_name, "gamma")

        self.assertEqual(options.options["d"].min, 1)
        self.assertEqual(options.options["d"].max, 4)
        self.assertEqual(options.options["d"].canon_name, "delta")

    def test_bad_expand_alternatives(self):
        a1 = generator.Option("a1", minimum=0, maximum=0, alternatives=['a'])
        a2 = generator.Option("a2", minimum=1, maximum=1, alternatives=['a'])

        with self.assertRaises(KeyError):
            options = generator.Options._from_list_object(
                [a1,a2],
                expand_alternatives=True,
            )

class Test_GeneratedCode(unittest.TestCase):
    def try_write(self, filename, syntax):
        try:
            with open(filename, 'w') as f:
                f.write(syntax)
        except OSError:
            self.skipTest("cannot write to file '{0}'".format(filename))
    def make_options(self):
        a = generator.Option("alpha", minimum=0, maximum=0, alternatives=['a'])
        b = generator.Option("beta", alternatives=['b'])
        y = generator.Option("gamma", minimum=0, alternatives=['y'])
        d = generator.Option("delta", minimum=1, maximum=4, alternatives=['d'])
        options = generator.Options._from_list_object(
            [a,b,y,d],
            expand_alternatives=True,
        )
        return options

    def test_generate(self):
        b = [True,False]
        for permutation in enumerate(itertools.product(b, repeat=4)):
            with self.subTest(permutation=permutation):
                options = self.make_options()
                number, settings = permutation

                options.attached_values(settings[0])
                options.raise_on_overfull(settings[1])
                options.executable(settings[2])
                options.debug_mode(settings[3])

                syntax = options.build_syntax()
                filename = "tests/generated_syntax/syntax{0}.py".format(number)
                self.try_write(filename, syntax)

if __name__ == "__main__":
    unittest.main()