From: Kyle Bowman Date: Sun, 24 Mar 2024 18:55:52 +0000 (-0400) Subject: Refactor: Change filenames to command/infer. X-Git-Tag: v0.1.0~4 X-Git-Url: https://git.rocketbowman.com/?a=commitdiff_plain;h=38febf942912f85ea48fbc6e359068cc44551316;p=proto.git Refactor: Change filenames to command/infer. --- diff --git a/src/proto/__init__.py b/src/proto/__init__.py index 85846f2..4e0fee2 100644 --- a/src/proto/__init__.py +++ b/src/proto/__init__.py @@ -1,3 +1,3 @@ -from proto.prototype import Command, command +from proto.command import Command, command __all__ = ['Command', 'command'] \ No newline at end of file diff --git a/src/proto/command.py b/src/proto/command.py new file mode 100644 index 0000000..29bfba9 --- /dev/null +++ b/src/proto/command.py @@ -0,0 +1,47 @@ +from argparse import ArgumentError +from collections.abc import Callable +from functools import partial +from typing import Optional +from proto.infer import get_defaults, get_types, get_parser + + +class Command: + def __init__(self, fn: Callable): + self.name = fn.__name__ + self._run = fn + self.types = get_types(fn) + self.parser = get_parser(fn) + self.required, self.defaults = get_defaults(fn) + self.args = [] + self.kwargs = {} + + def parse(self, args: Optional[list[str]] = None): + """ Parse the list to populate args and kwargs attributes. + If no list is specified, sys.argv is used. """ + # NOTE: Make parsing idemptotent. + self.args = [] + self.kwargs={} + + options = vars(self.parser.parse_args(args=args)) + for k, v in options.items(): + if k in self.required.keys(): + self.args.append(v) + elif k in self.defaults.keys(): + self.kwargs[k] = v + else: + # NOTE: Unrecognized args should be caught earlier in parse_args(). + raise ArgumentError(k,"Unrecognized argument {k}") + return self.args, self.kwargs + + def run(self, *args, **kwargs): + """ Invokes function using args/kwargs if specified. Otherwise, uses self. """ + if not args and not kwargs: + return partial(self._run, *self.args, **self.kwargs)() + else: + args = args if args else self.args + kwargs = kwargs if kwargs else self.kwargs + return self._run(*args, **kwargs) + +def command(fn: Callable): + """ Defines a decorator that is used to turn a function into a Command. """ + return Command(fn) \ No newline at end of file diff --git a/src/proto/infer.py b/src/proto/infer.py new file mode 100644 index 0000000..98ff1be --- /dev/null +++ b/src/proto/infer.py @@ -0,0 +1,105 @@ +import argparse +from collections.abc import Callable +from functools import singledispatch +import inspect +from pathlib import Path +from typing import Union + + +def get_defaults(fn: Callable)->tuple[list, dict]: + """ Returns a dict of required arguments and a dict of kwarg defaults. """ + sig = inspect.signature(fn) + args = {} + kwargs = {} + for name, prm in sig.parameters.items(): + # ASSUME: It's a Pythonic standard to use self and cls, but it's convention, not rule. Beware. + if name == "self" or name == "cls": + continue + if prm.default == inspect._empty: + # NOTE: Don't use args[name]=None. None could be a valid argument + # and we want to flag that a required argument is missing a value. + args[name] = inspect._empty + else: + kwargs[name] = prm.default + return args, kwargs + +def get_types(fn: Callable)->dict[str]: + """ Returns a dictionary of parameter names and their types as strings. """ + sig = inspect.signature(fn) + types = {} + for name, prm in sig.parameters.items(): + # ASSUME: It's a Pythonic standard to use self, but it's convention, not rule. Beware. + if name == "self" or name == "cls": + continue + if prm.annotation == inspect._empty: + types[name] = None + else: + types[name] = prm.annotation + return types + +class _ArgSpec(dict): + """ _ArgSpec contains key-value pairs used for parser.add_argument(). """ + + def __init__(self, prm: inspect.Parameter): + super().__init__() + self.update(self._parse_default(prm)) + self.update(self._parse_type(prm)) + + def _parse_default(self, prm): + # ASSUME: If a function specifies a default argument, we tell the parser to consider it optional. + dct = {} + if prm.default != prm.empty: + # NOTE: Argparse requires optional args to start with '-' or '--'. + dct['argname'] = "--" + prm.name + dct['required'] = False + dct['default'] = prm.default + else: + dct['argname'] = prm.name + return dct + + def _parse_type(self, prm): + # NOTE: If you don't specify type in add_argument(), it will be parsed as a string. + # Use get_argspecs() to add type-specific information to the arg_spec. + if isinstance(prm.annotation, type): # Basic types + return get_argspecs(prm.annotation()) + elif hasattr(prm.annotation, '__args__'): # Unions + # ASSUME: Order of types in signatures indicate order of preference. + for type_ in prm.annotation.__args__: + try: + return get_argspecs(type_()) + except TypeError as e: + raise e + else: + raise TypeError(f"Cannot instantiate. Check the type of {prm.annotation}") + +def get_parser(fn: Callable)->argparse.ArgumentParser: + """ Returns an argparse.ArgumentParser based on the function's signature. """ + sig = inspect.signature(fn) + parser = argparse.ArgumentParser() + for prm in sig.parameters.values(): + # ASSUME: It's a Pythonic standard to use self, but it's convention, not rule. Beware. + if prm.name == "self" or prm.name == "cls": + continue + argspec = _ArgSpec(prm) + argname = argspec.pop('argname') + parser.add_argument(argname, **argspec) + return parser + +@singledispatch +def get_argspecs(annotation: type)->dict: + """ Creates a partial argspec dictionary from a parameter annotation. """ + return {'type': type(annotation)} + +@get_argspecs.register +def empty_argspecs(annotation: inspect._empty)->dict: + """ Implements argspecs for unannotated parameters. """ + return {} + +@get_argspecs.register +def scalar_argspecs(annotation: Union[int, float, str])->dict: + """ Implements get_argspecs for integers, floats, and strings. """ + return {'type': type(annotation)} + +@get_argspecs.register +def path_argspecs(annotation: Path): + return {'type': type(annotation)} diff --git a/src/proto/prototype.py b/src/proto/prototype.py deleted file mode 100644 index 27609bb..0000000 --- a/src/proto/prototype.py +++ /dev/null @@ -1,47 +0,0 @@ -from argparse import ArgumentError -from collections.abc import Callable -from functools import partial -from typing import Optional -from proto.utils import get_defaults, get_types, get_parser - - -class Command: - def __init__(self, fn: Callable): - self.name = fn.__name__ - self._run = fn - self.types = get_types(fn) - self.parser = get_parser(fn) - self.required, self.defaults = get_defaults(fn) - self.args = [] - self.kwargs = {} - - def parse(self, args: Optional[list[str]] = None): - """ Parse the list to populate args and kwargs attributes. - If no list is specified, sys.argv is used. """ - # NOTE: Make parsing idemptotent. - self.args = [] - self.kwargs={} - - options = vars(self.parser.parse_args(args=args)) - for k, v in options.items(): - if k in self.required.keys(): - self.args.append(v) - elif k in self.defaults.keys(): - self.kwargs[k] = v - else: - # NOTE: Unrecognized args should be caught earlier in parse_args(). - raise ArgumentError(k,"Unrecognized argument {k}") - return self.args, self.kwargs - - def run(self, *args, **kwargs): - """ Invokes function using args/kwargs if specified. Otherwise, uses self. """ - if not args and not kwargs: - return partial(self._run, *self.args, **self.kwargs)() - else: - args = args if args else self.args - kwargs = kwargs if kwargs else self.kwargs - return self._run(*args, **kwargs) - -def command(fn: Callable): - """ Defines a decorator that is used to turn a function into a Command. """ - return Command(fn) \ No newline at end of file diff --git a/src/proto/utils.py b/src/proto/utils.py deleted file mode 100644 index 98ff1be..0000000 --- a/src/proto/utils.py +++ /dev/null @@ -1,105 +0,0 @@ -import argparse -from collections.abc import Callable -from functools import singledispatch -import inspect -from pathlib import Path -from typing import Union - - -def get_defaults(fn: Callable)->tuple[list, dict]: - """ Returns a dict of required arguments and a dict of kwarg defaults. """ - sig = inspect.signature(fn) - args = {} - kwargs = {} - for name, prm in sig.parameters.items(): - # ASSUME: It's a Pythonic standard to use self and cls, but it's convention, not rule. Beware. - if name == "self" or name == "cls": - continue - if prm.default == inspect._empty: - # NOTE: Don't use args[name]=None. None could be a valid argument - # and we want to flag that a required argument is missing a value. - args[name] = inspect._empty - else: - kwargs[name] = prm.default - return args, kwargs - -def get_types(fn: Callable)->dict[str]: - """ Returns a dictionary of parameter names and their types as strings. """ - sig = inspect.signature(fn) - types = {} - for name, prm in sig.parameters.items(): - # ASSUME: It's a Pythonic standard to use self, but it's convention, not rule. Beware. - if name == "self" or name == "cls": - continue - if prm.annotation == inspect._empty: - types[name] = None - else: - types[name] = prm.annotation - return types - -class _ArgSpec(dict): - """ _ArgSpec contains key-value pairs used for parser.add_argument(). """ - - def __init__(self, prm: inspect.Parameter): - super().__init__() - self.update(self._parse_default(prm)) - self.update(self._parse_type(prm)) - - def _parse_default(self, prm): - # ASSUME: If a function specifies a default argument, we tell the parser to consider it optional. - dct = {} - if prm.default != prm.empty: - # NOTE: Argparse requires optional args to start with '-' or '--'. - dct['argname'] = "--" + prm.name - dct['required'] = False - dct['default'] = prm.default - else: - dct['argname'] = prm.name - return dct - - def _parse_type(self, prm): - # NOTE: If you don't specify type in add_argument(), it will be parsed as a string. - # Use get_argspecs() to add type-specific information to the arg_spec. - if isinstance(prm.annotation, type): # Basic types - return get_argspecs(prm.annotation()) - elif hasattr(prm.annotation, '__args__'): # Unions - # ASSUME: Order of types in signatures indicate order of preference. - for type_ in prm.annotation.__args__: - try: - return get_argspecs(type_()) - except TypeError as e: - raise e - else: - raise TypeError(f"Cannot instantiate. Check the type of {prm.annotation}") - -def get_parser(fn: Callable)->argparse.ArgumentParser: - """ Returns an argparse.ArgumentParser based on the function's signature. """ - sig = inspect.signature(fn) - parser = argparse.ArgumentParser() - for prm in sig.parameters.values(): - # ASSUME: It's a Pythonic standard to use self, but it's convention, not rule. Beware. - if prm.name == "self" or prm.name == "cls": - continue - argspec = _ArgSpec(prm) - argname = argspec.pop('argname') - parser.add_argument(argname, **argspec) - return parser - -@singledispatch -def get_argspecs(annotation: type)->dict: - """ Creates a partial argspec dictionary from a parameter annotation. """ - return {'type': type(annotation)} - -@get_argspecs.register -def empty_argspecs(annotation: inspect._empty)->dict: - """ Implements argspecs for unannotated parameters. """ - return {} - -@get_argspecs.register -def scalar_argspecs(annotation: Union[int, float, str])->dict: - """ Implements get_argspecs for integers, floats, and strings. """ - return {'type': type(annotation)} - -@get_argspecs.register -def path_argspecs(annotation: Path): - return {'type': type(annotation)} diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 0000000..826cb9b --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,58 @@ +from abc import abstractmethod +from typing import Protocol +import pytest +from proto import command, Command + +class Stringable(Protocol): + + @abstractmethod + def __str__(self)->str: + ... + +#def echo_fn(arg: Stringable)->str: +def echo_fn(arg: str)->str: + return str(arg) + +@command +#def echo(arg: Stringable)->str: +def echo(arg: str)->str: + return echo_fn(arg) + +def test_attributes(): + cmd = Command(echo_fn) + assert cmd.name == "echo_fn" + cmd2 = echo + assert cmd2.name == "echo" + +def test_parse(): + echo.parse(args=['42']) + assert echo.kwargs == {} + # Okay for 42 to be a string because echo is defined on stringables + assert echo.args[0] == '42' + +def test_parse_argument_error(): + with pytest.raises(SystemExit): + echo.parse(args=['echo_target','--unknown']) + +def test_parse_idempotency(): + echo.parse(args=['42']) + echo.parse(args=['something else']) + assert '42' not in echo.args + assert 'something else' in echo.args + +def test_run_parsed_args(): + echo.parse(args=['yippee!']) + assert "yippee!" in echo.run() + +def test_run_override_parsed(): + echo.parse(args=['yippee!']) + assert "override" in echo.run("override") + assert '42' in echo.run(42) + + +if __name__ == "__main__": + @command + def test(char: str, n: int = 42)->tuple[str,int]: + return char, n + + test.parse(args=['--n', '36', 'char_field']) \ No newline at end of file diff --git a/tests/test_infer.py b/tests/test_infer.py new file mode 100644 index 0000000..8e42f7b --- /dev/null +++ b/tests/test_infer.py @@ -0,0 +1,136 @@ +import inspect +import os +import sys +from typing import Optional +import pytest + +from dummy import * +from proto.infer import get_defaults, get_types, get_parser + +def test_get_default_equivalence(): + """ Ensures that defaults are treated the same amongst Callables. """ + callable_defaults = get_defaults(DummyCallable()) + method_defaults = get_defaults(DummyClass().dummy_method) + function_defaults = get_defaults(dummy_fn_full) + assert callable_defaults == method_defaults == function_defaults + +def test_get_types_equivalence(): + """ Ensures that type signatures are treated the same amongst Callables. """ + callable_types = get_types(DummyCallable()) + method_types = get_types(DummyClass().dummy_method) + function_types = get_types(dummy_fn_full) + assert callable_types == method_types == function_types + +@pytest.mark.skip("Equivalence is not defined for argparse.ArgumentParser") +def test_get_parser_equivalence(): + callable_parser = get_parser(DummyCallable()) + method_parser = get_parser(DummyClass().dummy_method) + function_parser = get_parser(dummy_fn_full) + assert callable_parser == method_parser == function_parser + +# ASSUME: Assuming the above tests are valid and pass, we only have to test one +# kind of the Callable and the results should hold for the others. +def test_get_defaults(): + args, kwargs = get_defaults(dummy_fn_full) + assert args == {} + assert kwargs['string'] == "default" + assert kwargs['num'] == 42 + +def test_get_defaults_unspecified(): + args, kwargs = get_defaults(dummy_fn_no_signature) + assert args == {'string': inspect._empty, 'num': inspect._empty} + assert kwargs == {} + +def test_get_types(): + types = get_types(dummy_fn_typed) + assert types['string'] == str + assert types['num'] == int + +def test_get_types_unspecified(): + types = get_types(dummy_fn_no_signature) + assert types['string'] is None + assert types['num'] is None + +def test_get_types_optional(): + types = get_types(dummy_fn_optional) + assert types['string'] == Optional[str] + assert types['num'] == Optional[int] + +def test_validating_optional(): + types = get_types(dummy_fn_optional) + _, kwargs = get_defaults(dummy_fn_optional) + assert isinstance(kwargs['num'],types['num']) + +def test_get_parser_defaults(): + """ If a fn default is specified, use keyword syntax (optional). """ + string="not default" + num=42 + parser=get_parser(dummy_fn_full) + args = parser.parse_args(args=[f"--string={string}", "--num", str(num)]) + assert args.string == string + assert int(args.num) == num + +def test_get_parser_no_defaults(): + """ If no function default is specified, use postional syntax (required). """ + string="not default" + num=42 + parser=get_parser(dummy_fn_no_signature) + args = parser.parse_args(args=[string, str(num)]) + assert args.string == string + +def test_get_parser_method(): + """ Ensures that 'self' and 'cls' are ignored. """ + string="not default" + num=42 + parser=get_parser(DummyClass().dummy_method) + args = parser.parse_args(args=[f"--string={string}", "--num", str(num)]) + assert args.string == string + parser2=get_parser(DummyClass.dummy_classmethod) + args2 = parser2.parse_args([f"--string={string}", "--num", str(num)]) + assert args2.string == string + +def test_get_parser_types_scalar(): + """ Asserts ints, strings, and floats, are parsed from CLI as types. """ + string="not default" + num="42" + parser=get_parser(dummy_fn_full) + args = parser.parse_args(args=[f"--string={string}", "--num", str(num)]) + assert args.string == string + assert args.num == int(num) + +def test_get_parser_types_path(): + parser=get_parser(dummy_fn_path) + args = parser.parse_args(args=['.']) + assert isinstance(args.path, Path) + +def test_get_parser_types_union(): + parser=get_parser(dummy_fn_optional) + args=parser.parse_args(args=['--string','yay']) + assert args.string == "yay" + +def test_get_parser_types_union_defaults(): + parser=get_parser(dummy_fn_optional) + args=parser.parse_args(args=[]) + assert args.num == int(42) + assert args.string == None + +if __name__ == "__main__": + import inspect + sig = inspect.signature(dummy_fn_full) + p = sig.parameters['string'] + p2 = sig.parameters['num'] + def dummy_fn_file(filename: Optional[os.PathLike] = None): + if filename is None: + contents = sys.stdin.read() + return contents + else: + return str(filename) + sig2=inspect.signature(dummy_fn_file) + p3 = sig2.parameters['filename'] + + from tests.test_command import Stringable + def echo(arg: Stringable): + return str(arg) + sig3 = inspect.signature(echo) + p4 = sig3.parameters['arg'] + # p3.annotation.__args__ = (, ) \ No newline at end of file diff --git a/tests/test_proto.py b/tests/test_proto.py deleted file mode 100644 index 826cb9b..0000000 --- a/tests/test_proto.py +++ /dev/null @@ -1,58 +0,0 @@ -from abc import abstractmethod -from typing import Protocol -import pytest -from proto import command, Command - -class Stringable(Protocol): - - @abstractmethod - def __str__(self)->str: - ... - -#def echo_fn(arg: Stringable)->str: -def echo_fn(arg: str)->str: - return str(arg) - -@command -#def echo(arg: Stringable)->str: -def echo(arg: str)->str: - return echo_fn(arg) - -def test_attributes(): - cmd = Command(echo_fn) - assert cmd.name == "echo_fn" - cmd2 = echo - assert cmd2.name == "echo" - -def test_parse(): - echo.parse(args=['42']) - assert echo.kwargs == {} - # Okay for 42 to be a string because echo is defined on stringables - assert echo.args[0] == '42' - -def test_parse_argument_error(): - with pytest.raises(SystemExit): - echo.parse(args=['echo_target','--unknown']) - -def test_parse_idempotency(): - echo.parse(args=['42']) - echo.parse(args=['something else']) - assert '42' not in echo.args - assert 'something else' in echo.args - -def test_run_parsed_args(): - echo.parse(args=['yippee!']) - assert "yippee!" in echo.run() - -def test_run_override_parsed(): - echo.parse(args=['yippee!']) - assert "override" in echo.run("override") - assert '42' in echo.run(42) - - -if __name__ == "__main__": - @command - def test(char: str, n: int = 42)->tuple[str,int]: - return char, n - - test.parse(args=['--n', '36', 'char_field']) \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index e1b8af0..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,136 +0,0 @@ -import inspect -import os -import sys -from typing import Optional -import pytest - -from dummy import * -from proto.utils import get_defaults, get_types, get_parser - -def test_get_default_equivalence(): - """ Ensures that defaults are treated the same amongst Callables. """ - callable_defaults = get_defaults(DummyCallable()) - method_defaults = get_defaults(DummyClass().dummy_method) - function_defaults = get_defaults(dummy_fn_full) - assert callable_defaults == method_defaults == function_defaults - -def test_get_types_equivalence(): - """ Ensures that type signatures are treated the same amongst Callables. """ - callable_types = get_types(DummyCallable()) - method_types = get_types(DummyClass().dummy_method) - function_types = get_types(dummy_fn_full) - assert callable_types == method_types == function_types - -@pytest.mark.skip("Equivalence is not defined for argparse.ArgumentParser") -def test_get_parser_equivalence(): - callable_parser = get_parser(DummyCallable()) - method_parser = get_parser(DummyClass().dummy_method) - function_parser = get_parser(dummy_fn_full) - assert callable_parser == method_parser == function_parser - -# ASSUME: Assuming the above tests are valid and pass, we only have to test one -# kind of the Callable and the results should hold for the others. -def test_get_defaults(): - args, kwargs = get_defaults(dummy_fn_full) - assert args == {} - assert kwargs['string'] == "default" - assert kwargs['num'] == 42 - -def test_get_defaults_unspecified(): - args, kwargs = get_defaults(dummy_fn_no_signature) - assert args == {'string': inspect._empty, 'num': inspect._empty} - assert kwargs == {} - -def test_get_types(): - types = get_types(dummy_fn_typed) - assert types['string'] == str - assert types['num'] == int - -def test_get_types_unspecified(): - types = get_types(dummy_fn_no_signature) - assert types['string'] is None - assert types['num'] is None - -def test_get_types_optional(): - types = get_types(dummy_fn_optional) - assert types['string'] == Optional[str] - assert types['num'] == Optional[int] - -def test_validating_optional(): - types = get_types(dummy_fn_optional) - _, kwargs = get_defaults(dummy_fn_optional) - assert isinstance(kwargs['num'],types['num']) - -def test_get_parser_defaults(): - """ If a fn default is specified, use keyword syntax (optional). """ - string="not default" - num=42 - parser=get_parser(dummy_fn_full) - args = parser.parse_args(args=[f"--string={string}", "--num", str(num)]) - assert args.string == string - assert int(args.num) == num - -def test_get_parser_no_defaults(): - """ If no function default is specified, use postional syntax (required). """ - string="not default" - num=42 - parser=get_parser(dummy_fn_no_signature) - args = parser.parse_args(args=[string, str(num)]) - assert args.string == string - -def test_get_parser_method(): - """ Ensures that 'self' and 'cls' are ignored. """ - string="not default" - num=42 - parser=get_parser(DummyClass().dummy_method) - args = parser.parse_args(args=[f"--string={string}", "--num", str(num)]) - assert args.string == string - parser2=get_parser(DummyClass.dummy_classmethod) - args2 = parser2.parse_args([f"--string={string}", "--num", str(num)]) - assert args2.string == string - -def test_get_parser_types_scalar(): - """ Asserts ints, strings, and floats, are parsed from CLI as types. """ - string="not default" - num="42" - parser=get_parser(dummy_fn_full) - args = parser.parse_args(args=[f"--string={string}", "--num", str(num)]) - assert args.string == string - assert args.num == int(num) - -def test_get_parser_types_path(): - parser=get_parser(dummy_fn_path) - args = parser.parse_args(args=['.']) - assert isinstance(args.path, Path) - -def test_get_parser_types_union(): - parser=get_parser(dummy_fn_optional) - args=parser.parse_args(args=['--string','yay']) - assert args.string == "yay" - -def test_get_parser_types_union_defaults(): - parser=get_parser(dummy_fn_optional) - args=parser.parse_args(args=[]) - assert args.num == int(42) - assert args.string == None - -if __name__ == "__main__": - import inspect - sig = inspect.signature(dummy_fn_full) - p = sig.parameters['string'] - p2 = sig.parameters['num'] - def dummy_fn_file(filename: Optional[os.PathLike] = None): - if filename is None: - contents = sys.stdin.read() - return contents - else: - return str(filename) - sig2=inspect.signature(dummy_fn_file) - p3 = sig2.parameters['filename'] - - from test_proto import Stringable - def echo(arg: Stringable): - return str(arg) - sig3 = inspect.signature(echo) - p4 = sig3.parameters['arg'] - # p3.annotation.__args__ = (, ) \ No newline at end of file