-from proto.prototype import Command, command
+from proto.command import Command, command
__all__ = ['Command', 'command']
\ No newline at end of file
--- /dev/null
+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
--- /dev/null
+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)}
+++ /dev/null
-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
+++ /dev/null
-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)}
--- /dev/null
+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
--- /dev/null
+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__ = (<class 'os.PathLike'>, <class 'NoneType'>)
\ No newline at end of file
+++ /dev/null
-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
+++ /dev/null
-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__ = (<class 'os.PathLike'>, <class 'NoneType'>)
\ No newline at end of file