fastapi / typer

Typer, build great CLIs. Easy to code. Based on Python type hints.
https://typer.tiangolo.com/
MIT License
15.69k stars 668 forks source link

Type not yet supported: <class 'datetime.datetime'> when using `freezegun` #282

Closed paxcodes closed 1 year ago

paxcodes commented 3 years ago

Describe the bug

Typer doesn't support freezegun's FakeDateTime. I am getting a RuntimeError: Type not yet supported: <class 'datetime.datetime'> when I use freezegun to freeze the time for testing purposes and have datetime type in my argument.

To Reproduce

from datetime import datetime
import typer

app = typer.Typer()

@app.command()
def data(
    year_month: datetime = typer.Argument(
        f"{datetime.today():%Y-%m}", formats=["%Y-%m"]
    )
):
    typer.echo(f"Data for {year_month:%Y-%b}")

if __name__ == "__main__":
    app()
from typer.testing import CliRunner
from main import app

runner = CliRunner()

def test_data(freezer):
    freezer.move_to("2020-06-01")
    result = runner.invoke(app)
    assert "Data for 2020-June" in result.stdout
pytest -k test_data
>       raise RuntimeError(f"Type not yet supported: {annotation}")  # pragma no cover
E       RuntimeError: Type not yet supported: <class 'datetime.datetime'>

~/Library/Caches/pypoetry/virtualenvs/my_project-BAzU1U47-py3.8/lib/python3.8/site-packages/typer/main.py:587: RuntimeError
Complete Stacktrace ```sh -> % pytest -k test_data =================================================================================================== test session starts =================================================================================================== platform darwin -- Python 3.8.7, pytest-5.4.3, py-1.8.1, pluggy-0.13.1 rootdir: ~/my_project, inifile: pytest.ini, testpaths: tests plugins: pylama-7.7.1, cov-2.10.0, freezegun-0.4.1, mock-3.1.0, recording-0.8.1, socket-0.4.0, spec-3.2.0, testmon-1.1.0 collected 31 items / 30 deselected / 1 selected tests/cli/data/test_defaults.py: ✗ Data [100%] ======================================================================================================== FAILURES ========================================================================================================= ________________________________________________________________________________________________________ test_data ________________________________________________________________________________________________________ freezer = def test_data(freezer): freezer.move_to("2020-06-01") > result = runner.invoke(app) tests/cli/data/test_defaults.py:10: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ~/Library/Caches/pypoetry/virtualenvs/my_project-BAzU1U47-py3.8/lib/python3.8/site-packages/typer/testing.py:20: in invoke use_cli = _get_command(app) ~/Library/Caches/pypoetry/virtualenvs/my_project-BAzU1U47-py3.8/lib/python3.8/site-packages/typer/main.py:239: in get_command click_command = get_command_from_info(typer_instance.registered_commands[0]) ~/Library/Caches/pypoetry/virtualenvs/my_project-BAzU1U47-py3.8/lib/python3.8/site-packages/typer/main.py:423: in get_command_from_info ) = get_params_convertors_ctx_param_name_from_function(command_info.callback) ~/Library/Caches/pypoetry/virtualenvs/my_project-BAzU1U47-py3.8/lib/python3.8/site-packages/typer/main.py:404: in get_params_convertors_ctx_param_name_from_function click_param, convertor = get_click_param(param) ~/Library/Caches/pypoetry/virtualenvs/my_project-BAzU1U47-py3.8/lib/python3.8/site-packages/typer/main.py:656: in get_click_param parameter_type = get_click_type( _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ def get_click_type( *, annotation: Any, parameter_info: ParameterInfo ) -> click.ParamType: if annotation == str: return click.STRING elif annotation == int: if parameter_info.min is not None or parameter_info.max is not None: min_ = None max_ = None if parameter_info.min is not None: min_ = int(parameter_info.min) if parameter_info.max is not None: max_ = int(parameter_info.max) return click.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) else: return click.INT elif annotation == float: if parameter_info.min is not None or parameter_info.max is not None: return click.FloatRange( min=parameter_info.min, max=parameter_info.max, clamp=parameter_info.clamp, ) else: return click.FLOAT elif annotation == bool: return click.BOOL elif annotation == UUID: return click.UUID elif annotation == datetime: return click.DateTime(formats=parameter_info.formats) elif ( annotation == Path or parameter_info.allow_dash or parameter_info.path_type or parameter_info.resolve_path ): return click.Path( # type: ignore exists=parameter_info.exists, file_okay=parameter_info.file_okay, dir_okay=parameter_info.dir_okay, writable=parameter_info.writable, readable=parameter_info.readable, resolve_path=parameter_info.resolve_path, allow_dash=parameter_info.allow_dash, path_type=parameter_info.path_type, ) elif lenient_issubclass(annotation, FileTextWrite): return click.File( mode=parameter_info.mode or "w", encoding=parameter_info.encoding, errors=parameter_info.errors, lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, FileText): return click.File( mode=parameter_info.mode or "r", encoding=parameter_info.encoding, errors=parameter_info.errors, lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, FileBinaryRead): return click.File( mode=parameter_info.mode or "rb", encoding=parameter_info.encoding, errors=parameter_info.errors, lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, FileBinaryWrite): return click.File( mode=parameter_info.mode or "wb", encoding=parameter_info.encoding, errors=parameter_info.errors, lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) elif lenient_issubclass(annotation, Enum): return click.Choice( [item.value for item in annotation], case_sensitive=parameter_info.case_sensitive, ) > raise RuntimeError(f"Type not yet supported: {annotation}") # pragma no cover E RuntimeError: Type not yet supported: ~/Library/Caches/pypoetry/virtualenvs/my_project-BAzU1U47-py3.8/lib/python3.8/site-packages/typer/main.py:587: RuntimeError ================================================================================================= short test summary info ================================================================================================= FAILED tests/cli/data/test_defaults.py::test_data - RuntimeError: Type not yet supported: ============================================================================================ 1 failed, 30 deselected in 0.44s ============================================================================================= ```
✅  Pass

Expected behaviour

Freezing times in tests are common. I expect that the test pass.

Environment

sathoune commented 3 years ago

Hi,

I did not use pytest-freezer beforehand and I have no knowledge of how it works internally. I run your code and the message you are getting is interesting. Your argument is ellipsis so it is required, but you are not passing the argument to the invoke function and there is an error anyway. I tried a few variations of this scenarios:

  1. No freezer, no argument

    def test_data(
    # freezer,
    ):
    # freezer.move_to("2020-06-01")
    
    result = runner.invoke(app,)
    print(result.stdout)
    assert "Data for 2020-Jun" in result.stdout

    Raises Missing argument 'YEAR_MONTH:[%Y-%m]'.\n" = <Result SystemExit(2)>.stdout as it is not provided.

  2. Date as a string

    def test_data(
    # freezer,
    ):
    # freezer.move_to("2020-06-01")
    
    result = runner.invoke(app, ["2020-06"])
    print(result.stdout)
    assert "Data for 2020-Jun" in result.stdout

    passes

  3. Uncommenting freezer

def test_data( freezer, ):

freezer.move_to("2020-06-01")

result = runner.invoke(app, ["2020-06"])
print(result.stdout)
assert "Data for 2020-Jun" in result.stdout
Raises the `RuntimeError: Type not yet supported: <class 'datetime.datetime'>` straight away, while it is not used within the body of the test at all! Of course uncommenting the `move_to` would have the same result. Do you know what within the freezer could do to cause this?

One thing to point here is that `invokes` takes a string or list of strings for anything - whether these are integers or booleans, you cannot have other type but string from command line to begin with, it is later parsed by type (and other libraries) later. 
Example: If you would have integer argument in the command:
```python
def main(something: int):

You would have to invoke this with string anyway

runner.invoke(main, "4")

When running

runner.invoke(main, 4)

I am getting:

E        +  where '' = <Result TypeError("'int' object is not iterable")>.stdout

So the module expects a string anyway.

Have you maybe checked out if this works with click or any other CLI module? Because that might be an issue with the way you interact with command line.

paxcodes commented 3 years ago

Sorry for the clunky sample code there. It should be what I intended now. The main change of the sample code is that typer.Argument now has a default value.

Doing result = runner.invoke(app) should now fail as how I reported it to fail.

The only idea I have is that since freezer/freezegun "changes" datetime objects to FakeDateTime, it's failing around this part of the code: typer/main.py:587

sathoune commented 3 years ago

I did some print debugging within typer code and the line 589 you are referring to is within get_click_type. Its signature looks like this:

def get_click_type(
    *, annotation: Any, parameter_info: ParameterInfo
) -> click.ParamType:

In the body, it performs a series of if-checks for what type the annotation is - int, str, datetime...

I added two magic lines:


def get_click_type(
    *, annotation: Any, parameter_info: ParameterInfo
) -> click.ParamType:
    print("annonotation: ", annotation)
    print("the datetime: ", datetime)

and this gives me such a result:


annonotation:  <class 'datetime.datetime'>
the datetime:  <class 'freezegun.api.FakeDatetime'>

Now one of the if-checks is annotation == datetime and it clearly should not be True according to the above. It would pass if the check was issubclass(annotation, datetime), I suppose, however, pytest_freezegun should also change the type of annotation and not only one of the datetimes.

sathoune commented 3 years ago

I did one more check to confirm the observation:

Only main dependencies are pytest==6.2.4 and pytest-freezegun==0.4.2. Freezegun version is 1.1.0, so the newest one.

I have two files:

main.py

import datetime

def datetime_annotated(
    some_date: datetime.datetime
) -> None:
    print(some_date)

and test_datetime.py

from main import datetime_annotated
import datetime

def test_datetime_annotation_is_datetime(
    freezer
):
    annotations = datetime_annotated.__annotations__
    some_date_annotation = annotations["some_date"]

    assert datetime.datetime == some_date_annotation

Result of the run is failure:

test_datetime.py:9 (test_datetime_annotation_is_datetime)
<class 'freezegun.api.FakeDatetime'> != <class 'datetime.datetime'>

Expected :<class 'datetime.datetime'>
Actual   :<class 'freezegun.api.FakeDatetime'>

It means that freezegun substitutes the datetime object but does not do so within annotations. I also moved code of the function into the test file and the results are the same: Annotation stays datetime.datetime. For me it looks like not typer's fault but it's on the freezegun side since it omits annotations.

Below also test without pytest_freezegun but raw freezegun:


from freezegun import freeze_time

from main import datetime_annotated
import datetime

@freeze_time("2020-06-24")
def test_datetime_annotation_is_datetime(

):
    annotations = datetime_annotated.__annotations__
    some_date_annotation = annotations["some_date"]

    assert datetime.datetime == some_date_annotation

Result is the same as with freezer fixture.

paxcodes commented 3 years ago

Before I open an issue over at freezegun, how do maintainers here think about the solution of using an issubclass check?

>>> from freezegun.api import FakeDatetime
>>> from datetime import datetime
>>> issubclass(FakeDatetime, datetime)
True
>>> 

FakeDatetime class definition uses datetime.datetime as part of its metaclass that's why issubclass check works.

In freezegun/api.py#L345 class FakeDatetime(with_metaclass(FakeDatetimeMeta, real_datetime, FakeDate)): where real_datetime is real_datetime = datetime.datetime as seen in freezegun/api.py#L36

sathoune commented 3 years ago

I am no maintainer but in my opinion handling a case, where some library substitutes some object at runtime and forgets about annotations does not justify a change in the library that does not depend on it.

I have a possible solution for you, however!

With three files: main.py Please note:

app = typer.Typer()

@app.command() def data( year_month: datetime = typer.Argument( f"{datetime.now().strftime('%Y-%m')}", formats=["%Y-%m"]) ): print("Year-month: ", year_month) typer.echo(f"Data for {year_month:%Y-%b}")

def datetime_annotated( some_date: datetime ) -> None: print(some_date)

if name == "main": app()


`test_main.py`
* here I am using a wrapper around typer that I'll explain later
```python
import datetime

from freezegun import freeze_time
from typer.testing import CliRunner

from main import app,  datetime_annotated
from monkey_patch_typer import monkey_patch_commands, patch_annotations

runner = CliRunner()

def test_data(
    freezer,
):
    freezer.move_to("2020-06-01")
    result = runner.invoke(monkey_patch_commands(app), )

    assert "Data for 2021-May" in result.stdout

def test_data_with_argument(
    freezer,
):
    freezer.move_to("2020-06-01")
    result = runner.invoke(monkey_patch_commands(app,),  ["2520-07"])

    assert "Data for 2520-Jul" in result.stdout

@freeze_time("2020-06-24")
def test_datetime_annotation_is_datetime(

):
    patch_annotations(datetime_annotated)
    assert datetime.datetime == datetime_annotated.__annotations__['some_date']

monkey_patch_typer.py Here I monkey patch registered functions' annotations before I invoke the Typer. I am checking for the correct class by converting it to string. The above if would always fail and print Yes, as expected in the test runs. That happens, because datetime is already freezegun's object. There might be better way to check for the type than comparing strings, but that is what first came into my mind.

To use subcommands you would need also to monkey patch app.registered_groups but I did not have a look into it.

from datetime import datetime
from typing import Callable,  Dict

import typer

def patch_annotations(function: Callable):
    annotations: Dict = function.__annotations__
    new_annotations = {}
    for key, value in annotations.items():
        # This if below is redundant
        if value == datetime:
            print("That would be impressive")
        else:
            print("Yes, as expected")

        # Since above is always in the `else` department, we have to find other way to determine 
        # the type
        if str(value) == "<class 'datetime.datetime'>":
            value = datetime
        new_annotations |= {key: value}
    function.__annotations__ = new_annotations

def monkey_patch_commands(some_app: typer.Typer) -> typer.Typer:
    registered_commands = some_app.registered_commands
    for command in registered_commands:
        print(command.callback.__annotations__)
        patch_annotations(command.callback)

    return some_app

You can further improve the snippet by handling the subcommands and make the monkey patch a fixture so you don't have to invoke the function for each test.

tiangolo commented 1 year ago

Hey there! Sorry for the delay.

Yep, that's not a supported type, it's something dependent on that package (I haven't used it), nevertheless, I think you can probably do it with a custom type, this is somewhat new: https://typer.tiangolo.com/tutorial/parameter-types/custom-types/