diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0dadced --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + # Maintain dependencies for Python packages +- package-ecosystem: pip + directory: / + schedule: + interval: weekly + + # Maintain dependencies for GitHub Actions +- package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index e06516c..49265ba 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/pre-commit-autoupdate.yml b/.github/workflows/pre-commit-autoupdate.yml new file mode 100644 index 0000000..a161042 --- /dev/null +++ b/.github/workflows/pre-commit-autoupdate.yml @@ -0,0 +1,33 @@ +name: Pre-commit auto-update + +on: + schedule: + - cron: 0 0 * * 0 # every Sunday at midnight + +permissions: + pull-requests: write + +jobs: + auto-update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install pre-commit + run: pip install pre-commit + - name: Run pre-commit autoupdate + run: pre-commit autoupdate + - name: Create Pull Request + uses: peter-evans/create-pull-request@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: update/pre-commit-autoupdate + title: Auto-update pre-commit hooks + commit-message: Auto-update pre-commit hooks + body: | + Update versions of tools in pre-commit + configs to latest version + labels: dependencies diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 15d4a58..82d5141 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c5fedb0..511475c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7912666..d76f60f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v4.4.0 hooks: - id: check-ast - id: check-case-conflict @@ -11,7 +11,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell - rev: v2.1.0 + rev: v2.2.2 hooks: - id: codespell name: Fixing common spelling mistakes @@ -19,7 +19,7 @@ repos: - --write-changes - -L compiletime - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.32.2 + rev: v0.33.0 hooks: - id: markdownlint name: Fixing markdown linting errors @@ -30,12 +30,12 @@ repos: - id: absolufy-imports name: Make python imports absolute - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 23.1.0 hooks: - id: black name: Python formatting (black) - repo: https://github.com/PyCQA/docformatter - rev: v1.5.0 + rev: v1.6.0.rc1 hooks: - id: docformatter name: Python docstring formatting (docformatter) @@ -46,13 +46,13 @@ repos: name: Sorting python imports args: [--profile, black] - repo: https://github.com/asottile/pyupgrade - rev: v2.37.3 + rev: v3.3.1 hooks: - id: pyupgrade name: Upgrade common mistakes args: [--py38-plus] - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + rev: 6.0.0 hooks: - id: flake8 name: Linting Python code (flake8) @@ -68,7 +68,7 @@ repos: - id: pretty-format-toml args: [--autofix] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.971 + rev: v0.991 hooks: - id: mypy name: Static typechecking (mypy) diff --git a/README.md b/README.md index c03c775..424e8eb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # timeoutd ![pytest](https://github.com/juhannc/timeoutd/actions/workflows/pytest.yml/badge.svg) -[![Pypi Status](https://badge.fury.io/py/timeoutd.svg)](https://badge.fury.io/py/timeoutd) +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/juhannc/timeoutd/main.svg)](https://results.pre-commit.ci/latest/github/juhannc/timeoutd/main) [![codecov](https://codecov.io/gh/juhannc/timeoutd/branch/main/graph/badge.svg)](https://codecov.io/gh/juhannc/timeoutd) [![Maintainability](https://api.codeclimate.com/v1/badges/ba14c01e22ad0343af8c/maintainability)](https://codeclimate.com/github/juhannc/timeoutd/maintainability) +[![Pypi Status](https://badge.fury.io/py/timeoutd.svg)](https://badge.fury.io/py/timeoutd) + ## Installation From [PyPI](https://pypi.org/project/timeoutd/): @@ -22,8 +24,12 @@ pip install -e . ## Usage +The `timeoutd` module provides a decorator that can be used to limit the execution time of a function. +The decorator takes a single argument, the number of seconds or a specific date (as a datetime object) after which the function should be terminated. + ```python import time + import timeoutd @timeoutd.timeout(5) @@ -37,10 +43,67 @@ if __name__ == '__main__': mytest() ``` -Specify an alternate exception to raise on timeout: +The `timeout` decorator allows for multiple different ways to specify the timeout, for example with a datetime object: + +```python +import time +import datetime + +import timeoutd + +@timeoutd.timeout(datetime.datetime.now() + datetime.timedelta(0, 5)) +def mytest(): + print("Start") + for i in range(1, 10): + time.sleep(1) + print(f"{i} seconds have passed") + +if __name__ == '__main__': + mytest() +``` + +It also take a `timedelta` object: + +```python +import time +import datetime + +import timeoutd + +@timeoutd.timeout(datetime.timedelta(0, 5)) +def mytest(): + print("Start") + for i in range(1, 10): + time.sleep(1) + print(f"{i} seconds have passed") + +if __name__ == '__main__': + mytest() +``` + +But it can also take a delta in form of hours, minutes, and seconds via the kwargs: + +```python +import time + +import timeoutd + +@timeoutd.timeout(hours=0, minutes=0, seconds=5) +def mytest(): + print("Start") + for i in range(1, 10): + time.sleep(1) + print(f"{i} seconds have passed") + +if __name__ == '__main__': + mytest() +``` + +The `timeout` decorator also accepts a custom exception to raise on timeout: ```python import time + import timeoutd @timeoutd.timeout(5, exception_type=StopIteration) @@ -59,6 +122,7 @@ You can also specify a function to be called on timeout instead of raising an ex ```python import time + import timeoutd def add_two_numbers(i: int, j: int | None = None): @@ -94,6 +158,7 @@ To use it, just pass `use_signals=False` to the timeout decorator function: ```python import time + import timeoutd @timeoutd.timeout(5, use_signals=False) diff --git a/VERSION b/VERSION index ee6cdce..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.1 +0.7.0 diff --git a/setup.cfg b/setup.cfg index 760bb65..f7870a3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,11 +15,13 @@ classifiers = License :: OSI Approved :: MIT License Natural Language :: English Operating System :: OS Independent - Programming Language :: Python + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Topic :: Software Development :: Libraries :: Python Modules + Typing :: Typed platforms = any project_urls = diff --git a/tests/test_timeoutd.py b/tests/test_timeoutd.py index 536aba9..7041264 100644 --- a/tests/test_timeoutd.py +++ b/tests/test_timeoutd.py @@ -1,5 +1,6 @@ """Timeout decorator tests.""" import time +from datetime import datetime, timedelta import pytest @@ -86,6 +87,93 @@ def f(): f() +def test_timeout_with_only_args(use_signals): + @timeout(TIMEOUT, use_signals=use_signals) + def f(): + time.sleep(0.2) + + with pytest.raises(TimeoutError): + f() + + +def test_timeout_ok_with_only_args(use_signals): + @timeout(TIMEOUT, use_signals=use_signals) + def f(): + time.sleep(TIMEOUT / 2) + + f() + + +def test_timeout_with_datetime_object(use_signals): + @timeout(limit=datetime.now() + timedelta(0, TIMEOUT), use_signals=use_signals) + def f(): + time.sleep(0.2) + + with pytest.raises(TimeoutError): + f() + + +def test_timeout_ok_with_datetime_object(use_signals): + @timeout(limit=datetime.now() + timedelta(0, TIMEOUT), use_signals=use_signals) + def f(): + time.sleep(TIMEOUT / 2) + + f() + + +def test_timeout_with_timedelta_object(use_signals): + @timeout(limit=timedelta(0, TIMEOUT), use_signals=use_signals) + def f(): + time.sleep(0.2) + + with pytest.raises(TimeoutError): + f() + + +def test_timeout_ok_with_timedelta_object(use_signals): + @timeout(limit=timedelta(0, TIMEOUT), use_signals=use_signals) + def f(): + time.sleep(TIMEOUT / 2) + + f() + + +def test_timeout_with_hms_tuple(use_signals): + @timeout(hours=0, minutes=0, seconds=TIMEOUT, use_signals=use_signals) + def f(): + time.sleep(0.2) + + with pytest.raises(TimeoutError): + f() + + +def test_timeout_with_seconds(use_signals): + @timeout(seconds=TIMEOUT, use_signals=use_signals) + def f(): + time.sleep(0.2) + + with pytest.raises(TimeoutError): + f() + + +def test_timeout_with_minutes(use_signals): + @timeout(minutes=TIMEOUT / 60, use_signals=use_signals) + def f(): + time.sleep(0.2) + + with pytest.raises(TimeoutError): + f() + + +def test_timeout_with_hours(use_signals): + @timeout(hours=TIMEOUT / 3600, use_signals=use_signals) + def f(): + time.sleep(0.2) + + with pytest.raises(TimeoutError): + f() + + def test_function_name(use_signals): @timeout(seconds=0.2, use_signals=use_signals) def func_name(): @@ -154,9 +242,9 @@ def f(): assert f() == 3 +# fmt: off def test_timeout_pickle_error(): """Test that when a pickle error occurs a timeout error is raised.""" - @timeout(seconds=TIMEOUT, use_signals=False) def f(): time.sleep(0.1) @@ -168,6 +256,7 @@ class Test: with pytest.raises(TimeoutError): f() +# fmt: on def test_timeout_custom_exception_message(): diff --git a/timeoutd/converters.py b/timeoutd/converters.py new file mode 100644 index 0000000..118665e --- /dev/null +++ b/timeoutd/converters.py @@ -0,0 +1,28 @@ +"""Converters for timeoutd.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + + +def time_to_seconds( + limit: float | datetime | timedelta | None = None, + *, + seconds: float | None = None, + minutes: float | None = None, + hours: float | None = None, +) -> float: + """Convert time to seconds.""" + if limit is not None: + if isinstance(limit, datetime): + return (limit - datetime.now()).total_seconds() + if isinstance(limit, timedelta): + return limit.total_seconds() + return limit + if seconds is None: + seconds = 0 + if minutes is None: + minutes = 0 + if hours is None: + hours = 0 + return seconds + minutes * 60 + hours * 3600 diff --git a/timeoutd/timeout.py b/timeoutd/timeout.py index ab0946d..c466176 100644 --- a/timeoutd/timeout.py +++ b/timeoutd/timeout.py @@ -2,15 +2,20 @@ from __future__ import annotations +from datetime import datetime, timedelta from typing import Callable +from timeoutd.converters import time_to_seconds from timeoutd.wrappers import _exception_handler, _signaler def timeout( + limit: float | datetime | timedelta | None = None, + *, seconds: float | None = None, + minutes: float | None = None, + hours: float | None = None, on_timeout: Callable | None = None, - *, use_signals: bool = True, exception_type: type = TimeoutError, exception_message: str | None = None, @@ -19,15 +24,29 @@ def timeout( ) -> Callable: """Add a timeout parameter to a function and return it. - :param seconds: optional time limit in seconds or fractions of a - second. If None is passed, no timeout is applied. + :param limit: optional time limit in either seconds (or fractions of + it) or a datetime object with a specified timeout date. If one + wants to specify a timeout in minutes, hours, and seconds, the + seconds, minutes, and hours parameters can be used. + If neither is passed, no timeout is applied but if both a limit + and a set of hours, minutes, and seconds is provided, the limit + will be used. This adds some flexibility to the usage: you can disable timing out depending on the settings. - :type seconds: float + :type limit: float | datetime | None :param on_timeout: optional function to call when the timeout is reached instead of raising an exception. If None is passed, the default behavior is to raise a TimeoutError exception. - :type on_timeout: Callable + :type on_timeout: Callable | None + :param seconds: optional time limit in seconds or fractions of a + second. See the `limit` parameter for more information. + :type seconds: float | None + :param minutes: optional time limit in minutes or fractions of a + minute. See the `limit` parameter for more information. + :type minutes: float | None + :param hours: optional time limit in hours or fractions of an hour. + See the `limit` parameter for more information. + :type hours: float | None :param use_signals: flag indicating whether signals should be used for timing function out or the multiprocessing. When using multiprocessing, timeout granularity is limited to @@ -56,6 +75,10 @@ def timeout( if not issubclass(exception_type, Exception): raise TypeError("exception_type must be a subclass of Exception") + seconds = time_to_seconds( + limit=limit, seconds=seconds, minutes=minutes, hours=hours + ) + def decorate(function: Callable) -> Callable: if on_timeout is None: return _signaler( diff --git a/tox.ini b/tox.ini index 0a10a75..7b0ebc6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] distshare={homedir}/.tox/distshare -envlist=py{38,39,310} +envlist=py{38,39,310,311} skip_missing_interpreters=true indexserver= pypi = https://pypi.python.org/simple