├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── towncrier.toml ├── docs ├── history.rst ├── index.rst └── conf.py ├── .pre-commit-config.yaml ├── SECURITY.md ├── .editorconfig ├── .coveragerc ├── mypy.ini ├── .readthedocs.yaml ├── pytest.ini ├── tempora ├── utc.py ├── schedule.py ├── timing.py └── __init__.py ├── ruff.toml ├── tests ├── test_timing.py └── test_schedule.py ├── tox.ini ├── README.rst ├── pyproject.toml └── NEWS.rst /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/tempora 2 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | disable_warnings = 6 | couldnt-parse 7 | 8 | [report] 9 | show_missing = True 10 | exclude_also = 11 | # Exclude common false positives per 12 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 13 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 14 | class .*\bProtocol\): 15 | if TYPE_CHECKING: 16 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = False 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | 27 | # spulec/freezegun#508 28 | ignore:datetime.datetime.utcnow:DeprecationWarning:freezegun.api 29 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | history 12 | 13 | 14 | .. tidelift-referral-banner:: 15 | 16 | .. automodule:: tempora 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | Timing 22 | ------ 23 | 24 | .. automodule:: tempora.timing 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | 29 | Schedule 30 | -------- 31 | 32 | .. automodule:: tempora.schedule 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | UTC 38 | --- 39 | 40 | .. automodule:: tempora.utc 41 | :members: 42 | :undoc-members: 43 | :show-inheritance: 44 | 45 | 46 | Indices and tables 47 | ================== 48 | 49 | * :ref:`genindex` 50 | * :ref:`modindex` 51 | * :ref:`search` 52 | -------------------------------------------------------------------------------- /tempora/utc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Facilities for common time operations in UTC. 3 | 4 | Inspired by the `utc project `_. 5 | 6 | >>> dt = now() 7 | >>> dt == fromtimestamp(dt.timestamp()) 8 | True 9 | >>> dt.tzinfo 10 | datetime.timezone.utc 11 | 12 | >>> from time import time as timestamp 13 | >>> now().timestamp() - timestamp() < 0.1 14 | True 15 | 16 | >>> (now() - fromtimestamp(timestamp())).total_seconds() < 0.1 17 | True 18 | 19 | >>> datetime(2018, 6, 26, 0).tzinfo 20 | datetime.timezone.utc 21 | 22 | >>> time(0, 0).tzinfo 23 | datetime.timezone.utc 24 | 25 | Now should be affected by freezegun. 26 | 27 | >>> freezer = getfixture('freezer') 28 | >>> freezer.move_to('1999-12-31 17:00:00 -0700') 29 | >>> print(now()) 30 | 2000-01-01 00:00:00+00:00 31 | """ 32 | 33 | import datetime as std 34 | import functools 35 | 36 | __all__ = ['now', 'fromtimestamp', 'datetime', 'time'] 37 | 38 | 39 | def now() -> std.datetime: 40 | return std.datetime.now(std.timezone.utc) 41 | 42 | 43 | fromtimestamp = functools.partial(std.datetime.fromtimestamp, tz=std.timezone.utc) 44 | datetime = functools.partial(std.datetime, tzinfo=std.timezone.utc) 45 | time = functools.partial(std.time, tzinfo=std.timezone.utc) 46 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | ] 45 | 46 | [format] 47 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 48 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 49 | preview = true 50 | # https://docs.astral.sh/ruff/settings/#format_quote-style 51 | quote-style = "preserve" 52 | -------------------------------------------------------------------------------- /tests/test_timing.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import datetime 3 | import os 4 | import time 5 | from unittest import mock 6 | 7 | import pytest 8 | 9 | from tempora import timing 10 | 11 | 12 | def test_IntervalGovernor(): 13 | """ 14 | IntervalGovernor should prevent a function from being called more than 15 | once per interval. 16 | """ 17 | func_under_test = mock.MagicMock() 18 | # to look like a function, it needs a __name__ attribute 19 | func_under_test.__name__ = 'func_under_test' 20 | interval = datetime.timedelta(seconds=1) 21 | governed = timing.IntervalGovernor(interval)(func_under_test) 22 | governed('a') 23 | governed('b') 24 | governed(3, 'sir') 25 | func_under_test.assert_called_once_with('a') 26 | 27 | 28 | @pytest.fixture 29 | def alt_tz(monkeypatch): 30 | hasattr(time, 'tzset') or pytest.skip("tzset not available") 31 | 32 | @contextlib.contextmanager 33 | def change(): 34 | val = 'AEST-10AEDT-11,M10.5.0,M3.5.0' 35 | with monkeypatch.context() as ctx: 36 | ctx.setitem(os.environ, 'TZ', val) 37 | time.tzset() 38 | yield 39 | time.tzset() 40 | 41 | return change() 42 | 43 | 44 | def test_Stopwatch_timezone_change(alt_tz): 45 | """ 46 | The stopwatch should provide a consistent duration even 47 | if the timezone changes. 48 | """ 49 | watch = timing.Stopwatch() 50 | with alt_tz: 51 | assert abs(watch.split().total_seconds()) < 0.1 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | usedevelop = True 9 | extras = 10 | test 11 | check 12 | cover 13 | enabler 14 | type 15 | 16 | [testenv:diffcov] 17 | description = run tests and check that diff from main is covered 18 | deps = 19 | {[testenv]deps} 20 | diff-cover 21 | commands = 22 | pytest {posargs} --cov-report xml 23 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 24 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 25 | 26 | [testenv:docs] 27 | description = build the documentation 28 | extras = 29 | doc 30 | test 31 | changedir = docs 32 | commands = 33 | python -m sphinx -W --keep-going . {toxinidir}/build/html 34 | python -m sphinxlint 35 | 36 | [testenv:finalize] 37 | description = assemble changelog and tag a release 38 | skip_install = True 39 | deps = 40 | towncrier 41 | jaraco.develop >= 7.23 42 | pass_env = * 43 | commands = 44 | python -m jaraco.develop.finalize 45 | 46 | 47 | [testenv:release] 48 | description = publish the package to PyPI and GitHub 49 | skip_install = True 50 | deps = 51 | build 52 | twine>=3 53 | jaraco.develop>=7.1 54 | pass_env = 55 | TWINE_PASSWORD 56 | GITHUB_TOKEN 57 | setenv = 58 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 59 | commands = 60 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 61 | python -m build 62 | python -m twine upload dist/* 63 | python -m jaraco.develop.create-github-release 64 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | # Link dates and other references in the changelog 12 | extensions += ['rst.linker'] 13 | link_files = { 14 | '../NEWS.rst': dict( 15 | using=dict(GH='https://github.com'), 16 | replace=[ 17 | dict( 18 | pattern=r'(Issue #|\B#)(?P\d+)', 19 | url='{package_url}/issues/{issue}', 20 | ), 21 | dict( 22 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 23 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 24 | ), 25 | dict( 26 | pattern=r'PEP[- ](?P\d+)', 27 | url='https://peps.python.org/pep-{pep_number:0>4}/', 28 | ), 29 | ], 30 | ) 31 | } 32 | 33 | # Be strict about any broken references 34 | nitpicky = True 35 | nitpick_ignore: list[tuple[str, str]] = [] 36 | 37 | # Include Python intersphinx mapping to prevent failures 38 | # jaraco/skeleton#51 39 | extensions += ['sphinx.ext.intersphinx'] 40 | intersphinx_mapping = { 41 | 'python': ('https://docs.python.org/3', None), 42 | } 43 | 44 | # Preserve authored syntax for defaults 45 | autodoc_preserve_defaults = True 46 | 47 | # Add support for linking usernames, PyPI projects, Wikipedia pages 48 | github_url = 'https://github.com/' 49 | extlinks = { 50 | 'user': (f'{github_url}%s', '@%s'), 51 | 'pypi': ('https://pypi.org/project/%s', '%s'), 52 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 53 | } 54 | extensions += ['sphinx.ext.extlinks'] 55 | 56 | # local 57 | 58 | extensions += ['jaraco.tidelift'] 59 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/tempora.svg 2 | :target: https://pypi.org/project/tempora 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/tempora.svg 5 | 6 | .. image:: https://github.com/jaraco/tempora/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/jaraco/tempora/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/tempora/badge/?version=latest 15 | :target: https://tempora.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://tidelift.com/badges/package/pypi/tempora 21 | :target: https://tidelift.com/subscription/pkg/pypi-tempora?utm_source=pypi-tempora&utm_medium=readme 22 | 23 | Objects and routines pertaining to date and time (tempora). 24 | 25 | Modules include: 26 | 27 | - tempora (top level package module) contains miscellaneous 28 | utilities and constants. 29 | - timing contains routines for measuring and profiling. 30 | - schedule contains an event scheduler. 31 | - utc contains routines for getting datetime-aware UTC values. 32 | 33 | For Enterprise 34 | ============== 35 | 36 | Available as part of the Tidelift Subscription. 37 | 38 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 39 | 40 | `Learn more `_. 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "tempora" 12 | authors = [ 13 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 14 | ] 15 | description = "Objects and routines pertaining to date and time (tempora)" 16 | readme = "README.rst" 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Intended Audience :: Developers", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | ] 23 | requires-python = ">=3.9" 24 | license = "MIT" 25 | dependencies = [ 26 | "jaraco.functools>=1.20", 27 | "python-dateutil", 28 | ] 29 | dynamic = ["version"] 30 | 31 | [project.urls] 32 | Source = "https://github.com/jaraco/tempora" 33 | 34 | [project.optional-dependencies] 35 | test = [ 36 | # upstream 37 | "pytest >= 6, != 8.1.*", 38 | 39 | # local 40 | "pytest-freezer", 41 | "backports.zoneinfo; python_version < '3.9'", 42 | "tzdata; platform_system == 'Windows'", 43 | ] 44 | 45 | doc = [ 46 | # upstream 47 | "sphinx >= 3.5", 48 | "jaraco.packaging >= 9.3", 49 | "rst.linker >= 1.9", 50 | "furo", 51 | "sphinx-lint", 52 | 53 | # tidelift 54 | "jaraco.tidelift >= 1.4", 55 | 56 | # local 57 | ] 58 | 59 | check = [ 60 | "pytest-checkdocs >= 2.4", 61 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 62 | ] 63 | 64 | cover = [ 65 | "pytest-cov", 66 | ] 67 | 68 | enabler = [ 69 | "pytest-enabler >= 2.2", 70 | ] 71 | 72 | type = [ 73 | # upstream 74 | "pytest-mypy", 75 | 76 | # local 77 | "types-python-dateutil", 78 | ] 79 | 80 | 81 | [project.scripts] 82 | calc-prorate = "tempora:calculate_prorated_values" 83 | 84 | 85 | [tool.setuptools_scm] 86 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v5.8.1 2 | ====== 3 | 4 | No significant changes. 5 | 6 | 7 | v5.8.0 8 | ====== 9 | 10 | Features 11 | -------- 12 | 13 | - Drop support for Python 3.8, now EOL. 14 | 15 | 16 | v5.7.1 17 | ====== 18 | 19 | No significant changes. 20 | 21 | 22 | v5.7.0 23 | ====== 24 | 25 | Features 26 | -------- 27 | 28 | - Add a tzinfos mapping and parse method for easy datetime parsing with timezone support. 29 | 30 | 31 | v5.6.0 32 | ====== 33 | 34 | Features 35 | -------- 36 | 37 | - Removed dependency on pytz. (#29) 38 | - In utc.now(), bind late to allow for monkeypatching. (#31) 39 | 40 | 41 | v5.5.1 42 | ====== 43 | 44 | Bugfixes 45 | -------- 46 | 47 | - Remove test dependency on backports.unittest_mock. (#26) 48 | 49 | 50 | v5.5.0 51 | ====== 52 | 53 | Features 54 | -------- 55 | 56 | - Stopwatch now uses ``time.monotonic``. 57 | 58 | 59 | v5.4.0 60 | ====== 61 | 62 | Features 63 | -------- 64 | 65 | - Require Python 3.8 or later. 66 | 67 | 68 | v5.3.0 69 | ====== 70 | 71 | #24: Removed use of ``datetime.utc**`` functions 72 | deprecated in Python 3.12. 73 | 74 | v5.2.2 75 | ====== 76 | 77 | #22: Fixed bug in tests that would fail when a leap year 78 | was about a year away. 79 | 80 | v5.2.1 81 | ====== 82 | 83 | #21: Restored dependency on ``jaraco.functools``, still 84 | used in timing module. 85 | 86 | v5.2.0 87 | ====== 88 | 89 | Remove dependency on jaraco.functools. 90 | 91 | v5.1.1 92 | ====== 93 | 94 | Packaging refresh. 95 | 96 | v5.1.0 97 | ====== 98 | 99 | Introduced ``infer_datetime`` and added some type hints. 100 | 101 | v5.0.2 102 | ====== 103 | 104 | - Refreshed project. 105 | - Enrolled with Tidelift. 106 | 107 | v5.0.1 108 | ====== 109 | 110 | - Refreshed project. 111 | 112 | v5.0.0 113 | ====== 114 | 115 | - Removed deprecated ``divide_*`` functions and ``Parser`` 116 | class. 117 | - Require Python 3.7 or later. 118 | - #19: Fixed error reporting in parse_timedelta. 119 | 120 | v4.1.2 121 | ====== 122 | 123 | - #18: Docs now build without warnings. 124 | 125 | v4.1.1 126 | ====== 127 | 128 | - Fixed issue where digits were picked up in the unit when 129 | adjacent to the last unit. 130 | 131 | v4.1.0 132 | ====== 133 | 134 | - Added support for more formats in ``parse_timedelta``. 135 | - #17: ``parse_timedelta`` now supports formats emitted by 136 | ``timeit``, including honoring nanoseconds at the 137 | microsecond resolution. 138 | 139 | v4.0.2 140 | ====== 141 | 142 | - Refreshed package metadata. 143 | 144 | v4.0.1 145 | ====== 146 | 147 | - Refreshed package metadata. 148 | 149 | v4.0.0 150 | ====== 151 | 152 | - Removed ``strptime`` function in favor of 153 | `datetime.datetime.strptime `_. If passing 154 | a ``tzinfo`` parameter, instead invoke `.replace(tzinfo=...)` 155 | on the result. 156 | - Deprecated ``divide_timedelta`` and ``divide_timedelta_float`` 157 | now that Python supports this functionality natively. 158 | - Deprecated ``Parser`` class. The 159 | `dateutil.parser `_ 160 | provides more sophistication. 161 | 162 | v3.0.0 163 | ====== 164 | 165 | - #10: ``strftime`` now reverts to the stdlib behavior for 166 | ``%u``. Use tempora 2.1 or later and the ``%µ`` for 167 | microseconds. 168 | 169 | v2.1.1 170 | ====== 171 | 172 | - #8: Fixed error in ``PeriodicCommandFixedDelay.daily_at`` 173 | when timezone is more than 12 hours from UTC. 174 | 175 | v2.1.0 176 | ====== 177 | 178 | - #9: Fixed error when date object is passed to ``strftime``. 179 | - #11: ``strftime`` now honors upstream expectation of 180 | rendering date values on time objects and vice versa. 181 | - #10: ``strftime`` now honors ``%µ`` for rendering just 182 | the "microseconds" as ``%u`` supported previously. 183 | In a future, backward-incompatible release, the 184 | ``%u`` behavior will revert to the behavior as found 185 | in stdlib. 186 | 187 | v2.0.0 188 | ====== 189 | 190 | * Require Python 3.6 or later. 191 | * Removed DatetimeConstructor. 192 | 193 | 1.14.1 194 | ====== 195 | 196 | #7: Fix failing doctest in ``parse_timedelta``. 197 | 198 | 1.14 199 | ==== 200 | 201 | Package refresh, including use of declarative config in 202 | the package metadata. 203 | 204 | 1.13 205 | ==== 206 | 207 | Enhancements to BackoffDelay: 208 | 209 | - Added ``.reset`` method. 210 | - Made iterable to retrieve delay values. 211 | 212 | 1.12 213 | ==== 214 | 215 | Added UTC module (Python 3 only), inspired by the 216 | `utc project `_. 217 | 218 | 1.11 219 | ==== 220 | 221 | #5: Scheduler now honors daylight savings times in the 222 | PeriodicCommands. 223 | 224 | 1.10 225 | ==== 226 | 227 | Added ``timing.BackoffDelay``, suitable for implementing 228 | exponential backoff delays, such as those between retries. 229 | 230 | 1.9 231 | === 232 | 233 | Added support for months, years to ``parse_timedelta``. 234 | 235 | 1.8 236 | === 237 | 238 | Introducing ``timing.Timer``, featuring a ``expired`` 239 | method for detecting when a certain duration has been 240 | exceeded. 241 | 242 | 1.7.1 243 | ===== 244 | 245 | #3: Stopwatch now behaves reliably during timezone 246 | changes and (presumably) daylight savings time 247 | changes. 248 | 249 | 1.7 250 | === 251 | 252 | Update project skeleton. 253 | 254 | 1.6 255 | === 256 | 257 | Adopt ``irc.schedule`` as ``tempora.schedule``. 258 | 259 | 1.5 260 | === 261 | 262 | Adopt ``jaraco.timing`` as ``tempora.timing``. 263 | 264 | Automatic deployment with Travis-CI. 265 | 266 | 1.4 267 | === 268 | 269 | Moved to Github. 270 | 271 | Improved test support on Python 2. 272 | 273 | 1.3 274 | === 275 | 276 | Added divide_timedelta from ``svg.charts``. 277 | Added date_range from ``svg.charts``. 278 | -------------------------------------------------------------------------------- /tests/test_schedule.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import random 3 | import time 4 | import zoneinfo 5 | from unittest import mock 6 | 7 | import freezegun 8 | import pytest 9 | 10 | from tempora import schedule 11 | 12 | do_nothing = type(None) 13 | 14 | 15 | def test_delayed_command_order(): 16 | """ 17 | delayed commands should be sorted by delay time 18 | """ 19 | delays = [random.randint(0, 99) for x in range(5)] 20 | cmds = sorted(schedule.DelayedCommand.after(delay, do_nothing) for delay in delays) 21 | assert [c.delay.seconds for c in cmds] == sorted(delays) 22 | 23 | 24 | def test_periodic_command_delay(): 25 | "A PeriodicCommand must have a positive, non-zero delay." 26 | with pytest.raises(ValueError) as exc_info: 27 | schedule.PeriodicCommand.after(0, None) 28 | assert str(exc_info.value) == test_periodic_command_delay.__doc__ 29 | 30 | 31 | def test_periodic_command_fixed_delay(): 32 | """ 33 | Test that we can construct a periodic command with a fixed initial 34 | delay. 35 | """ 36 | fd = schedule.PeriodicCommandFixedDelay.at_time( 37 | at=schedule.now(), delay=datetime.timedelta(seconds=2), target=lambda: None 38 | ) 39 | assert fd.due() is True 40 | assert fd.next().due() is False 41 | 42 | 43 | class TestCommands: 44 | def test_delayed_command_from_timestamp(self): 45 | """ 46 | Ensure a delayed command can be constructed from a timestamp. 47 | """ 48 | t = time.time() 49 | schedule.DelayedCommand.at_time(t, do_nothing) 50 | 51 | def test_command_at_noon(self): 52 | """ 53 | Create a periodic command that's run at noon every day. 54 | """ 55 | when = datetime.time(12, 0, tzinfo=zoneinfo.ZoneInfo('UTC')) 56 | cmd = schedule.PeriodicCommandFixedDelay.daily_at(when, target=None) 57 | assert cmd.due() is False 58 | next_cmd = cmd.next() 59 | daily = datetime.timedelta(days=1) 60 | day_from_now = schedule.now() + daily 61 | two_days_from_now = day_from_now + daily 62 | assert day_from_now < next_cmd < two_days_from_now 63 | 64 | @pytest.mark.parametrize("hour", range(10, 14)) 65 | @pytest.mark.parametrize("tz_offset", (14, -14)) 66 | def test_command_at_noon_distant_local(self, hour, tz_offset): 67 | """ 68 | Run test_command_at_noon, but with the local timezone 69 | more than 12 hours away from UTC. 70 | """ 71 | with freezegun.freeze_time(f"2020-01-10 {hour:02}:01", tz_offset=tz_offset): 72 | self.test_command_at_noon() 73 | 74 | 75 | class TestTimezones: 76 | def test_alternate_timezone_west(self): 77 | target_tz = zoneinfo.ZoneInfo('US/Pacific') 78 | target = schedule.now().astimezone(target_tz) 79 | cmd = schedule.DelayedCommand.at_time(target, target=None) 80 | assert cmd.due() 81 | 82 | def test_alternate_timezone_east(self): 83 | target_tz = zoneinfo.ZoneInfo('Europe/Amsterdam') 84 | target = schedule.now().astimezone(target_tz) 85 | cmd = schedule.DelayedCommand.at_time(target, target=None) 86 | assert cmd.due() 87 | 88 | def test_daylight_savings(self): 89 | """ 90 | A command at 9am should always be 9am regardless of 91 | a DST boundary. 92 | """ 93 | with freezegun.freeze_time('2018-03-10'): 94 | target_tz = zoneinfo.ZoneInfo('US/Eastern') 95 | target_time = datetime.time(9, tzinfo=target_tz) 96 | cmd = schedule.PeriodicCommandFixedDelay.daily_at( 97 | target_time, target=lambda: None 98 | ) 99 | assert not cmd.due() 100 | 101 | def naive(dt): 102 | return dt.replace(tzinfo=None) 103 | 104 | assert naive(cmd) == datetime.datetime(2018, 3, 10, 9, 0, 0) 105 | 106 | with freezegun.freeze_time('2018-03-10 8:59:59 -0500'): 107 | assert not cmd.due() 108 | 109 | with freezegun.freeze_time('2018-03-10 9:00:00 -0500'): 110 | assert cmd.due() 111 | 112 | next_ = cmd.next() 113 | 114 | assert naive(next_) == datetime.datetime(2018, 3, 11, 9, 0, 0) 115 | 116 | with freezegun.freeze_time('2018-03-11 8:59:59 -0400'): 117 | assert not next_.due() 118 | 119 | with freezegun.freeze_time('2018-03-11 9:00:00 -0400'): 120 | assert next_.due() 121 | 122 | 123 | class TestScheduler: 124 | def test_invoke_scheduler(self): 125 | sched = schedule.InvokeScheduler() 126 | target = mock.MagicMock() 127 | cmd = schedule.DelayedCommand.after(0, target) 128 | sched.add(cmd) 129 | sched.run_pending() 130 | target.assert_called_once() 131 | assert not sched.queue 132 | 133 | def test_callback_scheduler(self): 134 | callback = mock.MagicMock() 135 | sched = schedule.CallbackScheduler(callback) 136 | target = mock.MagicMock() 137 | cmd = schedule.DelayedCommand.after(0, target) 138 | sched.add(cmd) 139 | sched.run_pending() 140 | callback.assert_called_once_with(target) 141 | 142 | def test_periodic_command(self): 143 | sched = schedule.InvokeScheduler() 144 | target = mock.MagicMock() 145 | 146 | before = datetime.datetime.now(tz=datetime.timezone.utc) 147 | 148 | cmd = schedule.PeriodicCommand.after(10, target) 149 | sched.add(cmd) 150 | sched.run_pending() 151 | target.assert_not_called() 152 | 153 | with freezegun.freeze_time(before + datetime.timedelta(seconds=15)): 154 | sched.run_pending() 155 | assert sched.queue 156 | target.assert_called_once() 157 | 158 | with freezegun.freeze_time(before + datetime.timedelta(seconds=25)): 159 | sched.run_pending() 160 | assert target.call_count == 2 161 | -------------------------------------------------------------------------------- /tempora/schedule.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classes for calling functions a schedule. Has time zone support. 3 | 4 | For example, to run a job at 08:00 every morning in 'Asia/Calcutta': 5 | 6 | >>> import zoneinfo 7 | >>> job = lambda: print("time is now", datetime.datetime()) 8 | >>> time = datetime.time(8, tzinfo=zoneinfo.ZoneInfo('Asia/Calcutta')) 9 | >>> cmd = PeriodicCommandFixedDelay.daily_at(time, job) 10 | >>> sched = InvokeScheduler() 11 | >>> sched.add(cmd) 12 | >>> while True: # doctest: +SKIP 13 | ... sched.run_pending() 14 | ... time.sleep(.1) 15 | 16 | By default, the scheduler uses timezone-aware times in UTC. A 17 | client may override the default behavior by overriding ``now`` 18 | and ``from_timestamp`` functions. 19 | 20 | >>> now() 21 | datetime.datetime(...utc) 22 | >>> from_timestamp(1718723533.7685602) 23 | datetime.datetime(...utc) 24 | """ 25 | 26 | from __future__ import annotations 27 | 28 | import abc 29 | import bisect 30 | import datetime 31 | import numbers 32 | from typing import TYPE_CHECKING, Any 33 | 34 | from .utc import fromtimestamp as from_timestamp 35 | from .utc import now 36 | 37 | if TYPE_CHECKING: 38 | from typing_extensions import Self 39 | 40 | 41 | class DelayedCommand(datetime.datetime): 42 | """ 43 | A command to be executed after some delay (seconds or timedelta). 44 | """ 45 | 46 | delay: datetime.timedelta = datetime.timedelta() 47 | target: Any # Expected type depends on the scheduler used 48 | 49 | @classmethod 50 | def from_datetime(cls, other) -> Self: 51 | return cls( 52 | other.year, 53 | other.month, 54 | other.day, 55 | other.hour, 56 | other.minute, 57 | other.second, 58 | other.microsecond, 59 | other.tzinfo, 60 | ) 61 | 62 | @classmethod 63 | def after(cls, delay, target) -> Self: 64 | if not isinstance(delay, datetime.timedelta): 65 | delay = datetime.timedelta(seconds=delay) 66 | due_time = now() + delay 67 | cmd = cls.from_datetime(due_time) 68 | cmd.delay = delay 69 | cmd.target = target 70 | return cmd 71 | 72 | @staticmethod 73 | def _from_timestamp(input): 74 | """ 75 | If input is a real number, interpret it as a Unix timestamp 76 | (seconds sinc Epoch in UTC) and return a timezone-aware 77 | datetime object. Otherwise return input unchanged. 78 | """ 79 | if not isinstance(input, numbers.Real): 80 | return input 81 | return from_timestamp(input) 82 | 83 | @classmethod 84 | def at_time(cls, at, target) -> Self: 85 | """ 86 | Construct a DelayedCommand to come due at `at`, where `at` may be 87 | a datetime or timestamp. 88 | """ 89 | at = cls._from_timestamp(at) 90 | cmd = cls.from_datetime(at) 91 | cmd.delay = at - now() 92 | cmd.target = target 93 | return cmd 94 | 95 | def due(self) -> bool: 96 | return now() >= self 97 | 98 | 99 | class PeriodicCommand(DelayedCommand): 100 | """ 101 | Like a delayed command, but expect this command to run every delay 102 | seconds. 103 | """ 104 | 105 | def _next_time(self) -> Self: 106 | """ 107 | Add delay to self, localized 108 | """ 109 | return self + self.delay 110 | 111 | def next(self) -> Self: 112 | cmd = self.__class__.from_datetime(self._next_time()) 113 | cmd.delay = self.delay 114 | cmd.target = self.target 115 | return cmd 116 | 117 | def __setattr__(self, key, value) -> None: 118 | if key == 'delay' and not value > datetime.timedelta(): 119 | raise ValueError("A PeriodicCommand must have a positive, non-zero delay.") 120 | super().__setattr__(key, value) 121 | 122 | 123 | class PeriodicCommandFixedDelay(PeriodicCommand): 124 | """ 125 | Like a periodic command, but don't calculate the delay based on 126 | the current time. Instead use a fixed delay following the initial 127 | run. 128 | """ 129 | 130 | @classmethod 131 | def at_time(cls, at, delay, target) -> Self: # type: ignore[override] # jaraco/tempora#39 132 | """ 133 | >>> cmd = PeriodicCommandFixedDelay.at_time(0, 30, None) 134 | >>> cmd.delay.total_seconds() 135 | 30.0 136 | """ 137 | at = cls._from_timestamp(at) 138 | cmd = cls.from_datetime(at) 139 | if isinstance(delay, numbers.Number): 140 | delay = datetime.timedelta(seconds=delay) # type: ignore[arg-type] # python/mypy#3186#issuecomment-1571512649 141 | cmd.delay = delay 142 | cmd.target = target 143 | return cmd 144 | 145 | @classmethod 146 | def daily_at(cls, at, target) -> Self: 147 | """ 148 | Schedule a command to run at a specific time each day. 149 | 150 | >>> from tempora import utc 151 | >>> noon = utc.time(12, 0) 152 | >>> cmd = PeriodicCommandFixedDelay.daily_at(noon, None) 153 | >>> cmd.delay.total_seconds() 154 | 86400.0 155 | """ 156 | daily = datetime.timedelta(days=1) 157 | # convert when to the next datetime matching this time 158 | when = datetime.datetime.combine(datetime.date.today(), at) 159 | when -= daily 160 | while when < now(): 161 | when += daily 162 | return cls.at_time(when, daily, target) 163 | 164 | 165 | class Scheduler: 166 | """ 167 | A rudimentary abstract scheduler accepting DelayedCommands 168 | and dispatching them on schedule. 169 | """ 170 | 171 | def __init__(self) -> None: 172 | self.queue: list[DelayedCommand] = [] 173 | 174 | def add(self, command: DelayedCommand) -> None: 175 | bisect.insort(self.queue, command) 176 | 177 | def run_pending(self) -> None: 178 | while self.queue: 179 | command = self.queue[0] 180 | if not command.due(): 181 | break 182 | self.run(command) 183 | if isinstance(command, PeriodicCommand): 184 | self.add(command.next()) 185 | del self.queue[0] 186 | 187 | @abc.abstractmethod 188 | def run(self, command: DelayedCommand) -> None: 189 | """ 190 | Run the command 191 | """ 192 | 193 | 194 | class InvokeScheduler(Scheduler): 195 | """ 196 | Command targets are functions to be invoked on schedule. 197 | """ 198 | 199 | def run(self, command: DelayedCommand) -> None: 200 | command.target() 201 | 202 | 203 | class CallbackScheduler(Scheduler): 204 | """ 205 | Command targets are passed to a dispatch callable on schedule. 206 | """ 207 | 208 | def __init__(self, dispatch) -> None: 209 | super().__init__() 210 | self.dispatch = dispatch 211 | 212 | def run(self, command: DelayedCommand) -> None: 213 | self.dispatch(command.target) 214 | -------------------------------------------------------------------------------- /tempora/timing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc 4 | import contextlib 5 | import datetime 6 | import functools 7 | import numbers 8 | import time 9 | from types import TracebackType 10 | from typing import TYPE_CHECKING 11 | 12 | import jaraco.functools 13 | 14 | if TYPE_CHECKING: 15 | from typing_extensions import Self 16 | 17 | 18 | class Stopwatch: 19 | """ 20 | A simple stopwatch that starts automatically. 21 | 22 | >>> w = Stopwatch() 23 | >>> _1_sec = datetime.timedelta(seconds=1) 24 | >>> w.split() < _1_sec 25 | True 26 | >>> time.sleep(1.0) 27 | >>> w.split() >= _1_sec 28 | True 29 | >>> w.stop() >= _1_sec 30 | True 31 | >>> w.reset() 32 | >>> w.start() 33 | >>> w.split() < _1_sec 34 | True 35 | 36 | Launch the Stopwatch in a context: 37 | 38 | >>> with Stopwatch() as watch: 39 | ... assert isinstance(watch.split(), datetime.timedelta) 40 | 41 | After exiting the context, the watch is stopped; read the 42 | elapsed time directly: 43 | 44 | >>> watch.elapsed 45 | datetime.timedelta(...) 46 | >>> watch.elapsed.seconds 47 | 0 48 | """ 49 | 50 | def __init__(self) -> None: 51 | self.reset() 52 | self.start() 53 | 54 | def reset(self) -> None: 55 | self.elapsed = datetime.timedelta(0) 56 | with contextlib.suppress(AttributeError): 57 | del self._start 58 | 59 | def _diff(self) -> datetime.timedelta: 60 | return datetime.timedelta(seconds=time.monotonic() - self._start) 61 | 62 | def start(self) -> None: 63 | self._start = time.monotonic() 64 | 65 | def stop(self) -> datetime.timedelta: 66 | self.elapsed += self._diff() 67 | del self._start 68 | return self.elapsed 69 | 70 | def split(self) -> datetime.timedelta: 71 | return self.elapsed + self._diff() 72 | 73 | # context manager support 74 | def __enter__(self) -> Self: 75 | self.start() 76 | return self 77 | 78 | def __exit__( 79 | self, 80 | exc_type: type[BaseException] | None, 81 | exc_value: BaseException | None, 82 | traceback: TracebackType | None, 83 | ) -> None: 84 | self.stop() 85 | 86 | 87 | class IntervalGovernor: 88 | """ 89 | Decorate a function to only allow it to be called once per 90 | min_interval. Otherwise, it returns None. 91 | 92 | >>> gov = IntervalGovernor(30) 93 | >>> gov.min_interval.total_seconds() 94 | 30.0 95 | """ 96 | 97 | def __init__(self, min_interval) -> None: 98 | if isinstance(min_interval, numbers.Number): 99 | min_interval = datetime.timedelta(seconds=min_interval) # type: ignore[arg-type] # python/mypy#3186#issuecomment-1571512649 100 | self.min_interval = min_interval 101 | self.last_call = None 102 | 103 | def decorate(self, func): 104 | @functools.wraps(func) 105 | def wrapper(*args, **kwargs): 106 | allow = not self.last_call or self.last_call.split() > self.min_interval 107 | if allow: 108 | self.last_call = Stopwatch() 109 | return func(*args, **kwargs) 110 | 111 | return wrapper 112 | 113 | __call__ = decorate 114 | 115 | 116 | class Timer(Stopwatch): 117 | """ 118 | Watch for a target elapsed time. 119 | 120 | >>> t = Timer(0.1) 121 | >>> t.expired() 122 | False 123 | >>> __import__('time').sleep(0.15) 124 | >>> t.expired() 125 | True 126 | """ 127 | 128 | def __init__(self, target=float('Inf')) -> None: 129 | self.target = self._accept(target) 130 | super().__init__() 131 | 132 | @staticmethod 133 | def _accept(target: float) -> float: 134 | """ 135 | Accept None or ∞ or datetime or numeric for target 136 | 137 | >>> Timer._accept(datetime.timedelta(seconds=30)) 138 | 30.0 139 | >>> Timer._accept(None) 140 | inf 141 | """ 142 | if isinstance(target, datetime.timedelta): 143 | target = target.total_seconds() 144 | 145 | if target is None: 146 | # treat None as infinite target 147 | target = float('Inf') 148 | 149 | return target 150 | 151 | def expired(self) -> bool: 152 | return self.split().total_seconds() > self.target 153 | 154 | 155 | class BackoffDelay(collections.abc.Iterator): 156 | """ 157 | Exponential backoff delay. 158 | 159 | Useful for defining delays between retries. Consider for use 160 | with ``jaraco.functools.retry_call`` as the cleanup. 161 | 162 | Default behavior has no effect; a delay or jitter must 163 | be supplied for the call to be non-degenerate. 164 | 165 | >>> bd = BackoffDelay() 166 | >>> bd() 167 | >>> bd() 168 | 169 | The following instance will delay 10ms for the first call, 170 | 20ms for the second, etc. 171 | 172 | >>> bd = BackoffDelay(delay=0.01, factor=2) 173 | >>> bd() 174 | >>> bd() 175 | 176 | Inspect and adjust the state of the delay anytime. 177 | 178 | >>> bd.delay 179 | 0.04 180 | >>> bd.delay = 0.01 181 | 182 | Set limit to prevent the delay from exceeding bounds. 183 | 184 | >>> bd = BackoffDelay(delay=0.01, factor=2, limit=0.015) 185 | >>> bd() 186 | >>> bd.delay 187 | 0.015 188 | 189 | To reset the backoff, simply call ``.reset()``: 190 | 191 | >>> bd.reset() 192 | >>> bd.delay 193 | 0.01 194 | 195 | Iterate on the object to retrieve/advance the delay values. 196 | 197 | >>> next(bd) 198 | 0.01 199 | >>> next(bd) 200 | 0.015 201 | >>> import itertools 202 | >>> tuple(itertools.islice(bd, 3)) 203 | (0.015, 0.015, 0.015) 204 | 205 | Limit may be a callable taking a number and returning 206 | the limited number. 207 | 208 | >>> at_least_one = lambda n: max(n, 1) 209 | >>> bd = BackoffDelay(delay=0.01, factor=2, limit=at_least_one) 210 | >>> next(bd) 211 | 0.01 212 | >>> next(bd) 213 | 1 214 | 215 | Pass a jitter to add or subtract seconds to the delay. 216 | 217 | >>> bd = BackoffDelay(jitter=0.01) 218 | >>> next(bd) 219 | 0 220 | >>> next(bd) 221 | 0.01 222 | 223 | Jitter may be a callable. To supply a non-deterministic jitter 224 | between -0.5 and 0.5, consider: 225 | 226 | >>> import random 227 | >>> jitter=functools.partial(random.uniform, -0.5, 0.5) 228 | >>> bd = BackoffDelay(jitter=jitter) 229 | >>> next(bd) 230 | 0 231 | >>> 0 <= next(bd) <= 0.5 232 | True 233 | """ 234 | 235 | factor = 1 236 | "Multiplier applied to delay" 237 | 238 | jitter: collections.abc.Callable[[], float] 239 | "Callable returning extra seconds to add to delay" 240 | 241 | @jaraco.functools.save_method_args 242 | def __init__( 243 | self, 244 | delay: float = 0, 245 | factor=1, 246 | limit: collections.abc.Callable[[float], float] | float = float('inf'), 247 | jitter: collections.abc.Callable[[], float] | float = 0, 248 | ) -> None: 249 | self.delay = delay 250 | self.factor = factor 251 | if isinstance(limit, numbers.Number): 252 | limit_ = limit 253 | 254 | def limit_func(n: float, /) -> float: 255 | return max(0, min(limit_, n)) 256 | 257 | else: 258 | # python/mypy#16946 or # python/mypy#13914 259 | limit_func: collections.abc.Callable[[float], float] = limit # type: ignore[no-redef] 260 | self.limit = limit_func 261 | if isinstance(jitter, numbers.Number): 262 | jitter_ = jitter 263 | 264 | def jitter_func() -> float: 265 | return jitter_ 266 | 267 | else: 268 | # python/mypy#16946 or # python/mypy#13914 269 | jitter_func: collections.abc.Callable[[], float] = jitter # type: ignore[no-redef] 270 | 271 | self.jitter = jitter_func 272 | 273 | def __call__(self) -> None: 274 | time.sleep(next(self)) 275 | 276 | def __next__(self) -> int | float: 277 | delay = self.delay 278 | self.bump() 279 | return delay 280 | 281 | def __iter__(self) -> Self: 282 | return self 283 | 284 | def bump(self) -> None: 285 | self.delay = self.limit(self.delay * self.factor + self.jitter()) 286 | 287 | def reset(self): 288 | saved = self._saved___init__ 289 | self.__init__(*saved.args, **saved.kwargs) 290 | -------------------------------------------------------------------------------- /tempora/__init__.py: -------------------------------------------------------------------------------- 1 | "Objects and routines pertaining to date and time (tempora)" 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import datetime 7 | import functools 8 | import numbers 9 | import re 10 | import time 11 | from collections.abc import Iterable, Iterator, Sequence 12 | from typing import TYPE_CHECKING, Union, cast 13 | 14 | import dateutil.parser 15 | import dateutil.tz 16 | 17 | if TYPE_CHECKING: 18 | from typing_extensions import TypeAlias 19 | 20 | # some useful constants 21 | osc_per_year = 290_091_329_207_984_000 22 | """ 23 | mean vernal equinox year expressed in oscillations of atomic cesium at the 24 | year 2000 (see http://webexhibits.org/calendars/timeline.html for more info). 25 | """ 26 | osc_per_second = 9_192_631_770 27 | seconds_per_second = 1 28 | seconds_per_year = 31_556_940 29 | seconds_per_minute = 60 30 | minutes_per_hour = 60 31 | hours_per_day = 24 32 | seconds_per_hour = seconds_per_minute * minutes_per_hour 33 | seconds_per_day = seconds_per_hour * hours_per_day 34 | days_per_year = seconds_per_year / seconds_per_day 35 | thirty_days = datetime.timedelta(days=30) 36 | # these values provide useful averages 37 | six_months = datetime.timedelta(days=days_per_year / 2) 38 | seconds_per_month = seconds_per_year / 12 39 | hours_per_month = hours_per_day * days_per_year / 12 40 | 41 | 42 | @functools.lru_cache() 43 | def _needs_year_help() -> bool: 44 | """ 45 | Some versions of Python render %Y with only three characters :( 46 | https://bugs.python.org/issue39103 47 | """ 48 | return len(datetime.date(900, 1, 1).strftime('%Y')) != 4 49 | 50 | 51 | AnyDatetime: TypeAlias = Union[datetime.datetime, datetime.date, datetime.time] 52 | StructDatetime: TypeAlias = Union[tuple[int, ...], time.struct_time] 53 | 54 | 55 | def ensure_datetime(ob: AnyDatetime) -> datetime.datetime: 56 | """ 57 | Given a datetime or date or time object from the ``datetime`` 58 | module, always return a datetime using default values. 59 | """ 60 | if isinstance(ob, datetime.datetime): 61 | return ob 62 | date = cast(datetime.date, ob) 63 | time = cast(datetime.time, ob) 64 | if isinstance(ob, datetime.date): 65 | time = datetime.time() 66 | if isinstance(ob, datetime.time): 67 | date = datetime.date(1900, 1, 1) 68 | return datetime.datetime.combine(date, time) 69 | 70 | 71 | def infer_datetime(ob: AnyDatetime | StructDatetime) -> datetime.datetime: 72 | if isinstance(ob, (time.struct_time, tuple)): 73 | # '"int" is not assignable to "tzinfo"', but we don't pass that many parameters 74 | ob = datetime.datetime(*ob[:6]) # type: ignore[arg-type] 75 | return ensure_datetime(ob) 76 | 77 | 78 | def strftime(fmt: str, t: AnyDatetime | tuple | time.struct_time) -> str: 79 | """ 80 | Portable strftime. 81 | 82 | In the stdlib, strftime has `known portability problems 83 | `_. This function 84 | aims to smooth over those issues and provide a 85 | consistent experience across the major platforms. 86 | 87 | >>> strftime('%Y', datetime.datetime(1890, 1, 1)) 88 | '1890' 89 | >>> strftime('%Y', datetime.datetime(900, 1, 1)) 90 | '0900' 91 | 92 | Supports time.struct_time, tuples, and datetime.datetime objects. 93 | 94 | >>> strftime('%Y-%m-%d', (1976, 5, 7)) 95 | '1976-05-07' 96 | 97 | Also supports date objects 98 | 99 | >>> strftime('%Y', datetime.date(1976, 5, 7)) 100 | '1976' 101 | 102 | Also supports milliseconds using %s. 103 | 104 | >>> strftime('%s', datetime.time(microsecond=20000)) 105 | '020' 106 | 107 | Also supports microseconds (3 digits) using %µ 108 | 109 | >>> strftime('%µ', datetime.time(microsecond=123456)) 110 | '456' 111 | 112 | Historically, %u was used for microseconds, but now 113 | it honors the value rendered by stdlib. 114 | 115 | >>> strftime('%u', datetime.date(1976, 5, 7)) 116 | '5' 117 | 118 | Also supports microseconds (6 digits) using %f 119 | 120 | >>> strftime('%f', datetime.time(microsecond=23456)) 121 | '023456' 122 | 123 | Even supports time values on date objects (discouraged): 124 | 125 | >>> strftime('%f', datetime.date(1976, 1, 1)) 126 | '000000' 127 | >>> strftime('%µ', datetime.date(1976, 1, 1)) 128 | '000' 129 | >>> strftime('%s', datetime.date(1976, 1, 1)) 130 | '000' 131 | 132 | And vice-versa: 133 | 134 | >>> strftime('%Y', datetime.time()) 135 | '1900' 136 | """ 137 | t = infer_datetime(t) 138 | subs = ( 139 | ('%s', '%03d' % (t.microsecond // 1000)), 140 | ('%µ', '%03d' % (t.microsecond % 1000)), 141 | ) + (('%Y', '%04d' % t.year),) * _needs_year_help() 142 | 143 | def doSub(s, sub): 144 | return s.replace(*sub) 145 | 146 | def doSubs(s): 147 | return functools.reduce(doSub, subs, s) 148 | 149 | fmt = '%%'.join(map(doSubs, fmt.split('%%'))) 150 | return t.strftime(fmt) 151 | 152 | 153 | def datetime_mod(dt: datetime.datetime, period, start=None) -> datetime.datetime: 154 | """ 155 | Find the time which is the specified date/time truncated to the time delta 156 | relative to the start date/time. 157 | By default, the start time is midnight of the same day as the specified 158 | date/time. 159 | 160 | >>> datetime_mod(datetime.datetime(2004, 1, 2, 3), 161 | ... datetime.timedelta(days = 1.5), 162 | ... start = datetime.datetime(2004, 1, 1)) 163 | datetime.datetime(2004, 1, 1, 0, 0) 164 | >>> datetime_mod(datetime.datetime(2004, 1, 2, 13), 165 | ... datetime.timedelta(days = 1.5), 166 | ... start = datetime.datetime(2004, 1, 1)) 167 | datetime.datetime(2004, 1, 2, 12, 0) 168 | >>> datetime_mod(datetime.datetime(2004, 1, 2, 13), 169 | ... datetime.timedelta(days = 7), 170 | ... start = datetime.datetime(2004, 1, 1)) 171 | datetime.datetime(2004, 1, 1, 0, 0) 172 | >>> datetime_mod(datetime.datetime(2004, 1, 10, 13), 173 | ... datetime.timedelta(days = 7), 174 | ... start = datetime.datetime(2004, 1, 1)) 175 | datetime.datetime(2004, 1, 8, 0, 0) 176 | """ 177 | if start is None: 178 | # use midnight of the same day 179 | start = datetime.datetime.combine(dt.date(), datetime.time()) 180 | # calculate the difference between the specified time and the start date. 181 | delta = dt - start 182 | 183 | # now aggregate the delta and the period into microseconds 184 | # Use microseconds because that's the highest precision of these time 185 | # pieces. Also, using microseconds ensures perfect precision (no floating 186 | # point errors). 187 | def get_time_delta_microseconds(td): 188 | return (td.days * seconds_per_day + td.seconds) * 1000000 + td.microseconds 189 | 190 | delta, period = map(get_time_delta_microseconds, (delta, period)) 191 | offset = datetime.timedelta(microseconds=delta % period) 192 | # the result is the original specified time minus the offset 193 | result = dt - offset 194 | return result 195 | 196 | 197 | def datetime_round(dt, period: datetime.timedelta, start=None) -> datetime.datetime: 198 | """ 199 | Find the nearest even period for the specified date/time. 200 | 201 | >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 11, 13), 202 | ... datetime.timedelta(hours = 1)) 203 | datetime.datetime(2004, 11, 13, 8, 0) 204 | >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 31, 13), 205 | ... datetime.timedelta(hours = 1)) 206 | datetime.datetime(2004, 11, 13, 9, 0) 207 | >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 30), 208 | ... datetime.timedelta(hours = 1)) 209 | datetime.datetime(2004, 11, 13, 9, 0) 210 | """ 211 | result = datetime_mod(dt, period, start) 212 | if abs(dt - result) >= period // 2: 213 | result += period 214 | return result 215 | 216 | 217 | def get_nearest_year_for_day(day) -> int: 218 | """ 219 | Returns the nearest year to now inferred from a Julian date. 220 | 221 | >>> freezer = getfixture('freezer') 222 | >>> freezer.move_to('2019-05-20') 223 | >>> get_nearest_year_for_day(20) 224 | 2019 225 | >>> get_nearest_year_for_day(340) 226 | 2018 227 | >>> freezer.move_to('2019-12-15') 228 | >>> get_nearest_year_for_day(20) 229 | 2020 230 | """ 231 | now = time.gmtime() 232 | result = now.tm_year 233 | # if the day is far greater than today, it must be from last year 234 | if day - now.tm_yday > 365 // 2: 235 | result -= 1 236 | # if the day is far less than today, it must be for next year. 237 | if now.tm_yday - day > 365 // 2: 238 | result += 1 239 | return result 240 | 241 | 242 | def gregorian_date(year, julian_day) -> datetime.date: 243 | """ 244 | Gregorian Date is defined as a year and a julian day (1-based 245 | index into the days of the year). 246 | 247 | >>> gregorian_date(2007, 15) 248 | datetime.date(2007, 1, 15) 249 | """ 250 | result = datetime.date(year, 1, 1) 251 | result += datetime.timedelta(days=julian_day - 1) 252 | return result 253 | 254 | 255 | def get_period_seconds(period) -> int: 256 | """ 257 | return the number of seconds in the specified period 258 | 259 | >>> get_period_seconds('day') 260 | 86400 261 | >>> get_period_seconds(86400) 262 | 86400 263 | >>> get_period_seconds(datetime.timedelta(hours=24)) 264 | 86400 265 | >>> get_period_seconds('day + os.system("rm -Rf *")') 266 | Traceback (most recent call last): 267 | ... 268 | ValueError: period not in (second, minute, hour, day, month, year) 269 | """ 270 | if isinstance(period, str): 271 | try: 272 | name = 'seconds_per_' + period.lower() 273 | result = globals()[name] 274 | except KeyError: 275 | msg = "period not in (second, minute, hour, day, month, year)" 276 | raise ValueError(msg) 277 | elif isinstance(period, numbers.Number): 278 | result = period 279 | elif isinstance(period, datetime.timedelta): 280 | result = period.days * get_period_seconds('day') + period.seconds 281 | else: 282 | raise TypeError('period must be a string or integer') 283 | return result 284 | 285 | 286 | def get_date_format_string(period) -> str: 287 | """ 288 | For a given period (e.g. 'month', 'day', or some numeric interval 289 | such as 3600 (in secs)), return the format string that can be 290 | used with strftime to format that time to specify the times 291 | across that interval, but no more detailed. 292 | For example, 293 | 294 | >>> get_date_format_string('month') 295 | '%Y-%m' 296 | >>> get_date_format_string(3600) 297 | '%Y-%m-%d %H' 298 | >>> get_date_format_string('hour') 299 | '%Y-%m-%d %H' 300 | >>> get_date_format_string(None) 301 | Traceback (most recent call last): 302 | ... 303 | TypeError: period must be a string or integer 304 | >>> get_date_format_string('garbage') 305 | Traceback (most recent call last): 306 | ... 307 | ValueError: period not in (second, minute, hour, day, month, year) 308 | """ 309 | # handle the special case of 'month' which doesn't have 310 | # a static interval in seconds 311 | if isinstance(period, str) and period.lower() == 'month': 312 | return '%Y-%m' 313 | file_period_secs = get_period_seconds(period) 314 | format_pieces: Sequence[str] = ('%Y', '-%m-%d', ' %H', '-%M', '-%S') 315 | seconds_per_second = 1 316 | intervals = ( 317 | seconds_per_year, 318 | seconds_per_day, 319 | seconds_per_hour, 320 | seconds_per_minute, 321 | seconds_per_second, 322 | ) 323 | mods = list(map(lambda interval: file_period_secs % interval, intervals)) 324 | format_pieces = format_pieces[: mods.index(0) + 1] 325 | return ''.join(format_pieces) 326 | 327 | 328 | def calculate_prorated_values() -> None: 329 | """ 330 | >>> monkeypatch = getfixture('monkeypatch') 331 | >>> import builtins 332 | >>> monkeypatch.setattr(builtins, 'input', lambda prompt: '3/hour') 333 | >>> calculate_prorated_values() 334 | per minute: 0.05 335 | per hour: 3.0 336 | per day: 72.0 337 | per month: 2191.454166666667 338 | per year: 26297.45 339 | """ 340 | rate = input("Enter the rate (3/hour, 50/month)> ") 341 | for period, value in _prorated_values(rate): 342 | print(f"per {period}: {value}") 343 | 344 | 345 | def _prorated_values(rate: str) -> Iterator[tuple[str, float]]: 346 | """ 347 | Given a rate (a string in units per unit time), and return that same 348 | rate for various time periods. 349 | 350 | >>> for period, value in _prorated_values('20/hour'): 351 | ... print('{period}: {value:0.3f}'.format(**locals())) 352 | minute: 0.333 353 | hour: 20.000 354 | day: 480.000 355 | month: 14609.694 356 | year: 175316.333 357 | 358 | """ 359 | match = re.match(r'(?P[\d.]+)/(?P\w+)$', rate) 360 | res = cast(re.Match, match).groupdict() 361 | value = float(res['value']) 362 | value_per_second = value / get_period_seconds(res['period']) 363 | for period in ('minute', 'hour', 'day', 'month', 'year'): 364 | period_value = value_per_second * get_period_seconds(period) 365 | yield period, period_value 366 | 367 | 368 | def parse_timedelta(str) -> datetime.timedelta: 369 | """ 370 | Take a string representing a span of time and parse it to a time delta. 371 | Accepts any string of comma-separated numbers each with a unit indicator. 372 | 373 | >>> parse_timedelta('1 day') 374 | datetime.timedelta(days=1) 375 | 376 | >>> parse_timedelta('1 day, 30 seconds') 377 | datetime.timedelta(days=1, seconds=30) 378 | 379 | >>> parse_timedelta('47.32 days, 20 minutes, 15.4 milliseconds') 380 | datetime.timedelta(days=47, seconds=28848, microseconds=15400) 381 | 382 | Supports weeks, months, years 383 | 384 | >>> parse_timedelta('1 week') 385 | datetime.timedelta(days=7) 386 | 387 | >>> parse_timedelta('1 year, 1 month') 388 | datetime.timedelta(days=395, seconds=58685) 389 | 390 | Note that months and years strict intervals, not aligned 391 | to a calendar: 392 | 393 | >>> date = datetime.datetime.fromisoformat('2000-01-01') 394 | >>> later = date + parse_timedelta('1 year') 395 | >>> diff = later.replace(year=date.year) - date 396 | >>> diff.seconds 397 | 20940 398 | 399 | >>> parse_timedelta('foo') 400 | Traceback (most recent call last): 401 | ... 402 | ValueError: Unexpected 'foo' 403 | 404 | >>> parse_timedelta('14 seconds foo') 405 | Traceback (most recent call last): 406 | ... 407 | ValueError: Unexpected 'foo' 408 | 409 | Supports abbreviations: 410 | 411 | >>> parse_timedelta('1s') 412 | datetime.timedelta(seconds=1) 413 | 414 | >>> parse_timedelta('1sec') 415 | datetime.timedelta(seconds=1) 416 | 417 | >>> parse_timedelta('5min1sec') 418 | datetime.timedelta(seconds=301) 419 | 420 | >>> parse_timedelta('1 ms') 421 | datetime.timedelta(microseconds=1000) 422 | 423 | >>> parse_timedelta('1 µs') 424 | datetime.timedelta(microseconds=1) 425 | 426 | >>> parse_timedelta('1 us') 427 | datetime.timedelta(microseconds=1) 428 | 429 | And supports the common colon-separated duration: 430 | 431 | >>> parse_timedelta('14:00:35.362') 432 | datetime.timedelta(seconds=50435, microseconds=362000) 433 | 434 | TODO: Should this be 14 hours or 14 minutes? 435 | 436 | >>> parse_timedelta('14:00') 437 | datetime.timedelta(seconds=50400) 438 | 439 | >>> parse_timedelta('14:00 minutes') 440 | Traceback (most recent call last): 441 | ... 442 | ValueError: Cannot specify units with composite delta 443 | 444 | Nanoseconds get rounded to the nearest microsecond: 445 | 446 | >>> parse_timedelta('600 ns') 447 | datetime.timedelta(microseconds=1) 448 | 449 | >>> parse_timedelta('.002 µs, 499 ns') 450 | datetime.timedelta(microseconds=1) 451 | 452 | Expect ValueError for other invalid inputs. 453 | 454 | >>> parse_timedelta('13 feet') 455 | Traceback (most recent call last): 456 | ... 457 | ValueError: Invalid unit feets 458 | """ 459 | return _parse_timedelta_nanos(str).resolve() 460 | 461 | 462 | def _parse_timedelta_nanos(str) -> _Saved_NS: 463 | parts = re.finditer(r'(?P[\d.:]+)\s?(?P[^\W\d_]+)?', str) 464 | chk_parts = _check_unmatched(parts, str) 465 | deltas = map(_parse_timedelta_part, chk_parts) 466 | return sum(deltas, _Saved_NS()) 467 | 468 | 469 | def _check_unmatched(matches: Iterable[re.Match[str]], text) -> Iterator[re.Match[str]]: 470 | """ 471 | Ensure no words appear in unmatched text. 472 | """ 473 | 474 | def check_unmatched(unmatched) -> None: 475 | found = re.search(r'\w+', unmatched) 476 | if found: 477 | raise ValueError(f"Unexpected {found.group(0)!r}") 478 | 479 | pos = 0 480 | for match in matches: 481 | check_unmatched(text[pos : match.start()]) 482 | yield match 483 | pos = match.end() 484 | check_unmatched(text[pos:]) 485 | 486 | 487 | _unit_lookup = { 488 | 'µs': 'microsecond', 489 | 'µsec': 'microsecond', 490 | 'us': 'microsecond', 491 | 'usec': 'microsecond', 492 | 'micros': 'microsecond', 493 | 'ms': 'millisecond', 494 | 'msec': 'millisecond', 495 | 'millis': 'millisecond', 496 | 's': 'second', 497 | 'sec': 'second', 498 | 'h': 'hour', 499 | 'hr': 'hour', 500 | 'm': 'minute', 501 | 'min': 'minute', 502 | 'w': 'week', 503 | 'wk': 'week', 504 | 'd': 'day', 505 | 'ns': 'nanosecond', 506 | 'nsec': 'nanosecond', 507 | 'nanos': 'nanosecond', 508 | } 509 | 510 | 511 | def _resolve_unit(raw_match) -> str: 512 | if raw_match is None: 513 | return 'second' 514 | text = raw_match.lower() 515 | return _unit_lookup.get(text, text) 516 | 517 | 518 | def _parse_timedelta_composite(raw_value, unit) -> _Saved_NS: 519 | if unit != 'seconds': 520 | raise ValueError("Cannot specify units with composite delta") 521 | values = raw_value.split(':') 522 | units = 'hours', 'minutes', 'seconds' 523 | composed = ' '.join(f'{value} {unit}' for value, unit in zip(values, units)) 524 | return _parse_timedelta_nanos(composed) 525 | 526 | 527 | def _parse_timedelta_part(match) -> _Saved_NS: 528 | unit = _resolve_unit(match.group('unit')) 529 | if not unit.endswith('s'): 530 | unit += 's' 531 | raw_value = match.group('value') 532 | if ':' in raw_value: 533 | return _parse_timedelta_composite(raw_value, unit) 534 | value = float(raw_value) 535 | if unit == 'months': 536 | unit = 'years' 537 | value = value / 12 538 | if unit == 'years': 539 | unit = 'days' 540 | value = value * days_per_year 541 | return _Saved_NS.derive(unit, value) 542 | 543 | 544 | class _Saved_NS: 545 | """ 546 | Bundle a timedelta with nanoseconds. 547 | 548 | >>> _Saved_NS.derive('microseconds', .001) 549 | _Saved_NS(td=datetime.timedelta(0), nanoseconds=1) 550 | """ 551 | 552 | td = datetime.timedelta() 553 | nanoseconds = 0 554 | multiplier = dict( 555 | seconds=1000000000, 556 | milliseconds=1000000, 557 | microseconds=1000, 558 | ) 559 | 560 | def __init__(self, **kwargs) -> None: 561 | vars(self).update(kwargs) 562 | 563 | @classmethod 564 | def derive(cls, unit, value) -> _Saved_NS: 565 | if unit == 'nanoseconds': 566 | return _Saved_NS(nanoseconds=value) 567 | 568 | try: 569 | raw_td = datetime.timedelta(**{unit: value}) 570 | except TypeError: 571 | raise ValueError(f"Invalid unit {unit}") 572 | res = _Saved_NS(td=raw_td) 573 | with contextlib.suppress(KeyError): 574 | res.nanoseconds = int(value * cls.multiplier[unit]) % 1000 575 | return res 576 | 577 | def __add__(self, other): 578 | return _Saved_NS( 579 | td=self.td + other.td, nanoseconds=self.nanoseconds + other.nanoseconds 580 | ) 581 | 582 | def resolve(self): 583 | """ 584 | Resolve any nanoseconds into the microseconds field, 585 | discarding any nanosecond resolution (but honoring partial 586 | microseconds). 587 | """ 588 | addl_micros = round(self.nanoseconds / 1000) 589 | return self.td + datetime.timedelta(microseconds=addl_micros) 590 | 591 | def __repr__(self): 592 | return f'_Saved_NS(td={self.td!r}, nanoseconds={self.nanoseconds!r})' 593 | 594 | 595 | def date_range(start=None, stop=None, step=None) -> Iterator[datetime.datetime]: 596 | """ 597 | Much like the built-in function range, but works with dates 598 | 599 | >>> range_items = date_range( 600 | ... datetime.datetime(2005,12,21), 601 | ... datetime.datetime(2005,12,25), 602 | ... ) 603 | >>> my_range = tuple(range_items) 604 | >>> datetime.datetime(2005,12,21) in my_range 605 | True 606 | >>> datetime.datetime(2005,12,22) in my_range 607 | True 608 | >>> datetime.datetime(2005,12,25) in my_range 609 | False 610 | >>> from_now = date_range(stop=datetime.datetime(2099, 12, 31)) 611 | >>> next(from_now) 612 | datetime.datetime(...) 613 | """ 614 | if step is None: 615 | step = datetime.timedelta(days=1) 616 | if start is None: 617 | start = datetime.datetime.now() 618 | while start < stop: 619 | yield start 620 | start += step 621 | 622 | 623 | tzinfos = dict( 624 | AEST=dateutil.tz.gettz("Australia/Sydney"), 625 | AEDT=dateutil.tz.gettz("Australia/Sydney"), 626 | ACST=dateutil.tz.gettz("Australia/Darwin"), 627 | ACDT=dateutil.tz.gettz("Australia/Adelaide"), 628 | AWST=dateutil.tz.gettz("Australia/Perth"), 629 | EST=dateutil.tz.gettz("America/New_York"), 630 | EDT=dateutil.tz.gettz("America/New_York"), 631 | CST=dateutil.tz.gettz("America/Chicago"), 632 | CDT=dateutil.tz.gettz("America/Chicago"), 633 | MST=dateutil.tz.gettz("America/Denver"), 634 | MDT=dateutil.tz.gettz("America/Denver"), 635 | PST=dateutil.tz.gettz("America/Los_Angeles"), 636 | PDT=dateutil.tz.gettz("America/Los_Angeles"), 637 | GMT=dateutil.tz.gettz("Etc/GMT"), 638 | UTC=dateutil.tz.gettz("UTC"), 639 | CET=dateutil.tz.gettz("Europe/Berlin"), 640 | CEST=dateutil.tz.gettz("Europe/Berlin"), 641 | IST=dateutil.tz.gettz("Asia/Kolkata"), 642 | BST=dateutil.tz.gettz("Europe/London"), 643 | MSK=dateutil.tz.gettz("Europe/Moscow"), 644 | EET=dateutil.tz.gettz("Europe/Helsinki"), 645 | EEST=dateutil.tz.gettz("Europe/Helsinki"), 646 | # Add more mappings as needed 647 | ) 648 | 649 | 650 | def parse(*args, **kwargs) -> datetime.datetime: 651 | """ 652 | Parse the input using dateutil.parser.parse with friendly tz support. 653 | 654 | >>> parse('2024-07-26 12:59:00 EDT') 655 | datetime.datetime(...America/New_York...) 656 | """ 657 | return dateutil.parser.parse(*args, tzinfos=tzinfos, **kwargs) 658 | --------------------------------------------------------------------------------