Skip to content

Commit

Permalink
fix: future annotations with custom types (#296)
Browse files Browse the repository at this point in the history
Fixes #295

When combining from __future__ import annotations, Annotated, and custom types, the click parser does not have access to the custom type class at runtime unless the eval_str option is provided to inspect.signature(). Unfortunately, this option is not available for python 3.9 or below. This fixes the bug for python 3.10+ and adds a workaround in the docs for 3.9.

---------

Co-authored-by: Matthew Anderson <matt@mandersience.com>
  • Loading branch information
maxb2 and Matthew Anderson authored Nov 7, 2024
1 parent 109b598 commit eb2f252
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 8 deletions.
139 changes: 138 additions & 1 deletion docs/known_issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,141 @@ apple pear lemon
$ python arg_list.py strawberry bear wolf snake tiger --config config.yml
apple pear strawberry
['bear', 'wolf', 'snake', 'tiger']
```
```

## Custom Types with `Annotated` and `from __future__ import annotations`

> Related Github issue: [typer-config#295](https://github.com/maxb2/typer-config/issues/295).
> Thanks to [@ElliottKasoar](https://github.com/ElliottKasoar) for catching this!
When [custom types](https://typer.tiangolo.com/tutorial/parameter-types/custom-types/) are combined with annotations, a `RuntimeError` is thrown.
This is because `from __future__ import annotations` converts all annotations to strings at runtime, but the `click` parser needs the class at runtime.
The `eval_str` option was added to `inspect.signature()` in python 3.10 to fix this, however there is no solution for 3.9.

```{.python title="annotated.py"}
from __future__ import annotations
from typing_extensions import Annotated
import typer
from typer_config import use_yaml_config
class CustomClass:
def __init__(self, value: str):
self.value = value
def __str__(self):
return f"<CustomClass: value={self.value}>"
def parse_custom_class(value: str):
return CustomClass(value * 2)
# NOTE: only works for python 3.10+
FooType = Annotated[CustomClass, typer.Argument(parser=parse_custom_class)]
app = typer.Typer()
@app.command()
@use_yaml_config()
def main(foo: FooType = 1):
print(foo)
if __name__ == "__main__":
app()
```

```{.bash title="Terminal" exec="false"}
# fails for python 3.9 or below
$ python3.9 annotated.py foo
RuntimeError: ...

# works for python 3.10 or above
$ python3.10 annotated.py foo
<CustomClass: value=foofoo>
```

<!---
```{.python exec="true" write="false"}
from typer.testing import CliRunner
import sys
RUNNER = CliRunner()
if sys.version_info < (3, 10):
try:
result = RUNNER.invoke(app, ["foo"])
raise Exception("Should have failed!")
except RuntimeError:
pass
else:
result = RUNNER.invoke(app, ["foo"])
assert result.exit_code == 0, "Custom Types with Annotated[] failed!"
assert (
result.stdout.strip() == "<CustomClass: value=foofoo>"
), f"Unexpected output for Annotated[] example"
```
--->

Alternatively, you can use the non-annotated way to define typer arguments/options which will work for all python versions:

```{.python title="non-annotated.py"}
from __future__ import annotations
from typing_extensions import Annotated
import typer
from typer_config import use_yaml_config
class CustomClass:
def __init__(self, value: str):
self.value = value
def __str__(self):
return f"<CustomClass: value={self.value}>"
def parse_custom_class(value: str):
return CustomClass(value * 2)
app = typer.Typer()
# NOTE: works for all versions of python
@app.command()
@use_yaml_config()
def main(foo: CustomClass = typer.Argument(parser=parse_custom_class)):
print(foo)
if __name__ == "__main__":
app()
```

```{.bash title="Terminal"}
$ python non-annotated.py foo
<CustomClass: value=foofoo>
```

<!---
```{.python exec="true" write="false"}
from typer.testing import CliRunner
import sys
RUNNER = CliRunner()
result = RUNNER.invoke(app, ["foo"])
assert result.exit_code == 0, "Custom Types without Annotated[] failed!"
assert (
result.stdout.strip() == "<CustomClass: value=foofoo>"
), f"Unexpected output for non-annotated example"
```
--->
10 changes: 5 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 26 additions & 1 deletion tests/doc_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,30 @@ def from_str(raw: str) -> "Fence":
"""
return Fence.from_re_groups(FENCED_BLOCK_RE.match(raw).groups())

def should_exec(self) -> bool:
"""Whether fence should be executed.
Returns:
bool: fence should execute
"""

exec_attr = self.attrs.get("exec", None)

exec_option = self.options.get("exec", None)

if exec_attr is None and exec_option is None:
return True

if exec_attr is not None and exec_attr.value.strip().lower() == "false":
return False

if ( # noqa: SIM103
exec_option is not None and exec_option.strip().lower() == "false"
):
return False

return True


class WorkingDirectory:
"""Sets the cwd within the context."""
Expand Down Expand Up @@ -329,4 +353,5 @@ def check_typer_md_file(fpath: Path):

with TemporaryDirectory() as td, WorkingDirectory(td):
for fence in fences:
_executors[fence.lang](fence, globals_=globals_)
if fence.should_exec():
_executors[fence.lang](fence, globals_=globals_)
6 changes: 5 additions & 1 deletion typer_config/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import sys
from enum import Enum
from functools import wraps
from inspect import Parameter, signature
Expand Down Expand Up @@ -71,7 +72,10 @@ def decorator(cmd: TyperCommand) -> TyperCommand:
# It does not affect the actual function implementation.
# So, a caller can be confused how to pass parameters to
# the function with modified signature.
sig = signature(cmd)
if sys.version_info < (3, 10):
sig = signature(cmd)
else:
sig = signature(cmd, eval_str=True)

config_param = Parameter(
param_name,
Expand Down

0 comments on commit eb2f252

Please sign in to comment.