From 4178b1dcb33759d2171d725832caab1430fbf80e Mon Sep 17 00:00:00 2001 From: kingbuzzman Date: Tue, 19 Aug 2025 15:05:36 +0100 Subject: [PATCH 1/2] Add marker support to pytest plugin --- docs/changelog.rst | 4 ++++ docs/pytest_plugin.rst | 9 ++++++++- pyproject.toml | 1 + src/time_machine/__init__.py | 28 ++++++++++++++++++++++++++-- tests/test_time_machine.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index fdda0a76..0e86e3b7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,10 @@ Changelog 2.18.0 (2025-08-18) ------------------- +* Add a pytest marker fixture supporting initial time setting and in-test time shifting. + + Thanks to Javier Buzzi in `PR #499 `__. + * Update the :ref:`migration CLI ` to detect unittest classes based on whether they use ``self.assert*`` methods like ``self.assertEqual()``. * Fix free-threaded Python warning: ``RuntimeWarning: The global interpreter lock (GIL) has been enabled...`` as seen on Python 3.13+. diff --git a/docs/pytest_plugin.rst b/docs/pytest_plugin.rst index 100c4657..fc2741e0 100644 --- a/docs/pytest_plugin.rst +++ b/docs/pytest_plugin.rst @@ -3,7 +3,7 @@ pytest plugin ============= time-machine also works as a pytest plugin. -It provides a function-scoped fixture called ``time_machine`` with methods ``move_to()`` and ``shift()``, which have the same signature as their equivalents in ``Coordinates``. +It provides a marker and function-scoped fixture called ``time_machine`` with methods ``move_to()`` and ``shift()``, which have the same signature as their equivalents in ``Coordinates``. This can be used to mock your test at different points in time and will automatically be un-mock when the test is torn down. For example: @@ -13,6 +13,13 @@ For example: import datetime as dt + @pytest.mark.time_machine(dt.datetime(1985, 10, 26)) + def test_delorean_marker(time_machine): + assert dt.date.today().isoformat() == "1985-10-26" + time_machine.move_to(dt.datetime(2015, 10, 21)) + assert dt.date.today().isoformat() == "2015-10-21" + + def test_delorean(time_machine): time_machine.move_to(dt.datetime(1985, 10, 26)) diff --git a/pyproject.toml b/pyproject.toml index 607c58b3..90317eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,7 @@ max_supported_python = "3.14" addopts = """\ --strict-config --strict-markers + -p pytester """ xfail_strict = true diff --git a/src/time_machine/__init__.py b/src/time_machine/__init__.py index 1af8dae0..c2d39495 100644 --- a/src/time_machine/__init__.py +++ b/src/time_machine/__init__.py @@ -378,6 +378,24 @@ def time_ns() -> int: # pytest plugin if HAVE_PYTEST: # pragma: no branch + MARKER_NAME = "time_machine" + FIXTURE_NAME = "time_machine" + + def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """ + Inject our fixture into any tests with our marker + """ + for item in items: + if item.get_closest_marker(MARKER_NAME): + item.fixturenames.insert(0, FIXTURE_NAME) # type: ignore[attr-defined] + + def pytest_configure(config: pytest.Config) -> None: + """ + Register our marker + """ + config.addinivalue_line( + "markers", f"{MARKER_NAME}(...): use time machine to set time" + ) class TimeMachineFixture: traveller: travel | None @@ -413,9 +431,15 @@ def stop(self) -> None: if self.traveller is not None: self.traveller.stop() - @pytest.fixture(name="time_machine") - def time_machine_fixture() -> TypingGenerator[TimeMachineFixture, None, None]: + @pytest.fixture(name=FIXTURE_NAME) + def time_machine_fixture( + request: pytest.FixtureRequest, + ) -> TypingGenerator[TimeMachineFixture, None, None]: fixture = TimeMachineFixture() + marker = request.node.get_closest_marker(MARKER_NAME) + if marker: + fixture.move_to(*marker.args, **marker.kwargs) + yield fixture fixture.stop() diff --git a/tests/test_time_machine.py b/tests/test_time_machine.py index 7bcf1e2f..a277bee6 100644 --- a/tests/test_time_machine.py +++ b/tests/test_time_machine.py @@ -948,6 +948,36 @@ def test_fixture_shift_without_move_to(time_machine): ) +def test_standalone_marker(testdir): + testdir.makepyfile( + """ + import pytest + import time + + @pytest.fixture + def current_time(): + return time.time() + + @pytest.fixture + def set_time(time_machine): + time_machine.move_to("2000-01-01") + + def test_normal(current_time): + assert current_time > 1742943111.0 + + @pytest.mark.time_machine("2000-01-01") + def test_mod(current_time, time_machine): + assert current_time == 946684800.0 + time_machine.shift(1) + assert current_time == 946684800.0 + assert int(time.time()) == 946684801 + """ + ) + + result = testdir.runpytest("-v", "-s") + assert result.ret == 0 + + # escape hatch tests From bf430ffb923553fb2b0188aad4603eaf26006b6f Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 19 Aug 2025 15:26:29 +0100 Subject: [PATCH 2/2] Review changes --- docs/changelog.rst | 9 +++-- docs/pytest_plugin.rst | 60 ++++++++++++++++++++++++++--- src/time_machine/__init__.py | 16 ++++---- tests/test_time_machine.py | 75 ++++++++++++++++++++++++++++-------- 4 files changed, 125 insertions(+), 35 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0e86e3b7..0b4f50cd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,11 @@ Changelog ========= +* Add marker support to :doc:`the pytest plugin `. + Decorate tests with ``@pytest.mark.time_machine()`` to set time during a test, affecting function-level fixtures as well. + + Thanks to Javier Buzzi in `PR #499 `__. + * Import date and time functions once in the C extension. This should improve speed a little bit, and avoid segmentation faults when the functions have been swapped out, such as when freezegun is in effect. @@ -10,10 +15,6 @@ Changelog 2.18.0 (2025-08-18) ------------------- -* Add a pytest marker fixture supporting initial time setting and in-test time shifting. - - Thanks to Javier Buzzi in `PR #499 `__. - * Update the :ref:`migration CLI ` to detect unittest classes based on whether they use ``self.assert*`` methods like ``self.assertEqual()``. * Fix free-threaded Python warning: ``RuntimeWarning: The global interpreter lock (GIL) has been enabled...`` as seen on Python 3.13+. diff --git a/docs/pytest_plugin.rst b/docs/pytest_plugin.rst index fc2741e0..4f442e1a 100644 --- a/docs/pytest_plugin.rst +++ b/docs/pytest_plugin.rst @@ -2,9 +2,14 @@ pytest plugin ============= -time-machine also works as a pytest plugin. -It provides a marker and function-scoped fixture called ``time_machine`` with methods ``move_to()`` and ``shift()``, which have the same signature as their equivalents in ``Coordinates``. -This can be used to mock your test at different points in time and will automatically be un-mock when the test is torn down. +time-machine works as a pytest plugin, which pytest will detect automatically. +The plugin supplies both a fixture and a marker to control the time during tests. + +``time_machine`` marker +----------------------- + +Use the ``time_machine`` `marker `__ with a valid destination for :class:`~.travel` to mock the time while a test function runs. +It applies for function-scoped fixtures too, meaning the time will be mocked for any setup or teardown code done in the test function. For example: @@ -14,10 +19,38 @@ For example: @pytest.mark.time_machine(dt.datetime(1985, 10, 26)) - def test_delorean_marker(time_machine): + def test_delorean_marker(): assert dt.date.today().isoformat() == "1985-10-26" - time_machine.move_to(dt.datetime(2015, 10, 21)) - assert dt.date.today().isoformat() == "2015-10-21" + +Or for a class: + +.. code-block:: python + + import datetime as dt + + import pytest + + + @pytest.mark.time_machine(dt.datetime(1985, 10, 26)) + class TestSomething: + def test_one(self): + assert dt.date.today().isoformat() == "1985-10-26" + + def test_two(self): + assert dt.date.today().isoformat() == "1985-10-26" + +``time_machine`` fixture +------------------------ + +Use the function-scoped `fixture `__ ``time_machine`` to control time in your tests. +It provides an object with two methods, ``move_to()`` and ``shift()``, which work the same as their equivalents in the :class:`time_machine.Coordinates` class. +Until you call ``move_to()``, time is not mocked. + +For example: + +.. code-block:: python + + import datetime as dt def test_delorean(time_machine): @@ -54,3 +87,18 @@ If you are using pytest test classes, you can apply the fixture to all test meth assert int(time.time()) == 1000.0 time_machine.move_to(2000.0) assert int(time.time()) == 2000.0 + +It’s possible to combine the marker and fixture in the same test: + +.. code-block:: python + + import datetime as dt + + import pytest + + + @pytest.mark.time_machine(dt.datetime(1985, 10, 26)) + def test_delorean_marker_and_fixture(time_machine): + assert dt.date.today().isoformat() == "1985-10-26" + time_machine.move_to(dt.datetime(2015, 10, 21)) + assert dt.date.today().isoformat() == "2015-10-21" diff --git a/src/time_machine/__init__.py b/src/time_machine/__init__.py index c2d39495..30d7abd7 100644 --- a/src/time_machine/__init__.py +++ b/src/time_machine/__init__.py @@ -378,23 +378,21 @@ def time_ns() -> int: # pytest plugin if HAVE_PYTEST: # pragma: no branch - MARKER_NAME = "time_machine" - FIXTURE_NAME = "time_machine" def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: """ - Inject our fixture into any tests with our marker + Add the fixture to any tests with the marker. """ for item in items: - if item.get_closest_marker(MARKER_NAME): - item.fixturenames.insert(0, FIXTURE_NAME) # type: ignore[attr-defined] + if item.get_closest_marker("time_machine"): + item.fixturenames.insert(0, "time_machine") # type: ignore[attr-defined] def pytest_configure(config: pytest.Config) -> None: """ - Register our marker + Register the marker. """ config.addinivalue_line( - "markers", f"{MARKER_NAME}(...): use time machine to set time" + "markers", "time_machine(...): set the time with time-machine" ) class TimeMachineFixture: @@ -431,12 +429,12 @@ def stop(self) -> None: if self.traveller is not None: self.traveller.stop() - @pytest.fixture(name=FIXTURE_NAME) + @pytest.fixture(name="time_machine") def time_machine_fixture( request: pytest.FixtureRequest, ) -> TypingGenerator[TimeMachineFixture, None, None]: fixture = TimeMachineFixture() - marker = request.node.get_closest_marker(MARKER_NAME) + marker = request.node.get_closest_marker("time_machine") if marker: fixture.move_to(*marker.args, **marker.kwargs) diff --git a/tests/test_time_machine.py b/tests/test_time_machine.py index a277bee6..351f6413 100644 --- a/tests/test_time_machine.py +++ b/tests/test_time_machine.py @@ -948,7 +948,7 @@ def test_fixture_shift_without_move_to(time_machine): ) -def test_standalone_marker(testdir): +def test_marker_function(testdir): testdir.makepyfile( """ import pytest @@ -956,26 +956,69 @@ def test_standalone_marker(testdir): @pytest.fixture def current_time(): - return time.time() + return time.time() - @pytest.fixture - def set_time(time_machine): - time_machine.move_to("2000-01-01") - - def test_normal(current_time): - assert current_time > 1742943111.0 - - @pytest.mark.time_machine("2000-01-01") - def test_mod(current_time, time_machine): - assert current_time == 946684800.0 - time_machine.shift(1) - assert current_time == 946684800.0 - assert int(time.time()) == 946684801 + @pytest.mark.time_machine(0) + def test(current_time): + assert current_time < 10.0 + """ + ) + + result = testdir.runpytest("-v", "-s") + result.assert_outcomes(passed=1) + + +def test_marker_and_fixture(testdir): + testdir.makepyfile( + """ + import pytest + import time + + @pytest.mark.time_machine(0) + def test(time_machine): + assert time.time() < 10.0 + time_machine.shift(100) + assert 100.0 <= time.time() < 110.0 + time_machine.move_to(0) + assert time.time() < 10.0 + """ + ) + result = testdir.runpytest("-v", "-s") + result.assert_outcomes(passed=1) + + +def test_marker_class(testdir): + testdir.makepyfile( + """ + import pytest + import time + + @pytest.mark.time_machine(0) + class TestTimeMachine: + def test(self): + assert time.time() < 10.0 + """ + ) + + result = testdir.runpytest("-v", "-s") + result.assert_outcomes(passed=1) + + +def test_marker_module(testdir): + testdir.makepyfile( + """ + import pytest + import time + + pytestmark = pytest.mark.time_machine(0) + + def test_module(): + assert time.time() < 10.0 """ ) result = testdir.runpytest("-v", "-s") - assert result.ret == 0 + result.assert_outcomes(passed=1) # escape hatch tests