diff --git a/docs/known_issues.md b/docs/known_issues.md index aaaa7fa..ebe1e50 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -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'] -``` \ No newline at end of file +``` + +## 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"" + + +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 + +``` + + + +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"" + + +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 + +``` + + diff --git a/poetry.lock b/poetry.lock index 8a2be99..3fb6a4a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1031,18 +1031,18 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" -version = "1.6.0" +version = "1.10.8" description = "A Python handler for mkdocstrings." optional = false python-versions = ">=3.8" files = [ - {file = "mkdocstrings_python-1.6.0-py3-none-any.whl", hash = "sha256:06f116112b335114372f2554b1bf61b709c74ab72605010e1605c1086932dffe"}, - {file = "mkdocstrings_python-1.6.0.tar.gz", hash = "sha256:6164ccaa6e488abc2a8fbccdfd1f21948c2c344d3f347847783a5d1c6fa2bfbf"}, + {file = "mkdocstrings_python-1.10.8-py3-none-any.whl", hash = "sha256:bb12e76c8b071686617f824029cb1dfe0e9afe89f27fb3ad9a27f95f054dcd89"}, + {file = "mkdocstrings_python-1.10.8.tar.gz", hash = "sha256:5856a59cbebbb8deb133224a540de1ff60bded25e54d8beacc375bb133d39016"}, ] [package.dependencies] -griffe = ">=0.35" -mkdocstrings = ">=0.20" +griffe = ">=0.49" +mkdocstrings = ">=0.25" [[package]] name = "mypy" diff --git a/tests/doc_examples.py b/tests/doc_examples.py index 3ce507e..881eb5b 100644 --- a/tests/doc_examples.py +++ b/tests/doc_examples.py @@ -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.""" @@ -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_) diff --git a/typer_config/decorators.py b/typer_config/decorators.py index 8206a50..6537e98 100644 --- a/typer_config/decorators.py +++ b/typer_config/decorators.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from enum import Enum from functools import wraps from inspect import Parameter, signature @@ -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,