├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.yml │ └── issue.yml ├── SECURITY.md ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .typos.toml ├── CHANGELOG.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── pyproject.toml ├── setup.py ├── src ├── _time_machine.c └── time_machine │ ├── __init__.py │ └── py.typed ├── tests ├── __init__.py ├── conftest.py └── test_time_machine.py ├── tox.ini └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.py] 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/). 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: adamchainz 2 | tidelift: pypi/time-machine 3 | custom: 4 | - "https://adamj.eu/books/" 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request an enhancement or new feature. 3 | body: 4 | - type: textarea 5 | id: description 6 | attributes: 7 | label: Description 8 | description: Please describe your feature request with appropriate detail. 9 | validations: 10 | required: true 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.yml: -------------------------------------------------------------------------------- 1 | name: Issue 2 | description: File an issue 3 | body: 4 | - type: input 5 | id: python_version 6 | attributes: 7 | label: Python Version 8 | description: Which version of Python were you using? 9 | placeholder: 3.9.0 10 | validations: 11 | required: false 12 | - type: input 13 | id: pytest_version 14 | attributes: 15 | label: pytest Version 16 | description: Which version of pytest were you using (if any)? 17 | placeholder: 6.2.4 18 | validations: 19 | required: false 20 | - type: input 21 | id: package_version 22 | attributes: 23 | label: Package Version 24 | description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved. 25 | placeholder: 1.0.0 26 | validations: 27 | required: false 28 | - type: textarea 29 | id: description 30 | attributes: 31 | label: Description 32 | description: Please describe your issue. 33 | validations: 34 | required: true 35 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | Please report security issues directly over email to me@adamj.eu 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | groups: 6 | "GitHub Actions": 7 | patterns: 8 | - "*" 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: 10 | 11 | concurrency: 12 | group: ${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | tests: 17 | name: Python ${{ matrix.python-version }} 18 | runs-on: ubuntu-24.04 19 | 20 | strategy: 21 | matrix: 22 | python-version: 23 | - '3.9' 24 | - '3.10' 25 | - '3.11' 26 | - '3.12' 27 | - '3.13' 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | allow-prereleases: true 36 | 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v6 39 | with: 40 | enable-cache: true 41 | 42 | - name: Install dependencies 43 | run: uv pip install --system tox tox-uv 44 | 45 | - name: Run tox targets for ${{ matrix.python-version }} 46 | run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) 47 | 48 | - name: Upload coverage data 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: coverage-data-${{ matrix.python-version }} 52 | path: '${{ github.workspace }}/.coverage.*' 53 | include-hidden-files: true 54 | if-no-files-found: error 55 | 56 | coverage: 57 | name: Coverage 58 | runs-on: ubuntu-24.04 59 | needs: tests 60 | steps: 61 | - uses: actions/checkout@v4 62 | 63 | - uses: actions/setup-python@v5 64 | with: 65 | python-version: '3.13' 66 | 67 | - name: Install uv 68 | uses: astral-sh/setup-uv@v6 69 | 70 | - name: Install dependencies 71 | run: uv pip install --system coverage[toml] 72 | 73 | - name: Download data 74 | uses: actions/download-artifact@v4 75 | with: 76 | path: ${{ github.workspace }} 77 | pattern: coverage-data-* 78 | merge-multiple: true 79 | 80 | - name: Combine coverage and fail if it's <100% 81 | run: | 82 | python -m coverage combine 83 | python -m coverage html --skip-covered --skip-empty 84 | python -m coverage report --fail-under=100 85 | echo "## Coverage summary" >> $GITHUB_STEP_SUMMARY 86 | python -m coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 87 | 88 | - name: Upload HTML report 89 | if: ${{ failure() }} 90 | uses: actions/upload-artifact@v4 91 | with: 92 | name: html-report 93 | path: htmlcov 94 | 95 | build: 96 | name: Build wheels on ${{ matrix.os }} 97 | if: > 98 | startsWith(github.ref, 'refs/tags/') || 99 | github.ref == 'refs/heads/main' || 100 | contains(github.event.pull_request.labels.*.name, 'Build') 101 | 102 | strategy: 103 | matrix: 104 | os: 105 | - linux 106 | - macos 107 | - windows 108 | 109 | runs-on: ${{ (matrix.os == 'linux' && 'ubuntu-24.04') || (matrix.os == 'macos' && 'macos-15') || (matrix.os == 'windows' && 'windows-2022') || 'unknown' }} 110 | 111 | env: 112 | CIBW_ARCHS_LINUX: x86_64 i686 aarch64 113 | CIBW_ARCHS_MACOS: x86_64 universal2 114 | CIBW_ARCHS_WINDOWS: AMD64 x86 ARM64 115 | CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-*" 116 | 117 | steps: 118 | - uses: actions/checkout@v4 119 | 120 | - uses: astral-sh/setup-uv@v6 121 | 122 | - name: Set up QEMU 123 | uses: docker/setup-qemu-action@v3 124 | if: matrix.os == 'linux' 125 | with: 126 | platforms: all 127 | 128 | - name: Build sdist 129 | if: ${{ matrix.os == 'linux' }} 130 | run: uv build --sdist 131 | 132 | - name: Build wheels 133 | run: uvx --from cibuildwheel==2.23.2 cibuildwheel --output-dir dist 134 | 135 | - run: ${{ (matrix.os == 'windows' && 'dir') || 'ls -lh' }} dist/ 136 | 137 | - uses: actions/upload-artifact@v4 138 | with: 139 | name: dist-${{ matrix.os }} 140 | path: dist 141 | 142 | release: 143 | needs: [coverage, build] 144 | if: success() && startsWith(github.ref, 'refs/tags/') 145 | runs-on: ubuntu-24.04 146 | environment: release 147 | 148 | permissions: 149 | contents: read 150 | id-token: write 151 | 152 | steps: 153 | - uses: actions/checkout@v4 154 | 155 | - name: get dist artifacts 156 | uses: actions/download-artifact@v4 157 | with: 158 | merge-multiple: true 159 | pattern: dist-* 160 | path: dist 161 | 162 | - uses: pypa/gh-action-pypi-publish@release/v1 163 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | /.coverage 4 | /.coverage.* 5 | /.tox 6 | /build/ 7 | /dist/ 8 | /wheelhouse/ 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | 4 | default_language_version: 5 | python: python3.13 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.0.0 10 | hooks: 11 | - id: check-added-large-files 12 | - id: check-case-conflict 13 | - id: check-json 14 | - id: check-merge-conflict 15 | - id: check-symlinks 16 | - id: check-toml 17 | - id: end-of-file-fixer 18 | - id: trailing-whitespace 19 | - repo: https://github.com/crate-ci/typos 20 | rev: 6cb49915af2e93e61f5f0d0a82216e28ad5c7c18 # frozen: v1 21 | hooks: 22 | - id: typos 23 | - repo: https://github.com/tox-dev/pyproject-fmt 24 | rev: 8184a5b72f4a8fcd003b041ecb04c41a9f34fd2b # frozen: v2.6.0 25 | hooks: 26 | - id: pyproject-fmt 27 | additional_dependencies: ["tox>=4.9"] 28 | - repo: https://github.com/tox-dev/tox-ini-fmt 29 | rev: e732f664aa3fd7b32cce3de8abbac43f4c3c375d # frozen: 1.5.0 30 | hooks: 31 | - id: tox-ini-fmt 32 | - repo: https://github.com/rstcheck/rstcheck 33 | rev: 27258fde1ee7d3b1e6a7bbc58f4c7b1dd0e719e5 # frozen: v6.2.5 34 | hooks: 35 | - id: rstcheck 36 | additional_dependencies: 37 | - tomli==2.0.1 38 | - repo: https://github.com/adamchainz/blacken-docs 39 | rev: 78a9dcbecf4f755f65d1f3dec556bc249d723600 # frozen: 1.19.1 40 | hooks: 41 | - id: blacken-docs 42 | additional_dependencies: 43 | - black==25.1.0 44 | - repo: https://github.com/astral-sh/ruff-pre-commit 45 | rev: d19233b89771be2d89273f163f5edc5a39bbc34a # frozen: v0.11.12 46 | hooks: 47 | - id: ruff-check 48 | args: [ --fix ] 49 | - id: ruff-format 50 | - repo: https://github.com/pre-commit/mirrors-mypy 51 | rev: 7010b10a09f65cd60a23c207349b539aa36dbec1 # frozen: v1.16.0 52 | hooks: 53 | - id: mypy 54 | additional_dependencies: 55 | - pytest==7.1.2 56 | - types-python-dateutil 57 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | # Configuration file for 'typos' tool 2 | # https://github.com/crate-ci/typos 3 | 4 | [default] 5 | extend-ignore-re = [ 6 | # Single line ignore comments 7 | "(?Rm)^.*(#|//)\\s*typos: ignore$", 8 | # Multi-line ignore comments 9 | "(?s)(#|//)\\s*typos: off.*?\\n\\s*(#|//)\\s*typos: on" 10 | ] 11 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 2.16.0 (2024-10-08) 6 | ------------------- 7 | 8 | * Drop Python 3.8 support. 9 | 10 | 2.15.0 (2024-08-06) 11 | ------------------- 12 | 13 | * Include wheels for Python 3.13. 14 | 15 | 2.14.2 (2024-06-29) 16 | ------------------- 17 | 18 | * Fix ``SystemError`` on Python 3.13 and Windows when starting time travelling. 19 | 20 | Thanks to Bernát Gábor for the report in `Issue #456 `__. 21 | 22 | 2.14.1 (2024-03-22) 23 | ------------------- 24 | 25 | * Fix segmentation fault when the first ``travel()`` call in a process uses a ``timedelta``. 26 | 27 | Thanks to Marcin Sulikowski for the report in `Issue #431 `__. 28 | 29 | 2.14.0 (2024-03-03) 30 | ------------------- 31 | 32 | * Fix ``utcfromtimestamp()`` warning on Python 3.12+. 33 | 34 | Thanks to Konstantin Baikov in `PR #424 `__. 35 | 36 | * Fix class decorator for classmethod overrides. 37 | 38 | Thanks to Pavel Bitiukov for the reproducer in `PR #404 `__. 39 | 40 | * Avoid calling deprecated ``uuid._load_system_functions()`` on Python 3.9+. 41 | 42 | Thanks to Nikita Sobolev for the ping in `CPython Issue #113308 `__. 43 | 44 | * Support Python 3.13 alpha 4. 45 | 46 | Thanks to Miro Hrončok in `PR #409 `__. 47 | 48 | 2.13.0 (2023-09-19) 49 | ------------------- 50 | 51 | * Add support for ``datetime.timedelta`` to ``time_machine.travel()``. 52 | 53 | Thanks to Nate Dudenhoeffer in `PR #298 `__. 54 | 55 | * Fix documentation about using local time for naive date(time) strings. 56 | 57 | Thanks to Stefaan Lippens in `PR #306 `__. 58 | 59 | * Add ``shift()`` method to the ``time_machine`` pytest fixture. 60 | 61 | Thanks to Stefaan Lippens in `PR #312 `__. 62 | 63 | * Mock ``time.monotonic()`` and ``time.monotonic_ns()``. 64 | They return the values of ``time.time()`` and ``time.time_ns()`` respectively, rather than real monotonic clocks. 65 | 66 | Thanks to Anthony Sottile in `PR #382 `__. 67 | 68 | 2.12.0 (2023-08-14) 69 | ------------------- 70 | 71 | * Include wheels for Python 3.12. 72 | 73 | 2.11.0 (2023-07-10) 74 | ------------------- 75 | 76 | * Drop Python 3.7 support. 77 | 78 | 2.10.0 (2023-06-16) 79 | ------------------- 80 | 81 | * Support Python 3.12. 82 | 83 | 2.9.0 (2022-12-31) 84 | ------------------ 85 | 86 | * Build Windows ARM64 wheels. 87 | 88 | * Explicitly error when attempting to install on PyPy. 89 | 90 | Thanks to Michał Górny in `PR #315 `__. 91 | 92 | 2.8.2 (2022-09-29) 93 | ------------------ 94 | 95 | * Improve type hints for ``time_machine.travel()`` to preserve the types of the wrapped function/coroutine/class. 96 | 97 | 2.8.1 (2022-08-16) 98 | ------------------ 99 | 100 | * Actually build Python 3.11 wheels. 101 | 102 | 2.8.0 (2022-08-15) 103 | ------------------ 104 | 105 | * Build Python 3.11 wheels. 106 | 107 | 2.7.1 (2022-06-24) 108 | ------------------ 109 | 110 | * Fix usage of ``ZoneInfo`` from the ``backports.zoneinfo`` package. 111 | This makes ``ZoneInfo`` support work for Python < 3.9. 112 | 113 | 2.7.0 (2022-05-11) 114 | ------------------ 115 | 116 | * Support Python 3.11 (no wheels yet, they will only be available when Python 3.11 is RC when the ABI is stable). 117 | 118 | 2.6.0 (2022-01-10) 119 | ------------------ 120 | 121 | * Drop Python 3.6 support. 122 | 123 | 2.5.0 (2021-12-14) 124 | ------------------ 125 | 126 | * Add ``time_machine.escape_hatch``, which provides functions to bypass time-machine. 127 | 128 | Thanks to Matt Pegler for the feature request in `Issue #206 `__. 129 | 130 | 2.4.1 (2021-11-27) 131 | ------------------ 132 | 133 | * Build musllinux wheels. 134 | 135 | 2.4.0 (2021-09-01) 136 | ------------------ 137 | 138 | * Support Python 3.10. 139 | 140 | 2.3.1 (2021-07-13) 141 | ------------------ 142 | 143 | * Build universal2 wheels for Python 3.8 on macOS. 144 | 145 | 2.3.0 (2021-07-05) 146 | ------------------ 147 | 148 | * Allow passing ``tick`` to ``Coordinates.move_to()`` and the pytest fixture’s 149 | ``time_machine.move_to()``. This allows freezing or unfreezing of time when 150 | travelling. 151 | 152 | 2.2.0 (2021-07-02) 153 | ------------------ 154 | 155 | * Include type hints. 156 | 157 | * Convert C module to use PEP 489 multi-phase extension module initialization. 158 | This makes the module ready for Python sub-interpreters. 159 | 160 | * Release now includes a universal2 wheel for Python 3.9 on macOS, to work on 161 | Apple Silicon. 162 | 163 | * Stop distributing tests to reduce package size. Tests are not intended to be 164 | run outside of the tox setup in the repository. Repackagers can use GitHub's 165 | tarballs per tag. 166 | 167 | 2.1.0 (2021-02-19) 168 | ------------------ 169 | 170 | * Release now includes wheels for ARM on Linux. 171 | 172 | 2.0.1 (2021-01-18) 173 | ------------------ 174 | 175 | * Prevent ``ImportError`` on Windows where ``time.tzset()`` is unavailable. 176 | 177 | 2.0.0 (2021-01-17) 178 | ------------------ 179 | 180 | * Release now includes wheels for Windows and macOS. 181 | * Move internal calculations to use nanoseconds, avoiding a loss of precision. 182 | * After a call to ``move_to()``, the first function call to retrieve the 183 | current time will return exactly the destination time, copying the behaviour 184 | of the first call to ``travel()``. 185 | * Add the ability to shift timezone by passing in a ``ZoneInfo`` timezone. 186 | * Remove ``tz_offset`` argument. This was incorrectly copied from 187 | ``freezegun``. Use the new timezone mocking with ``ZoneInfo`` instead. 188 | * Add pytest plugin and fixture ``time_machine``. 189 | * Work with Windows’ different epoch. 190 | 191 | 1.3.0 (2020-12-12) 192 | ------------------ 193 | 194 | * Support Python 3.9. 195 | * Move license from ISC to MIT License. 196 | 197 | 1.2.1 (2020-08-29) 198 | ------------------ 199 | 200 | * Correctly return naive datetimes from ``datetime.utcnow()`` whilst time 201 | travelling. 202 | 203 | Thanks to Søren Pilgård and Bart Van Loon for the report in 204 | `Issue #52 `__. 205 | 206 | 1.2.0 (2020-07-08) 207 | ------------------ 208 | 209 | * Add ``move_to()`` method to move to a different time whilst travelling. 210 | This is based on freezegun's ``move_to()`` method. 211 | 212 | 1.1.1 (2020-06-22) 213 | ------------------ 214 | 215 | * Move C-level ``clock_gettime()`` and ``clock_gettime_ns()`` checks to 216 | runtime to allow distribution of macOS wheels. 217 | 218 | 1.1.0 (2020-06-08) 219 | ------------------ 220 | 221 | * Add ``shift()`` method to move forward in time by a delta whilst travelling. 222 | This is based on freezegun's ``tick()`` method. 223 | 224 | Thanks to Alex Subbotin for the feature in 225 | `PR #27 `__. 226 | 227 | * Fix to work when either ``clock_gettime()`` or ``CLOCK_REALTIME`` is not 228 | present. This happens on some Unix platforms, for example on macOS with the 229 | official Python.org installer, which is compiled against macOS 10.9. 230 | 231 | Thanks to Daniel Crowe for the fix in 232 | `PR #30 `__. 233 | 234 | 1.0.1 (2020-05-29) 235 | ------------------ 236 | 237 | * Fix ``datetime.now()`` behaviour with the ``tz`` argument when not time-travelling. 238 | 239 | 1.0.0 (2020-05-29) 240 | ------------------ 241 | 242 | * First non-beta release. 243 | * Added support for ``tz_offset`` argument. 244 | * ``tick=True`` will only start time ticking after the first method return that retrieves the current time. 245 | * Added nestability of ``travel()``. 246 | * Support for ``time.time_ns()`` and ``time.clock_gettime_ns()``. 247 | 248 | 1.0.0b1 (2020-05-04) 249 | -------------------- 250 | 251 | * First release on PyPI. 252 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | See https://github.com/adamchainz/time-machine/blob/main/CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adam Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune tests 2 | include CHANGELOG.rst 3 | include LICENSE 4 | include pyproject.toml 5 | include README.rst 6 | include src/*/py.typed 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | time-machine 3 | ============ 4 | 5 | .. image:: https://img.shields.io/github/actions/workflow/status/adamchainz/time-machine/main.yml.svg?branch=main&style=for-the-badge 6 | :target: https://github.com/adamchainz/time-machine/actions?workflow=CI 7 | 8 | .. image:: https://img.shields.io/badge/Coverage-100%25-success?style=for-the-badge 9 | :target: https://github.com/adamchainz/time-machine/actions?workflow=CI 10 | 11 | .. image:: https://img.shields.io/pypi/v/time-machine.svg?style=for-the-badge 12 | :target: https://pypi.org/project/time-machine/ 13 | 14 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 15 | :target: https://github.com/psf/black 16 | 17 | .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge 18 | :target: https://github.com/pre-commit/pre-commit 19 | :alt: pre-commit 20 | 21 | Travel through time in your tests. 22 | 23 | A quick example: 24 | 25 | .. code-block:: python 26 | 27 | import datetime as dt 28 | from zoneinfo import ZoneInfo 29 | import time_machine 30 | 31 | hill_valley_tz = ZoneInfo("America/Los_Angeles") 32 | 33 | 34 | @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24, tzinfo=hill_valley_tz)) 35 | def test_delorean(): 36 | assert dt.date.today().isoformat() == "1985-10-26" 37 | 38 | For a bit of background, see `the introductory blog post `__ and `the benchmark blog post `__. 39 | 40 | ---- 41 | 42 | **Testing a Django project?** 43 | Check out my book `Speed Up Your Django Tests `__ which covers loads of ways to write faster, more accurate tests. 44 | I created time-machine whilst writing the book. 45 | 46 | ---- 47 | 48 | Installation 49 | ============ 50 | 51 | Use **pip**: 52 | 53 | .. code-block:: sh 54 | 55 | python -m pip install time-machine 56 | 57 | Python 3.9 to 3.13 supported. 58 | Only CPython is supported at this time because time-machine directly hooks into the C-level API. 59 | 60 | 61 | Usage 62 | ===== 63 | 64 | If you’re coming from freezegun or libfaketime, see also the below section on migrating. 65 | 66 | ``travel(destination, *, tick=True)`` 67 | ------------------------------------- 68 | 69 | ``travel()`` is a class that allows time travel, to the datetime specified by ``destination``. 70 | It does so by mocking all functions from Python's standard library that return the current date or datetime. 71 | It can be used independently, as a function decorator, or as a context manager. 72 | 73 | ``destination`` specifies the datetime to move to. 74 | It may be: 75 | 76 | * A ``datetime.datetime``. 77 | If it is naive, it will be assumed to have the UTC timezone. 78 | If it has ``tzinfo`` set to a |zoneinfo-instance|_, the current timezone will also be mocked. 79 | * A ``datetime.date``. 80 | This will be converted to a UTC datetime with the time 00:00:00. 81 | * A ``datetime.timedelta``. 82 | This will be interpreted relative to the current time. 83 | If already within a ``travel()`` block, the ``shift()`` method is easier to use (documented below). 84 | * A ``float`` or ``int`` specifying a `Unix timestamp `__ 85 | * A string, which will be parsed with `dateutil.parse `__ and converted to a timestamp. 86 | If the result is naive, it will be assumed to be local time. 87 | 88 | .. |zoneinfo-instance| replace:: ``zoneinfo.ZoneInfo`` instance 89 | .. _zoneinfo-instance: https://docs.python.org/3/library/zoneinfo.html#zoneinfo.ZoneInfo 90 | 91 | Additionally, you can provide some more complex types: 92 | 93 | * A generator, in which case ``next()`` will be called on it, with the result treated as above. 94 | * A callable, in which case it will be called with no parameters, with the result treated as above. 95 | 96 | ``tick`` defines whether time continues to "tick" after travelling, or is frozen. 97 | If ``True``, the default, successive calls to mocked functions return values increasing by the elapsed real time *since the first call.* 98 | So after starting travel to ``0.0`` (the UNIX epoch), the first call to any datetime function will return its representation of ``1970-01-01 00:00:00.000000`` exactly. 99 | The following calls "tick," so if a call was made exactly half a second later, it would return ``1970-01-01 00:00:00.500000``. 100 | 101 | Mocked Functions 102 | ^^^^^^^^^^^^^^^^ 103 | 104 | All datetime functions in the standard library are mocked to move to the destination current datetime: 105 | 106 | * ``datetime.datetime.now()`` 107 | * ``datetime.datetime.utcnow()`` 108 | * ``time.clock_gettime()`` (only for ``CLOCK_REALTIME``) 109 | * ``time.clock_gettime_ns()`` (only for ``CLOCK_REALTIME``) 110 | * ``time.gmtime()`` 111 | * ``time.localtime()`` 112 | * ``time.monotonic()`` (not a real monotonic clock, returns ``time.time()``) 113 | * ``time.monotonic_ns()`` (not a real monotonic clock, returns ``time.time_ns()``) 114 | * ``time.strftime()`` 115 | * ``time.time()`` 116 | * ``time.time_ns()`` 117 | 118 | The mocking is done at the C layer, replacing the function pointers for these built-ins. 119 | Therefore, it automatically affects everywhere those functions have been imported, unlike use of ``unittest.mock.patch()``. 120 | 121 | Usage with ``start()`` / ``stop()`` 122 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 123 | 124 | To use independently, create an instance, use ``start()`` to move to the destination time, and ``stop()`` to move back. 125 | For example: 126 | 127 | .. code-block:: python 128 | 129 | import datetime as dt 130 | import time_machine 131 | 132 | traveller = time_machine.travel(dt.datetime(1985, 10, 26)) 133 | traveller.start() 134 | # It's the past! 135 | assert dt.date.today() == dt.date(1985, 10, 26) 136 | traveller.stop() 137 | # We've gone back to the future! 138 | assert dt.date.today() > dt.date(2020, 4, 29) 139 | 140 | ``travel()`` instances are nestable, but you'll need to be careful when manually managing to call their ``stop()`` methods in the correct order, even when exceptions occur. 141 | It's recommended to use the decorator or context manager forms instead, to take advantage of Python features to do this. 142 | 143 | Function Decorator 144 | ^^^^^^^^^^^^^^^^^^ 145 | 146 | When used as a function decorator, time is mocked during the wrapped function's duration: 147 | 148 | .. code-block:: python 149 | 150 | import time 151 | import time_machine 152 | 153 | 154 | @time_machine.travel("1970-01-01 00:00 +0000") 155 | def test_in_the_deep_past(): 156 | assert 0.0 < time.time() < 1.0 157 | 158 | You can also decorate asynchronous functions (coroutines): 159 | 160 | .. code-block:: python 161 | 162 | import time 163 | import time_machine 164 | 165 | 166 | @time_machine.travel("1970-01-01 00:00 +0000") 167 | async def test_in_the_deep_past(): 168 | assert 0.0 < time.time() < 1.0 169 | 170 | Beware: time is a *global* state - `see below <#caveats>`__. 171 | 172 | Context Manager 173 | ^^^^^^^^^^^^^^^ 174 | 175 | When used as a context manager, time is mocked during the ``with`` block: 176 | 177 | .. code-block:: python 178 | 179 | import time 180 | import time_machine 181 | 182 | 183 | def test_in_the_deep_past(): 184 | with time_machine.travel(0.0): 185 | assert 0.0 < time.time() < 1.0 186 | 187 | Class Decorator 188 | ^^^^^^^^^^^^^^^ 189 | 190 | Only ``unittest.TestCase`` subclasses are supported. 191 | When applied as a class decorator to such classes, time is mocked from the start of ``setUpClass()`` to the end of ``tearDownClass()``: 192 | 193 | .. code-block:: python 194 | 195 | import time 196 | import time_machine 197 | import unittest 198 | 199 | 200 | @time_machine.travel(0.0) 201 | class DeepPastTests(TestCase): 202 | def test_in_the_deep_past(self): 203 | assert 0.0 < time.time() < 1.0 204 | 205 | Note this is different to ``unittest.mock.patch()``\'s behaviour, which is to mock only during the test methods. 206 | For pytest-style test classes, see the pattern `documented below <#pytest-plugin>`__. 207 | 208 | Timezone mocking 209 | ^^^^^^^^^^^^^^^^ 210 | 211 | If the ``destination`` passed to ``time_machine.travel()`` or ``Coordinates.move_to()`` has its ``tzinfo`` set to a |zoneinfo-instance2|_, the current timezone will be mocked. 212 | This will be done by calling |time-tzset|_, so it is only available on Unix. 213 | 214 | .. |zoneinfo-instance2| replace:: ``zoneinfo.ZoneInfo`` instance 215 | .. _zoneinfo-instance2: https://docs.python.org/3/library/zoneinfo.html#zoneinfo.ZoneInfo 216 | 217 | .. |time-tzset| replace:: ``time.tzset()`` 218 | .. _time-tzset: https://docs.python.org/3/library/time.html#time.tzset 219 | 220 | ``time.tzset()`` changes the ``time`` module’s `timezone constants `__ and features that rely on those, such as ``time.localtime()``. 221 | It won’t affect other concepts of “the current timezone”, such as Django’s (which can be changed with its |timezone-override|_). 222 | 223 | .. |timezone-override| replace:: ``timezone.override()`` 224 | .. _timezone-override: https://docs.djangoproject.com/en/stable/ref/utils/#django.utils.timezone.override 225 | 226 | Here’s a worked example changing the current timezone: 227 | 228 | .. code-block:: python 229 | 230 | import datetime as dt 231 | import time 232 | from zoneinfo import ZoneInfo 233 | import time_machine 234 | 235 | hill_valley_tz = ZoneInfo("America/Los_Angeles") 236 | 237 | 238 | @time_machine.travel(dt.datetime(2015, 10, 21, 16, 29, tzinfo=hill_valley_tz)) 239 | def test_hoverboard_era(): 240 | assert time.tzname == ("PST", "PDT") 241 | now = dt.datetime.now() 242 | assert (now.hour, now.minute) == (16, 29) 243 | 244 | ``Coordinates`` 245 | --------------- 246 | 247 | The ``start()`` method and entry of the context manager both return a ``Coordinates`` object that corresponds to the given "trip" in time. 248 | This has a couple methods that can be used to travel to other times. 249 | 250 | ``move_to(destination, tick=None)`` 251 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 252 | 253 | ``move_to()`` moves the current time to a new destination. 254 | ``destination`` may be any of the types supported by ``travel``. 255 | 256 | ``tick`` may be set to a boolean, to change the ``tick`` flag of ``travel``. 257 | 258 | For example: 259 | 260 | .. code-block:: python 261 | 262 | import datetime as dt 263 | import time 264 | import time_machine 265 | 266 | with time_machine.travel(0, tick=False) as traveller: 267 | assert time.time() == 0 268 | 269 | traveller.move_to(234) 270 | assert time.time() == 234 271 | 272 | ``shift(delta)`` 273 | ^^^^^^^^^^^^^^^^ 274 | 275 | ``shift()`` takes one argument, ``delta``, which moves the current time by the given offset. 276 | ``delta`` may be a ``timedelta`` or a number of seconds, which will be added to destination. 277 | It may be negative, in which case time will move to an earlier point. 278 | 279 | For example: 280 | 281 | .. code-block:: python 282 | 283 | import datetime as dt 284 | import time 285 | import time_machine 286 | 287 | with time_machine.travel(0, tick=False) as traveller: 288 | assert time.time() == 0 289 | 290 | traveller.shift(dt.timedelta(seconds=100)) 291 | assert time.time() == 100 292 | 293 | traveller.shift(-dt.timedelta(seconds=10)) 294 | assert time.time() == 90 295 | 296 | pytest plugin 297 | ------------- 298 | 299 | time-machine also works as a pytest plugin. 300 | 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``. 301 | 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. 302 | 303 | For example: 304 | 305 | .. code-block:: python 306 | 307 | import datetime as dt 308 | 309 | 310 | def test_delorean(time_machine): 311 | time_machine.move_to(dt.datetime(1985, 10, 26)) 312 | 313 | assert dt.date.today().isoformat() == "1985-10-26" 314 | 315 | time_machine.move_to(dt.datetime(2015, 10, 21)) 316 | 317 | assert dt.date.today().isoformat() == "2015-10-21" 318 | 319 | time_machine.shift(dt.timedelta(days=1)) 320 | 321 | assert dt.date.today().isoformat() == "2015-10-22" 322 | 323 | If you are using pytest test classes, you can apply the fixture to all test methods in a class by adding an autouse fixture: 324 | 325 | .. code-block:: python 326 | 327 | import time 328 | 329 | import pytest 330 | 331 | 332 | class TestSomething: 333 | @pytest.fixture(autouse=True) 334 | def set_time(self, time_machine): 335 | time_machine.move_to(1000.0) 336 | 337 | def test_one(self): 338 | assert int(time.time()) == 1000.0 339 | 340 | def test_two(self, time_machine): 341 | assert int(time.time()) == 1000.0 342 | time_machine.move_to(2000.0) 343 | assert int(time.time()) == 2000.0 344 | 345 | ``escape_hatch`` 346 | ---------------- 347 | 348 | The ``escape_hatch`` object provides functions to bypass time-machine. 349 | These allow you to call the real datetime functions, without any mocking. 350 | It also provides a way to check if time-machine is currently time travelling. 351 | 352 | These capabilities are useful in rare circumstances. 353 | For example, if you need to authenticate with an external service during time travel, you may need the real value of ``datetime.now()``. 354 | 355 | The functions are: 356 | 357 | * ``escape_hatch.is_travelling() -> bool`` - returns ``True`` if ``time_machine.travel()`` is active, ``False`` otherwise. 358 | 359 | * ``escape_hatch.datetime.datetime.now()`` - wraps the real ``datetime.datetime.now()``. 360 | 361 | * ``escape_hatch.datetime.datetime.utcnow()`` - wraps the real ``datetime.datetime.utcnow()``. 362 | 363 | * ``escape_hatch.time.clock_gettime()`` - wraps the real ``time.clock_gettime()``. 364 | 365 | * ``escape_hatch.time.clock_gettime_ns()`` - wraps the real ``time.clock_gettime_ns()``. 366 | 367 | * ``escape_hatch.time.gmtime()`` - wraps the real ``time.gmtime()``. 368 | 369 | * ``escape_hatch.time.localtime()`` - wraps the real ``time.localtime()``. 370 | 371 | * ``escape_hatch.time.strftime()`` - wraps the real ``time.strftime()``. 372 | 373 | * ``escape_hatch.time.time()`` - wraps the real ``time.time()``. 374 | 375 | * ``escape_hatch.time.time_ns()`` - wraps the real ``time.time_ns()``. 376 | 377 | For example: 378 | 379 | .. code-block:: python 380 | 381 | import time_machine 382 | 383 | 384 | with time_machine.travel(...): 385 | if time_machine.escape_hatch.is_travelling(): 386 | print("We need to go back to the future!") 387 | 388 | real_now = time_machine.escape_hatch.datetime.datetime.now() 389 | external_authenticate(now=real_now) 390 | 391 | Caveats 392 | ======= 393 | 394 | Time is a global state. 395 | Any concurrent threads or asynchronous functions are also be affected. 396 | Some aren't ready for time to move so rapidly or backwards, and may crash or produce unexpected results. 397 | 398 | Also beware that other processes are not affected. 399 | For example, if you use SQL datetime functions on a database server, they will return the real time. 400 | 401 | Comparison 402 | ========== 403 | 404 | There are some prior libraries that try to achieve the same thing. 405 | They have their own strengths and weaknesses. 406 | Here's a quick comparison. 407 | 408 | unittest.mock 409 | ------------- 410 | 411 | The standard library's `unittest.mock `__ can be used to target imports of ``datetime`` and ``time`` to change the returned value for current time. 412 | Unfortunately, this is fragile as it only affects the import location the mock targets. 413 | Therefore, if you have several modules in a call tree requesting the date/time, you need several mocks. 414 | This is a general problem with unittest.mock - see `Why Your Mock Doesn't Work `__. 415 | 416 | It's also impossible to mock certain references, such as function default arguments: 417 | 418 | .. code-block:: python 419 | 420 | def update_books(_now=time.time): # set as default argument so faster lookup 421 | for book in books: 422 | ... 423 | 424 | Although such references are rare, they are occasionally used to optimize highly repeated loops. 425 | 426 | freezegun 427 | --------- 428 | 429 | Steve Pulec's `freezegun `__ library is a popular solution. 430 | It provides a clear API which was much of the inspiration for time-machine. 431 | 432 | The main drawback is its slow implementation. 433 | It essentially does a find-and-replace mock of all the places that the ``datetime`` and ``time`` modules have been imported. 434 | This gets around the problems with using unittest.mock, but it means the time it takes to do the mocking is proportional to the number of loaded modules. 435 | In large projects, this can take several seconds, an impractical overhead for an individual test. 436 | 437 | It's also not a perfect search, since it searches only module-level imports. 438 | Such imports are definitely the most common way projects use date and time functions, but they're not the only way. 439 | freezegun won’t find functions that have been “hidden” inside arbitrary objects, such as class-level attributes. 440 | 441 | It also can't affect C extensions that call the standard library functions, including (I believe) Cython-ized Python code. 442 | 443 | python-libfaketime 444 | ------------------ 445 | 446 | Simon Weber's `python-libfaketime `__ wraps the `libfaketime `__ library. 447 | libfaketime replaces all the C-level system calls for the current time with its own wrappers. 448 | It's therefore a "perfect" mock for the current process, affecting every single point the current time might be fetched, and performs much faster than freezegun. 449 | 450 | Unfortunately python-libfaketime comes with the limitations of ``LD_PRELOAD``. 451 | This is a mechanism to replace system libraries for a program as it loads (`explanation `__). 452 | This causes two issues in particular when you use python-libfaketime. 453 | 454 | First, ``LD_PRELOAD`` is only available on Unix platforms, which prevents you from using it on Windows. 455 | 456 | Second, you have to help manage ``LD_PRELOAD``. 457 | You either use python-libfaketime's ``reexec_if_needed()`` function, which restarts (*re-execs*) your test process while loading, or manually manage the ``LD_PRELOAD`` environment variable. 458 | Neither is ideal. 459 | Re-execing breaks anything that might wrap your test process, such as profilers, debuggers, and IDE test runners. 460 | Manually managing the environment variable is a bit of overhead, and must be done for each environment you run your tests in, including each developer's machine. 461 | 462 | time-machine 463 | ------------ 464 | 465 | time-machine is intended to combine the advantages of freezegun and libfaketime. 466 | It works without ``LD_PRELOAD`` but still mocks the standard library functions everywhere they may be referenced. 467 | Its weak point is that other libraries using date/time system calls won't be mocked. 468 | Thankfully this is rare. 469 | It's also possible such python libraries can be added to the set mocked by time-machine. 470 | 471 | One drawback is that it only works with CPython, so can't be used with other Python interpreters like PyPy. 472 | However it may possible to extend it to support other interpreters through different mocking mechanisms. 473 | 474 | Migrating from libfaketime or freezegun 475 | ======================================= 476 | 477 | freezegun has a useful API, and python-libfaketime copies some of it, with a different function name. 478 | time-machine also copies some of freezegun's API, in ``travel()``\'s ``destination``, and ``tick`` arguments, and the ``shift()`` method. 479 | There are a few differences: 480 | 481 | * time-machine's ``tick`` argument defaults to ``True``, because code tends to make the (reasonable) assumption that time progresses whilst running, and should normally be tested as such. 482 | Testing with time frozen can make it easy to write complete assertions, but it's quite artificial. 483 | Write assertions against time ranges, rather than against exact values. 484 | 485 | * freezegun interprets dates and naive datetimes in the local time zone (including those parsed from strings with ``dateutil``). 486 | This means tests can pass when run in one time zone and fail in another. 487 | time-machine instead interprets dates and naive datetimes in UTC so they are fixed points in time. 488 | Provide time zones where required. 489 | 490 | * freezegun's ``tick()`` method has been implemented as ``shift()``, to avoid confusion with the ``tick`` argument. 491 | It also requires an explicit delta rather than defaulting to 1 second. 492 | 493 | * freezegun's ``tz_offset`` argument is not supported, since it only partially mocks the current time zone. 494 | Time zones are more complicated than a single offset from UTC, and freezegun only uses the offset in ``time.localtime()``. 495 | Instead, time-machine will mock the current time zone if you give it a ``datetime`` with a ``ZoneInfo`` timezone. 496 | 497 | Some features aren't supported like the ``auto_tick_seconds`` argument. 498 | These may be added in a future release. 499 | 500 | If you are only fairly simple function calls, you should be able to migrate by replacing calls to ``freezegun.freeze_time()`` and ``libfaketime.fake_time()`` with ``time_machine.travel()``. 501 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools>=77", 5 | ] 6 | 7 | [project] 8 | name = "time-machine" 9 | version = "2.16.0" 10 | description = "Travel through time in your tests." 11 | readme = "README.rst" 12 | keywords = [ 13 | "date", 14 | "datetime", 15 | "mock", 16 | "test", 17 | "testing", 18 | "tests", 19 | "time", 20 | "warp", 21 | ] 22 | license = "MIT" 23 | license-files = [ "LICENSE" ] 24 | authors = [ 25 | { name = "Adam Johnson", email = "me@adamj.eu" }, 26 | ] 27 | requires-python = ">=3.9" 28 | classifiers = [ 29 | "Development Status :: 5 - Production/Stable", 30 | "Framework :: Pytest", 31 | "Intended Audience :: Developers", 32 | "Natural Language :: English", 33 | "Operating System :: OS Independent", 34 | "Programming Language :: Python :: 3 :: Only", 35 | "Programming Language :: Python :: 3.9", 36 | "Programming Language :: Python :: 3.10", 37 | "Programming Language :: Python :: 3.11", 38 | "Programming Language :: Python :: 3.12", 39 | "Programming Language :: Python :: 3.13", 40 | "Typing :: Typed", 41 | ] 42 | dependencies = [ 43 | "python-dateutil", 44 | ] 45 | urls.Changelog = "https://github.com/adamchainz/time-machine/blob/main/CHANGELOG.rst" 46 | urls.Funding = "https://adamj.eu/books/" 47 | urls.Repository = "https://github.com/adamchainz/time-machine" 48 | entry-points.pytest11.time_machine = "time_machine" 49 | 50 | [dependency-groups] 51 | test = [ 52 | "backports-zoneinfo; python_version<'3.9'", 53 | "coverage[toml]", 54 | "pytest", 55 | "pytest-randomly", 56 | "python-dateutil", 57 | ] 58 | 59 | [tool.ruff] 60 | lint.select = [ 61 | # flake8-bugbear 62 | "B", 63 | # flake8-comprehensions 64 | "C4", 65 | # pycodestyle 66 | "E", 67 | # Pyflakes errors 68 | "F", 69 | # isort 70 | "I", 71 | # flake8-simplify 72 | "SIM", 73 | # flake8-tidy-imports 74 | "TID", 75 | # pyupgrade 76 | "UP", 77 | # Pyflakes warnings 78 | "W", 79 | ] 80 | lint.ignore = [ 81 | # flake8-bugbear opinionated rules 82 | "B9", 83 | # line-too-long 84 | "E501", 85 | # suppressible-exception 86 | "SIM105", 87 | # if-else-block-instead-of-if-exp 88 | "SIM108", 89 | ] 90 | lint.extend-safe-fixes = [ 91 | # non-pep585-annotation 92 | "UP006", 93 | ] 94 | lint.isort.required-imports = [ "from __future__ import annotations" ] 95 | 96 | [tool.pyproject-fmt] 97 | max_supported_python = "3.13" 98 | 99 | [tool.pytest.ini_options] 100 | addopts = """\ 101 | --strict-config 102 | --strict-markers 103 | """ 104 | xfail_strict = true 105 | 106 | [tool.coverage.run] 107 | branch = true 108 | parallel = true 109 | source = [ 110 | "src/_time_machine.c", 111 | "tests", 112 | ] 113 | 114 | [tool.coverage.paths] 115 | source = [ 116 | "src", 117 | ".tox/**/site-packages", 118 | ] 119 | 120 | [tool.coverage.report] 121 | show_missing = true 122 | 123 | [tool.mypy] 124 | enable_error_code = [ 125 | "ignore-without-code", 126 | "redundant-expr", 127 | "truthy-bool", 128 | ] 129 | mypy_path = "src/" 130 | namespace_packages = false 131 | strict = true 132 | warn_unreachable = true 133 | 134 | [[tool.mypy.overrides]] 135 | module = "tests.*" 136 | allow_untyped_defs = true 137 | 138 | [tool.rstcheck] 139 | report_level = "ERROR" 140 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | from setuptools import Extension, setup 6 | 7 | if hasattr(sys, "pypy_version_info"): 8 | raise RuntimeError( 9 | "PyPy is not currently supported by time-machine, see " 10 | "https://github.com/adamchainz/time-machine/issues/305" 11 | ) 12 | 13 | 14 | setup(ext_modules=[Extension(name="_time_machine", sources=["src/_time_machine.c"])]) 15 | -------------------------------------------------------------------------------- /src/_time_machine.c: -------------------------------------------------------------------------------- 1 | #include "Python.h" 2 | #include 3 | #include 4 | 5 | // Module state 6 | typedef struct { 7 | #if PY_VERSION_HEX >= 0x030d00a4 8 | PyCFunctionFastWithKeywords original_now; 9 | #else 10 | _PyCFunctionFastWithKeywords original_now; 11 | #endif 12 | PyCFunction original_utcnow; 13 | PyCFunction original_clock_gettime; 14 | PyCFunction original_clock_gettime_ns; 15 | PyCFunction original_gmtime; 16 | PyCFunction original_localtime; 17 | PyCFunction original_monotonic; 18 | PyCFunction original_monotonic_ns; 19 | PyCFunction original_strftime; 20 | PyCFunction original_time; 21 | PyCFunction original_time_ns; 22 | } _time_machine_state; 23 | 24 | static inline _time_machine_state* 25 | get_time_machine_state(PyObject *module) 26 | { 27 | void *state = PyModule_GetState(module); 28 | assert(state != NULL); 29 | return (_time_machine_state *)state; 30 | } 31 | 32 | /* datetime.datetime.now() */ 33 | 34 | static PyObject* 35 | _time_machine_now(PyTypeObject *type, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) 36 | 37 | { 38 | PyObject *result = NULL; 39 | 40 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 41 | PyObject *time_machine_now = PyObject_GetAttrString(time_machine_module, "now"); 42 | 43 | result = _PyObject_Vectorcall(time_machine_now, args, nargs, kwnames); 44 | 45 | Py_DECREF(time_machine_now); 46 | Py_DECREF(time_machine_module); 47 | 48 | return result; 49 | } 50 | 51 | static PyObject* 52 | _time_machine_original_now(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) 53 | { 54 | _time_machine_state *state = get_time_machine_state(module); 55 | 56 | PyObject *datetime_module = PyImport_ImportModule("datetime"); 57 | PyObject *datetime_class = PyObject_GetAttrString(datetime_module, "datetime"); 58 | 59 | PyObject* result = state->original_now(datetime_class, args, nargs, kwnames); 60 | 61 | Py_DECREF(datetime_class); 62 | Py_DECREF(datetime_module); 63 | 64 | return result; 65 | } 66 | PyDoc_STRVAR(original_now_doc, 67 | "original_now() -> datetime\n\ 68 | \n\ 69 | Call datetime.datetime.now() after patching."); 70 | 71 | /* datetime.datetime.utcnow() */ 72 | 73 | static PyObject* 74 | _time_machine_utcnow(PyObject *cls, PyObject *args) 75 | { 76 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 77 | PyObject *time_machine_utcnow = PyObject_GetAttrString(time_machine_module, "utcnow"); 78 | 79 | PyObject* result = PyObject_CallObject(time_machine_utcnow, args); 80 | 81 | Py_DECREF(time_machine_utcnow); 82 | Py_DECREF(time_machine_module); 83 | 84 | return result; 85 | } 86 | 87 | static PyObject* 88 | _time_machine_original_utcnow(PyObject *module, PyObject *args) 89 | { 90 | _time_machine_state *state = get_time_machine_state(module); 91 | 92 | PyObject *datetime_module = PyImport_ImportModule("datetime"); 93 | PyObject *datetime_class = PyObject_GetAttrString(datetime_module, "datetime"); 94 | 95 | PyObject* result = state->original_utcnow(datetime_class, args); 96 | 97 | Py_DECREF(datetime_class); 98 | Py_DECREF(datetime_module); 99 | 100 | return result; 101 | } 102 | PyDoc_STRVAR(original_utcnow_doc, 103 | "original_utcnow() -> datetime\n\ 104 | \n\ 105 | Call datetime.datetime.utcnow() after patching."); 106 | 107 | /* time.clock_gettime() */ 108 | 109 | static PyObject* 110 | _time_machine_clock_gettime(PyObject *self, PyObject *args) 111 | { 112 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 113 | PyObject *time_machine_clock_gettime = PyObject_GetAttrString(time_machine_module, "clock_gettime"); 114 | 115 | #if PY_VERSION_HEX >= 0x030d00a2 116 | PyObject* result = PyObject_CallOneArg(time_machine_clock_gettime, args); 117 | #else 118 | PyObject* result = PyObject_CallObject(time_machine_clock_gettime, args); 119 | #endif 120 | 121 | Py_DECREF(time_machine_clock_gettime); 122 | Py_DECREF(time_machine_module); 123 | 124 | return result; 125 | } 126 | 127 | static PyObject* 128 | _time_machine_original_clock_gettime(PyObject *module, PyObject *args) 129 | { 130 | _time_machine_state *state = get_time_machine_state(module); 131 | 132 | PyObject *time_module = PyImport_ImportModule("time"); 133 | 134 | PyObject* result = state->original_clock_gettime(time_module, args); 135 | 136 | Py_DECREF(time_module); 137 | 138 | return result; 139 | } 140 | PyDoc_STRVAR(original_clock_gettime_doc, 141 | "original_clock_gettime() -> floating point number\n\ 142 | \n\ 143 | Call time.clock_gettime() after patching."); 144 | 145 | /* time.clock_gettime_ns() */ 146 | 147 | static PyObject* 148 | _time_machine_clock_gettime_ns(PyObject *self, PyObject *args) 149 | { 150 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 151 | PyObject *time_machine_clock_gettime_ns = PyObject_GetAttrString(time_machine_module, "clock_gettime_ns"); 152 | 153 | #if PY_VERSION_HEX >= 0x030d00a2 154 | PyObject* result = PyObject_CallOneArg(time_machine_clock_gettime_ns, args); 155 | #else 156 | PyObject* result = PyObject_CallObject(time_machine_clock_gettime_ns, args); 157 | #endif 158 | 159 | Py_DECREF(time_machine_clock_gettime_ns); 160 | Py_DECREF(time_machine_module); 161 | 162 | return result; 163 | } 164 | 165 | static PyObject* 166 | _time_machine_original_clock_gettime_ns(PyObject *module, PyObject *args) 167 | { 168 | _time_machine_state *state = get_time_machine_state(module); 169 | 170 | PyObject *time_module = PyImport_ImportModule("time"); 171 | 172 | PyObject* result = state->original_clock_gettime_ns(time_module, args); 173 | 174 | Py_DECREF(time_module); 175 | 176 | return result; 177 | } 178 | PyDoc_STRVAR(original_clock_gettime_ns_doc, 179 | "original_clock_gettime_ns() -> int\n\ 180 | \n\ 181 | Call time.clock_gettime_ns() after patching."); 182 | 183 | /* time.gmtime() */ 184 | 185 | static PyObject* 186 | _time_machine_gmtime(PyObject *self, PyObject *args) 187 | { 188 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 189 | PyObject *time_machine_gmtime = PyObject_GetAttrString(time_machine_module, "gmtime"); 190 | 191 | PyObject* result = PyObject_CallObject(time_machine_gmtime, args); 192 | 193 | Py_DECREF(time_machine_gmtime); 194 | Py_DECREF(time_machine_module); 195 | 196 | return result; 197 | } 198 | 199 | static PyObject* 200 | _time_machine_original_gmtime(PyObject *module, PyObject *args) 201 | { 202 | _time_machine_state *state = get_time_machine_state(module); 203 | 204 | PyObject *time_module = PyImport_ImportModule("time"); 205 | 206 | PyObject* result = state->original_gmtime(time_module, args); 207 | 208 | Py_DECREF(time_module); 209 | 210 | return result; 211 | } 212 | PyDoc_STRVAR(original_gmtime_doc, 213 | "original_gmtime() -> floating point number\n\ 214 | \n\ 215 | Call time.gmtime() after patching."); 216 | 217 | /* time.localtime() */ 218 | 219 | static PyObject* 220 | _time_machine_localtime(PyObject *self, PyObject *args) 221 | { 222 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 223 | PyObject *time_machine_localtime = PyObject_GetAttrString(time_machine_module, "localtime"); 224 | 225 | PyObject* result = PyObject_CallObject(time_machine_localtime, args); 226 | 227 | Py_DECREF(time_machine_localtime); 228 | Py_DECREF(time_machine_module); 229 | 230 | return result; 231 | } 232 | 233 | static PyObject* 234 | _time_machine_original_localtime(PyObject *module, PyObject *args) 235 | { 236 | _time_machine_state *state = get_time_machine_state(module); 237 | 238 | PyObject *time_module = PyImport_ImportModule("time"); 239 | 240 | PyObject* result = state->original_localtime(time_module, args); 241 | 242 | Py_DECREF(time_module); 243 | 244 | return result; 245 | } 246 | PyDoc_STRVAR(original_localtime_doc, 247 | "original_localtime() -> floating point number\n\ 248 | \n\ 249 | Call time.localtime() after patching."); 250 | 251 | /* time.monotonic() */ 252 | 253 | static PyObject* 254 | _time_machine_original_monotonic(PyObject* module, PyObject* args) 255 | { 256 | _time_machine_state *state = get_time_machine_state(module); 257 | 258 | PyObject *time_module = PyImport_ImportModule("time"); 259 | 260 | PyObject* result = state->original_monotonic(time_module, args); 261 | 262 | Py_DECREF(time_module); 263 | 264 | return result; 265 | } 266 | PyDoc_STRVAR(original_monotonic_doc, 267 | "original_monotonic() -> floating point number\n\ 268 | \n\ 269 | Call time.monotonic() after patching."); 270 | 271 | /* time.monotonic_ns() */ 272 | 273 | static PyObject* 274 | _time_machine_original_monotonic_ns(PyObject* module, PyObject* args) 275 | { 276 | _time_machine_state *state = get_time_machine_state(module); 277 | 278 | PyObject *time_module = PyImport_ImportModule("time"); 279 | 280 | PyObject* result = state->original_monotonic_ns(time_module, args); 281 | 282 | Py_DECREF(time_module); 283 | 284 | return result; 285 | } 286 | PyDoc_STRVAR(original_monotonic_ns_doc, 287 | "original_monotonic_ns() -> int\n\ 288 | \n\ 289 | Call time.monotonic_ns() after patching."); 290 | 291 | /* time.strftime() */ 292 | 293 | static PyObject* 294 | _time_machine_strftime(PyObject *self, PyObject *args) 295 | { 296 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 297 | PyObject *time_machine_strftime = PyObject_GetAttrString(time_machine_module, "strftime"); 298 | 299 | PyObject* result = PyObject_CallObject(time_machine_strftime, args); 300 | 301 | Py_DECREF(time_machine_strftime); 302 | Py_DECREF(time_machine_module); 303 | 304 | return result; 305 | } 306 | 307 | static PyObject* 308 | _time_machine_original_strftime(PyObject *module, PyObject *args) 309 | { 310 | _time_machine_state *state = get_time_machine_state(module); 311 | 312 | PyObject *time_module = PyImport_ImportModule("time"); 313 | 314 | PyObject* result = state->original_strftime(time_module, args); 315 | 316 | Py_DECREF(time_module); 317 | 318 | return result; 319 | } 320 | PyDoc_STRVAR(original_strftime_doc, 321 | "original_strftime() -> floating point number\n\ 322 | \n\ 323 | Call time.strftime() after patching."); 324 | 325 | /* time.time() */ 326 | 327 | static PyObject* 328 | _time_machine_time(PyObject *self, PyObject *args) 329 | { 330 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 331 | PyObject *time_machine_time = PyObject_GetAttrString(time_machine_module, "time"); 332 | 333 | PyObject* result = PyObject_CallObject(time_machine_time, args); 334 | 335 | Py_DECREF(time_machine_time); 336 | Py_DECREF(time_machine_module); 337 | 338 | return result; 339 | } 340 | 341 | static PyObject* 342 | _time_machine_original_time(PyObject *module, PyObject *args) 343 | { 344 | _time_machine_state *state = get_time_machine_state(module); 345 | 346 | PyObject *time_module = PyImport_ImportModule("time"); 347 | 348 | PyObject* result = state->original_time(time_module, args); 349 | 350 | Py_DECREF(time_module); 351 | 352 | return result; 353 | } 354 | PyDoc_STRVAR(original_time_doc, 355 | "original_time() -> floating point number\n\ 356 | \n\ 357 | Call time.time() after patching."); 358 | 359 | /* time.time_ns() */ 360 | 361 | static PyObject* 362 | _time_machine_time_ns(PyObject *self, PyObject *args) 363 | { 364 | PyObject *time_machine_module = PyImport_ImportModule("time_machine"); 365 | PyObject *time_machine_time_ns = PyObject_GetAttrString(time_machine_module, "time_ns"); 366 | 367 | PyObject* result = PyObject_CallObject(time_machine_time_ns, args); 368 | 369 | Py_DECREF(time_machine_time_ns); 370 | Py_DECREF(time_machine_module); 371 | 372 | return result; 373 | } 374 | 375 | static PyObject* 376 | _time_machine_original_time_ns(PyObject *module, PyObject *args) 377 | { 378 | _time_machine_state *state = get_time_machine_state(module); 379 | 380 | PyObject *time_module = PyImport_ImportModule("time"); 381 | 382 | PyObject* result = state->original_time_ns(time_module, args); 383 | 384 | Py_DECREF(time_module); 385 | 386 | return result; 387 | } 388 | PyDoc_STRVAR(original_time_ns_doc, 389 | "original_time_ns() -> int\n\ 390 | \n\ 391 | Call time.time_ns() after patching."); 392 | 393 | static PyObject* 394 | _time_machine_patch_if_needed(PyObject *module, PyObject *unused) 395 | { 396 | _time_machine_state *state = PyModule_GetState(module); 397 | if (state == NULL) { 398 | return NULL; 399 | } 400 | 401 | if (state->original_time) 402 | Py_RETURN_NONE; 403 | 404 | PyObject *datetime_module = PyImport_ImportModule("datetime"); 405 | PyObject *datetime_class = PyObject_GetAttrString(datetime_module, "datetime"); 406 | 407 | PyCFunctionObject *datetime_datetime_now = (PyCFunctionObject *) PyObject_GetAttrString(datetime_class, "now"); 408 | #if PY_VERSION_HEX >= 0x030d00a4 409 | state->original_now = (PyCFunctionFastWithKeywords) datetime_datetime_now->m_ml->ml_meth; 410 | #else 411 | state->original_now = (_PyCFunctionFastWithKeywords) datetime_datetime_now->m_ml->ml_meth; 412 | #endif 413 | datetime_datetime_now->m_ml->ml_meth = (PyCFunction) _time_machine_now; 414 | Py_DECREF(datetime_datetime_now); 415 | 416 | PyCFunctionObject *datetime_datetime_utcnow = (PyCFunctionObject *) PyObject_GetAttrString(datetime_class, "utcnow"); 417 | state->original_utcnow = datetime_datetime_utcnow->m_ml->ml_meth; 418 | datetime_datetime_utcnow->m_ml->ml_meth = _time_machine_utcnow; 419 | Py_DECREF(datetime_datetime_utcnow); 420 | 421 | Py_DECREF(datetime_class); 422 | Py_DECREF(datetime_module); 423 | 424 | PyObject *time_module = PyImport_ImportModule("time"); 425 | 426 | /* 427 | time.clock_gettime(), only available on Unix platforms. 428 | */ 429 | PyCFunctionObject *time_clock_gettime = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "clock_gettime"); 430 | if (time_clock_gettime == NULL) { 431 | PyErr_Clear(); 432 | } else { 433 | state->original_clock_gettime = time_clock_gettime->m_ml->ml_meth; 434 | time_clock_gettime->m_ml->ml_meth = _time_machine_clock_gettime; 435 | Py_DECREF(time_clock_gettime); 436 | } 437 | 438 | /* 439 | time.clock_gettime_ns(), only available on Unix platforms. 440 | */ 441 | PyCFunctionObject *time_clock_gettime_ns = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "clock_gettime_ns"); 442 | if (time_clock_gettime_ns == NULL) { 443 | PyErr_Clear(); 444 | } else { 445 | state->original_clock_gettime_ns = time_clock_gettime_ns->m_ml->ml_meth; 446 | time_clock_gettime_ns->m_ml->ml_meth = _time_machine_clock_gettime_ns; 447 | Py_DECREF(time_clock_gettime_ns); 448 | } 449 | 450 | PyCFunctionObject *time_gmtime = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "gmtime"); 451 | state->original_gmtime = time_gmtime->m_ml->ml_meth; 452 | time_gmtime->m_ml->ml_meth = _time_machine_gmtime; 453 | Py_DECREF(time_gmtime); 454 | 455 | PyCFunctionObject *time_localtime = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "localtime"); 456 | state->original_localtime = time_localtime->m_ml->ml_meth; 457 | time_localtime->m_ml->ml_meth = _time_machine_localtime; 458 | Py_DECREF(time_localtime); 459 | 460 | PyCFunctionObject *time_monotonic = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "monotonic"); 461 | state->original_monotonic = time_monotonic->m_ml->ml_meth; 462 | time_monotonic->m_ml->ml_meth = _time_machine_time; 463 | Py_DECREF(time_monotonic); 464 | 465 | PyCFunctionObject *time_monotonic_ns = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "monotonic_ns"); 466 | state->original_monotonic_ns = time_monotonic_ns->m_ml->ml_meth; 467 | time_monotonic_ns->m_ml->ml_meth = _time_machine_time_ns; 468 | Py_DECREF(time_monotonic_ns); 469 | 470 | PyCFunctionObject *time_strftime = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "strftime"); 471 | state->original_strftime = time_strftime->m_ml->ml_meth; 472 | time_strftime->m_ml->ml_meth = _time_machine_strftime; 473 | Py_DECREF(time_strftime); 474 | 475 | PyCFunctionObject *time_time = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "time"); 476 | state->original_time = time_time->m_ml->ml_meth; 477 | time_time->m_ml->ml_meth = _time_machine_time; 478 | Py_DECREF(time_time); 479 | 480 | PyCFunctionObject *time_time_ns = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "time_ns"); 481 | state->original_time_ns = time_time_ns->m_ml->ml_meth; 482 | time_time_ns->m_ml->ml_meth = _time_machine_time_ns; 483 | Py_DECREF(time_time_ns); 484 | 485 | Py_DECREF(time_module); 486 | 487 | Py_RETURN_NONE; 488 | } 489 | PyDoc_STRVAR(patch_if_needed_doc, 490 | "patch_if_needed() -> None\n\ 491 | \n\ 492 | Swap in helpers."); 493 | 494 | 495 | 496 | PyDoc_STRVAR(module_doc, "_time_machine module"); 497 | 498 | static PyMethodDef module_functions[] = { 499 | {"original_now", (PyCFunction)_time_machine_original_now, METH_FASTCALL|METH_KEYWORDS, original_now_doc}, 500 | {"original_utcnow", (PyCFunction)_time_machine_original_utcnow, METH_NOARGS, original_utcnow_doc}, 501 | #if PY_VERSION_HEX >= 0x030d00a2 502 | {"original_clock_gettime", (PyCFunction)_time_machine_original_clock_gettime, METH_O, original_clock_gettime_doc}, 503 | {"original_clock_gettime_ns", (PyCFunction)_time_machine_original_clock_gettime_ns, METH_O, original_clock_gettime_ns_doc}, 504 | #else 505 | {"original_clock_gettime", (PyCFunction)_time_machine_original_clock_gettime, METH_VARARGS, original_clock_gettime_doc}, 506 | {"original_clock_gettime_ns", (PyCFunction)_time_machine_original_clock_gettime_ns, METH_VARARGS, original_clock_gettime_ns_doc}, 507 | #endif 508 | {"original_gmtime", (PyCFunction)_time_machine_original_gmtime, METH_VARARGS, original_gmtime_doc}, 509 | {"original_localtime", (PyCFunction)_time_machine_original_localtime, METH_VARARGS, original_localtime_doc}, 510 | {"original_monotonic", (PyCFunction)_time_machine_original_monotonic, METH_NOARGS, original_monotonic_doc}, 511 | {"original_monotonic_ns", (PyCFunction)_time_machine_original_monotonic_ns, METH_NOARGS, original_monotonic_ns_doc}, 512 | {"original_strftime", (PyCFunction)_time_machine_original_strftime, METH_VARARGS, original_strftime_doc}, 513 | {"original_time", (PyCFunction)_time_machine_original_time, METH_NOARGS, original_time_doc}, 514 | {"original_time_ns", (PyCFunction)_time_machine_original_time_ns, METH_NOARGS, original_time_ns_doc}, 515 | {"patch_if_needed", (PyCFunction)_time_machine_patch_if_needed, METH_NOARGS, patch_if_needed_doc}, 516 | {NULL, NULL} /* sentinel */ 517 | }; 518 | 519 | static PyModuleDef_Slot _time_machine_slots[] = { 520 | {0, NULL} 521 | }; 522 | 523 | static struct PyModuleDef _time_machine_module = { 524 | PyModuleDef_HEAD_INIT, 525 | .m_name = "_time_machine", 526 | .m_doc = module_doc, 527 | .m_size = sizeof(_time_machine_state), 528 | .m_methods = module_functions, 529 | .m_slots = _time_machine_slots, 530 | .m_traverse = NULL, 531 | .m_clear = NULL, 532 | .m_free = NULL 533 | }; 534 | 535 | PyMODINIT_FUNC 536 | PyInit__time_machine(void) 537 | { 538 | return PyModuleDef_Init(&_time_machine_module); 539 | } 540 | -------------------------------------------------------------------------------- /src/time_machine/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime as dt 4 | import functools 5 | import inspect 6 | import os 7 | import sys 8 | import time as time_module 9 | import uuid 10 | from collections.abc import Awaitable, Generator 11 | from collections.abc import Generator as TypingGenerator 12 | from time import gmtime as orig_gmtime 13 | from time import struct_time 14 | from types import TracebackType 15 | from typing import Any, Callable, TypeVar, Union, cast, overload 16 | from unittest import TestCase, mock 17 | from zoneinfo import ZoneInfo 18 | 19 | import _time_machine 20 | from dateutil.parser import parse as parse_datetime 21 | 22 | # time.clock_gettime and time.CLOCK_REALTIME not always available 23 | # e.g. on builds against old macOS = official Python.org installer 24 | try: 25 | from time import CLOCK_REALTIME 26 | except ImportError: 27 | # Dummy value that won't compare equal to any value 28 | CLOCK_REALTIME = sys.maxsize 29 | 30 | try: 31 | from time import tzset 32 | 33 | HAVE_TZSET = True 34 | except ImportError: # pragma: no cover 35 | # Windows 36 | HAVE_TZSET = False 37 | 38 | try: 39 | import pytest 40 | except ImportError: # pragma: no cover 41 | HAVE_PYTEST = False 42 | else: 43 | HAVE_PYTEST = True 44 | 45 | NANOSECONDS_PER_SECOND = 1_000_000_000 46 | 47 | # Windows' time epoch is not unix epoch but in 1601. This constant helps us 48 | # translate to it. 49 | _system_epoch = orig_gmtime(0) 50 | SYSTEM_EPOCH_TIMESTAMP_NS = int( 51 | dt.datetime( 52 | _system_epoch.tm_year, 53 | _system_epoch.tm_mon, 54 | _system_epoch.tm_mday, 55 | _system_epoch.tm_hour, 56 | _system_epoch.tm_min, 57 | _system_epoch.tm_sec, 58 | tzinfo=dt.timezone.utc, 59 | ).timestamp() 60 | * NANOSECONDS_PER_SECOND 61 | ) 62 | 63 | DestinationBaseType = Union[ 64 | int, 65 | float, 66 | dt.datetime, 67 | dt.timedelta, 68 | dt.date, 69 | str, 70 | ] 71 | DestinationType = Union[ 72 | DestinationBaseType, 73 | Callable[[], DestinationBaseType], 74 | TypingGenerator[DestinationBaseType, None, None], 75 | ] 76 | 77 | _F = TypeVar("_F", bound=Callable[..., Any]) 78 | _AF = TypeVar("_AF", bound=Callable[..., Awaitable[Any]]) 79 | TestCaseType = TypeVar("TestCaseType", bound=type[TestCase]) 80 | 81 | # copied from typeshed: 82 | _TimeTuple = tuple[int, int, int, int, int, int, int, int, int] 83 | 84 | 85 | def extract_timestamp_tzname( 86 | destination: DestinationType, 87 | ) -> tuple[float, str | None]: 88 | dest: DestinationBaseType 89 | if isinstance(destination, Generator): 90 | dest = next(destination) 91 | elif callable(destination): 92 | dest = destination() 93 | else: 94 | dest = destination 95 | 96 | timestamp: float 97 | tzname: str | None = None 98 | if isinstance(dest, int): 99 | timestamp = float(dest) 100 | elif isinstance(dest, float): 101 | timestamp = dest 102 | elif isinstance(dest, dt.datetime): 103 | if isinstance(dest.tzinfo, ZoneInfo): 104 | tzname = dest.tzinfo.key 105 | if dest.tzinfo is None: 106 | dest = dest.replace(tzinfo=dt.timezone.utc) 107 | timestamp = dest.timestamp() 108 | elif isinstance(dest, dt.timedelta): 109 | timestamp = time_module.time() + dest.total_seconds() 110 | elif isinstance(dest, dt.date): 111 | timestamp = dt.datetime.combine( 112 | dest, dt.time(0, 0), tzinfo=dt.timezone.utc 113 | ).timestamp() 114 | elif isinstance(dest, str): 115 | timestamp = parse_datetime(dest).timestamp() 116 | else: 117 | raise TypeError(f"Unsupported destination {dest!r}") 118 | 119 | return timestamp, tzname 120 | 121 | 122 | class Coordinates: 123 | def __init__( 124 | self, 125 | destination_timestamp: float, 126 | destination_tzname: str | None, 127 | tick: bool, 128 | ) -> None: 129 | self._destination_timestamp_ns = int( 130 | destination_timestamp * NANOSECONDS_PER_SECOND 131 | ) 132 | self._destination_tzname = destination_tzname 133 | self._tick = tick 134 | self._requested = False 135 | 136 | def time(self) -> float: 137 | return self.time_ns() / NANOSECONDS_PER_SECOND 138 | 139 | def time_ns(self) -> int: 140 | if not self._tick: 141 | return self._destination_timestamp_ns 142 | 143 | base = SYSTEM_EPOCH_TIMESTAMP_NS + self._destination_timestamp_ns 144 | now_ns: int = _time_machine.original_time_ns() 145 | 146 | if not self._requested: 147 | self._requested = True 148 | self._real_start_timestamp_ns = now_ns 149 | return base 150 | 151 | return base + (now_ns - self._real_start_timestamp_ns) 152 | 153 | def shift(self, delta: dt.timedelta | int | float) -> None: 154 | if isinstance(delta, dt.timedelta): 155 | total_seconds = delta.total_seconds() 156 | elif isinstance(delta, (int, float)): 157 | total_seconds = delta 158 | else: 159 | raise TypeError(f"Unsupported type for delta argument: {delta!r}") 160 | 161 | self._destination_timestamp_ns += int(total_seconds * NANOSECONDS_PER_SECOND) 162 | 163 | def move_to( 164 | self, 165 | destination: DestinationType, 166 | tick: bool | None = None, 167 | ) -> None: 168 | self._stop() 169 | timestamp, self._destination_tzname = extract_timestamp_tzname(destination) 170 | self._destination_timestamp_ns = int(timestamp * NANOSECONDS_PER_SECOND) 171 | self._requested = False 172 | self._start() 173 | if tick is not None: 174 | self._tick = tick 175 | 176 | def _start(self) -> None: 177 | if HAVE_TZSET and self._destination_tzname is not None: 178 | self._orig_tz = os.environ.get("TZ") 179 | os.environ["TZ"] = self._destination_tzname 180 | tzset() 181 | 182 | def _stop(self) -> None: 183 | if HAVE_TZSET and self._destination_tzname is not None: 184 | if self._orig_tz is None: 185 | del os.environ["TZ"] 186 | else: 187 | os.environ["TZ"] = self._orig_tz 188 | tzset() 189 | 190 | 191 | coordinates_stack: list[Coordinates] = [] 192 | 193 | # During time travel, patch the uuid module's time-based generation function to 194 | # None, which makes it use time.time(). Otherwise it makes a system call to 195 | # find the current datetime. The time it finds is stored in generated UUID1 196 | # values. 197 | uuid_generate_time_attr = "_generate_time_safe" 198 | uuid_generate_time_patcher = mock.patch.object(uuid, uuid_generate_time_attr, new=None) 199 | uuid_uuid_create_patcher = mock.patch.object(uuid, "_UuidCreate", new=None) 200 | 201 | 202 | class travel: 203 | def __init__(self, destination: DestinationType, *, tick: bool = True) -> None: 204 | self.destination_timestamp, self.destination_tzname = extract_timestamp_tzname( 205 | destination 206 | ) 207 | self.tick = tick 208 | 209 | def start(self) -> Coordinates: 210 | _time_machine.patch_if_needed() 211 | 212 | if not coordinates_stack: 213 | uuid_generate_time_patcher.start() 214 | uuid_uuid_create_patcher.start() 215 | 216 | coordinates = Coordinates( 217 | destination_timestamp=self.destination_timestamp, 218 | destination_tzname=self.destination_tzname, 219 | tick=self.tick, 220 | ) 221 | coordinates_stack.append(coordinates) 222 | coordinates._start() 223 | 224 | return coordinates 225 | 226 | def stop(self) -> None: 227 | coordinates_stack.pop()._stop() 228 | 229 | if not coordinates_stack: 230 | uuid_generate_time_patcher.stop() 231 | uuid_uuid_create_patcher.stop() 232 | 233 | def __enter__(self) -> Coordinates: 234 | return self.start() 235 | 236 | def __exit__( 237 | self, 238 | exc_type: type[BaseException] | None, 239 | exc_val: BaseException | None, 240 | exc_tb: TracebackType | None, 241 | ) -> None: 242 | self.stop() 243 | 244 | @overload 245 | def __call__(self, wrapped: TestCaseType) -> TestCaseType: # pragma: no cover 246 | ... 247 | 248 | @overload 249 | def __call__(self, wrapped: _AF) -> _AF: # pragma: no cover 250 | ... 251 | 252 | @overload 253 | def __call__(self, wrapped: _F) -> _F: # pragma: no cover 254 | ... 255 | 256 | # 'Any' below is workaround for Mypy error: 257 | # Overloaded function implementation does not accept all possible arguments 258 | # of signature 259 | def __call__( 260 | self, wrapped: TestCaseType | _AF | _F | Any 261 | ) -> TestCaseType | _AF | _F | Any: 262 | if isinstance(wrapped, type): 263 | # Class decorator 264 | if not issubclass(wrapped, TestCase): 265 | raise TypeError("Can only decorate unittest.TestCase subclasses.") 266 | 267 | # Modify the setUpClass method 268 | orig_setUpClass = wrapped.setUpClass.__func__ # type: ignore[attr-defined] 269 | 270 | @functools.wraps(orig_setUpClass) 271 | def setUpClass(cls: type[TestCase]) -> None: 272 | self.__enter__() 273 | try: 274 | orig_setUpClass(cls) 275 | except Exception: 276 | self.__exit__(*sys.exc_info()) 277 | raise 278 | 279 | wrapped.setUpClass = classmethod(setUpClass) # type: ignore[assignment] 280 | 281 | orig_tearDownClass = ( 282 | wrapped.tearDownClass.__func__ # type: ignore[attr-defined] 283 | ) 284 | 285 | @functools.wraps(orig_tearDownClass) 286 | def tearDownClass(cls: type[TestCase]) -> None: 287 | orig_tearDownClass(cls) 288 | self.__exit__(None, None, None) 289 | 290 | wrapped.tearDownClass = classmethod( # type: ignore[assignment] 291 | tearDownClass 292 | ) 293 | return cast(TestCaseType, wrapped) 294 | elif inspect.iscoroutinefunction(wrapped): 295 | 296 | @functools.wraps(wrapped) 297 | async def wrapper(*args: Any, **kwargs: Any) -> Any: 298 | with self: 299 | return await wrapped(*args, **kwargs) 300 | 301 | return cast(_AF, wrapper) 302 | else: 303 | assert callable(wrapped) 304 | 305 | @functools.wraps(wrapped) 306 | def wrapper(*args: Any, **kwargs: Any) -> Any: 307 | with self: 308 | return wrapped(*args, **kwargs) 309 | 310 | return cast(_F, wrapper) 311 | 312 | 313 | # datetime module 314 | 315 | 316 | def now(tz: dt.tzinfo | None = None) -> dt.datetime: 317 | if not coordinates_stack: 318 | result: dt.datetime = _time_machine.original_now(tz) 319 | return result 320 | return dt.datetime.fromtimestamp(time(), tz) 321 | 322 | 323 | def utcnow() -> dt.datetime: 324 | if not coordinates_stack: 325 | result: dt.datetime = _time_machine.original_utcnow() 326 | return result 327 | return dt.datetime.fromtimestamp(time(), dt.timezone.utc).replace(tzinfo=None) 328 | 329 | 330 | # time module 331 | 332 | 333 | def clock_gettime(clk_id: int) -> float: 334 | if not coordinates_stack or clk_id != CLOCK_REALTIME: 335 | result: float = _time_machine.original_clock_gettime(clk_id) 336 | return result 337 | return time() 338 | 339 | 340 | def clock_gettime_ns(clk_id: int) -> int: 341 | if not coordinates_stack or clk_id != CLOCK_REALTIME: 342 | result: int = _time_machine.original_clock_gettime_ns(clk_id) 343 | return result 344 | return time_ns() 345 | 346 | 347 | def gmtime(secs: float | None = None) -> struct_time: 348 | result: struct_time 349 | if not coordinates_stack or secs is not None: 350 | result = _time_machine.original_gmtime(secs) 351 | else: 352 | result = _time_machine.original_gmtime(coordinates_stack[-1].time()) 353 | return result 354 | 355 | 356 | def localtime(secs: float | None = None) -> struct_time: 357 | result: struct_time 358 | if not coordinates_stack or secs is not None: 359 | result = _time_machine.original_localtime(secs) 360 | else: 361 | result = _time_machine.original_localtime(coordinates_stack[-1].time()) 362 | return result 363 | 364 | 365 | def strftime(format: str, t: _TimeTuple | struct_time | None = None) -> str: 366 | result: str 367 | if t is not None: 368 | result = _time_machine.original_strftime(format, t) 369 | elif not coordinates_stack: 370 | result = _time_machine.original_strftime(format) 371 | else: 372 | result = _time_machine.original_strftime(format, localtime()) 373 | return result 374 | 375 | 376 | def time() -> float: 377 | if not coordinates_stack: 378 | result: float = _time_machine.original_time() 379 | return result 380 | return coordinates_stack[-1].time() 381 | 382 | 383 | def time_ns() -> int: 384 | if not coordinates_stack: 385 | result: int = _time_machine.original_time_ns() 386 | return result 387 | return coordinates_stack[-1].time_ns() 388 | 389 | 390 | # pytest plugin 391 | 392 | if HAVE_PYTEST: # pragma: no branch 393 | 394 | class TimeMachineFixture: 395 | traveller: travel | None 396 | coordinates: Coordinates | None 397 | 398 | def __init__(self) -> None: 399 | self.traveller = None 400 | self.coordinates = None 401 | 402 | def move_to( 403 | self, 404 | destination: DestinationType, 405 | tick: bool | None = None, 406 | ) -> None: 407 | if self.traveller is None: 408 | if tick is None: 409 | tick = True 410 | self.traveller = travel(destination, tick=tick) 411 | self.coordinates = self.traveller.start() 412 | else: 413 | assert self.coordinates is not None 414 | self.coordinates.move_to(destination, tick=tick) 415 | 416 | def shift(self, delta: dt.timedelta | int | float) -> None: 417 | if self.traveller is None: 418 | raise RuntimeError( 419 | "Initialize time_machine with move_to() before using shift()." 420 | ) 421 | assert self.coordinates is not None 422 | self.coordinates.shift(delta=delta) 423 | 424 | def stop(self) -> None: 425 | if self.traveller is not None: 426 | self.traveller.stop() 427 | 428 | @pytest.fixture(name="time_machine") 429 | def time_machine_fixture() -> TypingGenerator[TimeMachineFixture, None, None]: 430 | fixture = TimeMachineFixture() 431 | yield fixture 432 | fixture.stop() 433 | 434 | 435 | # escape hatch 436 | 437 | 438 | class _EscapeHatchDatetimeDatetime: 439 | def now(self, tz: dt.tzinfo | None = None) -> dt.datetime: 440 | result: dt.datetime = _time_machine.original_now(tz) 441 | return result 442 | 443 | def utcnow(self) -> dt.datetime: 444 | result: dt.datetime = _time_machine.original_utcnow() 445 | return result 446 | 447 | 448 | class _EscapeHatchDatetime: 449 | def __init__(self) -> None: 450 | self.datetime = _EscapeHatchDatetimeDatetime() 451 | 452 | 453 | class _EscapeHatchTime: 454 | def clock_gettime(self, clk_id: int) -> float: 455 | result: float = _time_machine.original_clock_gettime(clk_id) 456 | return result 457 | 458 | def clock_gettime_ns(self, clk_id: int) -> int: 459 | result: int = _time_machine.original_clock_gettime_ns(clk_id) 460 | return result 461 | 462 | def gmtime(self, secs: float | None = None) -> struct_time: 463 | result: struct_time = _time_machine.original_gmtime(secs) 464 | return result 465 | 466 | def localtime(self, secs: float | None = None) -> struct_time: 467 | result: struct_time = _time_machine.original_localtime(secs) 468 | return result 469 | 470 | def monotonic(self) -> float: 471 | result: float = _time_machine.original_monotonic() 472 | return result 473 | 474 | def monotonic_ns(self) -> int: 475 | result: int = _time_machine.original_monotonic_ns() 476 | return result 477 | 478 | def strftime(self, format: str, t: _TimeTuple | struct_time | None = None) -> str: 479 | result: str 480 | if t is not None: 481 | result = _time_machine.original_strftime(format, t) 482 | else: 483 | result = _time_machine.original_strftime(format) 484 | return result 485 | 486 | def time(self) -> float: 487 | result: float = _time_machine.original_time() 488 | return result 489 | 490 | def time_ns(self) -> int: 491 | result: int = _time_machine.original_time_ns() 492 | return result 493 | 494 | 495 | class _EscapeHatch: 496 | def __init__(self) -> None: 497 | self.datetime = _EscapeHatchDatetime() 498 | self.time = _EscapeHatchTime() 499 | 500 | def is_travelling(self) -> bool: 501 | return bool(coordinates_stack) 502 | 503 | 504 | escape_hatch = _EscapeHatch() 505 | -------------------------------------------------------------------------------- /src/time_machine/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchainz/time-machine/79c13d59de7a16644509d0cd5757a3abf79bde9a/src/time_machine/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchainz/time-machine/79c13d59de7a16644509d0cd5757a3abf79bde9a/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import time 5 | 6 | # Isolate tests from the host machine’s timezone 7 | os.environ["TZ"] = "UTC" 8 | time.tzset() 9 | -------------------------------------------------------------------------------- /tests/test_time_machine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import datetime as dt 5 | import os 6 | import subprocess 7 | import sys 8 | import time 9 | import typing 10 | import uuid 11 | from contextlib import contextmanager 12 | from importlib.util import module_from_spec, spec_from_file_location 13 | from textwrap import dedent 14 | from unittest import SkipTest, TestCase, mock 15 | from zoneinfo import ZoneInfo 16 | 17 | import pytest 18 | from dateutil import tz 19 | 20 | import time_machine 21 | 22 | NANOSECONDS_PER_SECOND = time_machine.NANOSECONDS_PER_SECOND 23 | EPOCH_DATETIME = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) 24 | EPOCH = EPOCH_DATETIME.timestamp() 25 | EPOCH_PLUS_ONE_YEAR_DATETIME = dt.datetime(1971, 1, 1, tzinfo=dt.timezone.utc) 26 | EPOCH_PLUS_ONE_YEAR = EPOCH_PLUS_ONE_YEAR_DATETIME.timestamp() 27 | LIBRARY_EPOCH_DATETIME = dt.datetime(2020, 4, 29) # The day this library was made 28 | LIBRARY_EPOCH = LIBRARY_EPOCH_DATETIME.timestamp() 29 | 30 | py_have_clock_gettime = pytest.mark.skipif( 31 | not hasattr(time, "clock_gettime"), reason="Doesn't have clock_gettime" 32 | ) 33 | 34 | 35 | def sleep_one_cycle(clock: int) -> None: 36 | time.sleep(time.clock_getres(clock)) 37 | 38 | 39 | @contextmanager 40 | def change_local_timezone(local_tz: str | None) -> typing.Iterator[None]: 41 | orig_tz = os.environ["TZ"] 42 | if local_tz: 43 | os.environ["TZ"] = local_tz 44 | else: 45 | del os.environ["TZ"] 46 | time.tzset() 47 | try: 48 | yield 49 | finally: 50 | os.environ["TZ"] = orig_tz 51 | time.tzset() 52 | 53 | 54 | @pytest.mark.skipif( 55 | not hasattr(time, "CLOCK_REALTIME"), reason="No time.CLOCK_REALTIME" 56 | ) 57 | def test_import_without_clock_realtime(): 58 | orig = time.CLOCK_REALTIME 59 | del time.CLOCK_REALTIME 60 | try: 61 | # Recipe for importing from path as documented in importlib 62 | spec = spec_from_file_location( 63 | f"{__name__}.time_machine_without_clock_realtime", time_machine.__file__ 64 | ) 65 | assert spec is not None 66 | module = module_from_spec(spec) 67 | # typeshed says exec_module does not always exist: 68 | spec.loader.exec_module(module) # type: ignore[union-attr] 69 | 70 | finally: 71 | time.CLOCK_REALTIME = orig 72 | 73 | # No assertions - testing for coverage only 74 | 75 | 76 | # datetime module 77 | 78 | 79 | def test_datetime_now_no_args(): 80 | with time_machine.travel(EPOCH): 81 | now = dt.datetime.now() 82 | assert now.year == 1970 83 | assert now.month == 1 84 | assert now.day == 1 85 | # Not asserting on hour/minute because local timezone could shift it 86 | assert now.second == 0 87 | assert now.microsecond == 0 88 | assert dt.datetime.now() >= LIBRARY_EPOCH_DATETIME 89 | 90 | 91 | def test_datetime_now_no_args_no_tick(): 92 | with time_machine.travel(EPOCH, tick=False): 93 | now = dt.datetime.now() 94 | assert now.microsecond == 0 95 | assert dt.datetime.now() >= LIBRARY_EPOCH_DATETIME 96 | 97 | 98 | def test_datetime_now_arg(): 99 | with time_machine.travel(EPOCH): 100 | now = dt.datetime.now(tz=dt.timezone.utc) 101 | assert now.year == 1970 102 | assert now.month == 1 103 | assert now.day == 1 104 | assert dt.datetime.now(dt.timezone.utc) >= LIBRARY_EPOCH_DATETIME.replace( 105 | tzinfo=dt.timezone.utc 106 | ) 107 | 108 | 109 | def test_datetime_utcnow(): 110 | with time_machine.travel(EPOCH): 111 | now = dt.datetime.utcnow() 112 | assert now.year == 1970 113 | assert now.month == 1 114 | assert now.day == 1 115 | assert now.hour == 0 116 | assert now.minute == 0 117 | assert now.second == 0 118 | assert now.microsecond == 0 119 | assert now.tzinfo is None 120 | assert dt.datetime.utcnow() >= LIBRARY_EPOCH_DATETIME 121 | 122 | 123 | def test_datetime_utcnow_no_tick(): 124 | with time_machine.travel(EPOCH, tick=False): 125 | now = dt.datetime.utcnow() 126 | assert now.microsecond == 0 127 | 128 | 129 | def test_date_today(): 130 | with time_machine.travel(EPOCH): 131 | today = dt.date.today() 132 | assert today.year == 1970 133 | assert today.month == 1 134 | assert today.day == 1 135 | assert dt.datetime.today() >= LIBRARY_EPOCH_DATETIME 136 | 137 | 138 | # time module 139 | 140 | 141 | @py_have_clock_gettime 142 | def test_time_clock_gettime_realtime(): 143 | with time_machine.travel(EPOCH + 180.0): 144 | now = time.clock_gettime(time.CLOCK_REALTIME) 145 | assert isinstance(now, float) 146 | assert now == EPOCH + 180.0 147 | 148 | now = time.clock_gettime(time.CLOCK_REALTIME) 149 | assert isinstance(now, float) 150 | assert now >= LIBRARY_EPOCH 151 | 152 | 153 | @py_have_clock_gettime 154 | def test_time_clock_gettime_monotonic_unaffected(): 155 | start = time.clock_gettime(time.CLOCK_MONOTONIC) 156 | sleep_one_cycle(time.CLOCK_MONOTONIC) 157 | with time_machine.travel(EPOCH + 180.0): 158 | frozen = time.clock_gettime(time.CLOCK_MONOTONIC) 159 | sleep_one_cycle(time.CLOCK_MONOTONIC) 160 | assert isinstance(frozen, float) 161 | assert frozen > start 162 | 163 | now = time.clock_gettime(time.CLOCK_MONOTONIC) 164 | assert isinstance(now, float) 165 | assert now > frozen 166 | 167 | 168 | @py_have_clock_gettime 169 | def test_time_clock_gettime_ns_realtime(): 170 | with time_machine.travel(EPOCH + 190.0): 171 | first = time.clock_gettime_ns(time.CLOCK_REALTIME) 172 | sleep_one_cycle(time.CLOCK_REALTIME) 173 | assert isinstance(first, int) 174 | assert first == int((EPOCH + 190.0) * NANOSECONDS_PER_SECOND) 175 | second = time.clock_gettime_ns(time.CLOCK_REALTIME) 176 | assert first < second < int((EPOCH + 191.0) * NANOSECONDS_PER_SECOND) 177 | 178 | now = time.clock_gettime_ns(time.CLOCK_REALTIME) 179 | assert isinstance(now, int) 180 | assert now >= int(LIBRARY_EPOCH * NANOSECONDS_PER_SECOND) 181 | 182 | 183 | @py_have_clock_gettime 184 | def test_time_clock_gettime_ns_monotonic_unaffected(): 185 | start = time.clock_gettime_ns(time.CLOCK_MONOTONIC) 186 | sleep_one_cycle(time.CLOCK_MONOTONIC) 187 | with time_machine.travel(EPOCH + 190.0): 188 | frozen = time.clock_gettime_ns(time.CLOCK_MONOTONIC) 189 | sleep_one_cycle(time.CLOCK_MONOTONIC) 190 | assert isinstance(frozen, int) 191 | assert frozen > start 192 | 193 | now = time.clock_gettime_ns(time.CLOCK_MONOTONIC) 194 | assert isinstance(now, int) 195 | assert now > frozen 196 | 197 | 198 | def test_time_gmtime_no_args(): 199 | with time_machine.travel(EPOCH): 200 | local_time = time.gmtime() 201 | assert local_time.tm_year == 1970 202 | assert local_time.tm_mon == 1 203 | assert local_time.tm_mday == 1 204 | now_time = time.gmtime() 205 | assert now_time.tm_year >= 2020 206 | 207 | 208 | def test_time_gmtime_no_args_no_tick(): 209 | with time_machine.travel(EPOCH, tick=False): 210 | local_time = time.gmtime() 211 | assert local_time.tm_sec == 0 212 | 213 | 214 | def test_time_gmtime_arg(): 215 | with time_machine.travel(EPOCH): 216 | local_time = time.gmtime(EPOCH_PLUS_ONE_YEAR) 217 | assert local_time.tm_year == 1971 218 | assert local_time.tm_mon == 1 219 | assert local_time.tm_mday == 1 220 | 221 | 222 | def test_time_localtime(): 223 | with time_machine.travel(EPOCH): 224 | local_time = time.localtime() 225 | assert local_time.tm_year == 1970 226 | assert local_time.tm_mon == 1 227 | assert local_time.tm_mday == 1 228 | now_time = time.localtime() 229 | assert now_time.tm_year >= 2020 230 | 231 | 232 | def test_time_localtime_no_tick(): 233 | with time_machine.travel(EPOCH, tick=False): 234 | local_time = time.localtime() 235 | assert local_time.tm_sec == 0 236 | 237 | 238 | def test_time_localtime_arg(): 239 | with time_machine.travel(EPOCH): 240 | local_time = time.localtime(EPOCH_PLUS_ONE_YEAR) 241 | assert local_time.tm_year == 1971 242 | assert local_time.tm_mon == 1 243 | assert local_time.tm_mday == 1 244 | 245 | 246 | def test_time_montonic(): 247 | with time_machine.travel(EPOCH, tick=False) as t: 248 | assert time.monotonic() == EPOCH 249 | t.shift(1) 250 | assert time.monotonic() == EPOCH + 1 251 | 252 | 253 | def test_time_monotonic_ns(): 254 | with time_machine.travel(EPOCH, tick=False) as t: 255 | assert time.monotonic_ns() == int(EPOCH * NANOSECONDS_PER_SECOND) 256 | t.shift(1) 257 | assert ( 258 | time.monotonic_ns() 259 | == int(EPOCH * NANOSECONDS_PER_SECOND) + NANOSECONDS_PER_SECOND 260 | ) 261 | 262 | 263 | def test_time_strftime_format(): 264 | with time_machine.travel(EPOCH): 265 | assert time.strftime("%Y-%m-%d") == "1970-01-01" 266 | assert int(time.strftime("%Y")) >= 2020 267 | 268 | 269 | def test_time_strftime_format_no_tick(): 270 | with time_machine.travel(EPOCH, tick=False): 271 | assert time.strftime("%S") == "00" 272 | 273 | 274 | def test_time_strftime_format_t(): 275 | with time_machine.travel(EPOCH): 276 | assert ( 277 | time.strftime("%Y-%m-%d", time.localtime(EPOCH_PLUS_ONE_YEAR)) 278 | == "1971-01-01" 279 | ) 280 | 281 | 282 | def test_time_time(): 283 | with time_machine.travel(EPOCH): 284 | first = time.time() 285 | sleep_one_cycle(time.CLOCK_MONOTONIC) 286 | assert isinstance(first, float) 287 | assert first == EPOCH 288 | second = time.time() 289 | assert first < second < EPOCH + 1.0 290 | 291 | now = time.time() 292 | assert isinstance(now, float) 293 | assert now >= LIBRARY_EPOCH 294 | 295 | 296 | windows_epoch_in_posix = -11_644_445_222 297 | 298 | 299 | @mock.patch.object( 300 | time_machine, 301 | "SYSTEM_EPOCH_TIMESTAMP_NS", 302 | (windows_epoch_in_posix * NANOSECONDS_PER_SECOND), 303 | ) 304 | def test_time_time_windows(): 305 | with time_machine.travel(EPOCH): 306 | first = time.time() 307 | sleep_one_cycle(time.CLOCK_MONOTONIC) 308 | assert isinstance(first, float) 309 | assert first == windows_epoch_in_posix 310 | 311 | second = time.time() 312 | assert isinstance(second, float) 313 | assert windows_epoch_in_posix < second < windows_epoch_in_posix + 1.0 314 | 315 | 316 | def test_time_time_no_tick(): 317 | with time_machine.travel(EPOCH, tick=False): 318 | assert time.time() == EPOCH 319 | 320 | 321 | def test_time_time_ns(): 322 | with time_machine.travel(EPOCH + 150.0): 323 | first = time.time_ns() 324 | sleep_one_cycle(time.CLOCK_MONOTONIC) 325 | assert isinstance(first, int) 326 | assert first == int((EPOCH + 150.0) * NANOSECONDS_PER_SECOND) 327 | second = time.time_ns() 328 | assert first < second < int((EPOCH + 151.0) * NANOSECONDS_PER_SECOND) 329 | 330 | now = time.time_ns() 331 | assert isinstance(now, int) 332 | assert now >= int(LIBRARY_EPOCH * NANOSECONDS_PER_SECOND) 333 | 334 | 335 | def test_time_time_ns_no_tick(): 336 | with time_machine.travel(EPOCH, tick=False): 337 | assert time.time_ns() == int(EPOCH * NANOSECONDS_PER_SECOND) 338 | 339 | 340 | # all supported forms 341 | 342 | 343 | def test_nestable(): 344 | with time_machine.travel(EPOCH + 55.0): 345 | assert time.time() == EPOCH + 55.0 346 | with time_machine.travel(EPOCH + 50.0): 347 | assert time.time() == EPOCH + 50.0 348 | 349 | 350 | def test_unsupported_type(): 351 | with pytest.raises(TypeError) as excinfo, time_machine.travel([]): # type: ignore[arg-type] 352 | pass # pragma: no cover 353 | 354 | assert excinfo.value.args == ("Unsupported destination []",) 355 | 356 | 357 | def test_exceptions_dont_break_it(): 358 | with pytest.raises(ValueError), time_machine.travel(0.0): 359 | raise ValueError("Hi") 360 | # Unreachable code analysis doesn’t work with raises being caught by 361 | # context manager 362 | with time_machine.travel(0.0): 363 | pass 364 | 365 | 366 | @time_machine.travel(EPOCH_DATETIME + dt.timedelta(seconds=70)) 367 | def test_destination_datetime(): 368 | assert time.time() == EPOCH + 70.0 369 | 370 | 371 | @time_machine.travel(EPOCH_DATETIME.replace(tzinfo=tz.gettz("America/Chicago"))) 372 | def test_destination_datetime_tzinfo_non_zoneinfo(): 373 | assert time.time() == EPOCH + 21600.0 374 | 375 | 376 | def test_destination_datetime_tzinfo_zoneinfo(): 377 | orig_timezone = time.timezone 378 | orig_altzone = time.altzone 379 | orig_tzname = time.tzname 380 | orig_daylight = time.daylight 381 | 382 | dest = LIBRARY_EPOCH_DATETIME.replace(tzinfo=ZoneInfo("Africa/Addis_Ababa")) 383 | with time_machine.travel(dest): 384 | assert time.timezone == -3 * 3600 385 | assert time.altzone == -3 * 3600 386 | assert time.tzname == ("EAT", "EAT") 387 | assert time.daylight == 0 388 | 389 | assert time.localtime() == time.struct_time( 390 | ( 391 | 2020, 392 | 4, 393 | 29, 394 | 0, 395 | 0, 396 | 0, 397 | 2, 398 | 120, 399 | 0, 400 | ) 401 | ) 402 | 403 | assert time.timezone == orig_timezone 404 | assert time.altzone == orig_altzone 405 | assert time.tzname == orig_tzname 406 | assert time.daylight == orig_daylight 407 | 408 | 409 | def test_destination_datetime_tzinfo_zoneinfo_nested(): 410 | orig_tzname = time.tzname 411 | 412 | dest = LIBRARY_EPOCH_DATETIME.replace(tzinfo=ZoneInfo("Africa/Addis_Ababa")) 413 | with time_machine.travel(dest): 414 | assert time.tzname == ("EAT", "EAT") 415 | 416 | dest2 = LIBRARY_EPOCH_DATETIME.replace(tzinfo=ZoneInfo("Pacific/Auckland")) 417 | with time_machine.travel(dest2): 418 | assert time.tzname == ("NZST", "NZDT") 419 | 420 | assert time.tzname == ("EAT", "EAT") 421 | 422 | assert time.tzname == orig_tzname 423 | 424 | 425 | def test_destination_datetime_tzinfo_zoneinfo_no_orig_tz(): 426 | with change_local_timezone(None): 427 | orig_tzname = time.tzname 428 | dest = LIBRARY_EPOCH_DATETIME.replace(tzinfo=ZoneInfo("Africa/Addis_Ababa")) 429 | 430 | with time_machine.travel(dest): 431 | assert time.tzname == ("EAT", "EAT") 432 | 433 | assert time.tzname == orig_tzname 434 | 435 | 436 | def test_destination_datetime_tzinfo_zoneinfo_windows(): 437 | orig_timezone = time.timezone 438 | 439 | pretend_windows_no_tzset = mock.patch.object(time_machine, "tzset", new=None) 440 | mock_have_tzset_false = mock.patch.object(time_machine, "HAVE_TZSET", new=False) 441 | 442 | dest = LIBRARY_EPOCH_DATETIME.replace(tzinfo=ZoneInfo("Africa/Addis_Ababa")) 443 | with pretend_windows_no_tzset, mock_have_tzset_false, time_machine.travel(dest): 444 | assert time.timezone == orig_timezone 445 | 446 | 447 | @time_machine.travel(int(EPOCH + 77)) 448 | def test_destination_int(): 449 | assert time.time() == int(EPOCH + 77) 450 | 451 | 452 | @time_machine.travel(EPOCH_DATETIME.replace(tzinfo=None) + dt.timedelta(seconds=120)) 453 | def test_destination_datetime_naive(): 454 | assert time.time() == EPOCH + 120.0 455 | 456 | 457 | @time_machine.travel(EPOCH_DATETIME.date()) 458 | def test_destination_date(): 459 | assert time.time() == EPOCH 460 | 461 | 462 | def test_destination_timedelta(): 463 | now = time.time() 464 | with time_machine.travel(dt.timedelta(seconds=3600)): 465 | assert now + 3600 <= time.time() <= now + 3601 466 | 467 | 468 | def test_destination_timedelta_first_travel_in_process(): 469 | # Would previously segfault 470 | subprocess.run( 471 | [ 472 | sys.executable, 473 | "-c", 474 | dedent( 475 | """ 476 | from datetime import timedelta 477 | import time_machine 478 | with time_machine.travel(timedelta()): 479 | pass 480 | """ 481 | ), 482 | ], 483 | check=True, 484 | ) 485 | 486 | 487 | def test_destination_timedelta_negative(): 488 | now = time.time() 489 | with time_machine.travel(dt.timedelta(seconds=-3600)): 490 | assert now - 3600 <= time.time() <= now - 3599 491 | 492 | 493 | def test_destination_timedelta_nested(): 494 | with time_machine.travel(EPOCH), time_machine.travel(dt.timedelta(seconds=10)): 495 | assert time.time() == EPOCH + 10.0 496 | 497 | 498 | @time_machine.travel("1970-01-01 00:01 +0000") 499 | def test_destination_string(): 500 | assert time.time() == EPOCH + 60.0 501 | 502 | 503 | @pytest.mark.parametrize( 504 | ["local_tz", "expected_offset"], 505 | [ 506 | ("UTC", 0), 507 | ("Europe/Amsterdam", -3600), 508 | ("US/Eastern", 5 * 3600), 509 | ], 510 | ) 511 | @pytest.mark.parametrize("destination", ["1970-01-01 00:00", "1970-01-01"]) 512 | def test_destination_string_naive(local_tz, expected_offset, destination): 513 | with change_local_timezone(local_tz), time_machine.travel(destination): 514 | assert time.time() == EPOCH + expected_offset 515 | 516 | 517 | @time_machine.travel(lambda: EPOCH + 140.0) 518 | def test_destination_callable_lambda_float(): 519 | assert time.time() == EPOCH + 140.0 520 | 521 | 522 | @time_machine.travel(lambda: "1970-01-01 00:02 +0000") 523 | def test_destination_callable_lambda_string(): 524 | assert time.time() == EPOCH + 120.0 525 | 526 | 527 | @time_machine.travel(EPOCH + 13.0 for _ in range(1)) # pragma: no branch 528 | def test_destination_generator(): 529 | assert time.time() == EPOCH + 13.0 530 | 531 | 532 | def test_traveller_object(): 533 | traveller = time_machine.travel(EPOCH + 10.0) 534 | assert time.time() >= LIBRARY_EPOCH 535 | try: 536 | traveller.start() 537 | assert time.time() == EPOCH + 10.0 538 | finally: 539 | traveller.stop() 540 | assert time.time() >= LIBRARY_EPOCH 541 | 542 | 543 | @time_machine.travel(EPOCH + 15.0) 544 | def test_function_decorator(): 545 | assert time.time() == EPOCH + 15.0 546 | 547 | 548 | def test_coroutine_decorator(): 549 | recorded_time = None 550 | 551 | @time_machine.travel(EPOCH + 140.0) 552 | async def record_time() -> None: 553 | nonlocal recorded_time 554 | recorded_time = time.time() 555 | 556 | asyncio.run(record_time()) 557 | 558 | assert recorded_time == EPOCH + 140.0 559 | 560 | 561 | def test_class_decorator_fails_non_testcase(): 562 | with pytest.raises(TypeError) as excinfo: 563 | 564 | @time_machine.travel(EPOCH) 565 | class Something: 566 | pass 567 | 568 | assert excinfo.value.args == ("Can only decorate unittest.TestCase subclasses.",) 569 | 570 | 571 | @time_machine.travel(EPOCH) 572 | class ClassDecoratorInheritanceBase(TestCase): 573 | prop: bool 574 | 575 | @classmethod 576 | def setUpClass(cls): 577 | super().setUpClass() 578 | cls.setUpTestData() 579 | 580 | @classmethod 581 | def setUpTestData(cls) -> None: 582 | cls.prop = True 583 | 584 | 585 | class ClassDecoratorInheritanceTests(ClassDecoratorInheritanceBase): 586 | @classmethod 587 | def setUpTestData(cls) -> None: 588 | super().setUpTestData() 589 | cls.prop = False 590 | 591 | def test_ineheritance_correctly_rebound(self): 592 | assert self.prop is False 593 | 594 | 595 | class TestMethodDecorator: 596 | @time_machine.travel(EPOCH + 95.0) 597 | def test_method_decorator(self): 598 | assert time.time() == EPOCH + 95.0 599 | 600 | 601 | class UnitTestMethodTests(TestCase): 602 | @time_machine.travel(EPOCH + 25.0) 603 | def test_method_decorator(self): 604 | assert time.time() == EPOCH + 25.0 605 | 606 | 607 | @time_machine.travel(EPOCH + 95.0) 608 | class UnitTestClassTests(TestCase): 609 | def test_class_decorator(self): 610 | sleep_one_cycle(time.CLOCK_MONOTONIC) 611 | assert EPOCH + 95.0 < time.time() < EPOCH + 96.0 612 | 613 | @time_machine.travel(EPOCH + 25.0) 614 | def test_stacked_method_decorator(self): 615 | assert time.time() == EPOCH + 25.0 616 | 617 | 618 | @time_machine.travel(EPOCH + 95.0) 619 | class UnitTestClassCustomSetUpClassTests(TestCase): 620 | custom_setupclass_ran: bool 621 | 622 | @classmethod 623 | def setUpClass(cls): 624 | super().setUpClass() 625 | cls.custom_setupclass_ran = True 626 | 627 | def test_class_decorator(self): 628 | sleep_one_cycle(time.CLOCK_MONOTONIC) 629 | assert EPOCH + 95.0 < time.time() < EPOCH + 96.0 630 | assert self.custom_setupclass_ran 631 | 632 | 633 | @time_machine.travel(EPOCH + 110.0) 634 | class UnitTestClassSetUpClassSkipTests(TestCase): 635 | @classmethod 636 | def setUpClass(cls): 637 | raise SkipTest("Not today") 638 | # Other tests would fail if the travel() wasn't stopped 639 | 640 | def test_thats_always_skipped(self): # pragma: no cover 641 | pass 642 | 643 | 644 | # shift() tests 645 | 646 | 647 | def test_shift_with_timedelta(): 648 | with time_machine.travel(EPOCH, tick=False) as traveller: 649 | traveller.shift(dt.timedelta(days=1)) 650 | assert time.time() == EPOCH + (3600.0 * 24) 651 | 652 | 653 | def test_shift_integer_delta(): 654 | with time_machine.travel(EPOCH, tick=False) as traveller: 655 | traveller.shift(10) 656 | assert time.time() == EPOCH + 10 657 | 658 | 659 | def test_shift_negative_delta(): 660 | with time_machine.travel(EPOCH, tick=False) as traveller: 661 | traveller.shift(-10) 662 | 663 | assert time.time() == EPOCH - 10 664 | 665 | 666 | def test_shift_wrong_delta(): 667 | with ( 668 | time_machine.travel(EPOCH, tick=False) as traveller, 669 | pytest.raises(TypeError) as excinfo, 670 | ): 671 | traveller.shift(delta="1.1") # type: ignore[arg-type] 672 | 673 | assert excinfo.value.args == ("Unsupported type for delta argument: '1.1'",) 674 | 675 | 676 | def test_shift_when_tick(): 677 | with time_machine.travel(EPOCH, tick=True) as traveller: 678 | traveller.shift(10.0) 679 | assert EPOCH + 10.0 <= time.time() < EPOCH + 20.0 680 | 681 | 682 | # move_to() tests 683 | 684 | 685 | def test_move_to_datetime(): 686 | with time_machine.travel(EPOCH) as traveller: 687 | assert time.time() == EPOCH 688 | 689 | traveller.move_to(EPOCH_PLUS_ONE_YEAR_DATETIME) 690 | 691 | first = time.time() 692 | sleep_one_cycle(time.CLOCK_MONOTONIC) 693 | assert first == EPOCH_PLUS_ONE_YEAR 694 | 695 | second = time.time() 696 | assert first < second < first + 1.0 697 | 698 | 699 | def test_move_to_datetime_no_tick(): 700 | with time_machine.travel(EPOCH, tick=False) as traveller: 701 | traveller.move_to(EPOCH_PLUS_ONE_YEAR_DATETIME) 702 | assert time.time() == EPOCH_PLUS_ONE_YEAR 703 | assert time.time() == EPOCH_PLUS_ONE_YEAR 704 | 705 | 706 | def test_move_to_past_datetime(): 707 | with time_machine.travel(EPOCH_PLUS_ONE_YEAR) as traveller: 708 | assert time.time() == EPOCH_PLUS_ONE_YEAR 709 | traveller.move_to(EPOCH_DATETIME) 710 | assert time.time() == EPOCH 711 | 712 | 713 | def test_move_to_datetime_with_tzinfo_zoneinfo(): 714 | orig_timezone = time.timezone 715 | orig_altzone = time.altzone 716 | orig_tzname = time.tzname 717 | orig_daylight = time.daylight 718 | 719 | with time_machine.travel(EPOCH) as traveller: 720 | assert time.time() == EPOCH 721 | assert time.timezone == orig_timezone 722 | assert time.altzone == orig_altzone 723 | assert time.tzname == orig_tzname 724 | assert time.daylight == orig_daylight 725 | 726 | dest = EPOCH_PLUS_ONE_YEAR_DATETIME.replace( 727 | tzinfo=ZoneInfo("Africa/Addis_Ababa") 728 | ) 729 | traveller.move_to(dest) 730 | 731 | assert time.timezone == -3 * 3600 732 | assert time.altzone == -3 * 3600 733 | assert time.tzname == ("EAT", "EAT") 734 | assert time.daylight == 0 735 | 736 | assert time.localtime() == time.struct_time( 737 | ( 738 | 1971, 739 | 1, 740 | 1, 741 | 0, 742 | 0, 743 | 0, 744 | 4, 745 | 1, 746 | 0, 747 | ) 748 | ) 749 | 750 | assert time.timezone == orig_timezone 751 | assert time.altzone == orig_altzone 752 | assert time.tzname == orig_tzname 753 | assert time.daylight == orig_daylight 754 | 755 | 756 | def test_move_to_datetime_change_tick_on(): 757 | with time_machine.travel(EPOCH, tick=False) as traveller: 758 | traveller.move_to(EPOCH_PLUS_ONE_YEAR_DATETIME, tick=True) 759 | assert time.time() == EPOCH_PLUS_ONE_YEAR 760 | sleep_one_cycle(time.CLOCK_MONOTONIC) 761 | assert time.time() > EPOCH_PLUS_ONE_YEAR 762 | 763 | 764 | def test_move_to_datetime_change_tick_off(): 765 | with time_machine.travel(EPOCH, tick=True) as traveller: 766 | traveller.move_to(EPOCH_PLUS_ONE_YEAR_DATETIME, tick=False) 767 | assert time.time() == EPOCH_PLUS_ONE_YEAR 768 | assert time.time() == EPOCH_PLUS_ONE_YEAR 769 | 770 | 771 | # uuid tests 772 | 773 | 774 | def time_from_uuid1(value: uuid.UUID) -> dt.datetime: 775 | return dt.datetime(1582, 10, 15) + dt.timedelta(microseconds=value.time // 10) 776 | 777 | 778 | def test_uuid1(): 779 | """ 780 | Test that the uuid.uuid1() methods generate values for the destination. 781 | They are a known location in the stdlib that can make system calls to find 782 | the current datetime. 783 | """ 784 | destination = dt.datetime(2017, 2, 6, 14, 8, 21) 785 | 786 | with time_machine.travel(destination, tick=False): 787 | assert time_from_uuid1(uuid.uuid1()) == destination 788 | 789 | 790 | # pytest plugin tests 791 | 792 | 793 | def test_fixture_unused(time_machine): 794 | assert time.time() >= LIBRARY_EPOCH 795 | 796 | 797 | def test_fixture_used(time_machine): 798 | time_machine.move_to(EPOCH) 799 | assert time.time() == EPOCH 800 | 801 | 802 | def test_fixture_used_tick_false(time_machine): 803 | time_machine.move_to(EPOCH, tick=False) 804 | assert time.time() == EPOCH 805 | assert time.time() == EPOCH 806 | 807 | 808 | def test_fixture_used_tick_true(time_machine): 809 | time_machine.move_to(EPOCH, tick=True) 810 | original = time.time() 811 | sleep_one_cycle(time.CLOCK_MONOTONIC) 812 | assert original == EPOCH 813 | assert original < time.time() < EPOCH + 10.0 814 | 815 | 816 | def test_fixture_move_to_twice(time_machine): 817 | time_machine.move_to(EPOCH) 818 | assert time.time() == EPOCH 819 | 820 | time_machine.move_to(EPOCH_PLUS_ONE_YEAR) 821 | assert time.time() == EPOCH_PLUS_ONE_YEAR 822 | 823 | 824 | def test_fixture_move_to_and_shift(time_machine): 825 | time_machine.move_to(EPOCH, tick=False) 826 | assert time.time() == EPOCH 827 | time_machine.shift(100) 828 | assert time.time() == EPOCH + 100 829 | 830 | 831 | def test_fixture_shift_without_move_to(time_machine): 832 | with pytest.raises(RuntimeError) as excinfo: 833 | time_machine.shift(100) 834 | 835 | assert excinfo.value.args == ( 836 | "Initialize time_machine with move_to() before using shift().", 837 | ) 838 | 839 | 840 | # escape hatch tests 841 | 842 | 843 | class TestEscapeHatch: 844 | def test_is_travelling_false(self): 845 | assert time_machine.escape_hatch.is_travelling() is False 846 | 847 | def test_is_travelling_true(self): 848 | with time_machine.travel(EPOCH): 849 | assert time_machine.escape_hatch.is_travelling() is True 850 | 851 | def test_datetime_now(self): 852 | real_now = dt.datetime.now() 853 | 854 | with time_machine.travel(EPOCH): 855 | eh_now = time_machine.escape_hatch.datetime.datetime.now() 856 | assert eh_now >= real_now 857 | 858 | def test_datetime_now_tz(self): 859 | real_now = dt.datetime.now(tz=dt.timezone.utc) 860 | 861 | with time_machine.travel(EPOCH): 862 | eh_now = time_machine.escape_hatch.datetime.datetime.now(tz=dt.timezone.utc) 863 | assert eh_now >= real_now 864 | 865 | def test_datetime_utcnow(self): 866 | real_now = dt.datetime.utcnow() 867 | 868 | with time_machine.travel(EPOCH): 869 | eh_now = time_machine.escape_hatch.datetime.datetime.utcnow() 870 | assert eh_now >= real_now 871 | 872 | @py_have_clock_gettime 873 | def test_time_clock_gettime(self): 874 | now = time.clock_gettime(time.CLOCK_REALTIME) 875 | 876 | with time_machine.travel(EPOCH + 180.0): 877 | eh_now = time_machine.escape_hatch.time.clock_gettime(time.CLOCK_REALTIME) 878 | assert eh_now >= now 879 | 880 | @py_have_clock_gettime 881 | def test_time_clock_gettime_ns(self): 882 | now = time.clock_gettime_ns(time.CLOCK_REALTIME) 883 | 884 | with time_machine.travel(EPOCH + 190.0): 885 | eh_now = time_machine.escape_hatch.time.clock_gettime_ns( 886 | time.CLOCK_REALTIME 887 | ) 888 | assert eh_now >= now 889 | 890 | def test_time_gmtime(self): 891 | now = time.gmtime() 892 | 893 | with time_machine.travel(EPOCH): 894 | eh_now = time_machine.escape_hatch.time.gmtime() 895 | assert eh_now >= now 896 | 897 | def test_time_localtime(self): 898 | now = time.localtime() 899 | 900 | with time_machine.travel(EPOCH): 901 | eh_now = time_machine.escape_hatch.time.localtime() 902 | assert eh_now >= now 903 | 904 | def test_time_monotonic(self): 905 | with time_machine.travel(LIBRARY_EPOCH): 906 | # real monotonic time counts from a small number 907 | assert time_machine.escape_hatch.time.monotonic() < LIBRARY_EPOCH 908 | 909 | def test_time_monotonic_ns(self): 910 | with time_machine.travel(LIBRARY_EPOCH): 911 | # real monotonic time counts from a small number 912 | assert ( 913 | time_machine.escape_hatch.time.monotonic_ns() 914 | < LIBRARY_EPOCH * NANOSECONDS_PER_SECOND 915 | ) 916 | 917 | def test_time_strftime_no_arg(self): 918 | today = dt.date.today() 919 | 920 | with time_machine.travel(EPOCH): 921 | eh_formatted = time_machine.escape_hatch.time.strftime("%Y-%m-%d") 922 | eh_today = dt.datetime.strptime(eh_formatted, "%Y-%m-%d").date() 923 | assert eh_today >= today 924 | 925 | def test_time_strftime_arg(self): 926 | with time_machine.travel(EPOCH): 927 | formatted = time_machine.escape_hatch.time.strftime( 928 | "%Y-%m-%d", 929 | time.localtime(EPOCH_PLUS_ONE_YEAR), 930 | ) 931 | assert formatted == "1971-01-01" 932 | 933 | def test_time_time(self): 934 | now = time.time() 935 | 936 | with time_machine.travel(EPOCH): 937 | eh_now = time_machine.escape_hatch.time.time() 938 | assert eh_now >= now 939 | 940 | def test_time_time_ns(self): 941 | now = time.time_ns() 942 | 943 | with time_machine.travel(EPOCH): 944 | eh_now = time_machine.escape_hatch.time.time_ns() 945 | assert eh_now >= now 946 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | py{313, 312, 311, 310, 39} 6 | 7 | [testenv] 8 | runner = uv-venv-lock-runner 9 | package = wheel 10 | set_env = 11 | PYTHONDEVMODE = 1 12 | commands = 13 | python \ 14 | -W error::ResourceWarning \ 15 | -W error::DeprecationWarning \ 16 | -W error::PendingDeprecationWarning \ 17 | -W ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning \ 18 | -W ignore:datetime.datetime.utcnow:DeprecationWarning \ 19 | -m coverage run \ 20 | -m pytest {posargs:tests} 21 | dependency_groups = 22 | test 23 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.9" 4 | 5 | [[package]] 6 | name = "colorama" 7 | version = "0.4.6" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "coverage" 16 | version = "7.8.2" 17 | source = { registry = "https://pypi.org/simple" } 18 | sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/26/6b/7dd06399a5c0b81007e3a6af0395cd60e6a30f959f8d407d3ee04642e896/coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a", size = 211573, upload-time = "2025-05-23T11:37:47.207Z" }, 21 | { url = "https://files.pythonhosted.org/packages/f0/df/2b24090820a0bac1412955fb1a4dade6bc3b8dcef7b899c277ffaf16916d/coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be", size = 212006, upload-time = "2025-05-23T11:37:50.289Z" }, 22 | { url = "https://files.pythonhosted.org/packages/c5/c4/e4e3b998e116625562a872a342419652fa6ca73f464d9faf9f52f1aff427/coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3", size = 241128, upload-time = "2025-05-23T11:37:52.229Z" }, 23 | { url = "https://files.pythonhosted.org/packages/b1/67/b28904afea3e87a895da850ba587439a61699bf4b73d04d0dfd99bbd33b4/coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6", size = 239026, upload-time = "2025-05-23T11:37:53.846Z" }, 24 | { url = "https://files.pythonhosted.org/packages/8c/0f/47bf7c5630d81bc2cd52b9e13043685dbb7c79372a7f5857279cc442b37c/coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622", size = 240172, upload-time = "2025-05-23T11:37:55.711Z" }, 25 | { url = "https://files.pythonhosted.org/packages/ba/38/af3eb9d36d85abc881f5aaecf8209383dbe0fa4cac2d804c55d05c51cb04/coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c", size = 240086, upload-time = "2025-05-23T11:37:57.724Z" }, 26 | { url = "https://files.pythonhosted.org/packages/9e/64/c40c27c2573adeba0fe16faf39a8aa57368a1f2148865d6bb24c67eadb41/coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3", size = 238792, upload-time = "2025-05-23T11:37:59.737Z" }, 27 | { url = "https://files.pythonhosted.org/packages/8e/ab/b7c85146f15457671c1412afca7c25a5696d7625e7158002aa017e2d7e3c/coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404", size = 239096, upload-time = "2025-05-23T11:38:01.693Z" }, 28 | { url = "https://files.pythonhosted.org/packages/d3/50/9446dad1310905fb1dc284d60d4320a5b25d4e3e33f9ea08b8d36e244e23/coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7", size = 214144, upload-time = "2025-05-23T11:38:03.68Z" }, 29 | { url = "https://files.pythonhosted.org/packages/23/ed/792e66ad7b8b0df757db8d47af0c23659cdb5a65ef7ace8b111cacdbee89/coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347", size = 215043, upload-time = "2025-05-23T11:38:05.217Z" }, 30 | { url = "https://files.pythonhosted.org/packages/6a/4d/1ff618ee9f134d0de5cc1661582c21a65e06823f41caf801aadf18811a8e/coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9", size = 211692, upload-time = "2025-05-23T11:38:08.485Z" }, 31 | { url = "https://files.pythonhosted.org/packages/96/fa/c3c1b476de96f2bc7a8ca01a9f1fcb51c01c6b60a9d2c3e66194b2bdb4af/coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879", size = 212115, upload-time = "2025-05-23T11:38:09.989Z" }, 32 | { url = "https://files.pythonhosted.org/packages/f7/c2/5414c5a1b286c0f3881ae5adb49be1854ac5b7e99011501f81c8c1453065/coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a", size = 244740, upload-time = "2025-05-23T11:38:11.947Z" }, 33 | { url = "https://files.pythonhosted.org/packages/cd/46/1ae01912dfb06a642ef3dd9cf38ed4996fda8fe884dab8952da616f81a2b/coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5", size = 242429, upload-time = "2025-05-23T11:38:13.955Z" }, 34 | { url = "https://files.pythonhosted.org/packages/06/58/38c676aec594bfe2a87c7683942e5a30224791d8df99bcc8439fde140377/coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11", size = 244218, upload-time = "2025-05-23T11:38:15.631Z" }, 35 | { url = "https://files.pythonhosted.org/packages/80/0c/95b1023e881ce45006d9abc250f76c6cdab7134a1c182d9713878dfefcb2/coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a", size = 243865, upload-time = "2025-05-23T11:38:17.622Z" }, 36 | { url = "https://files.pythonhosted.org/packages/57/37/0ae95989285a39e0839c959fe854a3ae46c06610439350d1ab860bf020ac/coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb", size = 242038, upload-time = "2025-05-23T11:38:19.966Z" }, 37 | { url = "https://files.pythonhosted.org/packages/4d/82/40e55f7c0eb5e97cc62cbd9d0746fd24e8caf57be5a408b87529416e0c70/coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54", size = 242567, upload-time = "2025-05-23T11:38:21.912Z" }, 38 | { url = "https://files.pythonhosted.org/packages/f9/35/66a51adc273433a253989f0d9cc7aa6bcdb4855382cf0858200afe578861/coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a", size = 214194, upload-time = "2025-05-23T11:38:23.571Z" }, 39 | { url = "https://files.pythonhosted.org/packages/f6/8f/a543121f9f5f150eae092b08428cb4e6b6d2d134152c3357b77659d2a605/coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975", size = 215109, upload-time = "2025-05-23T11:38:25.137Z" }, 40 | { url = "https://files.pythonhosted.org/packages/77/65/6cc84b68d4f35186463cd7ab1da1169e9abb59870c0f6a57ea6aba95f861/coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53", size = 213521, upload-time = "2025-05-23T11:38:27.123Z" }, 41 | { url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" }, 42 | { url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" }, 43 | { url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" }, 44 | { url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" }, 45 | { url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" }, 46 | { url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" }, 47 | { url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" }, 48 | { url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" }, 49 | { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, 50 | { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, 51 | { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, 52 | { url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" }, 53 | { url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" }, 54 | { url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" }, 55 | { url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" }, 56 | { url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" }, 57 | { url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" }, 58 | { url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" }, 59 | { url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" }, 60 | { url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" }, 61 | { url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" }, 62 | { url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" }, 63 | { url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" }, 64 | { url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" }, 65 | { url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" }, 66 | { url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" }, 67 | { url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" }, 68 | { url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" }, 69 | { url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" }, 70 | { url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" }, 71 | { url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" }, 72 | { url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" }, 73 | { url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" }, 74 | { url = "https://files.pythonhosted.org/packages/71/1e/388267ad9c6aa126438acc1ceafede3bb746afa9872e3ec5f0691b7d5efa/coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a", size = 211566, upload-time = "2025-05-23T11:39:32.333Z" }, 75 | { url = "https://files.pythonhosted.org/packages/8f/a5/acc03e5cf0bba6357f5e7c676343de40fbf431bb1e115fbebf24b2f7f65e/coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d", size = 211996, upload-time = "2025-05-23T11:39:34.512Z" }, 76 | { url = "https://files.pythonhosted.org/packages/5b/a2/0fc0a9f6b7c24fa4f1d7210d782c38cb0d5e692666c36eaeae9a441b6755/coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca", size = 240741, upload-time = "2025-05-23T11:39:36.252Z" }, 77 | { url = "https://files.pythonhosted.org/packages/e6/da/1c6ba2cf259710eed8916d4fd201dccc6be7380ad2b3b9f63ece3285d809/coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d", size = 238672, upload-time = "2025-05-23T11:39:38.03Z" }, 78 | { url = "https://files.pythonhosted.org/packages/ac/51/c8fae0dc3ca421e6e2509503696f910ff333258db672800c3bdef256265a/coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787", size = 239769, upload-time = "2025-05-23T11:39:40.24Z" }, 79 | { url = "https://files.pythonhosted.org/packages/59/8e/b97042ae92c59f40be0c989df090027377ba53f2d6cef73c9ca7685c26a6/coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7", size = 239555, upload-time = "2025-05-23T11:39:42.3Z" }, 80 | { url = "https://files.pythonhosted.org/packages/47/35/b8893e682d6e96b1db2af5997fc13ef62219426fb17259d6844c693c5e00/coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3", size = 237768, upload-time = "2025-05-23T11:39:44.069Z" }, 81 | { url = "https://files.pythonhosted.org/packages/03/6c/023b0b9a764cb52d6243a4591dcb53c4caf4d7340445113a1f452bb80591/coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7", size = 238757, upload-time = "2025-05-23T11:39:46.195Z" }, 82 | { url = "https://files.pythonhosted.org/packages/03/ed/3af7e4d721bd61a8df7de6de9e8a4271e67f3d9e086454558fd9f48eb4f6/coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a", size = 214166, upload-time = "2025-05-23T11:39:47.934Z" }, 83 | { url = "https://files.pythonhosted.org/packages/9d/30/ee774b626773750dc6128354884652507df3c59d6aa8431526107e595227/coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e", size = 215050, upload-time = "2025-05-23T11:39:50.252Z" }, 84 | { url = "https://files.pythonhosted.org/packages/69/2f/572b29496d8234e4a7773200dd835a0d32d9e171f2d974f3fe04a9dbc271/coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837", size = 203636, upload-time = "2025-05-23T11:39:52.002Z" }, 85 | { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, 86 | ] 87 | 88 | [package.optional-dependencies] 89 | toml = [ 90 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 91 | ] 92 | 93 | [[package]] 94 | name = "exceptiongroup" 95 | version = "1.3.0" 96 | source = { registry = "https://pypi.org/simple" } 97 | dependencies = [ 98 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 99 | ] 100 | sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } 101 | wheels = [ 102 | { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, 103 | ] 104 | 105 | [[package]] 106 | name = "importlib-metadata" 107 | version = "8.7.0" 108 | source = { registry = "https://pypi.org/simple" } 109 | dependencies = [ 110 | { name = "zipp" }, 111 | ] 112 | sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } 113 | wheels = [ 114 | { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, 115 | ] 116 | 117 | [[package]] 118 | name = "iniconfig" 119 | version = "2.1.0" 120 | source = { registry = "https://pypi.org/simple" } 121 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } 122 | wheels = [ 123 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, 124 | ] 125 | 126 | [[package]] 127 | name = "packaging" 128 | version = "25.0" 129 | source = { registry = "https://pypi.org/simple" } 130 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 131 | wheels = [ 132 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 133 | ] 134 | 135 | [[package]] 136 | name = "pluggy" 137 | version = "1.6.0" 138 | source = { registry = "https://pypi.org/simple" } 139 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 140 | wheels = [ 141 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 142 | ] 143 | 144 | [[package]] 145 | name = "pygments" 146 | version = "2.19.1" 147 | source = { registry = "https://pypi.org/simple" } 148 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } 149 | wheels = [ 150 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, 151 | ] 152 | 153 | [[package]] 154 | name = "pytest" 155 | version = "8.4.0" 156 | source = { registry = "https://pypi.org/simple" } 157 | dependencies = [ 158 | { name = "colorama", marker = "sys_platform == 'win32'" }, 159 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 160 | { name = "iniconfig" }, 161 | { name = "packaging" }, 162 | { name = "pluggy" }, 163 | { name = "pygments" }, 164 | { name = "tomli", marker = "python_full_version < '3.11'" }, 165 | ] 166 | sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } 167 | wheels = [ 168 | { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, 169 | ] 170 | 171 | [[package]] 172 | name = "pytest-randomly" 173 | version = "3.16.0" 174 | source = { registry = "https://pypi.org/simple" } 175 | dependencies = [ 176 | { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, 177 | { name = "pytest" }, 178 | ] 179 | sdist = { url = "https://files.pythonhosted.org/packages/c0/68/d221ed7f4a2a49a664da721b8e87b52af6dd317af2a6cb51549cf17ac4b8/pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26", size = 13367, upload-time = "2024-10-25T15:45:34.274Z" } 180 | wheels = [ 181 | { url = "https://files.pythonhosted.org/packages/22/70/b31577d7c46d8e2f9baccfed5067dd8475262a2331ffb0bfdf19361c9bde/pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6", size = 8396, upload-time = "2024-10-25T15:45:32.78Z" }, 182 | ] 183 | 184 | [[package]] 185 | name = "python-dateutil" 186 | version = "2.9.0.post0" 187 | source = { registry = "https://pypi.org/simple" } 188 | dependencies = [ 189 | { name = "six" }, 190 | ] 191 | sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } 192 | wheels = [ 193 | { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, 194 | ] 195 | 196 | [[package]] 197 | name = "six" 198 | version = "1.17.0" 199 | source = { registry = "https://pypi.org/simple" } 200 | sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } 201 | wheels = [ 202 | { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, 203 | ] 204 | 205 | [[package]] 206 | name = "time-machine" 207 | version = "2.16.0" 208 | source = { editable = "." } 209 | dependencies = [ 210 | { name = "python-dateutil" }, 211 | ] 212 | 213 | [package.dev-dependencies] 214 | test = [ 215 | { name = "coverage", extra = ["toml"] }, 216 | { name = "pytest" }, 217 | { name = "pytest-randomly" }, 218 | { name = "python-dateutil" }, 219 | ] 220 | 221 | [package.metadata] 222 | requires-dist = [{ name = "python-dateutil" }] 223 | 224 | [package.metadata.requires-dev] 225 | test = [ 226 | { name = "backports-zoneinfo", marker = "python_full_version < '3.9'" }, 227 | { name = "coverage", extras = ["toml"] }, 228 | { name = "pytest" }, 229 | { name = "pytest-randomly" }, 230 | { name = "python-dateutil" }, 231 | ] 232 | 233 | [[package]] 234 | name = "tomli" 235 | version = "2.2.1" 236 | source = { registry = "https://pypi.org/simple" } 237 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } 238 | wheels = [ 239 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, 240 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, 241 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, 242 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, 243 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, 244 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, 245 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, 246 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, 247 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, 248 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, 249 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, 250 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, 251 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, 252 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, 253 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, 254 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, 255 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, 256 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, 257 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, 258 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, 259 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, 260 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, 261 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, 262 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, 263 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, 264 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, 265 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, 266 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, 267 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, 268 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, 269 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, 270 | ] 271 | 272 | [[package]] 273 | name = "typing-extensions" 274 | version = "4.14.0" 275 | source = { registry = "https://pypi.org/simple" } 276 | sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } 277 | wheels = [ 278 | { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, 279 | ] 280 | 281 | [[package]] 282 | name = "zipp" 283 | version = "3.22.0" 284 | source = { registry = "https://pypi.org/simple" } 285 | sdist = { url = "https://files.pythonhosted.org/packages/12/b6/7b3d16792fdf94f146bed92be90b4eb4563569eca91513c8609aebf0c167/zipp-3.22.0.tar.gz", hash = "sha256:dd2f28c3ce4bc67507bfd3781d21b7bb2be31103b51a4553ad7d90b84e57ace5", size = 25257, upload-time = "2025-05-26T14:46:32.217Z" } 286 | wheels = [ 287 | { url = "https://files.pythonhosted.org/packages/ad/da/f64669af4cae46f17b90798a827519ce3737d31dbafad65d391e49643dc4/zipp-3.22.0-py3-none-any.whl", hash = "sha256:fe208f65f2aca48b81f9e6fd8cf7b8b32c26375266b009b413d45306b6148343", size = 9796, upload-time = "2025-05-26T14:46:30.775Z" }, 288 | ] 289 | --------------------------------------------------------------------------------