Skip to content

Commit

Permalink
feat: add InitArgs type to generate Corgy classes for constructors
Browse files Browse the repository at this point in the history
  • Loading branch information
jayanthkoushik committed Jan 18, 2022
1 parent 08c2e0a commit 1c8ab43
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 0 deletions.
76 changes: 76 additions & 0 deletions corgy/types.py
Expand Up @@ -23,6 +23,7 @@ class Args(Corgy):
parser = ArgumentParser()
parser.add_argument("--in-dir", type=InputDirectory, help="an existing directory")
"""
import inspect
import os
import sys
import typing
Expand All @@ -31,6 +32,8 @@ class Args(Corgy):
from pathlib import Path, PosixPath, WindowsPath
from typing import Dict, Generic, Iterator, List, Mapping, Tuple, Type, TypeVar, Union

from ._corgy import Corgy

if sys.version_info < (3, 8):
from typing_extensions import Protocol
else:
Expand All @@ -48,6 +51,7 @@ class Args(Corgy):
"InputDirectory",
"SubClass",
"KeyValuePairs",
"InitArgs",
)

StrOrPath = Union[str, Path]
Expand Down Expand Up @@ -726,3 +730,75 @@ def __repr__(self) -> str:

def __str__(self) -> str:
return super().__repr__()


class InitArgs(Corgy, Generic[_T]):
"""Corgy wrapper around arguments of a class's `__init__`.
Example::
$ cat test.py
class Foo:
def __init__(
self,
a: Annotated[int, "a help"],
b: Annotated[Sequence[str], "b help"],
c: Annotated[float, "c help"] = 0.0,
):
...
FooInitArgs = InitArgs[Foo]
foo_init_args = FooInitArgs.parse_from_cmdline()
foo = Foo(**foo_init_args.as_dict())
$ python test.py --help
usage: test.py [-h] --a int --b [str ...] [--c float]
options:
-h/--help show this help message and exit
--a int a help (required)
--b [str ...] b help (required)
--c float c help (default: 0.0)
This is a generic class, and on using the `InitArgs[Cls]` syntax, a concrete
`Corgy` class is created, which has attributes corresponding to the arguments of
`Cls.__init__`, with types inferred from annotations. The returned class can be used
as any other `Corgy` class, including as a type annotation within another `Corgy`
class.
All arguments of the `__init__` method must be annotated, following the same rules
as for other `Corgy` classes. Positional only arguments are not supported, since
they are not associated with an argument name. `TypeError` is raised if either of
these conditions is not met.
"""

__slots__ = ()

def __class_getitem__(cls, item: Type[_T]) -> Type["InitArgs[_T]"]:
if hasattr(cls, "__annotations__"):
raise TypeError(
f"cannot further sub-script `{cls.__name__}[{item.__name__}]`"
)

item_sig = inspect.signature(item)
item_annotations, item_defaults = {}, {}
for param_name, param in item_sig.parameters.items():
if param.annotation is inspect.Parameter.empty:
raise TypeError(
f"`{item}` is missing annotation for parameter `{param_name}`"
)

if param.kind is inspect.Parameter.POSITIONAL_ONLY:
raise TypeError(
f"positional-only paramter `{param_name}` is incompatible with "
f"`{cls.__name__}`"
)

item_annotations[param_name] = param.annotation
if param.default is not inspect.Parameter.empty:
item_defaults[param_name] = param.default

return type(
f"{cls.__name__}[{item.__name__}]",
(cls,),
{"__annotations__": item_annotations, **item_defaults},
)
41 changes: 41 additions & 0 deletions docs/corgy.types.md
Expand Up @@ -306,3 +306,44 @@ that the dictionary is not type-checked and is used as-is:
>>> repr(dic)
>>> KeyValuePairs[str, int]({'a': 1, 'b': 2})
```


### _class_ corgy.types.InitArgs(\*\*args)
Corgy wrapper around arguments of a class’s `__init__`.

Example:

```python
$ cat test.py
class Foo:
def __init__(
self,
a: Annotated[int, "a help"],
b: Annotated[Sequence[str], "b help"],
c: Annotated[float, "c help"] = 0.0,
):
...
FooInitArgs = InitArgs[Foo]
foo_init_args = FooInitArgs.parse_from_cmdline()
foo = Foo(**foo_init_args.as_dict())

$ python test.py --help
usage: test.py [-h] --a int --b [str ...] [--c float]

options:
-h/--help show this help message and exit
--a int a help (required)
--b [str ...] b help (required)
--c float c help (default: 0.0)
```

This is a generic class, and on using the `InitArgs[Cls]` syntax, a concrete
`Corgy` class is created, which has attributes corresponding to the arguments of
`Cls.__init__`, with types inferred from annotations. The returned class can be used
as any other `Corgy` class, including as a type annotation within another `Corgy`
class.

All arguments of the `__init__` method must be annotated, following the same rules
as for other `Corgy` classes. Positional only arguments are not supported, since
they are not associated with an argument name. `TypeError` is raised if either of
these conditions is not met.
64 changes: 64 additions & 0 deletions tests/test_types.py
@@ -1,5 +1,6 @@
import os
import stat
import sys
from argparse import ArgumentTypeError
from io import BufferedReader, BufferedWriter, TextIOWrapper
from pathlib import Path
Expand All @@ -8,7 +9,9 @@
from unittest import skipIf, TestCase
from unittest.mock import MagicMock, patch

from corgy import Corgy
from corgy.types import (
InitArgs,
InputBinFile,
InputDirectory,
InputTextFile,
Expand Down Expand Up @@ -591,4 +594,65 @@ def test_key_value_pairs_accepts_dict(self):
self.assertEqual(repr(dic), "KeyValuePairs[str, int]({'foo': 1, 'bar': 2})")


class TestInitArgs(TestCase):
def test_init_args_generates_correct_corgy_class(self):
class A:
def __init__(self, x: int, y: str):
...

type_ = InitArgs[A]
self.assertTrue(issubclass(type_, Corgy))
self.assertTrue(hasattr(type_, "x"))
self.assertIsInstance(type_.x, property)
self.assertTrue(hasattr(type_, "y"))
self.assertIsInstance(type_.y, property)

def test_init_args_instance_can_be_used_to_init_class(self):
class A:
def __init__(self, x: int, y: str):
self.x = x
self.y = y

type_ = InitArgs[A]
a_args = type_(x=1, y="2")
a = A(**a_args.as_dict())
self.assertEqual(a.x, 1)
self.assertEqual(a.y, "2")

def test_init_args_raises_if_re_subscripted(self):
class A:
def __init__(self, x: int, y: str):
...

type_ = InitArgs[A]
with self.assertRaises(TypeError):
type_ = type_[A] # type: ignore

def test_init_args_raises_if_missing_annotation(self):
class A:
def __init__(self, x: int, y):
...

with self.assertRaises(TypeError):
_ = InitArgs[A]

def test_init_args_handles_default_values(self):
class A:
def __init__(self, x: int, y: str = "foo"):
...

type_ = InitArgs[A]
a_args = type_(x=1)
self.assertDictEqual(a_args.as_dict(), {"x": 1, "y": "foo"})

@skipIf(sys.version_info < (3, 8), "positional-only parameters require Python 3.8+")
def test_init_args_raises_if_pos_only_arg_present(self):
class A:
def __init__(self, x: int, /, y: str):
...

with self.assertRaises(TypeError):
_ = InitArgs[A]


del _TestFile, _TestOutputFile, _TestLazyOutputFile, _TestInputFile, _TestDirectory

0 comments on commit 1c8ab43

Please sign in to comment.