Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
paxcodes opened this issue May 19, 2021 · 7 comments
Closed
Labels
question Question or problem

Comments

@paxcodes
Copy link

paxcodes commented May 19, 2021

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

  • Create a file main.py with:
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()
  • Install pytest-freezegun
  • Have a test:
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
  • Run the test:
pytest -k test_data
  • It outputs:
>       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
-> % 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 = <freezegun.api.FrozenDateTimeFactory object at 0x10be4ba30>

    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: <class 'datetime.datetime'>

~/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: <class 'datetime.datetime'>
============================================================================================ 1 failed, 30 deselected in 0.44s =============================================================================================
  • But I expected it to output:
✅  Pass

Expected behaviour

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

Environment

  • OS: macOS
  • Typer Version: 0.3.2
  • Python version: Python 3.8.7
@paxcodes paxcodes added the bug Something isn't working label May 19, 2021
@sathoune
Copy link

sathoune commented May 20, 2021

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:
0. 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.

  1. 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

  1. 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:

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
Copy link
Author

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
Copy link

sathoune commented May 20, 2021

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
Copy link

sathoune commented May 21, 2021

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
Copy link
Author

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
Copy link

sathoune commented May 21, 2021

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:

  • datetime_annotated is there just for an extra test
  • I had to add formatting in the default argument because the runner would tell me that I have a bad input.
from datetime import datetime
import typer

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
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 tiangolo changed the title [BUG] Type not yet supported: <class 'datetime.datetime'> when using freezegun Type not yet supported: <class 'datetime.datetime'> when using freezegun Aug 8, 2023
@tiangolo tiangolo added question Question or problem and removed bug Something isn't working labels Aug 8, 2023
@tiangolo
Copy link
Member

tiangolo commented Aug 8, 2023

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/

@fastapi fastapi locked and limited conversation to collaborators Aug 8, 2023
@tiangolo tiangolo converted this issue into discussion #652 Aug 8, 2023

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
question Question or problem
Projects
None yet
Development

No branches or pull requests

3 participants