]> git.rocketbowman.com Git - proto.git/commitdiff
Add handling for Union types.
authorKyle Bowman <kyle+github@rocketbowman.com>
Sat, 16 Mar 2024 20:25:35 +0000 (16:25 -0400)
committerKyle Bowman <kyle+github@rocketbowman.com>
Sat, 16 Mar 2024 20:25:35 +0000 (16:25 -0400)
develop.md
src/proto/utils.py
tests/dummy.py [new file with mode: 0644]
tests/test_proto.py
tests/test_utils.py

index d2a2262af9982e8c8c1a7e6936e510f3de122f8e..357a7c5ed79ba45bb38026cd1794b83a975ee976 100644 (file)
@@ -94,11 +94,12 @@ elif hasattr(prm.annotation, 'is_protocol' and prm.annotation._is_protocol):
 
 You can use `prm.annotation.__abstractmethods__` to determine what methods need to be defined.
 
-### Parser definition vs Runtime arguments
+### Parser definition vs Runtime arguments - Union Types
 
 * If you look through Union and define the parser based on the first type...
 * But then at runtime, the CLI looks like the second one...
 * You can't go back and redefine the parser.
+* This might be a problem with Union types.
 
 * Possible solution - define a type placeholder for Union. 
 * The type placeholder could itself dispatch to runtime parse/validators?
index fe58b23d4002cbd7b75ee5a5cda54eb3045fc798..cf527f1139b5c60525b3371fae7597701249a9b9 100644 (file)
@@ -61,20 +61,21 @@ def get_parser(fn: Callable)->argparse.ArgumentParser:
 
         # 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.
-        arg_specs | get_argspecs(prm.annotation(), arg_specs)
-        
+        if isinstance(prm.annotation, type):    # Basic types
+            arg_specs | get_argspecs(prm.annotation(), arg_specs)
+        elif hasattr(prm.annotation, '__args__'): # Unions
+            # ASSUME: Order of types in signatures indicate order of preference.
+            for type_ in prm.annotation.__args__:
+                try:
+                    arg_specs | get_argspecs(type_(), arg_specs)
+                    break
+                except TypeError as e:
+                    raise e
+        else:
+            raise TypeError(f"Cannot instantiate. Check the type of {prm.annotation}")
         parser.add_argument(argname, **arg_specs)
     return parser
 
-# NOTE: When a single dispatch function is invoked, the the first arg is inspected.
-# Based on the type of the first argument, a corresponding implementation is dispatched.
-# Use @foo.register to register an implementation to the single dispatch function foo.
-# The following two hacks are used throughout the get_argspecs implementations:
-# HACK: type(annotation) == type. But type(annotation()) == str | int | whatever. 
-# This hack works because of the following hack.
-# HACK: The 'type' type is callable. It behaves like a constructor for it's type.
-# For example: `type(42)('36')` creates an integer 36.
-# It works consistently, but I haven't seen it as defined/supported behavior.
 @singledispatch
 def get_argspecs(annotation: type, arg_specs: dict)->dict:
     """ Creates a partial argspec dictionary from a parameter annotation. """
diff --git a/tests/dummy.py b/tests/dummy.py
new file mode 100644 (file)
index 0000000..0905623
--- /dev/null
@@ -0,0 +1,32 @@
+"""
+This module contains dummy functions that are used by the test suite.
+"""
+
+from typing import Optional
+
+class DummyCallable:
+
+    def __call__(self, string: str = "default", num: int=42):
+        return f"String: {string} \n Integer: {num} \n"
+
+class DummyClass:
+
+    def dummy_method(self, string: str = "default", num: int=42):
+        return f"String: {string} \n Integer: {num} \n"
+
+    @classmethod    
+    def dummy_classmethod(cls, string: str = "default", num: int=42):
+        return f"String: {string} \n Integer: {num} \n"
+
+def dummy_fn_no_signature(string, num)->str:
+    return f"String: {string} \n Integer: {num} \n"
+
+def dummy_fn_typed(string: str, num: int)->str:
+    return f"String: {string} \n Integer: {num} \n"
+
+def dummy_fn_optional(string: Optional[str] = None, num: Optional[int] = 42)->str:
+    return f"String: {string} \n Integer: {num} \n"
+
+# NOTE: The dummy_fn_full function is the prototypical "happy path" for signagures.
+def dummy_fn_full(string: str="default", num: int = 42)->str:
+    return f"String: {string} \n Integer: {num} \n"
index fe16c7feb13afa59e9cf18e9abd14bd4e9a10783..826cb9b38cd451b6fe09605b6a0d00f35177d3f1 100644 (file)
@@ -1,17 +1,21 @@
+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: Stringable)->str:
+def echo_fn(arg: str)->str:
     return str(arg)
 
 @command
-def echo(arg: Stringable)->str:
+#def echo(arg: Stringable)->str:
+def echo(arg: str)->str:
     return echo_fn(arg)
 
 def test_attributes():
index a7c4054b569994cc480fef22c780eb7e0276f0d6..41f0b0339cad2f2156c73b4e2c78b534288d3ffe 100644 (file)
@@ -1,43 +1,11 @@
 import inspect
+import os
 import sys
-from typing import Optional
+from   typing import Optional
 import pytest
-from proto.utils import get_defaults, get_types, get_parser
-
-##############
-# Begin Data # -- signature testing
-##############
-# TODO: (?) Move these dummy functions to their own module within test dir.
-class DummyCallable:
-
-    def __call__(self, string: str = "default", num: int=42):
-        return f"String: {string} \n Integer: {num} \n"
-
-class DummyClass:
-
-    def dummy_method(self, string: str = "default", num: int=42):
-        return f"String: {string} \n Integer: {num} \n"
-
-    @classmethod    
-    def dummy_classmethod(cls, string: str = "default", num: int=42):
-        return f"String: {string} \n Integer: {num} \n"
 
-def dummy_fn_no_signature(string, num)->str:
-    return f"String: {string} \n Integer: {num} \n"
-
-def dummy_fn_typed(string: str, num: int)->str:
-    return f"String: {string} \n Integer: {num} \n"
-
-def dummy_fn_optional(string: Optional[str], num: Optional[int] = 42)->str:
-    return f"String: {string} \n Integer: {num} \n"
-
-# NOTE: The dummy_fn_full function is the prototypical "happy path" for signagures.
-def dummy_fn_full(string: str="default", num: int = 42)->str:
-    return f"String: {string} \n Integer: {num} \n"
-
-###############
-# Begin Tests #
-###############
+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. """
@@ -93,9 +61,6 @@ def test_validating_optional():
     _, kwargs = get_defaults(dummy_fn_optional)
     assert isinstance(kwargs['num'],types['num'])
 
-# NOTE: The parser.parse_args() method always returns keys and values as strings.
-# You must cast values yourself to compare.
-# NOTE: sys.argv[0] is the program name and is not needed.
 def test_get_parser_defaults():
     """ If a fn default is specified, use keyword syntax (optional). """
     string="not default"
@@ -137,9 +102,34 @@ def test_get_parser_types_scalar():
     assert args.string == string
     assert args.num == int(num)
 
+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']
\ No newline at end of file
+    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