pax_global_header00006660000000000000000000000064136164153730014523gustar00rootroot0000000000000052 comment=e4f6769f1c429315216467843f10c631876cefa5 tempora-2.1.1/000077500000000000000000000000001361641537300131735ustar00rootroot00000000000000tempora-2.1.1/.coveragerc000066400000000000000000000000621361641537300153120ustar00rootroot00000000000000[run] omit = .tox/* [report] show_missing = True tempora-2.1.1/.flake8000066400000000000000000000003661361641537300143530ustar00rootroot00000000000000[flake8] max-line-length = 88 ignore = # W503 violates spec https://github.com/PyCQA/pycodestyle/issues/513 W503 # W504 has issues https://github.com/OCA/maintainer-quality-tools/issues/545 W504 # Black creates whitespace before colon E203 tempora-2.1.1/.pre-commit-config.yaml000066400000000000000000000002601361641537300174520ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 19.10b0 hooks: - id: black - repo: https://github.com/asottile/blacken-docs rev: v1.4.0 hooks: - id: blacken-docs tempora-2.1.1/.readthedocs.yml000066400000000000000000000001121361641537300162530ustar00rootroot00000000000000python: version: 3 extra_requirements: - docs pip_install: true tempora-2.1.1/.travis.yml000066400000000000000000000004701361641537300153050ustar00rootroot00000000000000dist: xenial language: python python: - 3.6 - &latest_py3 3.8 cache: pip install: - pip install tox tox-venv before_script: # Disable IPv6. Ref travis-ci/travis-ci#8361 - if [ "${TRAVIS_OS_NAME}" == "linux" ]; then sudo sh -c 'echo 0 > /proc/sys/net/ipv6/conf/all/disable_ipv6'; fi script: tox tempora-2.1.1/CHANGES.rst000066400000000000000000000035271361641537300150040ustar00rootroot00000000000000v2.1.1 ====== - #8: Fixed error in ``PeriodicCommandFixedDelay.daily_at`` when timezone is more than 12 hours from UTC. v2.1.0 ====== - #9: Fixed error when date object is passed to ``strftime``. - #11: ``strftime`` now honors upstream expectation of rendering date values on time objects and vice versa. - #10: ``strftime`` now honors ``%µ`` for rendering just the "microseconds" as ``%u`` supported previously. In a future, backward-incompatible release, the ``%u`` behavior will revert to the behavior as found in stdlib. v2.0.0 ====== * Require Python 3.6 or later. * Removed DatetimeConstructor. 1.14.1 ====== #7: Fix failing doctest in ``parse_timedelta``. 1.14 ==== Package refresh, including use of declarative config in the package metadata. 1.13 ==== Enhancements to BackoffDelay: - Added ``.reset`` method. - Made iterable to retrieve delay values. 1.12 ==== Added UTC module (Python 3 only), inspired by the `utc project `_. 1.11 ==== #5: Scheduler now honors daylight savings times in the PeriodicCommands. 1.10 ==== Added ``timing.BackoffDelay``, suitable for implementing exponential backoff delays, such as those between retries. 1.9 === Added support for months, years to ``parse_timedelta``. 1.8 === Introducing ``timing.Timer``, featuring a ``expired`` method for detecting when a certain duration has been exceeded. 1.7.1 ===== #3: Stopwatch now behaves reliably during timezone changes and (presumably) daylight savings time changes. 1.7 === Update project skeleton. 1.6 === Adopt ``irc.schedule`` as ``tempora.schedule``. 1.5 === Adopt ``jaraco.timing`` as ``tempora.timing``. Automatic deployment with Travis-CI. 1.4 === Moved to Github. Improved test support on Python 2. 1.3 === Added divide_timedelta from ``svg.charts``. Added date_range from ``svg.charts``. tempora-2.1.1/LICENSE000066400000000000000000000020321361641537300141750ustar00rootroot00000000000000Copyright Jason R. Coombs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. tempora-2.1.1/README.rst000066400000000000000000000023451361641537300146660ustar00rootroot00000000000000.. image:: https://img.shields.io/pypi/v/tempora.svg :target: https://pypi.org/project/tempora .. image:: https://img.shields.io/pypi/pyversions/tempora.svg .. image:: https://dev.azure.com/jaraco/tempora/_apis/build/status/jaraco.tempora?branchName=master :target: https://dev.azure.com/jaraco/tempora/_build/latest?definitionId=1&branchName=master .. image:: https://img.shields.io/travis/jaraco/tempora/master.svg :target: https://travis-ci.org/jaraco/tempora .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black :alt: Code style: Black .. .. image:: https://img.shields.io/appveyor/ci/jaraco/tempora/master.svg .. :target: https://ci.appveyor.com/project/jaraco/tempora/branch/master .. image:: https://readthedocs.org/projects/tempora/badge/?version=latest :target: https://tempora.readthedocs.io/en/latest/?badge=latest Objects and routines pertaining to date and time (tempora). Modules include: - tempora (top level package module) contains miscellaneous utilities and constants. - timing contains routines for measuring and profiling. - schedule contains an event scheduler. - utc contains routines for getting datetime-aware UTC values (Python 3 only). tempora-2.1.1/appveyor.yml000066400000000000000000000007241361641537300155660ustar00rootroot00000000000000environment: APPVEYOR: true matrix: - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python38-x64" install: # symlink python from a directory with a space - "mklink /d \"C:\\Program Files\\Python\" %PYTHON%" - "SET PYTHON=\"C:\\Program Files\\Python\"" - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" build: off cache: - '%LOCALAPPDATA%\pip\Cache' test_script: - "python -m pip install -U tox tox-venv virtualenv" - "tox" version: '{build}' tempora-2.1.1/azure-pipelines.yml000066400000000000000000000026321361641537300170350ustar00rootroot00000000000000# Create the project in Azure with: # az project create --name $name --organization https://dev.azure.com/$org/ --visibility public # then configure the pipelines (through web UI) trigger: branches: include: - '*' tags: include: - '*' pool: vmimage: 'Ubuntu-18.04' variables: - group: Azure secrets stages: - stage: Test jobs: - job: 'Test' strategy: matrix: Python36: python.version: '3.6' Python38: python.version: '3.8' maxParallel: 4 steps: - task: UsePythonVersion@0 inputs: versionSpec: '$(python.version)' architecture: 'x64' - script: python -m pip install tox displayName: 'Install tox' - script: | tox -- --junit-xml=test-results.xml displayName: 'run tests' - task: PublishTestResults@2 inputs: testResultsFiles: '**/test-results.xml' testRunTitle: 'Python $(python.version)' condition: succeededOrFailed() - stage: Publish dependsOn: Test jobs: - job: 'Publish' steps: - task: UsePythonVersion@0 inputs: versionSpec: '3.8' architecture: 'x64' - script: python -m pip install tox displayName: 'Install tox' - script: | tox -e release env: TWINE_PASSWORD: $(PyPI-token) displayName: 'publish to PyPI' condition: contains(variables['Build.SourceBranch'], 'tags') tempora-2.1.1/conftest.py000066400000000000000000000006551361641537300154000ustar00rootroot00000000000000import sys import operator def pytest_collection_modifyitems(session, config, items): remove_parse_timedelta(items) def remove_parse_timedelta(items): # pragma: nocover """ Repr on older Pythons is different, so remove the offending test. """ if sys.version_info > (3, 7): return names = list(map(operator.attrgetter('name'), items)) del items[names.index('tempora.parse_timedelta')] tempora-2.1.1/docs/000077500000000000000000000000001361641537300141235ustar00rootroot00000000000000tempora-2.1.1/docs/conf.py000066400000000000000000000013631361641537300154250ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- extensions = ['sphinx.ext.autodoc', 'jaraco.packaging.sphinx', 'rst.linker'] master_doc = "index" link_files = { '../CHANGES.rst': dict( using=dict(GH='https://github.com'), replace=[ dict( pattern=r'(Issue #|\B#)(?P\d+)', url='{package_url}/issues/{issue}', ), dict( pattern=r'^(?m)((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n', with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', ), dict( pattern=r'PEP[- ](?P\d+)', url='https://www.python.org/dev/peps/pep-{pep_number:0>4}/', ), ], ) } tempora-2.1.1/docs/history.rst000066400000000000000000000001211361641537300163500ustar00rootroot00000000000000:tocdepth: 2 .. _changes: History ******* .. include:: ../CHANGES (links).rst tempora-2.1.1/docs/index.rst000066400000000000000000000010511361641537300157610ustar00rootroot00000000000000Welcome to tempora documentation! ================================= .. toctree:: :maxdepth: 1 history .. automodule:: tempora :members: :undoc-members: :show-inheritance: .. automodule:: tempora.timing :members: :undoc-members: :show-inheritance: .. automodule:: tempora.schedule :members: :undoc-members: :show-inheritance: .. automodule:: tempora.utc :members: :undoc-members: :show-inheritance: Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` tempora-2.1.1/pyproject.toml000066400000000000000000000002471361641537300161120ustar00rootroot00000000000000[build-system] requires = ["setuptools>=34.4", "wheel", "setuptools_scm>=1.15"] build-backend = "setuptools.build_meta" [tool.black] skip-string-normalization = true tempora-2.1.1/pytest.ini000066400000000000000000000003651361641537300152300ustar00rootroot00000000000000[pytest] norecursedirs=dist build .tox .eggs addopts=--doctest-modules --flake8 --black --cov doctest_optionflags=ALLOW_UNICODE ELLIPSIS filterwarnings= # suppress known warning ignore:Use datetime.datetime.strptime:DeprecationWarning:tempora tempora-2.1.1/setup.cfg000066400000000000000000000017621361641537300150220ustar00rootroot00000000000000[bdist_wheel] universal = 1 [metadata] license_file = LICENSE name = tempora author = Jason R. Coombs author_email = jaraco@jaraco.com description = Objects and routines pertaining to date and time (tempora) long_description = file:README.rst url = https://github.com/jaraco/tempora classifiers = Development Status :: 5 - Production/Stable Intended Audience :: Developers License :: OSI Approved :: MIT License Programming Language :: Python :: 3 [options] packages = find: include_package_data = true python_requires = >=3.6 install_requires = pytz jaraco.functools>=1.20 setup_requires = setuptools_scm >= 1.15.0 [options.extras_require] testing = # upstream pytest >= 3.5, !=3.7.3 pytest-checkdocs >= 1.2.3 pytest-flake8 pytest-black-multipy pytest-cov # local backports.unittest_mock freezegun pytest-freezegun docs = # upstream sphinx jaraco.packaging >= 3.2 rst.linker >= 1.9 # local [options.entry_points] console_scripts = calc-prorate = tempora:calculate_prorated_values tempora-2.1.1/setup.py000066400000000000000000000001601361641537300147020ustar00rootroot00000000000000#!/usr/bin/env python import setuptools if __name__ == "__main__": setuptools.setup(use_scm_version=True) tempora-2.1.1/skeleton.md000066400000000000000000000213641361641537300153470ustar00rootroot00000000000000# Overview This project is merged with [skeleton](https://github.com/jaraco/skeleton). What is skeleton? It's the scaffolding of a Python project jaraco [introduced in his blog](https://blog.jaraco.com/a-project-skeleton-for-python-projects/). It seeks to provide a means to re-use techniques and inherit advances when managing projects for distribution. ## An SCM Managed Approach While maintaining dozens of projects in PyPI, jaraco derives best practices for project distribution and publishes them in the [skeleton repo](https://github.com/jaraco/skeleton), a git repo capturing the evolution and culmination of these best practices. It's intended to be used by a new or existing project to adopt these practices and honed and proven techniques. Adopters are encouraged to use the project directly and maintain a small deviation from the technique, make their own fork for more substantial changes unique to their environment or preferences, or simply adopt the skeleton once and abandon it thereafter. The primary advantage to using an SCM for maintaining these techniques is that those tools help facilitate the merge between the template and its adopting projects. Another advantage to using an SCM-managed approach is that tools like GitHub recognize that a change in the skeleton is the _same change_ across all projects that merge with that skeleton. Without the ancestry, with a traditional copy/paste approach, a [commit like this](https://github.com/jaraco/skeleton/commit/12eed1326e1bc26ce256e7b3f8cd8d3a5beab2d5) would produce notifications in the upstream project issue for each and every application, but because it's centralized, GitHub provides just the one notification when the change is added to the skeleton. # Usage ## new projects To use skeleton for a new project, simply pull the skeleton into a new project: ``` $ git init my-new-project $ cd my-new-project $ git pull gh://jaraco/skeleton ``` Now customize the project to suit your individual project needs. ## existing projects If you have an existing project, you can still incorporate the skeleton by merging it into the codebase. ``` $ git merge skeleton --allow-unrelated-histories ``` The `--allow-unrelated-histories` is necessary because the history from the skeleton was previously unrelated to the existing codebase. Resolve any merge conflicts and commit to the master, and now the project is based on the shared skeleton. ## Updating Whenever a change is needed or desired for the general technique for packaging, it can be made in the skeleton project and then merged into each of the derived projects as needed, recommended before each release. As a result, features and best practices for packaging are centrally maintained and readily trickle into a whole suite of packages. This technique lowers the amount of tedious work necessary to create or maintain a project, and coupled with other techniques like continuous integration and deployment, lowers the cost of creating and maintaining refined Python projects to just a few, familiar git operations. Thereafter, the target project can make whatever customizations it deems relevant to the scaffolding. The project may even at some point decide that the divergence is too great to merit renewed merging with the original skeleton. This approach applies maximal guidance while creating minimal constraints. # Features The features/techniques employed by the skeleton include: - PEP 517/518 based build relying on setuptools as the build tool - setuptools declarative configuration using setup.cfg - tox for running tests - A README.rst as reStructuredText with some popular badges, but with readthedocs and appveyor badges commented out - A CHANGES.rst file intended for publishing release notes about the project - Use of [black](https://black.readthedocs.io/en/stable/) for code formatting (disabled on unsupported Python 3.5 and earlier) ## Packaging Conventions A pyproject.toml is included to enable PEP 517 and PEP 518 compatibility and declares the requirements necessary to build the project on setuptools (a minimum version compatible with setup.cfg declarative config). The setup.cfg file implements the following features: - Assumes universal wheel for release - Advertises the project's LICENSE file (MIT by default) - Reads the README.rst file into the long description - Some common Trove classifiers - Includes all packages discovered in the repo - Data files in the package are also included (not just Python files) - Declares the required Python versions - Declares install requirements (empty by default) - Declares setup requirements for legacy environments - Supplies two 'extras': - testing: requirements for running tests - docs: requirements for building docs - these extras split the declaration into "upstream" (requirements as declared by the skeleton) and "local" (those specific to the local project); these markers help avoid merge conflicts - Placeholder for defining entry points Additionally, the setup.py file declares `use_scm_version` which relies on [setuptools_scm](https://pypi.org/project/setuptools_scm) to do two things: - derive the project version from SCM tags - ensure that all files committed to the repo are automatically included in releases ## Running Tests The skeleton assumes the developer has [tox](https://pypi.org/project/tox) installed. The developer is expected to run `tox` to run tests on the current Python version using [pytest](https://pypi.org/project/pytest). Other environments (invoked with `tox -e {name}`) supplied include: - a `docs` environment to build the documentation - a `release` environment to publish the package to PyPI A pytest.ini is included to define common options around running tests. In particular: - rely on default test discovery in the current directory - avoid recursing into common directories not containing tests - run doctests on modules and invoke flake8 tests - in doctests, allow unicode literals and regular literals to match, allowing for doctests to run on Python 2 and 3. Also enable ELLIPSES, a default that would be undone by supplying the prior option. - filters out known warnings caused by libraries/functionality included by the skeleton Relies a .flake8 file to correct some default behaviors: - disable mutually incompatible rules W503 and W504 - support for black format ## Continuous Integration The project is pre-configured to run tests through multiple CI providers. ### Azure Pipelines [Azure Pipelines](https://azure.microsoft.com/en-us/services/devops/pipelines/) are the preferred provider as they provide free, fast, multi-platform services. See azure-pipelines.yml for more details. Features include: - test against multiple Python versions - run on Ubuntu Bionic ### Travis CI [Travis-CI](https://travis-ci.org) is configured through .travis.yml. Any new project must be enabled either through their web site or with the `travis enable` command. Features include: - test against 3 - run on Ubuntu Xenial - correct for broken IPv6 ### Appveyor A minimal template for running under Appveyor (Windows) is provided. ### Continuous Deployments In addition to running tests, an additional deploy stage is configured to automatically release tagged commits to PyPI using [API tokens](https://pypi.org/help/#apitoken). The release process expects an authorized token to be configured with Azure as the `Azure secrets` variable group. This variable group needs to be created only once per organization. For example: ``` # create a resource group if none exists az group create --name main --location eastus2 # create the vault (try different names until something works) az keyvault create --name secrets007 --resource-group main # create the secret az keyvault secret set --vault-name secrets007 --name PyPI-token --value $token ``` Then, in the web UI for the project's Pipelines Library, create the `Azure secrets` variable group referencing the key vault name. For more details, see [this blog entry](https://blog.jaraco.com/configuring-azure-pipelines-with-secets/). ## Building Documentation Documentation is automatically built by [Read the Docs](https://readthedocs.org) when the project is registered with it, by way of the .readthedocs.yml file. To test the docs build manually, a tox env may be invoked as `tox -e docs`. Both techniques rely on the dependencies declared in `setup.cfg/options.extras_require.docs`. In addition to building the sphinx docs scaffolded in `docs/`, the docs build a `history.html` file that first injects release dates and hyperlinks into the CHANGES.rst before incorporating it as history in the docs. ## Cutting releases By default, tagged commits are released through the continuous integration deploy stage. Releases may also be cut manually by invoking the tox environment `release` with the PyPI token set as the TWINE_PASSWORD: ``` TWINE_PASSWORD={token} tox -e release ``` tempora-2.1.1/tempora/000077500000000000000000000000001361641537300146425ustar00rootroot00000000000000tempora-2.1.1/tempora/__init__.py000066400000000000000000000413651361641537300167640ustar00rootroot00000000000000"Objects and routines pertaining to date and time (tempora)" import datetime import time import re import numbers import functools import warnings from jaraco.functools import once class Parser: """ Datetime parser: parses a date-time string using multiple possible formats. >>> p = Parser(('%H%M', '%H:%M')) >>> tuple(p.parse('1319')) (1900, 1, 1, 13, 19, 0, 0, 1, -1) >>> dateParser = Parser(('%m/%d/%Y', '%Y-%m-%d', '%d-%b-%Y')) >>> tuple(dateParser.parse('2003-12-20')) (2003, 12, 20, 0, 0, 0, 5, 354, -1) >>> tuple(dateParser.parse('16-Dec-1994')) (1994, 12, 16, 0, 0, 0, 4, 350, -1) >>> tuple(dateParser.parse('5/19/2003')) (2003, 5, 19, 0, 0, 0, 0, 139, -1) >>> dtParser = Parser(('%Y-%m-%d %H:%M:%S', '%a %b %d %H:%M:%S %Y')) >>> tuple(dtParser.parse('2003-12-20 19:13:26')) (2003, 12, 20, 19, 13, 26, 5, 354, -1) >>> tuple(dtParser.parse('Tue Jan 20 16:19:33 2004')) (2004, 1, 20, 16, 19, 33, 1, 20, -1) Be forewarned, a ValueError will be raised if more than one format matches: >>> Parser(('%H%M', '%H%M%S')).parse('732') Traceback (most recent call last): ... ValueError: More than one format string matched target 732. >>> Parser(('%H',)).parse('22:21') Traceback (most recent call last): ... ValueError: No format strings matched the target 22:21. """ formats = ('%m/%d/%Y', '%m/%d/%y', '%Y-%m-%d', '%d-%b-%Y', '%d-%b-%y') "some common default formats" def __init__(self, formats=None): if formats: self.formats = formats def parse(self, target): self.target = target results = tuple(filter(None, map(self._parse, self.formats))) del self.target if not results: tmpl = "No format strings matched the target {target}." raise ValueError(tmpl.format(**locals())) if not len(results) == 1: tmpl = "More than one format string matched target {target}." raise ValueError(tmpl.format(**locals())) return results[0] def _parse(self, format): try: result = time.strptime(self.target, format) except ValueError: result = False return result # some useful constants osc_per_year = 290091329207984000 """ mean vernal equinox year expressed in oscillations of atomic cesium at the year 2000 (see http://webexhibits.org/calendars/timeline.html for more info). """ osc_per_second = 9192631770 seconds_per_second = 1 seconds_per_year = 31556940 seconds_per_minute = 60 minutes_per_hour = 60 hours_per_day = 24 seconds_per_hour = seconds_per_minute * minutes_per_hour seconds_per_day = seconds_per_hour * hours_per_day days_per_year = seconds_per_year / seconds_per_day thirty_days = datetime.timedelta(days=30) # these values provide useful averages six_months = datetime.timedelta(days=days_per_year / 2) seconds_per_month = seconds_per_year / 12 hours_per_month = hours_per_day * days_per_year / 12 @once def _needs_year_help(): """ Some versions of Python render %Y with only three characters :( https://bugs.python.org/issue39103 """ return len(datetime.date(900, 1, 1).strftime('%Y')) != 4 def ensure_datetime(ob): """ Given a datetime or date or time object from the ``datetime`` module, always return a datetime using default values. """ if isinstance(ob, datetime.datetime): return ob date = time = ob if isinstance(ob, datetime.date): time = datetime.time() if isinstance(ob, datetime.time): date = datetime.date(1900, 1, 1) return datetime.datetime.combine(date, time) def strftime(fmt, t): """ Portable strftime. In the stdlib, strftime has `known portability problems `_. This function aims to smooth over those issues and provide a consistent experience across the major platforms. >>> strftime('%Y', datetime.datetime(1890, 1, 1)) '1890' >>> strftime('%Y', datetime.datetime(900, 1, 1)) '0900' Supports time.struct_time, tuples, and datetime.datetime objects. >>> strftime('%Y-%m-%d', (1976, 5, 7)) '1976-05-07' Also supports date objects >>> strftime('%Y', datetime.date(1976, 5, 7)) '1976' Also supports milliseconds using %s. >>> strftime('%s', datetime.time(microsecond=20000)) '020' Also supports microseconds (3 digits) using %µ >>> strftime('%µ', datetime.time(microsecond=123456)) '456' Currently, microseconds are also rendered when %u is indicated, but this behavior is deprecated and will revert to the stdlib behavior in the future. >>> strftime('%u', datetime.date(1976, 5, 7)) '000' (should be '4') Also supports microseconds (6 digits) using %f >>> strftime('%f', datetime.time(microsecond=23456)) '023456' Even supports time values on date objects (discouraged): >>> strftime('%f', datetime.date(1976, 1, 1)) '000000' >>> strftime('%µ', datetime.date(1976, 1, 1)) '000' >>> strftime('%s', datetime.date(1976, 1, 1)) '000' And vice-versa: >>> strftime('%Y', datetime.time()) '1900' """ if isinstance(t, (time.struct_time, tuple)): t = datetime.datetime(*t[:6]) t = ensure_datetime(t) subs = ( ('%s', '%03d' % (t.microsecond // 1000)), ('%u', '%03d' % (t.microsecond % 1000)), ('%µ', '%03d' % (t.microsecond % 1000)), ) if _needs_year_help(): # pragma: nocover subs += (('%Y', '%04d' % t.year),) def doSub(s, sub): return s.replace(*sub) def doSubs(s): return functools.reduce(doSub, subs, s) fmt = '%%'.join(map(doSubs, fmt.split('%%'))) return t.strftime(fmt) def strptime(s, fmt, tzinfo=None): """ A function to replace strptime in the time module. Should behave identically to the strptime function except it returns a datetime.datetime object instead of a time.struct_time object. Also takes an optional tzinfo parameter which is a time zone info object. >>> strptime('2019-09-20', '%Y-%m-%d') datetime.datetime(2019, 9, 20, 0, 0) """ warnings.warn("Use datetime.datetime.strptime", DeprecationWarning) return datetime.datetime.strptime(s, fmt).replace(tzinfo=tzinfo) def datetime_mod(dt, period, start=None): """ Find the time which is the specified date/time truncated to the time delta relative to the start date/time. By default, the start time is midnight of the same day as the specified date/time. >>> datetime_mod(datetime.datetime(2004, 1, 2, 3), ... datetime.timedelta(days = 1.5), ... start = datetime.datetime(2004, 1, 1)) datetime.datetime(2004, 1, 1, 0, 0) >>> datetime_mod(datetime.datetime(2004, 1, 2, 13), ... datetime.timedelta(days = 1.5), ... start = datetime.datetime(2004, 1, 1)) datetime.datetime(2004, 1, 2, 12, 0) >>> datetime_mod(datetime.datetime(2004, 1, 2, 13), ... datetime.timedelta(days = 7), ... start = datetime.datetime(2004, 1, 1)) datetime.datetime(2004, 1, 1, 0, 0) >>> datetime_mod(datetime.datetime(2004, 1, 10, 13), ... datetime.timedelta(days = 7), ... start = datetime.datetime(2004, 1, 1)) datetime.datetime(2004, 1, 8, 0, 0) """ if start is None: # use midnight of the same day start = datetime.datetime.combine(dt.date(), datetime.time()) # calculate the difference between the specified time and the start date. delta = dt - start # now aggregate the delta and the period into microseconds # Use microseconds because that's the highest precision of these time # pieces. Also, using microseconds ensures perfect precision (no floating # point errors). def get_time_delta_microseconds(td): return (td.days * seconds_per_day + td.seconds) * 1000000 + td.microseconds delta, period = map(get_time_delta_microseconds, (delta, period)) offset = datetime.timedelta(microseconds=delta % period) # the result is the original specified time minus the offset result = dt - offset return result def datetime_round(dt, period, start=None): """ Find the nearest even period for the specified date/time. >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 11, 13), ... datetime.timedelta(hours = 1)) datetime.datetime(2004, 11, 13, 8, 0) >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 31, 13), ... datetime.timedelta(hours = 1)) datetime.datetime(2004, 11, 13, 9, 0) >>> datetime_round(datetime.datetime(2004, 11, 13, 8, 30), ... datetime.timedelta(hours = 1)) datetime.datetime(2004, 11, 13, 9, 0) """ result = datetime_mod(dt, period, start) if abs(dt - result) >= period // 2: result += period return result def get_nearest_year_for_day(day): """ Returns the nearest year to now inferred from a Julian date. >>> freezer = getfixture('freezer') >>> freezer.move_to('2019-05-20') >>> get_nearest_year_for_day(20) 2019 >>> get_nearest_year_for_day(340) 2018 >>> freezer.move_to('2019-12-15') >>> get_nearest_year_for_day(20) 2020 """ now = time.gmtime() result = now.tm_year # if the day is far greater than today, it must be from last year if day - now.tm_yday > 365 // 2: result -= 1 # if the day is far less than today, it must be for next year. if now.tm_yday - day > 365 // 2: result += 1 return result def gregorian_date(year, julian_day): """ Gregorian Date is defined as a year and a julian day (1-based index into the days of the year). >>> gregorian_date(2007, 15) datetime.date(2007, 1, 15) """ result = datetime.date(year, 1, 1) result += datetime.timedelta(days=julian_day - 1) return result def get_period_seconds(period): """ return the number of seconds in the specified period >>> get_period_seconds('day') 86400 >>> get_period_seconds(86400) 86400 >>> get_period_seconds(datetime.timedelta(hours=24)) 86400 >>> get_period_seconds('day + os.system("rm -Rf *")') Traceback (most recent call last): ... ValueError: period not in (second, minute, hour, day, month, year) """ if isinstance(period, str): try: name = 'seconds_per_' + period.lower() result = globals()[name] except KeyError: msg = "period not in (second, minute, hour, day, month, year)" raise ValueError(msg) elif isinstance(period, numbers.Number): result = period elif isinstance(period, datetime.timedelta): result = period.days * get_period_seconds('day') + period.seconds else: raise TypeError('period must be a string or integer') return result def get_date_format_string(period): """ For a given period (e.g. 'month', 'day', or some numeric interval such as 3600 (in secs)), return the format string that can be used with strftime to format that time to specify the times across that interval, but no more detailed. For example, >>> get_date_format_string('month') '%Y-%m' >>> get_date_format_string(3600) '%Y-%m-%d %H' >>> get_date_format_string('hour') '%Y-%m-%d %H' >>> get_date_format_string(None) Traceback (most recent call last): ... TypeError: period must be a string or integer >>> get_date_format_string('garbage') Traceback (most recent call last): ... ValueError: period not in (second, minute, hour, day, month, year) """ # handle the special case of 'month' which doesn't have # a static interval in seconds if isinstance(period, str) and period.lower() == 'month': return '%Y-%m' file_period_secs = get_period_seconds(period) format_pieces = ('%Y', '-%m-%d', ' %H', '-%M', '-%S') seconds_per_second = 1 intervals = ( seconds_per_year, seconds_per_day, seconds_per_hour, seconds_per_minute, seconds_per_second, ) mods = list(map(lambda interval: file_period_secs % interval, intervals)) format_pieces = format_pieces[: mods.index(0) + 1] return ''.join(format_pieces) def divide_timedelta_float(td, divisor): """ Divide a timedelta by a float value >>> one_day = datetime.timedelta(days=1) >>> half_day = datetime.timedelta(days=.5) >>> divide_timedelta_float(one_day, 2.0) == half_day True >>> divide_timedelta_float(one_day, 2) == half_day True """ # td is comprised of days, seconds, microseconds dsm = [getattr(td, attr) for attr in ('days', 'seconds', 'microseconds')] dsm = map(lambda elem: elem / divisor, dsm) return datetime.timedelta(*dsm) def calculate_prorated_values(): """ >>> monkeypatch = getfixture('monkeypatch') >>> import builtins >>> monkeypatch.setattr(builtins, 'input', lambda prompt: '3/hour') >>> calculate_prorated_values() per minute: 0.05 per hour: 3.0 per day: 72.0 per month: 2191.454166666667 per year: 26297.45 """ rate = input("Enter the rate (3/hour, 50/month)> ") for period, value in _prorated_values(rate): print("per {period}: {value}".format(**locals())) def _prorated_values(rate): """ Given a rate (a string in units per unit time), and return that same rate for various time periods. >>> for period, value in _prorated_values('20/hour'): ... print('{period}: {value:0.3f}'.format(**locals())) minute: 0.333 hour: 20.000 day: 480.000 month: 14609.694 year: 175316.333 """ res = re.match(r'(?P[\d.]+)/(?P\w+)$', rate).groupdict() value = float(res['value']) value_per_second = value / get_period_seconds(res['period']) for period in ('minute', 'hour', 'day', 'month', 'year'): period_value = value_per_second * get_period_seconds(period) yield period, period_value def parse_timedelta(str): """ Take a string representing a span of time and parse it to a time delta. Accepts any string of comma-separated numbers each with a unit indicator. >>> parse_timedelta('1 day') datetime.timedelta(days=1) >>> parse_timedelta('1 day, 30 seconds') datetime.timedelta(days=1, seconds=30) >>> parse_timedelta('47.32 days, 20 minutes, 15.4 milliseconds') datetime.timedelta(days=47, seconds=28848, microseconds=15400) Supports weeks, months, years >>> parse_timedelta('1 week') datetime.timedelta(days=7) >>> parse_timedelta('1 year, 1 month') datetime.timedelta(days=395, seconds=58685) Note that months and years strict intervals, not aligned to a calendar: >>> now = datetime.datetime.now() >>> later = now + parse_timedelta('1 year') >>> diff = later.replace(year=now.year) - now >>> diff.seconds 20940 """ deltas = (_parse_timedelta_part(part.strip()) for part in str.split(',')) return sum(deltas, datetime.timedelta()) def _parse_timedelta_part(part): """ >>> _parse_timedelta_part('foo') Traceback (most recent call last): ... ValueError: Unable to parse 'foo' as a time delta """ match = re.match(r'(?P[\d.]+) (?P\w+)', part) if not match: msg = "Unable to parse {part!r} as a time delta".format(**locals()) raise ValueError(msg) unit = match.group('unit').lower() if not unit.endswith('s'): unit += 's' value = float(match.group('value')) if unit == 'months': unit = 'years' value = value / 12 if unit == 'years': unit = 'days' value = value * days_per_year return datetime.timedelta(**{unit: value}) def divide_timedelta(td1, td2): """ Get the ratio of two timedeltas >>> one_day = datetime.timedelta(days=1) >>> one_hour = datetime.timedelta(hours=1) >>> divide_timedelta(one_hour, one_day) == 1 / 24 True """ try: return td1 / td2 except TypeError: # pragma: nocover # Python 3.2 gets division # http://bugs.python.org/issue2706 return td1.total_seconds() / td2.total_seconds() def date_range(start=None, stop=None, step=None): """ Much like the built-in function range, but works with dates >>> range_items = date_range( ... datetime.datetime(2005,12,21), ... datetime.datetime(2005,12,25), ... ) >>> my_range = tuple(range_items) >>> datetime.datetime(2005,12,21) in my_range True >>> datetime.datetime(2005,12,22) in my_range True >>> datetime.datetime(2005,12,25) in my_range False >>> from_now = date_range(stop=datetime.datetime(2099, 12, 31)) >>> next(from_now) datetime.datetime(...) """ if step is None: step = datetime.timedelta(days=1) if start is None: start = datetime.datetime.now() while start < stop: yield start start += step tempora-2.1.1/tempora/schedule.py000066400000000000000000000130261361641537300170120ustar00rootroot00000000000000""" Classes for calling functions a schedule. """ import datetime import numbers import abc import bisect import pytz def now(): """ Provide the current timezone-aware datetime. A client may override this function to change the default behavior, such as to use local time or timezone-naïve times. """ return datetime.datetime.utcnow().replace(tzinfo=pytz.utc) def from_timestamp(ts): """ Convert a numeric timestamp to a timezone-aware datetime. A client may override this function to change the default behavior, such as to use local time or timezone-naïve times. """ return datetime.datetime.utcfromtimestamp(ts).replace(tzinfo=pytz.utc) class DelayedCommand(datetime.datetime): """ A command to be executed after some delay (seconds or timedelta). """ @classmethod def from_datetime(cls, other): return cls( other.year, other.month, other.day, other.hour, other.minute, other.second, other.microsecond, other.tzinfo, ) @classmethod def after(cls, delay, target): if not isinstance(delay, datetime.timedelta): delay = datetime.timedelta(seconds=delay) due_time = now() + delay cmd = cls.from_datetime(due_time) cmd.delay = delay cmd.target = target return cmd @staticmethod def _from_timestamp(input): """ If input is a real number, interpret it as a Unix timestamp (seconds sinc Epoch in UTC) and return a timezone-aware datetime object. Otherwise return input unchanged. """ if not isinstance(input, numbers.Real): return input return from_timestamp(input) @classmethod def at_time(cls, at, target): """ Construct a DelayedCommand to come due at `at`, where `at` may be a datetime or timestamp. """ at = cls._from_timestamp(at) cmd = cls.from_datetime(at) cmd.delay = at - now() cmd.target = target return cmd def due(self): return now() >= self class PeriodicCommand(DelayedCommand): """ Like a delayed command, but expect this command to run every delay seconds. """ def _next_time(self): """ Add delay to self, localized """ return self._localize(self + self.delay) @staticmethod def _localize(dt): """ Rely on pytz.localize to ensure new result honors DST. """ try: tz = dt.tzinfo return tz.localize(dt.replace(tzinfo=None)) except AttributeError: return dt def next(self): cmd = self.__class__.from_datetime(self._next_time()) cmd.delay = self.delay cmd.target = self.target return cmd def __setattr__(self, key, value): if key == 'delay' and not value > datetime.timedelta(): raise ValueError( "A PeriodicCommand must have a positive, " "non-zero delay." ) super(PeriodicCommand, self).__setattr__(key, value) class PeriodicCommandFixedDelay(PeriodicCommand): """ Like a periodic command, but don't calculate the delay based on the current time. Instead use a fixed delay following the initial run. """ @classmethod def at_time(cls, at, delay, target): """ >>> cmd = PeriodicCommandFixedDelay.at_time(0, 30, None) >>> cmd.delay.total_seconds() 30.0 """ at = cls._from_timestamp(at) cmd = cls.from_datetime(at) if isinstance(delay, numbers.Number): delay = datetime.timedelta(seconds=delay) cmd.delay = delay cmd.target = target return cmd @classmethod def daily_at(cls, at, target): """ Schedule a command to run at a specific time each day. >>> from tempora import utc >>> noon = utc.time(12, 0) >>> cmd = PeriodicCommandFixedDelay.daily_at(noon, None) >>> cmd.delay.total_seconds() 86400.0 """ daily = datetime.timedelta(days=1) # convert when to the next datetime matching this time when = datetime.datetime.combine(datetime.date.today(), at) when -= daily while when < now(): when += daily return cls.at_time(cls._localize(when), daily, target) class Scheduler: """ A rudimentary abstract scheduler accepting DelayedCommands and dispatching them on schedule. """ def __init__(self): self.queue = [] def add(self, command): assert isinstance(command, DelayedCommand) bisect.insort(self.queue, command) def run_pending(self): while self.queue: command = self.queue[0] if not command.due(): break self.run(command) if isinstance(command, PeriodicCommand): self.add(command.next()) del self.queue[0] @abc.abstractmethod def run(self, command): """ Run the command """ class InvokeScheduler(Scheduler): """ Command targets are functions to be invoked on schedule. """ def run(self, command): command.target() class CallbackScheduler(Scheduler): """ Command targets are passed to a dispatch callable on schedule. """ def __init__(self, dispatch): super().__init__() self.dispatch = dispatch def run(self, command): self.dispatch(command.target) tempora-2.1.1/tempora/tests/000077500000000000000000000000001361641537300160045ustar00rootroot00000000000000tempora-2.1.1/tempora/tests/test_schedule.py000066400000000000000000000110451361641537300212120ustar00rootroot00000000000000import time import random import datetime from unittest import mock import pytest import pytz import freezegun from tempora import schedule do_nothing = type(None) def test_delayed_command_order(): """ delayed commands should be sorted by delay time """ delays = [random.randint(0, 99) for x in range(5)] cmds = sorted( [schedule.DelayedCommand.after(delay, do_nothing) for delay in delays] ) assert [c.delay.seconds for c in cmds] == sorted(delays) def test_periodic_command_delay(): "A PeriodicCommand must have a positive, non-zero delay." with pytest.raises(ValueError) as exc_info: schedule.PeriodicCommand.after(0, None) assert str(exc_info.value) == test_periodic_command_delay.__doc__ def test_periodic_command_fixed_delay(): """ Test that we can construct a periodic command with a fixed initial delay. """ fd = schedule.PeriodicCommandFixedDelay.at_time( at=schedule.now(), delay=datetime.timedelta(seconds=2), target=lambda: None ) assert fd.due() is True assert fd.next().due() is False class TestCommands: def test_delayed_command_from_timestamp(self): """ Ensure a delayed command can be constructed from a timestamp. """ t = time.time() schedule.DelayedCommand.at_time(t, do_nothing) def test_command_at_noon(self): """ Create a periodic command that's run at noon every day. """ when = datetime.time(12, 0, tzinfo=pytz.utc) cmd = schedule.PeriodicCommandFixedDelay.daily_at(when, target=None) assert cmd.due() is False next_cmd = cmd.next() daily = datetime.timedelta(days=1) day_from_now = schedule.now() + daily two_days_from_now = day_from_now + daily assert day_from_now < next_cmd < two_days_from_now @pytest.mark.parametrize("hour", range(10, 14)) @pytest.mark.parametrize("tz_offset", (14, -14)) def test_command_at_noon_distant_local(self, hour, tz_offset): """ Run test_command_at_noon, but with the local timezone more than 12 hours away from UTC. """ with freezegun.freeze_time(f"2020-01-10 {hour:02}:01", tz_offset=tz_offset): self.test_command_at_noon() class TestTimezones: def test_alternate_timezone_west(self): target_tz = pytz.timezone('US/Pacific') target = schedule.now().astimezone(target_tz) cmd = schedule.DelayedCommand.at_time(target, target=None) assert cmd.due() def test_alternate_timezone_east(self): target_tz = pytz.timezone('Europe/Amsterdam') target = schedule.now().astimezone(target_tz) cmd = schedule.DelayedCommand.at_time(target, target=None) assert cmd.due() def test_daylight_savings(self): """ A command at 9am should always be 9am regardless of a DST boundary. """ with freezegun.freeze_time('2018-03-10 08:00:00'): target_tz = pytz.timezone('US/Eastern') target_time = datetime.time(9, tzinfo=target_tz) cmd = schedule.PeriodicCommandFixedDelay.daily_at( target_time, target=lambda: None ) def naive(dt): return dt.replace(tzinfo=None) assert naive(cmd) == datetime.datetime(2018, 3, 10, 9, 0, 0) next_ = cmd.next() assert naive(next_) == datetime.datetime(2018, 3, 11, 9, 0, 0) assert next_ - cmd == datetime.timedelta(hours=23) class TestScheduler: def test_invoke_scheduler(self): sched = schedule.InvokeScheduler() target = mock.MagicMock() cmd = schedule.DelayedCommand.after(0, target) sched.add(cmd) sched.run_pending() target.assert_called_once() assert not sched.queue def test_callback_scheduler(self): callback = mock.MagicMock() sched = schedule.CallbackScheduler(callback) target = mock.MagicMock() cmd = schedule.DelayedCommand.after(0, target) sched.add(cmd) sched.run_pending() callback.assert_called_once_with(target) def test_periodic_command(self): sched = schedule.InvokeScheduler() target = mock.MagicMock() cmd = schedule.PeriodicCommand.at_time(time.time() + 0.1, target) sched.add(cmd) sched.run_pending() target.assert_not_called() time.sleep(0.1) sched.run_pending() assert sched.queue target.assert_called_once() time.sleep(0.1) sched.run_pending() assert target.call_count == 2 tempora-2.1.1/tempora/tests/test_timing.py000066400000000000000000000022111361641537300207000ustar00rootroot00000000000000import datetime import time import contextlib import os from unittest import mock from tempora import timing def test_IntervalGovernor(): """ IntervalGovernor should prevent a function from being called more than once per interval. """ func_under_test = mock.MagicMock() # to look like a function, it needs a __name__ attribute func_under_test.__name__ = 'func_under_test' interval = datetime.timedelta(seconds=1) governed = timing.IntervalGovernor(interval)(func_under_test) governed('a') governed('b') governed(3, 'sir') func_under_test.assert_called_once_with('a') @contextlib.contextmanager def change(alt_tz, monkeypatch): monkeypatch.setitem(os.environ, 'TZ', alt_tz) time.tzset() try: yield finally: monkeypatch.delitem(os.environ, 'TZ') time.tzset() def test_Stopwatch_timezone_change(monkeypatch): """ The stopwatch should provide a consistent duration even if the timezone changes. """ watch = timing.Stopwatch() with change('AEST-10AEDT-11,M10.5.0,M3.5.0', monkeypatch): assert abs(watch.split().total_seconds()) < 0.1 tempora-2.1.1/tempora/timing.py000066400000000000000000000140661361641537300165120ustar00rootroot00000000000000import datetime import functools import numbers import time import collections.abc import contextlib import jaraco.functools class Stopwatch: """ A simple stopwatch which starts automatically. >>> w = Stopwatch() >>> _1_sec = datetime.timedelta(seconds=1) >>> w.split() < _1_sec True >>> import time >>> time.sleep(1.0) >>> w.split() >= _1_sec True >>> w.stop() >= _1_sec True >>> w.reset() >>> w.start() >>> w.split() < _1_sec True It should be possible to launch the Stopwatch in a context: >>> with Stopwatch() as watch: ... assert isinstance(watch.split(), datetime.timedelta) In that case, the watch is stopped when the context is exited, so to read the elapsed time:: >>> watch.elapsed datetime.timedelta(...) >>> watch.elapsed.seconds 0 """ def __init__(self): self.reset() self.start() def reset(self): self.elapsed = datetime.timedelta(0) with contextlib.suppress(AttributeError): del self.start_time def start(self): self.start_time = datetime.datetime.utcnow() def stop(self): stop_time = datetime.datetime.utcnow() self.elapsed += stop_time - self.start_time del self.start_time return self.elapsed def split(self): local_duration = datetime.datetime.utcnow() - self.start_time return self.elapsed + local_duration # context manager support def __enter__(self): self.start() return self def __exit__(self, exc_type, exc_value, traceback): self.stop() class IntervalGovernor: """ Decorate a function to only allow it to be called once per min_interval. Otherwise, it returns None. >>> gov = IntervalGovernor(30) >>> gov.min_interval.total_seconds() 30.0 """ def __init__(self, min_interval): if isinstance(min_interval, numbers.Number): min_interval = datetime.timedelta(seconds=min_interval) self.min_interval = min_interval self.last_call = None def decorate(self, func): @functools.wraps(func) def wrapper(*args, **kwargs): allow = not self.last_call or self.last_call.split() > self.min_interval if allow: self.last_call = Stopwatch() return func(*args, **kwargs) return wrapper __call__ = decorate class Timer(Stopwatch): """ Watch for a target elapsed time. >>> t = Timer(0.1) >>> t.expired() False >>> __import__('time').sleep(0.15) >>> t.expired() True """ def __init__(self, target=float('Inf')): self.target = self._accept(target) super(Timer, self).__init__() @staticmethod def _accept(target): """ Accept None or ∞ or datetime or numeric for target >>> Timer._accept(datetime.timedelta(seconds=30)) 30.0 >>> Timer._accept(None) inf """ if isinstance(target, datetime.timedelta): target = target.total_seconds() if target is None: # treat None as infinite target target = float('Inf') return target def expired(self): return self.split().total_seconds() > self.target class BackoffDelay(collections.abc.Iterator): """ Exponential backoff delay. Useful for defining delays between retries. Consider for use with ``jaraco.functools.retry_call`` as the cleanup. Default behavior has no effect; a delay or jitter must be supplied for the call to be non-degenerate. >>> bd = BackoffDelay() >>> bd() >>> bd() The following instance will delay 10ms for the first call, 20ms for the second, etc. >>> bd = BackoffDelay(delay=0.01, factor=2) >>> bd() >>> bd() Inspect and adjust the state of the delay anytime. >>> bd.delay 0.04 >>> bd.delay = 0.01 Set limit to prevent the delay from exceeding bounds. >>> bd = BackoffDelay(delay=0.01, factor=2, limit=0.015) >>> bd() >>> bd.delay 0.015 To reset the backoff, simply call ``.reset()``: >>> bd.reset() >>> bd.delay 0.01 Iterate on the object to retrieve/advance the delay values. >>> next(bd) 0.01 >>> next(bd) 0.015 >>> import itertools >>> tuple(itertools.islice(bd, 3)) (0.015, 0.015, 0.015) Limit may be a callable taking a number and returning the limited number. >>> at_least_one = lambda n: max(n, 1) >>> bd = BackoffDelay(delay=0.01, factor=2, limit=at_least_one) >>> next(bd) 0.01 >>> next(bd) 1 Pass a jitter to add or subtract seconds to the delay. >>> bd = BackoffDelay(jitter=0.01) >>> next(bd) 0 >>> next(bd) 0.01 Jitter may be a callable. To supply a non-deterministic jitter between -0.5 and 0.5, consider: >>> import random >>> jitter=functools.partial(random.uniform, -0.5, 0.5) >>> bd = BackoffDelay(jitter=jitter) >>> next(bd) 0 >>> 0 <= next(bd) <= 0.5 True """ delay = 0 factor = 1 "Multiplier applied to delay" jitter = 0 "Number or callable returning extra seconds to add to delay" @jaraco.functools.save_method_args def __init__(self, delay=0, factor=1, limit=float('inf'), jitter=0): self.delay = delay self.factor = factor if isinstance(limit, numbers.Number): limit_ = limit def limit(n): return max(0, min(limit_, n)) self.limit = limit if isinstance(jitter, numbers.Number): jitter_ = jitter def jitter(): return jitter_ self.jitter = jitter def __call__(self): time.sleep(next(self)) def __next__(self): delay = self.delay self.bump() return delay def __iter__(self): return self def bump(self): self.delay = self.limit(self.delay * self.factor + self.jitter()) def reset(self): saved = self._saved___init__ self.__init__(*saved.args, **saved.kwargs) tempora-2.1.1/tempora/utc.py000066400000000000000000000015161361641537300160120ustar00rootroot00000000000000""" Facilities for common time operations in UTC. Inspired by the `utc project `_. >>> dt = now() >>> dt == fromtimestamp(dt.timestamp()) True >>> dt.tzinfo datetime.timezone.utc >>> from time import time as timestamp >>> now().timestamp() - timestamp() < 0.1 True >>> (now() - fromtimestamp(timestamp())).total_seconds() < 0.1 True >>> datetime(2018, 6, 26, 0).tzinfo datetime.timezone.utc >>> time(0, 0).tzinfo datetime.timezone.utc """ import datetime as std import functools __all__ = ['now', 'fromtimestamp', 'datetime', 'time'] now = functools.partial(std.datetime.now, std.timezone.utc) fromtimestamp = functools.partial(std.datetime.fromtimestamp, tz=std.timezone.utc) datetime = functools.partial(std.datetime, tzinfo=std.timezone.utc) time = functools.partial(std.time, tzinfo=std.timezone.utc) tempora-2.1.1/tox.ini000066400000000000000000000013711361641537300145100ustar00rootroot00000000000000[tox] envlist = python minversion = 3.2 # https://github.com/jaraco/skeleton/issues/6 tox_pip_extensions_ext_venv_update = true # Ensure that a late version of pip is used even on tox-venv. requires = tox-pip-version>=0.0.6 tox-venv [testenv] deps = setuptools>=31.0.1 pip_version = pip commands = pytest {posargs} usedevelop = True extras = testing [testenv:docs] extras = docs testing changedir = docs commands = python -m sphinx . {toxinidir}/build/html [testenv:release] skip_install = True deps = pep517>=0.5 twine[keyring]>=1.13 path passenv = TWINE_PASSWORD setenv = TWINE_USERNAME = {env:TWINE_USERNAME:__token__} commands = python -c "import path; path.Path('dist').rmtree_p()" python -m pep517.build . python -m twine upload dist/*