Closed paxcodes closed 1 year 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:
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.
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
Uncommenting freezer
def test_data( freezer, ):
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.
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
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.
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.
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
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
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
```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.
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/
Describe the bug
Typer doesn't support freezegun's
FakeDateTime
. I am getting aRuntimeError: Type not yet supported: <class 'datetime.datetime'>
when I usefreezegun
to freeze the time for testing purposes and havedatetime
type in my argument.To Reproduce
main.py
with:pytest-freezegun
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 =Expected behaviour
Freezing times in tests are common. I expect that the test pass.
Environment