├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── PKGBUILD ├── README.md ├── ci └── secrets.py ├── doc ├── Makefile ├── __init__.py ├── _assets │ ├── aio_kwargs.rst │ ├── code_style_black.svg │ ├── kwargs.rst │ ├── logo.svg │ ├── logo_name.svg │ └── prolog.rst ├── _static │ ├── custom.css │ └── custom_pygments.css ├── _templates │ ├── custom-base-template.rst │ ├── custom-class-template.rst │ ├── custom-module-template.rst │ ├── custom-reduced-class-template.rst │ └── custom-reduced-module-template.rst ├── api_reference.rst ├── conf.py ├── conftest.py ├── examples.rst ├── faq.rst ├── favicon.png ├── guides.rst ├── index.rst ├── logo_w_border.svg ├── pages │ ├── examples │ │ ├── aliases.rst │ │ ├── asyncio.rst │ │ ├── job_batching.rst │ │ ├── job_deletion.rst │ │ ├── job_prioritization.rst │ │ ├── metrics.rst │ │ ├── parameters.rst │ │ ├── quick_start.rst │ │ ├── tags.rst │ │ ├── threading.rst │ │ └── timezones.rst │ └── guides │ │ └── custom_prioritization.rst └── readme.rst ├── pyproject.toml ├── requirements.txt ├── scheduler ├── __init__.py ├── asyncio │ ├── __init__.py │ ├── job.py │ └── scheduler.py ├── base │ ├── __init__.py │ ├── definition.py │ ├── job.py │ ├── job_timer.py │ ├── job_util.py │ ├── scheduler.py │ ├── scheduler_util.py │ └── timingtype.py ├── error.py ├── message.py ├── prioritization.py ├── py.typed ├── threading │ ├── __init__.py │ ├── job.py │ └── scheduler.py ├── trigger │ ├── __init__.py │ └── core.py └── util.py └── tests ├── __init__.py ├── asyncio ├── __init__.py ├── test_async_job.py ├── test_async_scheduler.py ├── test_async_scheduler_cyclic.py ├── test_async_scheduler_repr.py └── test_async_scheduler_str.py ├── conftest.py ├── helpers.py ├── test_jobtimer.py ├── test_misc.py ├── test_prioritization.py ├── test_readme.py ├── test_util.py └── threading ├── __init__.py ├── job ├── __init__.py ├── test_job_init.py ├── test_job_misc.py ├── test_job_repr.py └── test_job_str.py └── scheduler ├── __init__.py ├── test_sch_cyclic.py ├── test_sch_cyclic_fail.py ├── test_sch_daily.py ├── test_sch_dead_lock.py ├── test_sch_delete_jobs.py ├── test_sch_exec_jobs.py ├── test_sch_get_jobs.py ├── test_sch_hourly.py ├── test_sch_init.py ├── test_sch_minutely.py ├── test_sch_once.py ├── test_sch_repr.py ├── test_sch_skip_missing.py ├── test_sch_start_stop.py ├── test_sch_str.py ├── test_sch_threading.py └── test_sch_weekly.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | tmp.* 3 | 4 | # pip 5 | *.egg-info/ 6 | .python-version 7 | 8 | # build directories 9 | build/ 10 | dist/ 11 | pkg/ 12 | src/ 13 | *.zst 14 | *.gz 15 | *.whl 16 | 17 | # testing 18 | venv 19 | .coverage* 20 | *htmlcov/ 21 | .mypy_cache/ 22 | .pytest_cache/ 23 | 24 | # sphinx-build 25 | doc/_build/ 26 | doc/_autosummary/ 27 | 28 | # vscode 29 | .vscode/ 30 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | before_script: 2 | - python --version 3 | - python ci/secrets.py 4 | - pip install -r requirements.txt 5 | 6 | stages: 7 | - analysis 8 | - test 9 | - release 10 | 11 | #################################################################################################### 12 | ### Analysis Stage 13 | #################################################################################################### 14 | 15 | pylint_3_9_16: 16 | stage: analysis 17 | image: "python:3.9.16-alpine3.17" 18 | script: 19 | - pylint scheduler 20 | allow_failure: true 21 | 22 | mypy_3_9_16: 23 | stage: analysis 24 | image: "python:3.9.16-alpine3.17" 25 | script: 26 | - mypy scheduler 27 | allow_failure: true 28 | 29 | pydocstyle_3_9_16: 30 | stage: analysis 31 | image: "python:3.9.16-alpine3.17" 32 | script: 33 | - pydocstyle scheduler 34 | allow_failure: true 35 | 36 | bandit_3_9_16: 37 | stage: analysis 38 | image: "python:3.9.16-alpine3.17" 39 | script: 40 | - bandit -r scheduler 41 | allow_failure: false 42 | 43 | #################################################################################################### 44 | ### Test Stage 45 | #################################################################################################### 46 | 47 | doc_3_9_16: 48 | stage: test 49 | image: "python:3.9.16-alpine3.17" 50 | script: 51 | - sphinx-build -b html doc/ doc/_build/html 52 | artifacts: 53 | paths: 54 | - doc/_build/html/ 55 | 56 | pytest_3_9_16: 57 | stage: test 58 | image: "python:3.9.16-alpine3.17" 59 | script: 60 | - pytest --cov=scheduler/ tests/ 61 | 62 | pydoctest_3_9_16: 63 | stage: test 64 | image: "python:3.9.16-alpine3.17" 65 | script: 66 | - pytest --doctest-modules doc/pages/*/*.rst 67 | 68 | pytest_3_10_9: 69 | stage: test 70 | image: "python:3.10.9-alpine3.17" 71 | script: 72 | - pytest --cov=scheduler/ tests/ 73 | 74 | pydoctest_3_10_9: 75 | stage: test 76 | image: "python:3.10.9-alpine3.17" 77 | script: 78 | - pytest --doctest-modules doc/pages/*/*.rst 79 | 80 | pytest_3_11_1: 81 | stage: test 82 | image: "python:3.11.1-alpine3.17" 83 | script: 84 | - pytest --cov=scheduler/ tests/ 85 | 86 | pydoctest_3_11_1: 87 | stage: test 88 | image: "python:3.11.1-alpine3.17" 89 | script: 90 | - pytest --doctest-modules doc/pages/*/*.rst 91 | 92 | pytest_3_12_1: 93 | stage: test 94 | image: "python:3.12.1-alpine3.19" 95 | script: 96 | - pytest --cov=scheduler/ tests/ 97 | 98 | pydoctest_3_12_1: 99 | stage: test 100 | image: "python:3.12.1-alpine3.19" 101 | script: 102 | - pytest --doctest-modules doc/pages/*/*.rst 103 | 104 | pytest_3_13_1: 105 | stage: test 106 | image: "python:3.13.1-alpine3.21" 107 | coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' 108 | script: 109 | - pytest --cov=scheduler/ tests/ 110 | - python -m coverage html 111 | artifacts: 112 | paths: 113 | - htmlcov/ 114 | 115 | pydoctest_3_13_1: 116 | stage: test 117 | image: "python:3.13.1-alpine3.21" 118 | script: 119 | - pytest --doctest-modules doc/pages/*/*.rst 120 | 121 | #################################################################################################### 122 | ### Release Stage 123 | #################################################################################################### 124 | 125 | push_doc: 126 | image: archlinux:base-20230108.0.116909 127 | stage: release 128 | before_script: [] 129 | script: 130 | - tar -C ./doc/_build/html -cpz . -f ./doc_build.tar.gz 131 | - > 132 | curl -X 'POST' 133 | 'https://digon.io/hyd/api/v1/version/upload' 134 | -H 'accept: application/json' 135 | -H "Authorization: Bearer ${HYD_TOKEN}" 136 | -H 'Content-Type: multipart/form-data' 137 | -F 'file=@doc_build.tar.gz;type=application/gzip' 138 | -F "project_id=${HYD_PROJECT_ID}" 139 | -F "version=${CI_COMMIT_SHORT_SHA}" 140 | - > 141 | curl -X 'PATCH' 142 | "https://digon.io/hyd/api/v1/tag/move?project_id=${HYD_PROJECT_ID}&tag=${CI_COMMIT_BRANCH}&version=${CI_COMMIT_SHORT_SHA}" 143 | -H 'accept: application/json' 144 | -H "Authorization: Bearer ${HYD_TOKEN}" 145 | only: 146 | - master 147 | - development 148 | 149 | push_cov: 150 | image: archlinux:base-20230108.0.116909 151 | stage: release 152 | before_script: [] 153 | script: 154 | - tar -C ./htmlcov -cpz . -f ./cov_report.tar.gz 155 | - > 156 | curl -X 'POST' 157 | 'https://digon.io/hyd/api/v1/version/upload' 158 | -H 'accept: application/json' 159 | -H "Authorization: Bearer ${HYD_TOKEN}" 160 | -H 'Content-Type: multipart/form-data' 161 | -F 'file=@cov_report.tar.gz;type=application/gzip' 162 | -F "project_id=${HYD_PROJECT_ID}" 163 | -F "version=${CI_COMMIT_SHORT_SHA}_coverage_report" 164 | - > 165 | curl -X 'PATCH' 166 | "https://digon.io/hyd/api/v1/tag/move?project_id=${HYD_PROJECT_ID}&tag=${CI_COMMIT_BRANCH}_coverage_report&version=${CI_COMMIT_SHORT_SHA}_coverage_report" 167 | -H 'accept: application/json' 168 | -H "Authorization: Bearer ${HYD_TOKEN}" 169 | only: 170 | - development 171 | 172 | pypi: 173 | image: python:3.10.9-bullseye 174 | stage: release 175 | script: 176 | - pip install twine build 177 | - python -m build 178 | - twine upload dist/* 179 | rules: 180 | - if: '$CI_COMMIT_TAG =~ /^\d+.\d+.\d+/' 181 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: isort 5 | name: isort 6 | entry: isort 7 | require_serial: true 8 | language: python 9 | language_version: python3 10 | types_or: [cython, pyi, python] 11 | args: ["--filter-files"] 12 | - id: black 13 | name: black 14 | description: "Black: The uncompromising Python code formatter" 15 | entry: black 16 | language: python 17 | minimum_pre_commit_version: 2.9.2 18 | require_serial: true 19 | types_or: [python, pyi] 20 | args: [--safe, --quiet] 21 | - id: blacken-docs 22 | name: blacken-docs 23 | description: Run `black` on python code blocks in documentation files 24 | entry: blacken-docs 25 | language: python 26 | language_version: python3 27 | files: '\.(rst|md|markdown|py|tex)$' 28 | - id: pytest-check 29 | name: pytest-check 30 | entry: pytest 31 | language: system 32 | pass_filenames: false 33 | always_run: true 34 | args: [tests/] 35 | - id: pytest-docs 36 | name: pytest-docs 37 | entry: pytest 38 | language: system 39 | pass_filenames: false 40 | always_run: true 41 | args: [--doctest-glob=*.rst, doc/pages/] 42 | - id: sphinx 43 | name: sphinx 44 | entry: sphinx-build 45 | language: system 46 | pass_filenames: false 47 | always_run: true 48 | args: [-b, html, doc/, doc/_build/html] -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions to scheduler are highly appreciated. 4 | 5 | ## Code of Conduct 6 | 7 | When participating in this project, please treat other people respectfully. 8 | Generally the guidelines pointed out in the 9 | [Python Community Code of Conduct](https://www.python.org/psf/conduct/) 10 | are a good standard we aim to uphold. 11 | 12 | ## Feedback and feature requests 13 | 14 | We'd like to hear from you if you are using `scheduler`. 15 | 16 | For suggestions and feature requests feel free to submit them to our 17 | [issue tracker](https://gitlab.com/DigonIO/scheduler/-/issues). 18 | 19 | ## Bugs 20 | 21 | Found a bug? Please report back to our 22 | [issue tracker](https://gitlab.com/DigonIO/scheduler/-/issues). 23 | 24 | If possible include: 25 | 26 | * Operating system name and version 27 | * python and `scheduler` version 28 | * Steps needed to reproduce the bug 29 | 30 | ## Development Setup 31 | 32 | Clone the `scheduler` repository with `git` and enter the directory: 33 | 34 | ```bash 35 | git clone https://gitlab.com/DigonIO/scheduler.git 36 | cd scheduler 37 | ``` 38 | 39 | Create and activate a virtual environment: 40 | 41 | ```bash 42 | python -m venv venv 43 | source ./venv/bin/activate 44 | ``` 45 | 46 | Install the project with the development requirements and install 47 | [pre-commit](https://pre-commit.com/) for the repository: 48 | 49 | ```bash 50 | pip install -e . 51 | pip install -r requirements.txt 52 | pre-commit install 53 | ``` 54 | 55 | ## Running tests 56 | 57 | Testing is done using [pytest](https://pypi.org/project/pytest/). With 58 | [pytest-cov](https://pypi.org/project/pytest-cov/) and 59 | [coverage](https://pypi.org/project/coverage/) a report for the test coverage can be generated: 60 | 61 | ```bash 62 | pytest --cov=scheduler/ tests/ 63 | coverage html 64 | ``` 65 | 66 | To test the examples in the documentation run: 67 | 68 | ```bash 69 | pytest --doctest-modules doc/pages/*/* 70 | ``` 71 | 72 | ## Building the documentation 73 | 74 | To build the documentation locally, run: 75 | 76 | ```bash 77 | sphinx-build -b html doc/ doc/_build/html 78 | ``` 79 | 80 | We are using Sphinx with [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) 81 | formatting. Additionally the documentation is tested with `pytest`. 82 | 83 | ## Pull requests 84 | 85 | 1. Fork the repository and check out on the `development` branch. 86 | 2. Enable and install [pre-commit](https://pre-commit.com/) to ensure style-guides. 87 | 3. Utilize the the static code analysis tools 88 | `pylint`, `pydocstyle`, `bandit` and `mypy` on the codebase in `scheduler/` and check the 89 | output to avoid introduction of regressions. 90 | 4. Create a commit with a descriptive summary of your changes. 91 | 5. Create a [pull request](https://gitlab.com/DigonIO/scheduler/-/merge_requests) 92 | on the official repository. 93 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pkgname=python-scheduler 3 | pkgver=0.8.8 4 | pkgrel=1 5 | pkgdec='A simple in-process python scheduler' 6 | arch=('any') 7 | license=('LGPL3') 8 | depends=('python' 'python-typeguard') 9 | makedepends=('python-setuptools' 'python-build' 'python-installer') 10 | checkdepends=('mypy' 'python-pytest-cov' 'python-typing_extensions' 'python-pytest-asyncio') 11 | source=("https://gitlab.com/DigonIO/scheduler/-/archive/$pkgver/scheduler-$pkgver.tar.gz") 12 | 13 | b2sums=('SKIP') 14 | 15 | build() { 16 | cd "$srcdir"/scheduler-"$pkgver" || exit 17 | python -m build --wheel 18 | } 19 | 20 | check() { 21 | cd "$srcdir"/scheduler-"$pkgver" || exit 22 | py.test --cov=scheduler tests/ 23 | py.test --doctest-modules doc/pages/*/*.rst 24 | } 25 | 26 | package() { 27 | cd "$srcdir"/scheduler-"$pkgver" || exit 28 | python -m installer --destdir="$pkgdir" dist/*.whl 29 | 30 | install -vDm 644 LICENSE -t "$pkgdir"/usr/share/licenses/$pkgname/ 31 | install -vDm 644 README.md -t "$pkgdir"/usr/share/doc/$pkgname/ 32 | } 33 | -------------------------------------------------------------------------------- /ci/secrets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Automatic `pip.conf` file generation. 3 | 4 | Notes 5 | ----- 6 | The following script creates a `pip.conf` file 7 | and saves it at the position expected by pip. 8 | Authentication information such as URL, username 9 | and password of the local PyPI cache are entered. 10 | In order to work properly, the following three 11 | environment variables must be entered in the 12 | `GitLab-CI` settings: 13 | `PIP_REPOSITORY`, `PIP_USERNAME`, `PIP_PASSWORD` 14 | """ 15 | 16 | import os 17 | from pathlib import Path 18 | 19 | 20 | def main(): 21 | prot = os.getenv("PIP_PROTOCOL") 22 | repo = os.getenv("PIP_REPOSITORY") 23 | user = os.getenv("PIP_USERNAME") 24 | passw = os.getenv("PIP_PASSWORD") 25 | configpath = os.path.expanduser("~/.config/pip") 26 | print(os.getcwd()) 27 | 28 | if prot is repo is user is passw is None: 29 | print("No secrets provided, doing nothing.") 30 | elif None in [prot, repo, user, passw]: 31 | raise Exception("PIP environment variables incomplete.") 32 | else: 33 | if os.path.exists(f"{configpath}/pip.conf"): 34 | raise Exception(f"{configpath}/pip.conf exists, refusing to overwrite.") 35 | Path(configpath).mkdir(parents=True, exist_ok=True) 36 | with open(f"{configpath}/pip.conf", "w") as outfile: 37 | outfile.write( 38 | f"[global]\n" 39 | f"index = {prot}://{user}:{passw}@{repo}/pypi\n" 40 | f"index-url = {prot}://{user}:{passw}@{repo}/simple\n" 41 | ) 42 | if prot == "http": 43 | outfile.write(f"trusted-host = {repo.split('/')[0]}") 44 | print("Created config ~/.config/pip/pip.conf") 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/doc/__init__.py -------------------------------------------------------------------------------- /doc/_assets/aio_kwargs.rst: -------------------------------------------------------------------------------- 1 | +----------------------------------------------------+----------------------+ 2 | | :attr:`~scheduler.base.job.BaseJob.args` | |args_text| | 3 | +----------------------------------------------------+----------------------+ 4 | | :attr:`~scheduler.base.job.BaseJob.kwargs` | |kwargs_text| | 5 | +----------------------------------------------------+----------------------+ 6 | | :attr:`~scheduler.base.job.BaseJob.max_attempts` | |max_attempts_text| | 7 | +----------------------------------------------------+----------------------+ 8 | | :attr:`~scheduler.base.job.BaseJob.tags` | |tags_text| | 9 | +----------------------------------------------------+----------------------+ 10 | | :attr:`~scheduler.base.job.BaseJob.delay` | |delay_text| | 11 | +----------------------------------------------------+----------------------+ 12 | | :attr:`~scheduler.base.job.BaseJob.start` | |start_text| | 13 | +----------------------------------------------------+----------------------+ 14 | | :attr:`~scheduler.base.job.BaseJob.stop` | |stop_text| | 15 | +----------------------------------------------------+----------------------+ 16 | | :attr:`~scheduler.base.job.BaseJob.skip_missing` | |skip_missing_text| | 17 | +----------------------------------------------------+----------------------+ 18 | | :attr:`~scheduler.base.job.BaseJob.alias` | |alias_text| | 19 | +----------------------------------------------------+----------------------+ 20 | -------------------------------------------------------------------------------- /doc/_assets/code_style_black.svg: -------------------------------------------------------------------------------- 1 | 2 | code style: black 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | code style 18 | 19 | black 20 | 21 | -------------------------------------------------------------------------------- /doc/_assets/kwargs.rst: -------------------------------------------------------------------------------- 1 | +----------------------------------------------------+----------------------+ 2 | | :attr:`~scheduler.base.job.BaseJob.args` | |args_text| | 3 | +----------------------------------------------------+----------------------+ 4 | | :attr:`~scheduler.base.job.BaseJob.kwargs` | |kwargs_text| | 5 | +----------------------------------------------------+----------------------+ 6 | | :attr:`~scheduler.base.job.BaseJob.max_attempts` | |max_attempts_text| | 7 | +----------------------------------------------------+----------------------+ 8 | | :attr:`~scheduler.base.job.BaseJob.tags` | |tags_text| | 9 | +----------------------------------------------------+----------------------+ 10 | | :attr:`~scheduler.base.job.BaseJob.delay` | |delay_text| | 11 | +----------------------------------------------------+----------------------+ 12 | | :attr:`~scheduler.base.job.BaseJob.start` | |start_text| | 13 | +----------------------------------------------------+----------------------+ 14 | | :attr:`~scheduler.base.job.BaseJob.stop` | |stop_text| | 15 | +----------------------------------------------------+----------------------+ 16 | | :attr:`~scheduler.base.job.BaseJob.skip_missing` | |skip_missing_text| | 17 | +----------------------------------------------------+----------------------+ 18 | | :attr:`~scheduler.base.job.BaseJob.alias` | |alias_text| | 19 | +----------------------------------------------------+----------------------+ 20 | | :attr:`~scheduler.base.job.BaseJob.weight` | |weight_text| | 21 | +----------------------------------------------------+----------------------+ 22 | -------------------------------------------------------------------------------- /doc/_assets/prolog.rst: -------------------------------------------------------------------------------- 1 | .. |br| raw:: html 2 | 3 |
4 | 5 | .. |BaseJob| replace:: :py:class:`~scheduler.base.job.BaseJob` 6 | 7 | .. |BaseScheduler| replace:: :py:class:`~scheduler.base.scheduler.BaseScheduler` 8 | 9 | .. |Job| replace:: :py:class:`~scheduler.threading.job.Job` 10 | 11 | .. |Scheduler| replace:: :py:class:`~scheduler.threading.scheduler.Scheduler` 12 | 13 | .. |AioJob| replace:: :py:class:`~scheduler.asyncio.job.Job` 14 | 15 | .. |AioScheduler| replace:: :py:class:`~scheduler.asyncio.scheduler.Scheduler` 16 | 17 | .. |JobTimer| replace:: :py:class:`~scheduler.base.job_timer.JobTimer` 18 | 19 | .. |Weekday| replace:: :py:class:`~scheduler.trigger.core.Weekday` 20 | 21 | .. |args_text| replace:: Positional argument payload for the function handle within a |Job|. 22 | 23 | .. |kwargs_text| replace:: Keyword arguments payload for the function handle within a |Job|. 24 | 25 | .. |tags_text| replace:: A `set` of `str` identifiers for a |Job|. 26 | 27 | .. |weight_text| replace:: Relative weight against other |Job|\ s. 28 | 29 | .. |delay_text| replace:: *Deprecated*: If ``True`` wait with the execution for 30 | the next scheduled time. 31 | 32 | .. |start_text| replace:: Set the reference `datetime.datetime` stamp the 33 | |Job| will be scheduled against. Default value is `datetime.datetime.now()`. 34 | 35 | .. |stop_text| replace:: Define a point in time after which a |Job| 36 | will be stopped and deleted. 37 | 38 | .. |max_attempts_text| replace:: Number of times the |Job| will be 39 | executed where ``0 <=> inf``. A |Job| with no free attempt 40 | will be deleted. 41 | 42 | .. |skip_missing_text| replace:: If ``True`` a |Job| will only 43 | schedule it's newest planned execution and drop older ones. 44 | 45 | .. |alias_text| replace:: Overwrites the function handle name in the string representation. 46 | -------------------------------------------------------------------------------- /doc/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* Colours */ 2 | .black { color: black; } 3 | .gray { color: gray; } 4 | .silver { color: silver; } 5 | .white { color: white; } 6 | .maroon { color: maroon; } 7 | .red { color: red; } 8 | .fuchsia { color: fuchsia; } 9 | .pink { color: pink; } 10 | .orange { color: orange; } 11 | .yellow { color: yellow; } 12 | .lime { color: lime; } 13 | .green { color: green; } 14 | .olive { color: olive; } 15 | .teal { color: teal; } 16 | .cyan { color: cyan; } 17 | .aqua { color: aqua; } 18 | .blue { color: blue; } 19 | .navy { color: navy; } 20 | .purple { color: purple; } 21 | 22 | /* Text Sizes */ 23 | .huge { font-size: huge; } 24 | .big { font-size: big; } 25 | .small { font-size: small; } 26 | .tiny { font-size: tiny; } 27 | 28 | /* Text markup */ 29 | 30 | body { 31 | --color-brand-primary: #204a87; 32 | --color-brand-content: #204a87; 33 | } 34 | 35 | .incremental { color: #CC3300;; font-style: italic; } 36 | .pre:not(:where(dl *)) { color: #204a87; } 37 | .pre:not(:where(a *, dl *)) { color: #7694c2; } 38 | 39 | @media not print { 40 | body[data-theme="dark"] .incremental { color: #CE9178; font-style: italic; } 41 | body[data-theme="dark"] .pre:not(:where(dl *)) { color: #368ce2; } 42 | body[data-theme="dark"] .pre:not(:where(a *, dl *)) { color: #9CDCFE; /* color: #ed9d13; */ } 43 | @media (prefers-color-scheme: dark) { 44 | body:not([data-theme="light"]) .incremental { color: #CE9178; font-style: italic; } 45 | body:not([data-theme="light"]) .pre:not(:where(dl *)) { color: #368ce2; } 46 | body:not([data-theme="light"]) .pre:not(:where(a *, dl *)) { color: #9CDCFE; /* color: #ed9d13; */ } 47 | } 48 | } 49 | 50 | 51 | /* 52 | @media not print { 53 | @media (prefers-color-scheme: dark) { 54 | body:not([data-theme="light"]) { 55 | --color-api-keyword: #569CD6; 56 | --color-problematic: green; 57 | --color-api-pre-name: #4EC9B0; 58 | --color-api-name: #DCDCAA; 59 | } 60 | } 61 | } 62 | */ -------------------------------------------------------------------------------- /doc/_static/custom_pygments.css: -------------------------------------------------------------------------------- 1 | /* CODE HIGHLIGHTING DARK MODE */ 2 | /* Heavily copied from VSCode Dark+ (default) color scheme*/ 3 | 4 | /* NOTE: this is not the best way to define our own color scheme for pygments */ 5 | /* It would be better to define our own pygments style written in python */ 6 | /* https://stackoverflow.com/questions/48615629/how-to-include-pygments-styles-in-a-sphinx-project */ 7 | /* Settings needs to be duplicated, as browsers not set with preferes-color-scheme dark */ 8 | /* Would be using the defaults otherwise when using the color switch */ 9 | 10 | .highlight { background: #f0f3f3; /* color: #d0d0d0 */ } 11 | .highlight .l { color: #CC3300 } /* Literal, yaml value */ 12 | .highlight .k { color: #204a87; /* color: #569CD6; */ font-weight: bold } /* Keyword, Literal['class', 'return', 'for', 'def'] */ 13 | .highlight .n { color: #006699 /* color: #4EC9B0; */ } /* Name, named variables/identifier/class */ 14 | .highlight .o { font-weight: bold } /* Operator, operators: ['.', '=', '->'] */ 15 | .highlight .go { color: #888888 } /* Generic.Output */ 16 | .highlight .kn { color: #AA22FF; font-weight: bold } /* Keyword.Namespace, Literal['import'] | Literal['from'] */ 17 | .highlight .nb { color: #00AA88 } /* Name.Builtin, functions, dtypes? Literal['set', 'str', 'dict', 'int'] */ 18 | .highlight .nc { color: #00AA88; font-weight: bold; text-decoration: none } /* Name.Class, identifier class */ 19 | .highlight .nd { color: #00AA88 /* color: #DCDCAA; */ } /* Name.Decorator, Literal['@property'] */ 20 | .highlight .nn { color: #00AA88; font-weight: bold; text-decoration: none } /* Name.Namespace, import: module name */ 21 | .highlight .nt { color: #330099; font-weight: bold } /* Name.Tag, yaml key */ 22 | .highlight .s2 { color: #CC3300 } /* Literal.String.Double, strings two ticks */ 23 | .highlight .s1 { color: #CC3300 } /* Literal.String.Single, strings one tick */ 24 | .highlight .sd { color: #CC3300; font-style: italic } /* Literal.String.Doc */ 25 | 26 | @media not print { 27 | body[data-theme="dark"] .highlight { background: #1E1E1E; /* color: #d0d0d0 */ } 28 | body[data-theme="dark"] .highlight .k { color: #C586C0; /* color: #569CD6; */ font-weight: bold } /* Keyword, Literal['class', 'return', 'for', 'def'] */ 29 | body[data-theme="dark"] .highlight .l { color: #CE9178 } /* Literal, yaml value */ 30 | body[data-theme="dark"] .highlight .n { color: #9CDCFE /* color: #4EC9B0; */ } /* Name, named variables/identifier/class */ 31 | body[data-theme="dark"] .highlight .o { color: #FFD700; /*#d0d0d0*/ font-weight: bold } /* Operator, operators: ['.', '=', '->'] */ 32 | body[data-theme="dark"] .highlight .p { color: #FFD700 } /* Punctuation, paranthesis, commas: ['{', '}', '(', ')', ','] */ 33 | body[data-theme="dark"] .highlight .c1 { color: #6A9955; font-style: italic } /* Comment.Single, comment [`#`] */ 34 | body[data-theme="dark"] .highlight .go { color: #cccccc; /*D4D4D4*/ } /* Generic.Output */ 35 | body[data-theme="dark"] .highlight .gp { color: #11D116; /* color: #cccccc; */ } /* Generic.Prompt, Literal['>>>'] */ 36 | body[data-theme="dark"] .highlight .kc { color: #569CD6; font-weight: bold } /* Keyword.Constant, None */ 37 | body[data-theme="dark"] .highlight .kn { color: #C586C0; font-weight: bold } /* Keyword.Namespace, Literal['import'] | Literal['from'] */ 38 | body[data-theme="dark"] .highlight .nb { color: #4EC9B0 /* color: #2fbccd; */ } /* Name.Builtin, functions, dtypes? Literal['set', 'str', 'dict', 'int'] */ 39 | body[data-theme="dark"] .highlight .nc { color: #4EC9B0; font-weight: bold; text-decoration: none } /* Name.Class, identifier class */ 40 | body[data-theme="dark"] .highlight .nd { color: #4EC9B0 /* color: #DCDCAA; */ } /* Name.Decorator, Literal['@property'] */ 41 | body[data-theme="dark"] .highlight .ne { color: #4EC9B0; font-weight: bold } /* Name.Exception */ 42 | body[data-theme="dark"] .highlight .nf { color: #DCDCAA /* color: #9CDCFE; */ } /* Name.Function, identifier function */ 43 | body[data-theme="dark"] .highlight .nn { color: #4EC9B0; font-weight: bold; text-decoration: none } /* Name.Namespace, import: module name */ 44 | body[data-theme="dark"] .highlight .nt { color: #569CD6; font-weight: bold } /* Name.Tag, yaml key */ 45 | body[data-theme="dark"] .highlight .ow { color: #569CD6; font-weight: bold } /* Operator.Word, operators: ['in'] */ 46 | body[data-theme="dark"] .highlight .s2 { color: #CE9178 } /* Literal.String.Double, strings two ticks */ 47 | body[data-theme="dark"] .highlight .s1 { color: #CE9178 } /* Literal.String.Single, strings one tick */ 48 | body[data-theme="dark"] .highlight .sd { color: #CE9178; font-style: italic } /* Literal.String.Doc */ 49 | body[data-theme="dark"] .highlight .bp { color: #2fbccd } /* Name.Builtin.Pseudo, Literal['self'] */ 50 | @media (prefers-color-scheme: dark) { 51 | body:not([data-theme="light"]) .highlight { background: #1E1E1E; /* color: #d0d0d0 */ } 52 | body:not([data-theme="light"]) .highlight .k { color: #C586C0; /* color: #569CD6; */ font-weight: bold } /* Keyword, Literal['class', 'return', 'for', 'def'] */ 53 | body:not([data-theme="light"]) .highlight .l { color: #CE9178 } /* Literal, yaml value */ 54 | body:not([data-theme="light"]) .highlight .n { color: #9CDCFE /* color: #4EC9B0; */ } /* Name, named variables/identifier/class */ 55 | body:not([data-theme="light"]) .highlight .o { color: #FFD700; /*#d0d0d0*/ font-weight: bold } /* Operator, operators: ['.', '=', '->'] */ 56 | body:not([data-theme="light"]) .highlight .p { color: #FFD700 } /* Punctuation, paranthesis, commas: ['{', '}', '(', ')', ','] */ 57 | body:not([data-theme="light"]) .highlight .c1 { color: #6A9955; font-style: italic } /* Comment.Single, comment [`#`] */ 58 | body:not([data-theme="light"]) .highlight .go { color: #cccccc; /*D4D4D4*/ } /* Generic.Output */ 59 | body:not([data-theme="light"]) .highlight .gp { color: #11D116; /* color: #cccccc; */ } /* Generic.Prompt, Literal['>>>'] */ 60 | body:not([data-theme="light"]) .highlight .kc { color: #569CD6; font-weight: bold } /* Keyword.Constant, None */ 61 | body:not([data-theme="light"]) .highlight .kn { color: #C586C0; font-weight: bold } /* Keyword.Namespace, Literal['import'] | Literal['from'] */ 62 | body:not([data-theme="light"]) .highlight .nb { color: #4EC9B0 } /* Name.Builtin, functions, dtypes? Literal['set', 'str', 'dict', 'int'] */ 63 | body:not([data-theme="light"]) .highlight .nc { color: #4EC9B0; font-weight: bold; text-decoration: none } /* Name.Class, identifier class */ 64 | body:not([data-theme="light"]) .highlight .nd { color: #4EC9B0 /* color: #DCDCAA; */ } /* Name.Decorator, Literal['@property'] */ 65 | body:not([data-theme="light"]) .highlight .ne { color: #4EC9B0; font-weight: bold } /* Name.Exception */ 66 | body:not([data-theme="light"]) .highlight .nf { color: #DCDCAA /* color: #9CDCFE; */ } /* Name.Function, identifier function */ 67 | body:not([data-theme="light"]) .highlight .nn { color: #4EC9B0; font-weight: bold; text-decoration: none } /* Name.Namespace, import: module name */ 68 | body:not([data-theme="light"]) .highlight .nt { color: #569CD6; font-weight: bold } /* Name.Tag, yaml key */ 69 | body:not([data-theme="light"]) .highlight .ow { color: #569CD6; font-weight: bold } /* Operator.Word, operators: ['in'] */ 70 | body:not([data-theme="light"]) .highlight .s2 { color: #CE9178 } /* Literal.String.Double, strings two ticks */ 71 | body:not([data-theme="light"]) .highlight .s1 { color: #CE9178 } /* Literal.String.Single, strings one tick */ 72 | body:not([data-theme="light"]) .highlight .sd { color: #CE9178; font-style: italic } /* Literal.String.Doc */ 73 | body:not([data-theme="light"]) .highlight .bp { color: #2fbccd } /* Name.Builtin.Pseudo, Literal['self'] */ 74 | } 75 | } -------------------------------------------------------------------------------- /doc/_templates/custom-base-template.rst: -------------------------------------------------------------------------------- 1 | {{ name | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. auto{{ objtype }}:: {{ objname }} 6 | -------------------------------------------------------------------------------- /doc/_templates/custom-class-template.rst: -------------------------------------------------------------------------------- 1 | {{ name | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | :show-inheritance: 8 | :inherited-members: 9 | 10 | {% block methods %} 11 | 12 | {% if methods %} 13 | .. rubric:: {{ _('Methods') }} 14 | 15 | .. autosummary:: 16 | {% for item in methods %} 17 | ~{{ name }}.{{ item }} 18 | {%- endfor %} 19 | {% endif %} 20 | {% endblock %} 21 | 22 | {% block attributes %} 23 | {% if attributes %} 24 | .. rubric:: {{ _('Attributes') }} 25 | 26 | .. autosummary:: 27 | {% for item in attributes %} 28 | ~{{ name }}.{{ item }} 29 | {%- endfor %} 30 | {% endif %} 31 | {% endblock %} -------------------------------------------------------------------------------- /doc/_templates/custom-module-template.rst: -------------------------------------------------------------------------------- 1 | .. _{{ fullname }}: 2 | 3 | {{ name | escape | underline}} 4 | 5 | .. automodule:: {{ fullname }} 6 | 7 | {% block attributes %} 8 | {% if attributes %} 9 | .. rubric:: Module Attributes 10 | 11 | .. autosummary:: 12 | :toctree: 13 | {% for item in attributes %} 14 | {{ item }} 15 | {%- endfor %} 16 | {% endif %} 17 | {% endblock %} 18 | 19 | {% block functions %} 20 | {% if functions %} 21 | .. rubric:: {{ _('Functions') }} 22 | 23 | .. autosummary:: 24 | :toctree: 25 | :template: custom-base-template.rst 26 | {% for item in functions %} 27 | {{ item }} 28 | {%- endfor %} 29 | {% endif %} 30 | {% endblock %} 31 | 32 | {% block classes %} 33 | {% if classes %} 34 | .. rubric:: {{ _('Classes') }} 35 | 36 | .. autosummary:: 37 | :toctree: 38 | :template: custom-class-template.rst 39 | {% for item in classes %} 40 | {{ item }} 41 | {%- endfor %} 42 | {% endif %} 43 | {% endblock %} 44 | 45 | {% block exceptions %} 46 | {% if exceptions %} 47 | .. rubric:: {{ _('Exceptions') }} 48 | 49 | .. autosummary:: 50 | :toctree: 51 | :template: custom-base-template.rst 52 | {% for item in exceptions %} 53 | {{ item }} 54 | {%- endfor %} 55 | {% endif %} 56 | {% endblock %} 57 | 58 | {% block modules %} 59 | {% if modules %} 60 | .. rubric:: Modules 61 | 62 | .. autosummary:: 63 | :toctree: 64 | :template: custom-module-template.rst 65 | :recursive: 66 | {% for item in modules %} 67 | {{ item }} 68 | {%- endfor %} 69 | {% endif %} 70 | {% endblock %} -------------------------------------------------------------------------------- /doc/_templates/custom-reduced-class-template.rst: -------------------------------------------------------------------------------- 1 | {{ name | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | 8 | {% block methods %} 9 | 10 | {% if methods %} 11 | .. rubric:: {{ _('Methods') }} 12 | 13 | .. autosummary:: 14 | {% for item in methods %} 15 | ~{{ name }}.{{ item }} 16 | {%- endfor %} 17 | {% endif %} 18 | {% endblock %} 19 | 20 | {% block attributes %} 21 | {% if attributes %} 22 | .. rubric:: {{ _('Attributes') }} 23 | 24 | .. autosummary:: 25 | {% for item in attributes %} 26 | ~{{ name }}.{{ item }} 27 | {%- endfor %} 28 | {% endif %} 29 | {% endblock %} -------------------------------------------------------------------------------- /doc/_templates/custom-reduced-module-template.rst: -------------------------------------------------------------------------------- 1 | .. _{{ fullname }}: 2 | 3 | {{ name | escape | underline}} 4 | 5 | .. automodule:: {{ fullname }} 6 | 7 | {% block attributes %} 8 | {% if attributes %} 9 | .. rubric:: Module Attributes 10 | 11 | .. autosummary:: 12 | :toctree: 13 | {% for item in attributes %} 14 | {{ item }} 15 | {%- endfor %} 16 | {% endif %} 17 | {% endblock %} 18 | 19 | {% block functions %} 20 | {% if functions %} 21 | .. rubric:: {{ _('Functions') }} 22 | 23 | .. autosummary:: 24 | :toctree: 25 | :template: custom-base-template.rst 26 | {% for item in functions %} 27 | {{ item }} 28 | {%- endfor %} 29 | {% endif %} 30 | {% endblock %} 31 | 32 | {% block classes %} 33 | {% if classes %} 34 | .. rubric:: {{ _('Classes') }} 35 | 36 | .. autosummary:: 37 | :toctree: 38 | :template: custom-reduced-class-template.rst 39 | {% for item in classes %} 40 | {{ item }} 41 | {%- endfor %} 42 | {% endif %} 43 | {% endblock %} 44 | 45 | {% block exceptions %} 46 | {% if exceptions %} 47 | .. rubric:: {{ _('Exceptions') }} 48 | 49 | .. autosummary:: 50 | :toctree: 51 | :template: custom-base-template.rst 52 | {% for item in exceptions %} 53 | {{ item }} 54 | {%- endfor %} 55 | {% endif %} 56 | {% endblock %} 57 | 58 | {% block modules %} 59 | {% if modules %} 60 | .. rubric:: Modules 61 | 62 | .. autosummary:: 63 | :toctree: 64 | :template: custom-reduced-module-template.rst 65 | :recursive: 66 | {% for item in modules %} 67 | {{ item }} 68 | {%- endfor %} 69 | {% endif %} 70 | {% endblock %} -------------------------------------------------------------------------------- /doc/api_reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ------------- 3 | 4 | .. currentmodule:: scheduler 5 | 6 | .. autosummary:: 7 | :toctree: _autosummary 8 | :template: custom-module-template.rst 9 | :recursive: 10 | 11 | base 12 | threading 13 | asyncio 14 | trigger 15 | error 16 | prioritization 17 | util -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | 14 | import os 15 | import sys 16 | 17 | from sphinx.locale import _ 18 | 19 | sys.path.insert(0, os.path.abspath("..")) 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | with open("../scheduler/__init__.py", "r") as file: 24 | for line in file: 25 | if "__version__" in line: 26 | version = line.split('"')[1] 27 | if "__author__" in line: 28 | author = line.split('"')[1] 29 | 30 | project = "scheduler" 31 | copyright = "2023, " + author 32 | author = author 33 | 34 | # The full version, including alpha/beta/rc tags 35 | release = version 36 | 37 | 38 | # -- General configuration --------------------------------------------------- 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | "sphinx.ext.autodoc", 45 | "sphinx.ext.autosummary", 46 | "sphinx.ext.intersphinx", 47 | "sphinx.ext.imgconverter", 48 | "sphinx.ext.coverage", 49 | "sphinx.ext.imgmath", 50 | "sphinx.ext.viewcode", 51 | "numpydoc", 52 | "m2r2", 53 | ] 54 | 55 | with open("_assets/prolog.rst", encoding="utf-8") as f: 56 | rst_prolog = f.read() 57 | 58 | imgmath_image_format = "svg" 59 | # Add any paths that contain templates here, relative to this directory. 60 | numpydoc_show_class_members = False 61 | templates_path = ["_templates"] 62 | source_suffix = ".rst" 63 | master_doc = "index" 64 | 65 | autosummary_generate = True 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | # This pattern also affects html_static_path and html_extra_path. 69 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "_assets"] 70 | pygments_style = "manni" 71 | 72 | # -- Options for HTML output ------------------------------------------------- 73 | 74 | # The theme to use for HTML and HTML Help pages. See the documentation for 75 | # a list of builtin themes. 76 | # 77 | html_theme = "furo" 78 | html_theme_path = [ 79 | "_themes", 80 | ] 81 | html_favicon = "favicon.png" 82 | html_logo = "logo_w_border.svg" 83 | 84 | 85 | # Add any paths that contain custom static files (such as style sheets) here, 86 | # relative to this directory. They are copied after the builtin static files, 87 | # so a file named "default.css" will overwrite the builtin "default.css". 88 | html_static_path = ["_static"] 89 | 90 | html_css_files = ["custom.css", "custom_pygments.css"] 91 | 92 | imgmath_latex_preamble = ( 93 | "\\usepackage{xcolor}\n\\definecolor{formulacolor}{RGB}{128,128,128}" "\\color{formulacolor}" 94 | ) 95 | 96 | 97 | latex_elements = { 98 | "preamble": [ 99 | r"\usepackage[columns=1]{idxlayout}\makeindex", 100 | ], 101 | } 102 | -------------------------------------------------------------------------------- /doc/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/doc/conftest.py -------------------------------------------------------------------------------- /doc/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | -------- 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Table of Contents 7 | 8 | pages/examples/quick_start 9 | pages/examples/threading 10 | pages/examples/asyncio 11 | pages/examples/timezones 12 | pages/examples/parameters 13 | pages/examples/tags 14 | pages/examples/job_deletion 15 | pages/examples/job_batching 16 | pages/examples/aliases 17 | pages/examples/job_prioritization 18 | pages/examples/metrics -------------------------------------------------------------------------------- /doc/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | When to use `scheduler`? 5 | `scheduler` is designed to get you started with scheduling of |Job|\ s in no time. 6 | The API is aimed to be expressive and provides feedback in what you are doing. 7 | 8 | Integrating the python standard `datetime` library allows for simple yet powerful scheduling 9 | options including recurring and oneshot jobs and timezone support while being lightweight. 10 | 11 | Parallel execution is integrated for `asyncio `_ 12 | and `threading `_. 13 | 14 | When to look for other solutions? 15 | As the development for `scheduler` started fairly recently, the userbase is still small. While 16 | we are aiming to provide high quality code there might still be some undetected issues. 17 | With additional features still under development, there is no guarantee for 18 | future releases not to break current APIs. 19 | 20 | Currently `scheduler` is purely implemented as an in-process scheduler and does not 21 | run standalone or with a command line interface. 22 | 23 | Backends for job storing have to be implemented by the user. 24 | 25 | When you rely on accurate timings for real-time applications. 26 | 27 | Implementation details 28 | ---------------------- 29 | 30 | Why is there no monthly scheduling implementation? 31 | As the scheduler currently does not preserve its state after a restart, we believe 32 | planning of long time intervals has to be a conscious choice of the user. 33 | Use :py:func:`~scheduler.threading.scheduler.Scheduler.once` to schedule your job 34 | for a specific day and time using a `datetime.datetime` object. 35 | 36 | Why is there no `datetime.date` support? 37 | Because of the ambiguity and missing timezone support. Use `datetime.datetime` instead. 38 | 39 | Why are you not using the `python-dateutil` library? 40 | We believe that the additional flexibility comes with a cost in complexity that does not 41 | pay off for the intended use of the `scheduler` library. 42 | -------------------------------------------------------------------------------- /doc/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/doc/favicon.png -------------------------------------------------------------------------------- /doc/guides.rst: -------------------------------------------------------------------------------- 1 | Guides 2 | ------ 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Table of Contents 7 | 8 | pages/guides/custom_prioritization -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. 2 | documentation master file, created by 3 | sphinx-quickstart on Sat Jul 20 03:10:17 2019. 4 | You can adapt this file completely to your liking, but it should at least 5 | contain the root `toctree` directive. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | :caption: Table of Contents 10 | 11 | readme 12 | examples 13 | guides 14 | api_reference 15 | faq 16 | -------------------------------------------------------------------------------- /doc/pages/examples/aliases.rst: -------------------------------------------------------------------------------- 1 | .. _examples.aliases: 2 | 3 | Aliases 4 | ======= 5 | 6 | By default a job is represented by its :py:class:`~scheduler.definition.JobType` 7 | and function handle. If multiple jobs are scheduled that have identical type and handle, 8 | it can be difficult to distinguish between these. 9 | 10 | 11 | 12 | The example below shows how a hypothetical function for ordering goods is reused and 13 | cannot be uniquely identified in the table below for the given orders. 14 | 15 | .. code-block:: pycon 16 | 17 | >>> import datetime as dt 18 | 19 | >>> from scheduler import Scheduler 20 | 21 | >>> def order(unit): 22 | ... print(f"ordering {unit} units") 23 | ... 24 | 25 | >>> schedule = Scheduler() 26 | 27 | >>> job1 = schedule.cyclic(dt.timedelta(seconds=1), order, args=(2,)) 28 | >>> job2 = schedule.cyclic(dt.timedelta(seconds=1), order, args=(9,)) 29 | >>> print(schedule) # doctest:+SKIP 30 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=2 31 | 32 | type function / alias due at due in attempts weight 33 | -------- ---------------- ------------------- --------- ------------- ------ 34 | CYCLIC order() 2022-05-04 14:51:25 0:00:00 0/inf 1 35 | CYCLIC order() 2022-05-04 14:51:26 0:00:00 0/inf 1 36 | 37 | 38 | To avoid confusion in the job representation, use the :attr:`~scheduler.base.job.BaseJob.alias` 39 | keyword as listed below: 40 | 41 | .. code-block:: pycon 42 | 43 | >>> schedule = Scheduler() 44 | 45 | >>> job1 = schedule.cyclic(dt.timedelta(seconds=1), order, alias="small order", args=(2,)) 46 | >>> job2 = schedule.cyclic(dt.timedelta(seconds=2), order, alias="medium order", args=(9,)) 47 | >>> print(schedule) # doctest:+SKIP 48 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=2 49 | 50 | type function / alias due at due in attempts weight 51 | -------- ---------------- ------------------- --------- ------------- ------ 52 | CYCLIC small order 2022-05-04 14:54:33 0:00:00 0/inf 1 53 | CYCLIC medium order 2022-05-04 14:54:34 0:00:00 0/inf 1 54 | 55 | 56 | .. note:: This feature is available for both, the default threading |Scheduler| and the asyncio 57 | |AioScheduler|. 58 | -------------------------------------------------------------------------------- /doc/pages/examples/asyncio.rst: -------------------------------------------------------------------------------- 1 | Asyncio 2 | ======= 3 | 4 | To use `asyncio `_ with the `scheduler` library, 5 | replace the default threading Scheduler (:py:class:`scheduler.threading.scheduler.Scheduler`) 6 | with the asyncio Scheduler (:py:class:`scheduler.asyncio.scheduler.Scheduler`) variant. 7 | Both schedulers provide a nearly identical API - the only difference is the lack of 8 | prioritization and weighting support for the asyncio |AioScheduler|. 9 | 10 | .. note:: In contrast to the threading |Scheduler| it is necessary to instanciate 11 | the asyncio |AioScheduler| within a coroutine. 12 | 13 | The following example shows how to use the asyncio |AioScheduler| with a simple coroutine. 14 | 15 | .. code-block:: python 16 | 17 | import asyncio 18 | import datetime as dt 19 | 20 | from scheduler.asyncio import Scheduler 21 | 22 | 23 | async def foo(): 24 | print("foo") 25 | 26 | 27 | async def main(): 28 | schedule = Scheduler() 29 | 30 | schedule.once(dt.timedelta(seconds=5), foo) 31 | schedule.cyclic(dt.timedelta(minutes=10), foo) 32 | 33 | while True: 34 | await asyncio.sleep(1) 35 | 36 | 37 | asyncio.run(main()) 38 | 39 | 40 | To initialize the |AioScheduler| with a user defined event loop, use the `loop` keyword 41 | argument: 42 | 43 | .. code-block:: python 44 | 45 | import asyncio 46 | import datetime as dt 47 | 48 | from scheduler.asyncio import Scheduler 49 | 50 | 51 | async def foo(): 52 | print("foo") 53 | 54 | 55 | async def main(): 56 | loop = asyncio.get_running_loop() 57 | schedule = Scheduler(loop=loop) 58 | 59 | schedule.once(dt.timedelta(seconds=5), foo) 60 | schedule.cyclic(dt.timedelta(minutes=10), foo) 61 | 62 | while True: 63 | await asyncio.sleep(1) 64 | 65 | asyncio.run(main()) -------------------------------------------------------------------------------- /doc/pages/examples/job_batching.rst: -------------------------------------------------------------------------------- 1 | Job Batching 2 | ============ 3 | 4 | It is possible to bundle a |Job| with more than one 5 | |JobTimer|. Except for :py:func:`~scheduler.core.Scheduler.once` 6 | and :func:`~scheduler.core.Scheduler.cyclic`, |Scheduler| supports 7 | passing of the `timing` argument via a `list` for the `scheduling` functions 8 | 9 | :py:func:`~scheduler.core.Scheduler.minutely`, 10 | :py:func:`~scheduler.core.Scheduler.hourly`, 11 | :py:func:`~scheduler.core.Scheduler.daily` and 12 | :py:func:`~scheduler.core.Scheduler.weekly`. 13 | 14 | .. warning:: When bundling multiple times in a single |Job|, they 15 | are required to be distinct within the given context. Note that mixing of timezones 16 | can lead to indistinguishable times. If indistinguishable times are used, a 17 | :py:exc:`~scheduler.util.SchedulerError` will be raised. 18 | 19 | For :py:func:`~scheduler.core.Scheduler.daily` we can embed several timers in one |Job| as follows: 20 | 21 | .. code-block:: pycon 22 | 23 | >>> import datetime as dt 24 | >>> import time 25 | 26 | >>> from scheduler import Scheduler 27 | 28 | >>> def foo(): 29 | ... print("foo") 30 | ... 31 | 32 | >>> schedule = Scheduler() 33 | 34 | >>> timings = [dt.time(hour=0), dt.time(hour=12), dt.time(hour=18)] 35 | >>> schedule.daily(timing=timings, handle=foo) # doctest:+ELLIPSIS 36 | scheduler.Job(...DAILY..., [...time(0, 0), ...time(12, 0), ...time(18, 0)]...) 37 | 38 | In consequence, this |Scheduler| instance only contains a single |Job| instance of the `DAILY` type: 39 | 40 | .. code-block:: pycon 41 | 42 | >>> print(schedule) # doctest:+SKIP 43 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=1 44 | 45 | type function / alias due at due in attempts weight 46 | -------- ---------------- ------------------- --------- ------------- ------ 47 | DAILY foo() 2021-06-20 12:00:00 9:23:13 0/inf 1 48 | 49 | 50 | In the given example, the job will be scheduled three times a day. Note that each call to 51 | :py:meth:`~scheduler.core.Scheduler.exec_jobs` will only call the function handle 52 | of the |Job| once, even if several timers are overdue. 53 | -------------------------------------------------------------------------------- /doc/pages/examples/job_deletion.rst: -------------------------------------------------------------------------------- 1 | .. _examples.job_deletion: 2 | 3 | Job Deletion 4 | ============ 5 | 6 | There are two ways to remove |Job|\ s from a scheduler. 7 | 8 | Delete a specific Job by it's reference 9 | --------------------------------------- 10 | 11 | Setup a couple of |Job|\ s 12 | 13 | .. code-block:: pycon 14 | 15 | >>> import datetime as dt 16 | >>> import time 17 | 18 | >>> from scheduler import Scheduler 19 | 20 | >>> def foo(): 21 | ... print("foo") 22 | ... 23 | 24 | >>> schedule = Scheduler() 25 | >>> j1 = schedule.cyclic(dt.timedelta(seconds=1), foo) # doctest:+ELLIPSIS 26 | >>> j2 = schedule.cyclic(dt.timedelta(seconds=2), foo) # doctest:+ELLIPSIS 27 | >>> j3 = schedule.cyclic(dt.timedelta(seconds=3), foo) # doctest:+ELLIPSIS 28 | >>> print(schedule) # doctest:+SKIP 29 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=3 30 | 31 | type function / alias due at due in attempts weight 32 | -------- ---------------- ------------------- --------- ------------- ------ 33 | CYCLIC foo() 2021-06-20 05:22:29 0:00:00 0/inf 1 34 | CYCLIC foo() 2021-06-20 05:22:30 0:00:01 0/inf 1 35 | CYCLIC foo() 2021-06-20 05:22:31 0:00:02 0/inf 1 36 | 37 | 38 | Remove the specified |Job| `j2` from the |Scheduler| via 39 | the :py:meth:`~scheduler.core.Scheduler.delete_job` method: 40 | 41 | .. code-block:: pycon 42 | 43 | >>> schedule.delete_job(j2) 44 | >>> print(schedule) # doctest:+SKIP 45 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=2 46 | 47 | type function / alias due at due in attempts weight 48 | -------- ---------------- ------------------- --------- ------------- ------ 49 | CYCLIC foo() 2021-06-20 05:22:29 0:00:00 0/inf 1 50 | CYCLIC foo() 2021-06-20 05:22:31 0:00:02 0/inf 1 51 | 52 | 53 | 54 | Delete Jobs 55 | ----------- 56 | 57 | Setup a couple of |Job|\ s 58 | 59 | .. code-block:: pycon 60 | 61 | >>> import datetime as dt 62 | >>> import time 63 | 64 | >>> from scheduler import Scheduler 65 | 66 | >>> def foo(): 67 | ... print("foo") 68 | ... 69 | 70 | >>> schedule = Scheduler() 71 | >>> schedule.cyclic(dt.timedelta(seconds=1), foo) # doctest:+ELLIPSIS 72 | scheduler.Job(...CYCLIC...timedelta(seconds=1)...foo...) 73 | >>> schedule.cyclic(dt.timedelta(seconds=2), foo) # doctest:+ELLIPSIS 74 | scheduler.Job(...CYCLIC...timedelta(seconds=2)...foo...) 75 | >>> schedule.cyclic(dt.timedelta(seconds=3), foo) # doctest:+ELLIPSIS 76 | scheduler.Job(...CYCLIC...timedelta(seconds=3)...foo...) 77 | >>> print(schedule) # doctest:+SKIP 78 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=3 79 | 80 | type function / alias due at due in attempts weight 81 | -------- ---------------- ------------------- --------- ------------- ------ 82 | CYCLIC foo() 2021-06-20 05:22:29 0:00:00 0/inf 1 83 | CYCLIC foo() 2021-06-20 05:22:30 0:00:01 0/inf 1 84 | CYCLIC foo() 2021-06-20 05:22:31 0:00:02 0/inf 1 85 | 86 | 87 | Clear the |Scheduler| from |Job|\ s 88 | with a single function call to :py:meth:`~scheduler.core.Scheduler.delete_jobs`. 89 | 90 | .. code-block:: pycon 91 | 92 | >>> schedule.delete_jobs() 93 | 3 94 | >>> print(schedule) # doctest:+SKIP 95 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=0 96 | 97 | type function / alias due at due in attempts weight 98 | -------- ---------------- ------------------- --------- ------------- ------ 99 | 100 | 101 | .. note:: Additionally :py:meth:`~scheduler.core.Scheduler.delete_jobs` supports the 102 | tagging system described in :ref:`examples.tags`. -------------------------------------------------------------------------------- /doc/pages/examples/job_prioritization.rst: -------------------------------------------------------------------------------- 1 | .. _examples.weights: 2 | 3 | Job Prioritization 4 | ================== 5 | 6 | |Job|\ s can be prioritized using `weight`\ s. 7 | Prioritization becomes particulary relevant with increasing |Job| 8 | execution times compared to the |Scheduler|\ s cycle length. 9 | 10 | The `weight` parameter is available for all scheduling functions of 11 | |Scheduler|: 12 | 13 | :py:func:`~scheduler.core.Scheduler.once`, 14 | :py:func:`~scheduler.core.Scheduler.cyclic`, 15 | :py:func:`~scheduler.core.Scheduler.minutely`, 16 | :py:func:`~scheduler.core.Scheduler.hourly`, 17 | :py:func:`~scheduler.core.Scheduler.daily`, 18 | :py:func:`~scheduler.core.Scheduler.weekly` 19 | 20 | .. _examples.weights.default_behaviour: 21 | 22 | Default behaviour 23 | ----------------- 24 | 25 | By default, the |Scheduler| will prioritize using a linear function 26 | (:py:func:`~scheduler.prioritization.linear_priority_function`) that depends on the 27 | |Job|\ s `weight` and time it is overdue. 28 | 29 | .. tip:: It is possible to change the prioritization behaviour of a 30 | |Scheduler| instance using the `priority_function` argument. 31 | Details can be found in the guide :ref:`guides.prioritization`. 32 | 33 | If several |Job|\ s are scheduled for the same point in time, 34 | they will be executed in order of their weights, starting with the |Job| 35 | of the highest weight: 36 | 37 | .. code-block:: pycon 38 | 39 | >>> import datetime as dt 40 | >>> import time 41 | 42 | >>> from scheduler import Scheduler 43 | 44 | >>> now = dt.datetime.now() 45 | >>> schedule = Scheduler(max_exec=3) 46 | 47 | >>> for weight in (2, 3, 1, 4): 48 | ... job = schedule.once(now, print, weight=weight, kwargs={"end": f"{weight = }\n"}) 49 | ... 50 | 51 | >>> exec_count = schedule.exec_jobs() 52 | weight = 4 53 | weight = 3 54 | weight = 2 55 | 56 | >>> print(schedule) # doctest:+SKIP 57 | max_exec=3, tzinfo=None, priority_function=linear_priority_function, #jobs=1 58 | 59 | type function / alias due at due in attempts weight 60 | -------- ---------------- ------------------- --------- ------------- ------ 61 | ONCE print(?) 2021-06-21 03:24:23 -0:00:00 0/1 1 62 | 63 | Note that in this example the |Job| with the lowest weight was not 64 | executed, as the execution count per call for the |Scheduler| 65 | has been set to ``3`` via the `max_exec` parameter. 66 | 67 | If several |Job|\ s of the same weight are overdue, the 68 | |Job|\ s are prioritized by their delay, starting with the 69 | |Job| of the highest delay. 70 | 71 | .. code-block:: pycon 72 | 73 | >>> import datetime as dt 74 | >>> import time 75 | 76 | >>> from scheduler import Scheduler 77 | 78 | >>> now = dt.datetime.now() 79 | >>> schedule = Scheduler(max_exec=3) 80 | 81 | >>> for delayed_by in (2, 3, 1, 4): 82 | ... exec_time = now - dt.timedelta(seconds=delayed_by) 83 | ... job = schedule.once(exec_time, print, kwargs={"end": f"{delayed_by = }s\n"}) 84 | ... 85 | 86 | >>> exec_count = schedule.exec_jobs() 87 | delayed_by = 4s 88 | delayed_by = 3s 89 | delayed_by = 2s 90 | 91 | >>> print(schedule) # doctest:+SKIP 92 | max_exec=3, tzinfo=None, priority_function=linear_priority_function, #jobs=1 93 | 94 | type function / alias due at due in attempts weight 95 | -------- ---------------- ------------------- --------- ------------- ------ 96 | ONCE print(?) 2021-06-21 03:24:23 -0:00:00 0/1 1 97 | -------------------------------------------------------------------------------- /doc/pages/examples/metrics.rst: -------------------------------------------------------------------------------- 1 | Metrics 2 | ======= 3 | 4 | The |Scheduler| and |Job| classes 5 | provide access to various metrics that can be of interest for the using program. 6 | 7 | Starting with a |Scheduler| and two |Job|\ s: 8 | 9 | .. code-block:: pycon 10 | 11 | >>> import datetime as dt 12 | >>> import time 13 | 14 | >>> from scheduler import Scheduler 15 | 16 | >>> def foo(): 17 | ... print("foo") 18 | ... 19 | 20 | >>> schedule = Scheduler() 21 | >>> job = schedule.once(dt.timedelta(minutes=10), foo) 22 | >>> print(schedule) # doctest:+SKIP 23 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=1 24 | 25 | type function / alias due at due in attempts weight 26 | -------- ---------------- ------------------- --------- ------------- ------ 27 | ONCE foo() 2021-06-21 04:53:34 0:09:59 0/1 1 28 | 29 | 30 | |Scheduler| provides access to the set of |Job|\ s stored with the `jobs` 31 | We can access the |Job|\ s of the scheduler via the :py:attr:`~scheduler.core.Scheduler.jobs` property. 32 | 33 | .. code-block:: pycon 34 | 35 | >>> schedule.jobs == {job} 36 | True 37 | 38 | For the |Job| with the following string representation 39 | 40 | .. code-block:: pycon 41 | 42 | >>> print(job) # doctest:+SKIP 43 | ONCE, foo(), at=2020-07-16 23:56:12, tz=None, in=0:09:59, #0/1, w=1.000 44 | 45 | The scheduled :py:attr:`~scheduler.job.Job.datetime` and :py:attr:`~scheduler.job.Job.timedelta` 46 | informations can directly be accessed and might look like similar to what is listed below: 47 | 48 | .. code-block:: pycon 49 | 50 | >>> print(f"{job.datetime = !s}\n{job.timedelta() = !s}") # doctest:+SKIP 51 | job.datetime = 2021-06-21 04:53:34.879346 52 | job.timedelta() = 0:09:59.999948 53 | 54 | These metrics can change during the |Job|\ s lifetime. We can exemplify this 55 | for the :py:attr:`~scheduler.job.Job.attempts` attribute: 56 | 57 | .. code-block:: pycon 58 | 59 | >>> job = schedule.cyclic(dt.timedelta(seconds=0.1), foo, max_attempts=2) 60 | >>> print(job) # doctest:+SKIP 61 | CYCLIC, foo(), at=2021-06-21 04:53:34, tz=None, in=0:00:00, #0/2, w=1.000 62 | 63 | >>> print(job.attempts, job.max_attempts) 64 | 0 2 65 | 66 | >>> time.sleep(0.1) 67 | >>> exec_count = schedule.exec_jobs() 68 | foo 69 | 70 | >>> print(job.attempts, job.max_attempts) 71 | 1 2 72 | 73 | >>> time.sleep(0.1) 74 | >>> exec_count = schedule.exec_jobs() 75 | foo 76 | 77 | >>> print(job.attempts, job.max_attempts) 78 | 2 2 79 | -------------------------------------------------------------------------------- /doc/pages/examples/parameters.rst: -------------------------------------------------------------------------------- 1 | Parameter Forwarding 2 | ==================== 3 | 4 | It is possible to forward positional and keyword arguments to the the scheduled callback function 5 | via the arguments `args` and `kwargs`. Positional arguments are passed by a `tuple`, keyword 6 | arguments are passed as a `dictionary` with strings referencing the callback function's 7 | arguments. 8 | |Scheduler| supports both types of argument passing for all of the scheduling functions 9 | 10 | :func:`~scheduler.core.Scheduler.once`, 11 | :func:`~scheduler.core.Scheduler.cyclic`, 12 | :func:`~scheduler.core.Scheduler.minutely`, 13 | :func:`~scheduler.core.Scheduler.hourly`, 14 | :func:`~scheduler.core.Scheduler.daily` and 15 | :func:`~scheduler.core.Scheduler.weekly`. 16 | 17 | In the following example we schedule two |Job|\ s via 18 | :func:`~scheduler.core.Scheduler.once`. The first |Job| exhibits the function's default behaviour. 19 | Whereas the second |Job| prints the modified message defined in the `kwargs` argument. 20 | 21 | For function with a positional argument use the `args` tuple as follows: 22 | 23 | .. code-block:: pycon 24 | 25 | >>> import datetime as dt 26 | >>> import time 27 | 28 | >>> from scheduler import Scheduler 29 | 30 | >>> def foo(msg): 31 | ... print(msg) 32 | ... 33 | 34 | >>> schedule = Scheduler() 35 | 36 | >>> schedule.once(dt.timedelta(), foo, args=("foo",)) # doctest:+ELLIPSIS 37 | scheduler.Job(...function foo..., ('foo',)...) 38 | 39 | >>> n_exec = schedule.exec_jobs() 40 | foo 41 | 42 | Defining a function `bar` with the keyword argument `msg`, we can observe the default behaviour 43 | when the `kwargs` dictionary is ommited. Given a `kwargs` argument as in the second example, we 44 | observe the expected behaviour with the modified message. 45 | 46 | .. code-block:: pycon 47 | 48 | >>> def bar(msg="bar"): 49 | ... print(msg) 50 | ... 51 | 52 | >>> schedule.once(dt.timedelta(), bar) 53 | scheduler.Job(...bar...) 54 | 55 | >>> n_exec = schedule.exec_jobs() 56 | bar 57 | 58 | >>> schedule.once(dt.timedelta(), bar, kwargs={"msg": "Hello World"}) 59 | scheduler.Job(...function bar...{'msg': 'Hello World'}...) 60 | 61 | >>> n_exec = schedule.exec_jobs() 62 | Hello World 63 | 64 | It is possible to schedule functions with both, positional and keyword arguments, as demonstrated 65 | below when specifying `args` and `kwargs` together: 66 | 67 | .. code-block:: pycon 68 | 69 | >>> def foobar(foo, bar="bar"): 70 | ... print(foo, bar) 71 | ... 72 | 73 | >>> schedule.once(dt.timedelta(), foobar, args=("foo",), kwargs={"bar": "123"}) 74 | scheduler.Job(...function foobar...('foo',), {'bar': '123'}...) 75 | 76 | >>> n_exec = schedule.exec_jobs() 77 | foo 123 78 | -------------------------------------------------------------------------------- /doc/pages/examples/quick_start.rst: -------------------------------------------------------------------------------- 1 | Quick Start 2 | =========== 3 | 4 | To get started with the basic functions and |Job| types of the scheduler 5 | module, create a |Scheduler| instance and the function `foo` to schedule: 6 | 7 | .. code-block:: pycon 8 | 9 | >>> import datetime as dt 10 | 11 | >>> from scheduler import Scheduler 12 | >>> import scheduler.trigger as trigger 13 | 14 | >>> def foo(): 15 | ... print("foo") 16 | ... 17 | 18 | >>> schedule = Scheduler() 19 | 20 | Schedule a job that runs every 10 minutes: 21 | 22 | .. code-block:: pycon 23 | 24 | >>> schedule.cyclic(dt.timedelta(minutes=10), foo) # doctest:+ELLIPSIS 25 | scheduler.Job(...CYCLIC...datetime.timedelta(seconds=600)...foo...0, 1...) 26 | 27 | Schedule a job that runs every minute at ``XX:XX:15``: 28 | 29 | .. code-block:: pycon 30 | 31 | >>> schedule.minutely(dt.time(second=15), foo) # doctest:+ELLIPSIS 32 | scheduler.Job(...MINUTELY...datetime.time(0, 0, 15)...foo...0, 1...) 33 | 34 | Schedule a job that runs every hour at ``XX:30:15``: 35 | 36 | .. code-block:: pycon 37 | 38 | >>> schedule.hourly(dt.time(minute=30, second=15), foo) # doctest:+ELLIPSIS 39 | scheduler.Job(...HOURLY...datetime.time(0, 30, 15)...foo...0, 1...) 40 | 41 | Schedule a job that runs every day at ``16:30:00``: 42 | 43 | .. code-block:: pycon 44 | 45 | >>> schedule.daily(dt.time(hour=16, minute=30), foo) # doctest:+ELLIPSIS 46 | scheduler.Job(...DAILY...datetime.time(16, 30)...foo...0, 1...) 47 | 48 | Schedule a job that runs every monday at ``00:00``: 49 | 50 | .. code-block:: pycon 51 | 52 | >>> schedule.weekly(trigger.Monday(), foo) # doctest:+ELLIPSIS 53 | scheduler.Job(...WEEKLY...Monday...foo...0, 1...) 54 | 55 | Schedule a job that runs every monday at ``16:30:00``: 56 | 57 | .. code-block:: pycon 58 | 59 | >>> schedule.weekly(trigger.Monday(dt.time(hour=16, minute=30)), foo) # doctest:+ELLIPSIS 60 | scheduler.Job(...WEEKLY...[Monday(time=datetime.time(16, 30))]...foo...0, 1...) 61 | 62 | Schedule a job that runs exactly once in 10 minutes 63 | 64 | .. code-block:: pycon 65 | 66 | >>> schedule.once(dt.timedelta(minutes=10), foo) # doctest:+ELLIPSIS 67 | scheduler.Job(...CYCLIC...datetime.timedelta(seconds=600)...foo...1, 1...) 68 | 69 | Schedule a job that runs exactly once next monday at ``00:00``: 70 | 71 | .. code-block:: pycon 72 | 73 | >>> schedule.once(trigger.Monday(), foo) # doctest:+ELLIPSIS 74 | scheduler.Job(...WEEKLY...[Monday(time=datetime.time(0, 0))]...foo...1, 1...) 75 | 76 | Schedule a job that runs exactly once at the given date at ``2022-02-15 00:45:00``: 77 | 78 | .. code-block:: pycon 79 | 80 | >>> schedule.once( 81 | ... dt.datetime(year=2022, month=2, day=15, minute=45), foo 82 | ... ) # doctest:+ELLIPSIS 83 | scheduler.Job(...CYCLIC...foo...1, 1...datetime(2022, 2, 15, 0, 45)...) 84 | 85 | A human readable overview of the scheduled jobs can be created with a simple `print` statement: 86 | 87 | .. code-block:: pycon 88 | 89 | >>> print(schedule) # doctest:+SKIP 90 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=9 91 | 92 | type function / alias due at due in attempts weight 93 | -------- ---------------- ------------------- --------- ------------- ------ 94 | MINUTELY foo(..) 2021-06-18 00:37:15 0:00:14 0/inf 1 95 | CYCLIC foo() 2021-06-18 00:46:58 0:09:58 0/inf 1 96 | ONCE foo() 2021-06-18 00:46:59 0:09:58 0/1 1 97 | HOURLY foo() 2021-06-18 01:30:15 0:53:14 0/inf 1 98 | DAILY foo(..) 2021-06-18 16:30:00 15:52:59 0/inf 1 99 | WEEKLY foo() 2021-06-21 00:00:00 2 days 0/inf 1 100 | ONCE foo(..) 2021-06-21 00:00:00 2 days 0/1 1 101 | WEEKLY foo(..) 2021-06-21 16:30:00 3 days 0/inf 1 102 | ONCE foo() 2022-02-15 00:45:00 242 days 0/1 1 103 | 104 | 105 | Unless |Scheduler| was given a limit on the execution count via the `max_exec` option, a call to 106 | the Scheduler instances :py:meth:`~scheduler.core.Scheduler.exec_jobs` function will execute every 107 | overdue job exactly once. 108 | 109 | For cyclic execution of |Job|\ s, the :py:meth:`~scheduler.core.Scheduler.exec_jobs` function should 110 | be embedded in a loop of the host program: 111 | 112 | .. code-block:: pycon 113 | 114 | >>> import time 115 | 116 | >>> while True: # doctest:+SKIP 117 | ... schedule.exec_jobs() 118 | ... time.sleep(1) 119 | ... 120 | -------------------------------------------------------------------------------- /doc/pages/examples/tags.rst: -------------------------------------------------------------------------------- 1 | .. _examples.tags: 2 | 3 | Tags 4 | ==== 5 | 6 | The `scheduler` library provides a tagging system for |Job| categorization. This 7 | allows for collective selection and deletion of |Job|\ s. 8 | 9 | Create a number of tagged |Job|\ s using the :attr:`~scheduler.job.Job.tags` attribute. 10 | For demonstration we mix the `tags` ``spam``, ``eggs``, ``ham`` and ``sausage``, where 11 | ``spam`` is a tag of every |Job|: 12 | 13 | .. code-block:: pycon 14 | 15 | >>> import datetime as dt 16 | 17 | >>> from scheduler import Scheduler 18 | 19 | >>> def foo(): 20 | ... print("foo") 21 | ... 22 | 23 | >>> schedule = Scheduler() 24 | 25 | >>> dish1 = schedule.once(dt.timedelta(), foo, tags={"spam", "eggs"}) 26 | >>> dish2 = schedule.once(dt.timedelta(), foo, tags={"spam", "ham"}) 27 | >>> dish3 = schedule.once(dt.timedelta(), foo, tags={"spam", "ham", "eggs"}) 28 | >>> dish4 = schedule.once(dt.timedelta(), foo, tags={"spam", "sausage", "eggs"}) 29 | 30 | The default behaviour of |Job| selection by tags require a |Job| to contain all of the 31 | targeted tags for a match. If the `any_tag` flag is set to ``True``, only one of the targeted 32 | tags has to exist in the |Job| for a match. 33 | |Scheduler| currently supports the tagging system for the functions 34 | 35 | :py:meth:`~scheduler.core.Scheduler.get_jobs` and 36 | :py:meth:`~scheduler.core.Scheduler.delete_jobs`. 37 | 38 | .. note:: If an empty `set` of tags is be passed to above functions, no filter is applied 39 | and all |Job|\ s are selected. 40 | 41 | Match all tags 42 | -------------- 43 | This is the default behavior. 44 | 45 | .. code-block:: pycon 46 | 47 | >>> dishes = schedule.get_jobs({"ham", "eggs"}) 48 | >>> dishes == {dish3} 49 | True 50 | 51 | >>> dishes = schedule.get_jobs({"eggs"}) 52 | >>> dishes == {dish1, dish3, dish4} 53 | True 54 | 55 | Match any tag 56 | ------------- 57 | With the `any_tag` flag set to ``True``, one matching tag is sufficient: 58 | 59 | .. code-block:: pycon 60 | 61 | >>> dishes = schedule.get_jobs({"sausage", "ham"}, any_tag=True) 62 | >>> dishes == {dish2, dish3, dish4} 63 | True 64 | 65 | >>> dishes = schedule.get_jobs({"eggs"}, any_tag=True) 66 | >>> dishes == {dish1, dish3, dish4} 67 | True 68 | 69 | .. note:: Additionally the tagging system is supported by the 70 | :py:meth:`~scheduler.core.Scheduler.delete_jobs` method. 71 | -------------------------------------------------------------------------------- /doc/pages/examples/threading.rst: -------------------------------------------------------------------------------- 1 | Threading 2 | ========= 3 | 4 | The |Scheduler| is thread safe and supports parallel execution 5 | of pending |Job|\ s. 6 | |Job|\ s with a relevant execution time or blocking IO operations 7 | can delay each other. 8 | 9 | .. warning:: When running |Job|\ s in parallel, be sure that possible side effects 10 | of the scheduled functions are implemented in a thread safe manner. 11 | 12 | The following examples show the difference between concurrent and parallel 13 | |Scheduler|\ s: 14 | 15 | Concurrent execution 16 | -------------------- 17 | 18 | By default the |Scheduler| will execute its 19 | |Job|\ s sequentially. The total duration when executing multiple 20 | |Job|\ s will therefore be greater than the sum of the individual 21 | run times. 22 | 23 | .. code-block:: pycon 24 | 25 | >>> import datetime as dt 26 | >>> import time 27 | 28 | >>> from scheduler import Scheduler 29 | 30 | >>> def sleep(secs: float): 31 | ... time.sleep(secs) 32 | ... 33 | 34 | >>> schedule = Scheduler() 35 | >>> job_1 = schedule.once(dt.timedelta(), sleep, kwargs={"secs": 0.1}) 36 | >>> job_2 = schedule.once(dt.timedelta(), sleep, kwargs={"secs": 0.1}) 37 | 38 | >>> start_time = time.perf_counter() 39 | >>> n_exec = schedule.exec_jobs() 40 | >>> total_seconds = time.perf_counter() - start_time 41 | >>> n_exec 42 | 2 43 | 44 | >>> 0.2 < total_seconds and total_seconds < 0.21 45 | True 46 | 47 | Parallel execution 48 | ------------------ 49 | 50 | The number of worker threads for the |Scheduler| can be defined 51 | with the `n_threads` argument. For ``n_threads = 0`` the |Scheduler| 52 | will spawn a seperate worker thread for every pending |Job|. 53 | 54 | .. code-block:: pycon 55 | 56 | >>> import datetime as dt 57 | >>> import time 58 | 59 | >>> from scheduler import Scheduler 60 | 61 | >>> def sleep(secs: float): 62 | ... time.sleep(secs) 63 | ... 64 | 65 | >>> schedule = Scheduler(n_threads=0) 66 | >>> job_1 = schedule.once(dt.timedelta(), sleep, kwargs={"secs": 0.1}) 67 | >>> job_2 = schedule.once(dt.timedelta(), sleep, kwargs={"secs": 0.1}) 68 | 69 | >>> start_time = time.perf_counter() 70 | >>> n_exec = schedule.exec_jobs() 71 | >>> total_seconds = time.perf_counter() - start_time 72 | >>> n_exec 73 | 2 74 | 75 | >>> 0.1 < total_seconds and total_seconds < 0.11 76 | True 77 | -------------------------------------------------------------------------------- /doc/pages/examples/timezones.rst: -------------------------------------------------------------------------------- 1 | Timezones 2 | ========= 3 | 4 | The `scheduler` library supports timezones via the standard `datetime` library. 5 | 6 | .. warning:: **Mixing of offset-naive and offset-aware** `datetime.time` **and** 7 | `datetime.datetime` **objects is not supported.** 8 | 9 | If a |Scheduler| is initialized with a timezone, all `datetime.time`, `datetime.datetime` and 10 | |Job| objects require timezones. 11 | Vice versa a |Scheduler| without timezone informations does not support 12 | `datetime` or |Job| objects with timezones. 13 | 14 | For demonstration purposes, we will create a |Scheduler| with 15 | |Job|\ s defined in different timezones of the world. 16 | 17 | First create the timezones of a few known cities and a useful function to schedule. 18 | 19 | .. code-block:: pycon 20 | 21 | >>> import datetime as dt 22 | 23 | >>> def useful(): 24 | ... print("Very useful function.") 25 | ... 26 | 27 | >>> tz_new_york = dt.timezone(dt.timedelta(hours=-5)) 28 | >>> tz_wuppertal = dt.timezone(dt.timedelta(hours=2)) 29 | >>> tz_sydney = dt.timezone(dt.timedelta(hours=10)) 30 | 31 | Next initialize a |Scheduler| with UTC as its reference timezone: 32 | 33 | .. code-block:: pycon 34 | 35 | >>> from scheduler import Scheduler 36 | >>> import scheduler.trigger as trigger 37 | 38 | >>> schedule = Scheduler(tzinfo=dt.timezone.utc) 39 | 40 | Schedule our useful function :py:func:`~scheduler.core.Scheduler.once` for the current point 41 | in time but using New York local time with: 42 | 43 | .. code-block:: pycon 44 | 45 | >>> job_ny = schedule.once(dt.datetime.now(tz_new_york), useful) 46 | 47 | A daily job running at ``11:45`` local time of Wuppertal can be scheduled with: 48 | 49 | .. code-block:: pycon 50 | 51 | >>> job_wu = schedule.daily(dt.time(hour=11, minute=45, tzinfo=tz_wuppertal), useful) 52 | 53 | Lastly create a job running every Monday at ``10:00`` local time of Sydney as follows: 54 | 55 | .. code-block:: pycon 56 | 57 | >>> job_sy = schedule.weekly(trigger.Monday(dt.time(hour=10, tzinfo=tz_sydney)), useful) 58 | 59 | A simple `print(schedule)` statement can be used for an overview of the scheduled 60 | |Job|\ s. As this |Scheduler| instance is timezone 61 | aware, the table contains a `tzinfo` column. Verify if the |Job|\ s are 62 | scheduled as expected. 63 | 64 | .. code-block:: pycon 65 | 66 | >>> print(schedule) # doctest:+SKIP 67 | max_exec=inf, tzinfo=UTC, priority_function=linear_priority_function, #jobs=3 68 | 69 | type function / alias due at tzinfo due in attempts weight 70 | -------- ---------------- ------------------- ------------ --------- ------------- ------ 71 | ONCE useful() 2021-07-01 11:49:49 UTC-05:00 -0:00:00 0/1 1 72 | DAILY useful() 2021-07-02 11:45:00 UTC+02:00 16:55:10 0/inf 1 73 | WEEKLY useful() 2021-07-05 10:00:00 UTC+10:00 3 days 0/inf 1 74 | 75 | -------------------------------------------------------------------------------- /doc/readme.rst: -------------------------------------------------------------------------------- 1 | Readme 2 | ====== 3 | 4 | .. mdinclude:: ../README.md -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "scheduler" 7 | dynamic = ["readme", "version"] 8 | authors = [ 9 | { name = "Fabian A. Preiss", email = "fpreiss@digon.io" }, 10 | { name = "Jendrik A. Potyka", email = "jpotyka@digon.io" }, 11 | ] 12 | maintainers = [ 13 | { name = "Fabian A. Preiss", email = "fpreiss@digon.io" }, 14 | { name = "Jendrik A. Potyka", email = "jpotyka@digon.io" }, 15 | ] 16 | description = "A simple in-process python scheduler library with asyncio, threading and timezone support." 17 | license = { text = "LGPLv3" } 18 | keywords = [ 19 | "scheduler", 20 | "schedule", 21 | "asyncio", 22 | "threading", 23 | "datetime", 24 | "date", 25 | "time", 26 | "timedelta", 27 | "timezone", 28 | "timing", 29 | ] 30 | classifiers = [ 31 | "Development Status :: 4 - Beta", 32 | "Intended Audience :: Developers", 33 | "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Programming Language :: Python :: 3.13", 39 | "Programming Language :: Python :: Implementation :: CPython", 40 | "Topic :: Scientific/Engineering", 41 | "Topic :: Software Development :: Libraries", 42 | "Operating System :: OS Independent", 43 | "Typing :: Typed", 44 | ] 45 | requires-python = ">=3.9" 46 | dependencies = ["typeguard>=3.0.0"] 47 | 48 | 49 | [project.urls] 50 | Repository = "https://gitlab.com/DigonIO/scheduler" 51 | Documentation = "https://digon.io/hyd/project/scheduler/t/master" 52 | Changelog = "https://gitlab.com/DigonIO/scheduler/-/blob/master/CHANGELOG.md" 53 | "Bug Tracker" = "https://gitlab.com/DigonIO/scheduler/-/issues" 54 | 55 | [tool.setuptools.dynamic] 56 | version = { attr = "scheduler.__version__" } 57 | readme = { file = "README.md", content-type = "text/markdown" } 58 | 59 | 60 | [tool.black] 61 | line-length = 100 62 | target-version = ['py39', 'py310', 'py311', 'py312'] 63 | 64 | [tool.isort] 65 | py_version = 312 66 | profile = "black" 67 | 68 | [tool.pydocstyle] 69 | convention = "numpy" 70 | add-ignore = ["D105"] 71 | 72 | [tool.mypy] 73 | python_version = "3.9" 74 | # strict = true 75 | warn_return_any = true 76 | warn_unused_configs = true 77 | disallow_any_generics = true 78 | disallow_subclassing_any = true 79 | 80 | [tool.pytest.ini_options] 81 | asyncio_mode = "strict" 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pylint==3.3.3 2 | pytest==8.3.4 3 | pytest-cov==6.0.0 4 | pytest-asyncio==0.25.2 5 | coverage==7.6.10 6 | mypy==1.14.1 7 | bandit==1.8.2 8 | pydocstyle==6.3.0 9 | Sphinx==6.1.2 10 | numpydoc==1.8.0 11 | mistune==0.8.4 12 | m2r2==0.3.3 13 | docutils==0.19 14 | furo==2024.8.6 15 | typeguard==4.4.1 16 | black==24.10.0 17 | blacken-docs==1.19.1 18 | pre-commit==4.1.0 19 | isort==5.13.2 -------------------------------------------------------------------------------- /scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple in-process python `scheduler` library for `datetime` objects. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | __version__ = "0.8.8" 8 | __author__ = "Jendrik A. Potyka, Fabian A. Preiss" 9 | 10 | from scheduler.error import SchedulerError 11 | from scheduler.threading.scheduler import Scheduler 12 | 13 | __all__ = ["SchedulerError", "Scheduler"] 14 | -------------------------------------------------------------------------------- /scheduler/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of a `asyncio` compatible in-process scheduler. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | from scheduler.asyncio.scheduler import Scheduler 8 | from scheduler.error import SchedulerError 9 | 10 | __all__ = ["Scheduler", "SchedulerError"] 11 | -------------------------------------------------------------------------------- /scheduler/asyncio/job.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of job for the `asyncio` scheduler. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from logging import Logger 10 | from typing import Any, Callable, Coroutine 11 | 12 | from scheduler.base.job import BaseJob 13 | 14 | 15 | class Job(BaseJob[Callable[..., Coroutine[Any, Any, None]]]): 16 | r""" 17 | |AioJob| class bundling time and callback function methods. 18 | 19 | Parameters 20 | ---------- 21 | job_type : JobType 22 | Indicator which defines which calculations has to be used. 23 | timing : TimingWeekly 24 | Desired execution time(s). 25 | handle : Callable[..., None] 26 | Handle to a callback function. 27 | args : tuple[Any] 28 | Positional argument payload for the function handle within a |AioJob|. 29 | kwargs : Optional[dict[str, Any]] 30 | Keyword arguments payload for the function handle within a |AioJob|. 31 | max_attempts : Optional[int] 32 | Number of times the |AioJob| will be executed where ``0 <=> inf``. 33 | A |AioJob| with no free attempt will be deleted. 34 | tags : Optional[set[str]] 35 | The tags of the |AioJob|. 36 | delay : Optional[bool] 37 | *Deprecated*: If ``True`` wait with the execution for the next scheduled time. 38 | start : Optional[datetime.datetime] 39 | Set the reference `datetime.datetime` stamp the |AioJob| 40 | will be scheduled against. Default value is `datetime.datetime.now()`. 41 | stop : Optional[datetime.datetime] 42 | Define a point in time after which a |AioJob| will be stopped 43 | and deleted. 44 | skip_missing : Optional[bool] 45 | If ``True`` a |AioJob| will only schedule it's newest planned 46 | execution and drop older ones. 47 | alias : Optional[str] 48 | Overwrites the function handle name in the string representation. 49 | tzinfo : Optional[datetime.tzinfo] 50 | Set the timezone of the |AioScheduler| the |AioJob| 51 | is scheduled in. 52 | 53 | Returns 54 | ------- 55 | Job 56 | Instance of a scheduled |AioJob|. 57 | """ 58 | 59 | # pylint: disable=no-member invalid-name 60 | 61 | async def _exec(self, logger: Logger) -> None: 62 | coroutine = self._BaseJob__handle(*self._BaseJob__args, **self._BaseJob__kwargs) # type: ignore 63 | try: 64 | await coroutine 65 | except Exception: 66 | logger.exception("Unhandled exception in `%r`!", self) 67 | self._BaseJob__failed_attempts += 1 # type: ignore 68 | self._BaseJob__attempts += 1 # type: ignore 69 | 70 | # pylint: enable=no-member invalid-name 71 | 72 | def __repr__(self) -> str: 73 | params: tuple[str, ...] = self._repr() 74 | return f"scheduler.asyncio.job.Job({', '.join(params)})" 75 | -------------------------------------------------------------------------------- /scheduler/base/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base implementation for scheduler. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | -------------------------------------------------------------------------------- /scheduler/base/definition.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic definitions for a abstract `BaseJob` and `BaseScheduler`. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | import datetime as dt 8 | from enum import Enum, auto 9 | 10 | from scheduler.base.timingtype import ( 11 | _TimingCyclicList, 12 | _TimingDailyList, 13 | _TimingWeeklyList, 14 | ) 15 | from scheduler.message import ( 16 | CYCLIC_TYPE_ERROR_MSG, 17 | DAILY_TYPE_ERROR_MSG, 18 | HOURLY_TYPE_ERROR_MSG, 19 | MINUTELY_TYPE_ERROR_MSG, 20 | WEEKLY_TYPE_ERROR_MSG, 21 | ) 22 | from scheduler.trigger import ( 23 | Friday, 24 | Monday, 25 | Saturday, 26 | Sunday, 27 | Thursday, 28 | Tuesday, 29 | Wednesday, 30 | ) 31 | 32 | 33 | class JobType(Enum): 34 | """Indicate the `JobType` of a |BaseJob|.""" 35 | 36 | CYCLIC = auto() 37 | MINUTELY = auto() 38 | HOURLY = auto() 39 | DAILY = auto() 40 | WEEKLY = auto() 41 | 42 | 43 | JOB_TYPE_MAPPING = { 44 | dt.timedelta: JobType.CYCLIC, 45 | dt.time: JobType.DAILY, 46 | Monday: JobType.WEEKLY, 47 | Tuesday: JobType.WEEKLY, 48 | Wednesday: JobType.WEEKLY, 49 | Thursday: JobType.WEEKLY, 50 | Friday: JobType.WEEKLY, 51 | Saturday: JobType.WEEKLY, 52 | Sunday: JobType.WEEKLY, 53 | } 54 | 55 | JOB_TIMING_TYPE_MAPPING = { 56 | JobType.CYCLIC: { 57 | "type": _TimingCyclicList, 58 | "err": CYCLIC_TYPE_ERROR_MSG, 59 | }, 60 | JobType.MINUTELY: { 61 | "type": _TimingDailyList, 62 | "err": MINUTELY_TYPE_ERROR_MSG, 63 | }, 64 | JobType.HOURLY: { 65 | "type": _TimingDailyList, 66 | "err": HOURLY_TYPE_ERROR_MSG, 67 | }, 68 | JobType.DAILY: { 69 | "type": _TimingDailyList, 70 | "err": DAILY_TYPE_ERROR_MSG, 71 | }, 72 | JobType.WEEKLY: { 73 | "type": _TimingWeeklyList, 74 | "err": WEEKLY_TYPE_ERROR_MSG, 75 | }, 76 | } 77 | -------------------------------------------------------------------------------- /scheduler/base/job_timer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of the essential timer for a `BaseJob`. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import datetime as dt 10 | import threading 11 | from typing import Optional, cast 12 | 13 | from scheduler.base.definition import JobType 14 | from scheduler.base.timingtype import TimingJobTimerUnion 15 | from scheduler.trigger.core import Weekday 16 | from scheduler.util import JOB_NEXT_DAYLIKE_MAPPING, next_weekday_time_occurrence 17 | 18 | 19 | class JobTimer: 20 | """ 21 | The class provides the internal `datetime.datetime` calculations for a |BaseJob|. 22 | 23 | Parameters 24 | ---------- 25 | job_type : JobType 26 | Indicator which defines which calculations has to be used. 27 | timing : TimingJobTimerUnion 28 | Desired execution time(s). 29 | start : datetime.datetime 30 | Timestamp reference from which future executions will be calculated. 31 | skip_missing : bool 32 | If ``True`` a |BaseJob| will only schedule it's newest planned 33 | execution and drop older ones. 34 | """ 35 | 36 | def __init__( 37 | self, 38 | job_type: JobType, 39 | timing: TimingJobTimerUnion, 40 | start: dt.datetime, 41 | skip_missing: bool = False, 42 | ): 43 | self.__lock = threading.RLock() 44 | self.__job_type = job_type 45 | self.__timing = timing 46 | self.__next_exec = start 47 | self.__skip = skip_missing 48 | self.calc_next_exec() 49 | 50 | def calc_next_exec(self, ref: Optional[dt.datetime] = None) -> None: 51 | """ 52 | Generate the next execution `datetime.datetime` stamp. 53 | 54 | Parameters 55 | ---------- 56 | ref : Optional[datetime.datetime] 57 | Datetime reference for scheduling the next execution datetime. 58 | """ 59 | with self.__lock: 60 | if self.__job_type == JobType.CYCLIC: 61 | if self.__skip and ref is not None: 62 | self.__next_exec = ref 63 | self.__next_exec = self.__next_exec + cast(dt.timedelta, self.__timing) 64 | return 65 | 66 | if self.__job_type == JobType.WEEKLY: 67 | self.__timing = cast(Weekday, self.__timing) 68 | if self.__timing.time.tzinfo: 69 | self.__next_exec = self.__next_exec.astimezone(self.__timing.time.tzinfo) 70 | self.__next_exec = next_weekday_time_occurrence( 71 | self.__next_exec, self.__timing, self.__timing.time 72 | ) 73 | 74 | else: # self.__job_type in JOB_NEXT_DAYLIKE_MAPPING: 75 | self.__timing = cast(dt.time, self.__timing) 76 | if self.__next_exec.tzinfo: 77 | self.__next_exec = self.__next_exec.astimezone(self.__timing.tzinfo) 78 | self.__next_exec = JOB_NEXT_DAYLIKE_MAPPING[self.__job_type]( 79 | self.__next_exec, self.__timing 80 | ) 81 | 82 | if self.__skip and ref is not None and self.__next_exec < ref: 83 | self.__next_exec = ref 84 | self.calc_next_exec() 85 | 86 | @property 87 | def datetime(self) -> dt.datetime: 88 | """ 89 | Get the `datetime.datetime` object for the planed execution. 90 | 91 | Returns 92 | ------- 93 | datetime.datetime 94 | Execution `datetime.datetime` stamp. 95 | """ 96 | with self.__lock: 97 | return self.__next_exec 98 | 99 | def timedelta(self, dt_stamp: dt.datetime) -> dt.timedelta: 100 | """ 101 | Get the `datetime.timedelta` until the execution of this `Job`. 102 | 103 | Parameters 104 | ---------- 105 | dt_stamp : datetime.datetime 106 | Time to be compared with the planned execution time 107 | to determine the time difference. 108 | 109 | Returns 110 | ------- 111 | datetime.timedelta 112 | `datetime.timedelta` to the execution. 113 | """ 114 | with self.__lock: 115 | return self.__next_exec - dt_stamp 116 | -------------------------------------------------------------------------------- /scheduler/base/job_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of essential functions for a `BaseJob`. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import datetime as dt 10 | from typing import Optional, cast 11 | 12 | import typeguard as tg 13 | 14 | from scheduler.base.definition import JOB_TIMING_TYPE_MAPPING, JobType 15 | from scheduler.base.job_timer import JobTimer 16 | from scheduler.base.timingtype import TimingJobUnion 17 | from scheduler.error import SchedulerError 18 | from scheduler.message import ( 19 | _TZ_ERROR_MSG, 20 | DUPLICATE_EFFECTIVE_TIME, 21 | START_STOP_ERROR, 22 | TZ_ERROR_MSG, 23 | ) 24 | from scheduler.trigger.core import Weekday 25 | from scheduler.util import are_times_unique, are_weekday_times_unique 26 | 27 | 28 | def prettify_timedelta(timedelta: dt.timedelta) -> str: 29 | """ 30 | Humanize timedelta string readability for negative values. 31 | 32 | Parameters 33 | ---------- 34 | timedelta : datetime.timedelta 35 | datetime instance 36 | 37 | Returns 38 | ------- 39 | str 40 | Human readable string representation rounded to seconds 41 | """ 42 | seconds = timedelta.total_seconds() 43 | if seconds < 0: 44 | res = f"-{-timedelta}" 45 | else: 46 | res = str(timedelta) 47 | return res.split(",")[0].split(".")[0] 48 | 49 | 50 | def get_pending_timer(timers: list[JobTimer]) -> JobTimer: 51 | """Get the the timer with the largest overdue time.""" 52 | unsorted_timer_datetimes: dict[JobTimer, dt.datetime] = {} 53 | for timer in timers: 54 | unsorted_timer_datetimes[timer] = timer.datetime 55 | sorted_timers = sorted( 56 | unsorted_timer_datetimes, 57 | key=unsorted_timer_datetimes.get, # type: ignore 58 | ) 59 | return sorted_timers[0] 60 | 61 | 62 | def sane_timing_types(job_type: JobType, timing: TimingJobUnion) -> None: 63 | """ 64 | Determine if the `JobType` is fulfilled by the type of the specified `timing`. 65 | 66 | Parameters 67 | ---------- 68 | job_type : JobType 69 | :class:`~scheduler.job.JobType` to test agains. 70 | timing : TimingJobUnion 71 | The `timing` object to be tested. 72 | 73 | Raises 74 | ------ 75 | TypeError 76 | If the `timing` object has the wrong `Type` for a specific `JobType`. 77 | """ 78 | try: 79 | tg.check_type(timing, JOB_TIMING_TYPE_MAPPING[job_type]["type"]) 80 | if job_type == JobType.CYCLIC: 81 | if not len(timing) == 1: 82 | raise TypeError 83 | except TypeError as err: 84 | raise SchedulerError(JOB_TIMING_TYPE_MAPPING[job_type]["err"]) from err 85 | 86 | 87 | def standardize_timing_format(job_type: JobType, timing: TimingJobUnion) -> TimingJobUnion: 88 | r""" 89 | Return timings in standardized form. 90 | 91 | Clears irrelevant time positionals for `JobType.MINUTELY` and `JobType.HOURLY`. 92 | """ 93 | if job_type is JobType.MINUTELY: 94 | timing = [time.replace(hour=0, minute=0) for time in cast(list[dt.time], timing)] 95 | elif job_type is JobType.HOURLY: 96 | timing = [time.replace(hour=0) for time in cast(list[dt.time], timing)] 97 | return timing 98 | 99 | 100 | def check_timing_tzinfo( 101 | job_type: JobType, 102 | timing: TimingJobUnion, 103 | tzinfo: Optional[dt.tzinfo], 104 | ) -> None: 105 | """Raise if `timing` incompatible with `tzinfo` for `job_type`.""" 106 | if job_type is JobType.WEEKLY: 107 | for weekday in cast(list[Weekday], timing): 108 | if bool(weekday.time.tzinfo) ^ bool(tzinfo): 109 | raise SchedulerError(TZ_ERROR_MSG) 110 | elif job_type in (JobType.MINUTELY, JobType.HOURLY, JobType.DAILY): 111 | for time in cast(list[dt.time], timing): 112 | if bool(time.tzinfo) ^ bool(tzinfo): 113 | raise SchedulerError(TZ_ERROR_MSG) 114 | 115 | 116 | def check_duplicate_effective_timings( 117 | job_type: JobType, 118 | timing: TimingJobUnion, 119 | tzinfo: Optional[dt.tzinfo], 120 | ) -> None: 121 | """Raise given timings are not effectively duplicates.""" 122 | if job_type is JobType.WEEKLY: 123 | if not are_weekday_times_unique(cast(list[Weekday], timing), tzinfo): 124 | raise SchedulerError(DUPLICATE_EFFECTIVE_TIME) 125 | elif job_type in ( 126 | JobType.MINUTELY, 127 | JobType.HOURLY, 128 | JobType.DAILY, 129 | ): 130 | if not are_times_unique(cast(list[dt.time], timing)): 131 | raise SchedulerError(DUPLICATE_EFFECTIVE_TIME) 132 | 133 | 134 | def set_start_check_stop_tzinfo( 135 | start: Optional[dt.datetime], 136 | stop: Optional[dt.datetime], 137 | tzinfo: Optional[dt.tzinfo], 138 | ) -> dt.datetime: 139 | """Raise if `start`, `stop` and `tzinfo` incompatible; Make start.""" 140 | if start: 141 | if bool(start.tzinfo) ^ bool(tzinfo): 142 | raise SchedulerError(_TZ_ERROR_MSG.format("start")) 143 | else: 144 | start = dt.datetime.now(tzinfo) 145 | if stop: 146 | if bool(stop.tzinfo) ^ bool(tzinfo): 147 | raise SchedulerError(_TZ_ERROR_MSG.format("stop")) 148 | if stop is not None: 149 | if start >= stop: 150 | raise SchedulerError(START_STOP_ERROR) 151 | return start 152 | -------------------------------------------------------------------------------- /scheduler/base/scheduler.py: -------------------------------------------------------------------------------- 1 | """Implementation of a `BaseScheduler`. 2 | 3 | Author: Jendrik A. Potyka, Fabian A. Preiss 4 | """ 5 | 6 | import warnings 7 | from abc import ABC, abstractmethod 8 | from collections.abc import Iterable 9 | from functools import wraps 10 | from logging import Logger, getLogger 11 | from typing import Any, Callable, Generic, List, Optional, TypeVar 12 | 13 | from scheduler.base.job import BaseJobType 14 | from scheduler.base.timingtype import ( 15 | TimingCyclic, 16 | TimingDailyUnion, 17 | TimingOnceUnion, 18 | TimingWeeklyUnion, 19 | ) 20 | 21 | # TODO: 22 | # import sys 23 | # if sys.version_info < (3, 10): 24 | # from typing_extensions import ParamSpec 25 | # else: 26 | # from typing import ParamSpec 27 | 28 | 29 | LOGGER = getLogger("scheduler") 30 | 31 | 32 | def select_jobs_by_tag( 33 | jobs: set[BaseJobType], 34 | tags: set[str], 35 | any_tag: bool, 36 | ) -> set[BaseJobType]: 37 | r""" 38 | Select |BaseJob|\ s by matching `tags`. 39 | 40 | Parameters 41 | ---------- 42 | jobs : set[BaseJob] 43 | Unfiltered set of |BaseJob|\ s. 44 | tags : set[str] 45 | Tags to filter |BaseJob|\ s. 46 | any_tag : bool 47 | False: To match a |BaseJob| all tags have to match. 48 | True: To match a |BaseJob| at least one tag has to match. 49 | 50 | Returns 51 | ------- 52 | set[BaseJob] 53 | Selected |BaseJob|\ s. 54 | """ 55 | if any_tag: 56 | return {job for job in jobs if tags & job.tags} 57 | return {job for job in jobs if tags <= job.tags} 58 | 59 | 60 | def deprecated(fields: List[str]) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 61 | """ 62 | Decorator for marking specified function arguments as deprecated. 63 | 64 | Parameters 65 | ---------- 66 | fields : List[str] 67 | A list of strings representing the names of the function arguments that are deprecated. 68 | 69 | Examples 70 | -------- 71 | .. code-block:: python 72 | 73 | @deprecated(["old_arg"]) 74 | def some_function(new_arg, old_arg=None): 75 | pass 76 | 77 | Calling `some_function(new_arg=5, old_arg=3)` generates a deprecation warning for using 'old_arg'. 78 | """ 79 | 80 | def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: 81 | @wraps(func) 82 | def real_wrapper(*args: tuple[Any, ...], **kwargs: dict[str, Any]) -> Any: 83 | for f in fields: 84 | if f in kwargs and kwargs[f] is not None: 85 | # keep it in kwargs 86 | warnings.warn( 87 | ( 88 | f"Using the `{f}` argument is deprecated and will " 89 | "be removed in the next minor release." 90 | ), 91 | DeprecationWarning, 92 | stacklevel=3, 93 | ) 94 | return func(*args, **kwargs) 95 | 96 | return real_wrapper 97 | 98 | return wrapper 99 | 100 | 101 | T = TypeVar("T", bound=Callable[[], Any]) 102 | 103 | 104 | class BaseScheduler( 105 | ABC, Generic[BaseJobType, T] 106 | ): # NOTE maybe a typing Protocol class is better than an ABC class 107 | """ 108 | Interface definition of an abstract scheduler. 109 | 110 | Author: Jendrik A. Potyka, Fabian A. Preiss 111 | """ 112 | 113 | _logger: Logger 114 | 115 | def __init__(self, logger: Optional[Logger] = None) -> None: 116 | self._logger = logger if logger else LOGGER 117 | 118 | @abstractmethod 119 | def delete_job(self, job: BaseJobType) -> None: 120 | """Delete a |BaseJob| from the `BaseScheduler`.""" 121 | 122 | @abstractmethod 123 | def delete_jobs( 124 | self, 125 | tags: Optional[set[str]] = None, 126 | any_tag: bool = False, 127 | ) -> int: 128 | r"""Delete a set of |BaseJob|\ s from the `BaseScheduler` by tags.""" 129 | 130 | @abstractmethod 131 | def get_jobs( 132 | self, 133 | tags: Optional[set[str]] = None, 134 | any_tag: bool = False, 135 | ) -> set[BaseJobType]: 136 | r"""Get a set of |BaseJob|\ s from the `BaseScheduler` by tags.""" 137 | 138 | @abstractmethod 139 | def cyclic(self, timing: TimingCyclic, handle: T, **kwargs) -> BaseJobType: 140 | """Schedule a cyclic |BaseJob|.""" 141 | 142 | @abstractmethod 143 | def minutely(self, timing: TimingDailyUnion, handle: T, **kwargs) -> BaseJobType: 144 | """Schedule a minutely |BaseJob|.""" 145 | 146 | @abstractmethod 147 | def hourly(self, timing: TimingDailyUnion, handle: T, **kwargs) -> BaseJobType: 148 | """Schedule an hourly |BaseJob|.""" 149 | 150 | @abstractmethod 151 | def daily(self, timing: TimingDailyUnion, handle: T, **kwargs) -> BaseJobType: 152 | """Schedule a daily |BaseJob|.""" 153 | 154 | @abstractmethod 155 | def weekly(self, timing: TimingWeeklyUnion, handle: T, **kwargs) -> BaseJobType: 156 | """Schedule a weekly |BaseJob|.""" 157 | 158 | @abstractmethod 159 | def once( 160 | self, 161 | timing: TimingOnceUnion, 162 | handle: T, 163 | *, 164 | args: Optional[tuple[Any]] = None, 165 | kwargs: Optional[dict[str, Any]] = None, 166 | tags: Optional[Iterable[str]] = None, 167 | alias: Optional[str] = None, 168 | ) -> BaseJobType: 169 | """Schedule a oneshot |BaseJob|.""" 170 | 171 | @property 172 | @abstractmethod 173 | def jobs(self) -> set[BaseJobType]: 174 | r"""Get the set of all |BaseJob|\ s.""" 175 | -------------------------------------------------------------------------------- /scheduler/base/scheduler_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of essential functions and components for a `BaseJob`. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | import datetime as dt 8 | from typing import Optional, Union, cast 9 | 10 | from scheduler.base.job import BaseJobType 11 | from scheduler.base.timingtype import ( 12 | TimingCyclic, 13 | TimingDailyUnion, 14 | TimingJobUnion, 15 | TimingWeeklyUnion, 16 | ) 17 | from scheduler.error import SchedulerError 18 | 19 | 20 | def str_cutoff(string: str, max_length: int, cut_tail: bool = False) -> str: 21 | """ 22 | Abbreviate a string to a given length. 23 | 24 | The resulting string will carry an indicator if it's abbreviated, 25 | like ``stri#``. 26 | 27 | Parameters 28 | ---------- 29 | string : str 30 | String which is to be cut. 31 | max_length : int 32 | Max resulting string length. 33 | cut_tail : bool 34 | ``False`` for string abbreviation from the front, else ``True``. 35 | 36 | Returns 37 | ------- 38 | str 39 | Resulting string 40 | """ 41 | if max_length < 1: 42 | raise ValueError("max_length < 1 not allowed") 43 | 44 | if len(string) > max_length: 45 | pos = max_length - 1 46 | return string[:pos] + "#" if cut_tail else "#" + string[-pos:] 47 | 48 | return string 49 | 50 | 51 | def check_tzname(tzinfo: Optional[dt.tzinfo]) -> Optional[str]: 52 | """Composed of the datetime.datetime.tzname and the datetime._check_tzname methode.""" 53 | if tzinfo is None: 54 | return None 55 | name: Optional[str] = tzinfo.tzname(None) 56 | if not isinstance(name, str): 57 | raise SchedulerError(f"tzinfo.tzname() must return None or string, not {type(name)}") 58 | return name 59 | 60 | 61 | def create_job_instance( 62 | job_class: type[BaseJobType], 63 | timing: Union[TimingCyclic, TimingDailyUnion, TimingWeeklyUnion], 64 | **kwargs, 65 | ) -> BaseJobType: 66 | """Create a job instance from the given input parameters.""" 67 | if not isinstance(timing, list): 68 | timing_list = cast(TimingJobUnion, [timing]) 69 | else: 70 | timing_list = cast(TimingJobUnion, timing) 71 | 72 | return job_class( 73 | timing=timing_list, 74 | **kwargs, 75 | ) 76 | -------------------------------------------------------------------------------- /scheduler/base/timingtype.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the typing for all trigger objects. 3 | 4 | Combines custom trigger objects like Weekday with the python in build 5 | types for the datetime library. 6 | 7 | Author: Jendrik A. Potyka, Fabian A. Preiss 8 | """ 9 | 10 | import datetime as dt 11 | from typing import Union 12 | 13 | from scheduler.trigger.core import Weekday 14 | 15 | # execution interval 16 | TimingCyclic = dt.timedelta # Scheduler 17 | _TimingCyclicList = list[TimingCyclic] 18 | 19 | # time on the clock 20 | _TimingDaily = dt.time # JobTimer 21 | # TimingDaily = Union[dt.time, list[dt.time]] 22 | _TimingDailyList = list[_TimingDaily] # Job 23 | TimingDailyUnion = Union[_TimingDaily, _TimingDailyList] # Scheduler 24 | 25 | # day of the week or time on the clock 26 | _TimingWeekly = Weekday 27 | _TimingWeeklyList = list[_TimingWeekly] 28 | TimingWeeklyUnion = Union[_TimingWeekly, _TimingWeeklyList] # Scheduler 29 | 30 | TimingJobTimerUnion = Union[TimingCyclic, _TimingDaily, _TimingWeekly] # JobTimer 31 | TimingJobUnion = Union[_TimingCyclicList, _TimingDailyList, _TimingWeeklyList] # Job 32 | 33 | TimingOnceUnion = Union[dt.datetime, TimingCyclic, _TimingWeekly, _TimingDaily] # Scheduler.once 34 | -------------------------------------------------------------------------------- /scheduler/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generic scheduler error definition. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | 8 | class SchedulerError(Exception): 9 | """Generic Scheduler exception.""" 10 | -------------------------------------------------------------------------------- /scheduler/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | Information and error messages. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | DUPLICATE_EFFECTIVE_TIME = "Times that are effectively identical are not allowed." 8 | 9 | CYCLIC_TYPE_ERROR_MSG = "Wrong input for Cyclic! Expected input type:\n" + "datetime.timedelta" 10 | _DAILY_TYPE_ERROR_MSG = ( 11 | "Wrong input for {0}! Select one of the following input types:\n" 12 | + "datetime.time | list[datetime.time]" 13 | ) 14 | MINUTELY_TYPE_ERROR_MSG = _DAILY_TYPE_ERROR_MSG.format("Minutely") 15 | HOURLY_TYPE_ERROR_MSG = _DAILY_TYPE_ERROR_MSG.format("Hourly") 16 | DAILY_TYPE_ERROR_MSG = _DAILY_TYPE_ERROR_MSG.format("Daily") 17 | WEEKLY_TYPE_ERROR_MSG = ( 18 | "Wrong input for Weekly! Select one of the following input types:\n" 19 | + "DAY | list[DAY]\n" 20 | + "where `DAY = Weekday`" 21 | ) 22 | 23 | TZ_ERROR_MSG = "Can't use offset-naive and offset-aware datetimes together." 24 | _TZ_ERROR_MSG = TZ_ERROR_MSG[:-1] + " for {0}." 25 | 26 | START_STOP_ERROR = "Start argument must be smaller than the stop argument." 27 | 28 | ONCE_TYPE_ERROR_MSG = ( 29 | "Wrong input for Once! Select one of the following input types:\n" 30 | + "dt.datetime | dt.timedelta | Weekday | dt.time" 31 | ) 32 | -------------------------------------------------------------------------------- /scheduler/prioritization.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of prioritization functions. 3 | 4 | For compatibility with the |Scheduler|, the prioritization 5 | functions have to be of type ``Callable[[float, Job, int, int], float]``. 6 | 7 | Author: Jendrik A. Potyka, Fabian A. Preiss 8 | """ 9 | 10 | import random 11 | 12 | from scheduler.base.job import BaseJobType 13 | from scheduler.threading.job import Job 14 | 15 | 16 | def constant_weight_prioritization( 17 | time_delta: float, job: Job, max_exec: int, job_count: int 18 | ) -> float: 19 | r""" 20 | Interprets the `Job`'s weight as its priority. 21 | 22 | Return the |Job|'s weight for overdue 23 | |Job|\ s, otherwise return zero: 24 | 25 | .. math:: 26 | \left(\mathtt{time\_delta},\mathtt{weight}\right)\ {\mapsto}\begin{cases} 27 | 0 & :\ \mathtt{time\_delta}<0\\ 28 | \mathtt{weight} & :\ \mathtt{time\_delta}\geq0 29 | \end{cases} 30 | 31 | Parameters 32 | ---------- 33 | time_delta : float 34 | The time in seconds that a |Job| is overdue. 35 | job : Job 36 | The |Job| instance 37 | max_exec : int 38 | Limits the number of overdue |Job|\ s that can be executed 39 | by calling function `Scheduler.exec_jobs()`. 40 | job_count : int 41 | Number of scheduled |Job|\ s 42 | 43 | Returns 44 | ------- 45 | float 46 | The weight of a |Job| as priority. 47 | """ 48 | _ = max_exec 49 | _ = job_count 50 | if time_delta < 0: 51 | return 0 52 | return job.weight 53 | 54 | 55 | def linear_priority_function(time_delta: float, job: Job, max_exec: int, job_count: int) -> float: 56 | r""" 57 | Compute the |Job|\ s default linear priority. 58 | 59 | Linear |Job| prioritization such that the priority increases 60 | linearly with the amount of time that a |Job| is overdue. 61 | At the exact time of the scheduled execution, the priority is equal to the 62 | |Job|\ s weight. 63 | 64 | The function is defined as 65 | 66 | .. math:: 67 | \left(\mathtt{time\_delta},\mathtt{weight}\right)\ {\mapsto}\begin{cases} 68 | 0 & :\ \mathtt{time\_delta}<0\\ 69 | {\left(\mathtt{time\_delta}+1\right)}\cdot\mathtt{weight} & :\ \mathtt{time\_delta}\geq0 70 | \end{cases} 71 | 72 | Parameters 73 | ---------- 74 | time_delta : float 75 | The time in seconds that a |Job| is overdue. 76 | job : Job 77 | The |Job| instance 78 | max_exec : int 79 | Limits the number of overdue |Job|\ s that can be executed 80 | by calling function `Scheduler.exec_jobs()`. 81 | job_count : int 82 | Number of scheduled |Job|\ s 83 | 84 | Returns 85 | ------- 86 | float 87 | The time dependant priority for a |Job| 88 | """ 89 | _ = max_exec 90 | _ = job_count 91 | 92 | if time_delta < 0: 93 | return 0 94 | return (time_delta + 1) * job.weight 95 | 96 | 97 | def random_priority_function(time: float, job: Job, max_exec: int, job_count: int) -> float: 98 | """ 99 | Generate random priority values from weights. 100 | 101 | .. warning:: Not suitable for security relevant purposes. 102 | 103 | The priority generator will return 1 if the random number 104 | is lower then the |Job|'s weight, otherwise it will return 0. 105 | """ 106 | _ = time 107 | _ = max_exec 108 | _ = job_count 109 | if random.random() < job.weight: # nosec 110 | return 1 111 | return 0 112 | -------------------------------------------------------------------------------- /scheduler/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/scheduler/py.typed -------------------------------------------------------------------------------- /scheduler/threading/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of a `threading` compatible in-process scheduler. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | from scheduler.error import SchedulerError 8 | from scheduler.threading.scheduler import Scheduler 9 | 10 | __all__ = ["SchedulerError", "Scheduler"] 11 | -------------------------------------------------------------------------------- /scheduler/threading/job.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of job for the `threading` scheduler. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | import datetime as dt 8 | import threading 9 | from logging import Logger 10 | from typing import Any, Callable, Optional 11 | 12 | from scheduler.base.definition import JobType 13 | from scheduler.base.job import BaseJob 14 | from scheduler.base.timingtype import TimingJobUnion 15 | 16 | 17 | class Job(BaseJob[Callable[..., None]]): 18 | r""" 19 | |Job| class bundling time and callback function methods. 20 | 21 | Parameters 22 | ---------- 23 | job_type : JobType 24 | Indicator which defines which calculations has to be used. 25 | timing : TimingWeekly 26 | Desired execution time(s). 27 | handle : Callable[..., None] 28 | Handle to a callback function. 29 | args : tuple[Any] 30 | Positional argument payload for the function handle within a |Job|. 31 | kwargs : Optional[dict[str, Any]] 32 | Keyword arguments payload for the function handle within a |Job|. 33 | max_attempts : Optional[int] 34 | Number of times the |Job| will be executed where ``0 <=> inf``. 35 | A |Job| with no free attempt will be deleted. 36 | tags : Optional[set[str]] 37 | The tags of the |Job|. 38 | delay : Optional[bool] 39 | *Deprecated*: If ``True`` wait with the execution for the next scheduled time. 40 | start : Optional[datetime.datetime] 41 | Set the reference `datetime.datetime` stamp the |Job| 42 | will be scheduled against. Default value is `datetime.datetime.now()`. 43 | stop : Optional[datetime.datetime] 44 | Define a point in time after which a |Job| will be stopped 45 | and deleted. 46 | skip_missing : Optional[bool] 47 | If ``True`` a |Job| will only schedule it's newest planned 48 | execution and drop older ones. 49 | alias : Optional[str] 50 | Overwrites the function handle name in the string representation. 51 | tzinfo : Optional[datetime.tzinfo] 52 | Set the timezone of the |Scheduler| the |Job| 53 | is scheduled in. 54 | weight : Optional[float] 55 | Relative `weight` against other |Job|\ s. 56 | 57 | Returns 58 | ------- 59 | Job 60 | Instance of a scheduled |Job|. 61 | """ 62 | 63 | __weight: float 64 | __lock: threading.RLock 65 | 66 | def __init__( 67 | self, 68 | job_type: JobType, 69 | timing: TimingJobUnion, 70 | handle: Callable[..., None], 71 | *, 72 | args: Optional[tuple[Any, ...]] = None, 73 | kwargs: Optional[dict[str, Any]] = None, 74 | max_attempts: int = 0, 75 | tags: Optional[set[str]] = None, 76 | delay: bool = True, 77 | start: Optional[dt.datetime] = None, 78 | stop: Optional[dt.datetime] = None, 79 | skip_missing: bool = False, 80 | alias: Optional[str] = None, 81 | tzinfo: Optional[dt.tzinfo] = None, 82 | weight: float = 1, 83 | ): 84 | super().__init__( 85 | job_type, 86 | timing, 87 | handle, 88 | args=args, 89 | kwargs=kwargs, 90 | max_attempts=max_attempts, 91 | tags=tags, 92 | delay=delay, 93 | start=start, 94 | stop=stop, 95 | skip_missing=skip_missing, 96 | alias=alias, 97 | tzinfo=tzinfo, 98 | ) 99 | self.__lock = threading.RLock() 100 | self.__weight = weight 101 | 102 | # pylint: disable=no-member invalid-name 103 | 104 | def _exec(self, logger: Logger) -> None: 105 | """Execute the callback function.""" 106 | with self.__lock: 107 | try: 108 | self._BaseJob__handle(*self._BaseJob__args, **self._BaseJob__kwargs) # type: ignore 109 | except Exception: 110 | logger.exception("Unhandled exception in `%r`!", self) 111 | self._BaseJob__failed_attempts += 1 # type: ignore 112 | self._BaseJob__attempts += 1 # type: ignore 113 | 114 | # pylint: enable=no-member invalid-name 115 | 116 | def _calc_next_exec(self, ref_dt: dt.datetime) -> None: 117 | with self.__lock: 118 | super()._calc_next_exec(ref_dt) 119 | 120 | def __repr__(self) -> str: 121 | with self.__lock: 122 | params: tuple[str, ...] = self._repr() 123 | params_sum: str = ", ".join(params[:6] + (repr(self.__weight),) + params[6:]) 124 | return f"scheduler.Job({params_sum})" 125 | 126 | def __str__(self) -> str: 127 | return f"{super().__str__()}, w={self.weight:.3g}" 128 | 129 | def timedelta(self, dt_stamp: Optional[dt.datetime] = None) -> dt.timedelta: 130 | with self.__lock: 131 | return super().timedelta(dt_stamp) 132 | 133 | @property 134 | def datetime(self) -> dt.datetime: 135 | with self.__lock: 136 | return super().datetime 137 | 138 | @property 139 | def weight(self) -> float: 140 | """ 141 | Return the weight of the `Job` instance. 142 | 143 | Returns 144 | ------- 145 | float 146 | |Job| `weight`. 147 | """ 148 | return self.__weight 149 | 150 | @property 151 | def has_attempts_remaining(self) -> bool: 152 | with self.__lock: 153 | return super().has_attempts_remaining 154 | -------------------------------------------------------------------------------- /scheduler/trigger/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Trigger collection. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | from scheduler.trigger.core import ( 8 | Friday, 9 | Monday, 10 | Saturday, 11 | Sunday, 12 | Thursday, 13 | Tuesday, 14 | Wednesday, 15 | weekday, 16 | ) 17 | 18 | __all__ = ["Friday", "Monday", "Saturday", "Sunday", "Thursday", "Tuesday", "Wednesday", "weekday"] 19 | -------------------------------------------------------------------------------- /scheduler/trigger/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Trigger implementations. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | 7 | import datetime as dt 8 | from abc import ABC, abstractmethod 9 | from typing import Union 10 | 11 | 12 | class Weekday(ABC): 13 | """ 14 | |Weekday| object with time. 15 | 16 | Parameters 17 | ---------- 18 | time : datetime.time 19 | Time on the clock at the specific |Weekday|. 20 | """ 21 | 22 | __value: int 23 | __time: dt.time 24 | 25 | @abstractmethod 26 | def __init__(self, time: dt.time, value: int) -> None: 27 | """|Weekday| object with time.""" 28 | self.__time = time 29 | self.__value = value 30 | 31 | def __repr__(self) -> str: 32 | return f"{self.__class__.__qualname__}(time={self.time!r})" 33 | 34 | @property 35 | def time(self) -> dt.time: 36 | """ 37 | Return time of the |Weekday|. 38 | 39 | Returns 40 | ------- 41 | datetime.time 42 | Time on the clock at the specific |Weekday|. 43 | """ 44 | return self.__time 45 | 46 | @property 47 | def value(self) -> int: 48 | """ 49 | Return value of the given |Weekday|. 50 | 51 | Notes 52 | ----- 53 | Enumeration analogous to datetime library (0: Monday, ... 6: Sunday). 54 | 55 | Returns 56 | ------- 57 | int 58 | Value 59 | """ 60 | return self.__value 61 | 62 | 63 | # NOTE: pylint missing-class-docstring is just silly here, given functionality and usuage of parent 64 | class Monday(Weekday): # pylint: disable=missing-class-docstring # noqa: D101 65 | __doc__ = Weekday.__doc__ 66 | 67 | def __init__(self, time: dt.time = dt.time()) -> None: 68 | super().__init__(time, 0) 69 | 70 | 71 | class Tuesday(Weekday): # pylint: disable=missing-class-docstring # noqa: D101 72 | __doc__ = Weekday.__doc__ 73 | 74 | def __init__(self, time: dt.time = dt.time()) -> None: 75 | super().__init__(time, 1) 76 | 77 | 78 | class Wednesday(Weekday): # pylint: disable=missing-class-docstring # noqa: D101 79 | __doc__ = Weekday.__doc__ 80 | 81 | def __init__(self, time: dt.time = dt.time()) -> None: 82 | super().__init__(time, 2) 83 | 84 | 85 | class Thursday(Weekday): # pylint: disable=missing-class-docstring # noqa: D101 86 | __doc__ = Weekday.__doc__ 87 | 88 | def __init__(self, time: dt.time = dt.time()) -> None: 89 | super().__init__(time, 3) 90 | 91 | 92 | class Friday(Weekday): # pylint: disable=missing-class-docstring # noqa: D101 93 | __doc__ = Weekday.__doc__ 94 | 95 | def __init__(self, time: dt.time = dt.time()) -> None: 96 | super().__init__(time, 4) 97 | 98 | 99 | class Saturday(Weekday): # pylint: disable=missing-class-docstring # noqa: D101 100 | __doc__ = Weekday.__doc__ 101 | 102 | def __init__(self, time: dt.time = dt.time()) -> None: 103 | super().__init__(time, 5) 104 | 105 | 106 | class Sunday(Weekday): # pylint: disable=missing-class-docstring # noqa: D101 107 | __doc__ = Weekday.__doc__ 108 | 109 | def __init__(self, time: dt.time = dt.time()) -> None: 110 | super().__init__(time, 6) 111 | 112 | 113 | _Weekday = Union[Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday] 114 | 115 | _weekday_mapping: dict[int, type[_Weekday]] = { 116 | 0: Monday, 117 | 1: Tuesday, 118 | 2: Wednesday, 119 | 3: Thursday, 120 | 4: Friday, 121 | 5: Saturday, 122 | 6: Sunday, 123 | } 124 | 125 | 126 | def weekday(value: int, time: dt.time = dt.time()) -> Weekday: 127 | """ 128 | Return |Weekday| from given value with optional time. 129 | 130 | Notes 131 | ----- 132 | Enumeration analogous to datetime library (0: Monday, ... 6: Sunday). 133 | 134 | Parameters 135 | ---------- 136 | value : int 137 | Integer representation of |Weekday| 138 | time : datetime.time 139 | Time on the clock at the specific weekday. 140 | 141 | Returns 142 | ------- 143 | Weekday 144 | |Weekday| object with given time. 145 | """ 146 | weekday_cls: type[_Weekday] = _weekday_mapping[value] 147 | weekday_instance = weekday_cls(time) 148 | return weekday_instance 149 | -------------------------------------------------------------------------------- /scheduler/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of datetime and trigger related utility functions. 3 | 4 | Author: Jendrik A. Potyka, Fabian A. Preiss 5 | """ 6 | from __future__ import annotations 7 | 8 | import datetime as dt 9 | from typing import Optional 10 | 11 | from scheduler.base.definition import JobType 12 | from scheduler.error import SchedulerError 13 | from scheduler.trigger.core import Weekday 14 | 15 | 16 | def days_to_weekday(wkdy_src: int, wkdy_dest: int) -> int: 17 | """ 18 | Calculate the days to a specific destination weekday. 19 | 20 | Notes 21 | ----- 22 | Weekday enumeration based on 23 | the `datetime` standard library. 24 | 25 | Parameters 26 | ---------- 27 | wkdy_src : int 28 | Source :class:`~scheduler.util.Weekday` integer representation. 29 | wkdy_dest : int 30 | Destination :class:`~scheduler.util.Weekday` integer representation. 31 | 32 | Returns 33 | ------- 34 | int 35 | Days to the destination :class:`~scheduler.util.Weekday`. 36 | """ 37 | if not (0 <= wkdy_src <= 6 and 0 <= wkdy_dest <= 6): 38 | raise SchedulerError("Weekday enumeration interval: [0,6] <=> [Monday, Sunday]") 39 | 40 | return (wkdy_dest - wkdy_src - 1) % 7 + 1 41 | 42 | 43 | def next_daily_occurrence(now: dt.datetime, target_time: dt.time) -> dt.datetime: 44 | """ 45 | Estimate the next daily occurrence of a given time. 46 | 47 | .. warning:: Both arguments are expected to have the same tzinfo, no internal checks. 48 | 49 | Parameters 50 | ---------- 51 | now : datetime.datetime 52 | `datetime.datetime` object of today 53 | target_time : datetime.time 54 | Desired `datetime.time`. 55 | 56 | Returns 57 | ------- 58 | datetime.datetime 59 | Next `datetime.datetime` object with the desired time. 60 | """ 61 | target = now.replace( 62 | hour=target_time.hour, 63 | minute=target_time.minute, 64 | second=target_time.second, 65 | microsecond=target_time.microsecond, 66 | ) 67 | if (target - now).total_seconds() <= 0: 68 | target = target + dt.timedelta(days=1) 69 | return target 70 | 71 | 72 | def next_hourly_occurrence(now: dt.datetime, target_time: dt.time) -> dt.datetime: 73 | """ 74 | Estimate the next hourly occurrence of a given time. 75 | 76 | .. warning:: Both arguments are expected to have the same tzinfo, no internal checks. 77 | 78 | Parameters 79 | ---------- 80 | now : datetime.datetime 81 | `datetime.datetime` object of today 82 | target_time : datetime.time 83 | Desired `datetime.time`. 84 | 85 | Returns 86 | ------- 87 | datetime.datetime 88 | Next `datetime.datetime` object with the desired time. 89 | """ 90 | target = now.replace( 91 | minute=target_time.minute, 92 | second=target_time.second, 93 | microsecond=target_time.microsecond, 94 | ) 95 | if (target - now).total_seconds() <= 0: 96 | target = target + dt.timedelta(hours=1) 97 | return target 98 | 99 | 100 | def next_minutely_occurrence(now: dt.datetime, target_time: dt.time) -> dt.datetime: 101 | """ 102 | Estimate the next weekly occurrence of a given time. 103 | 104 | .. warning:: Both arguments are expected to have the same tzinfo, no internal checks. 105 | 106 | Parameters 107 | ---------- 108 | now : datetime.datetime 109 | `datetime.datetime` object of today 110 | target_time : datetime.time 111 | Desired `datetime.time`. 112 | 113 | Returns 114 | ------- 115 | datetime.datetime 116 | Next `datetime.datetime` object with the desired time. 117 | """ 118 | target = now.replace( 119 | second=target_time.second, 120 | microsecond=target_time.microsecond, 121 | ) 122 | if (target - now).total_seconds() <= 0: 123 | return target + dt.timedelta(minutes=1) 124 | return target 125 | 126 | 127 | def next_weekday_time_occurrence( 128 | now: dt.datetime, weekday: Weekday, target_time: dt.time 129 | ) -> dt.datetime: 130 | """ 131 | Estimate the next occurrence of a given weekday and time. 132 | 133 | .. warning:: Arguments `now` and `target_time` are expected to have the same tzinfo, 134 | no internal checks. 135 | 136 | Parameters 137 | ---------- 138 | now : datetime.datetime 139 | `datetime.datetime` object of today 140 | weekday : Weekday 141 | Desired :class:`~scheduler.util.Weekday`. 142 | target_time : datetime.time 143 | Desired `datetime.time`. 144 | 145 | Returns 146 | ------- 147 | datetime.datetime 148 | Next `datetime.datetime` object with the desired weekday and time. 149 | """ 150 | days = days_to_weekday(now.weekday(), weekday.value) 151 | if days == 7: 152 | candidate = next_daily_occurrence(now, target_time) 153 | if candidate.date() == now.date(): 154 | return candidate 155 | 156 | delta = dt.timedelta(days=days) 157 | target = now.replace( 158 | hour=target_time.hour, 159 | minute=target_time.minute, 160 | second=target_time.second, 161 | microsecond=target_time.microsecond, 162 | ) 163 | return target + delta 164 | 165 | 166 | JOB_NEXT_DAYLIKE_MAPPING = { 167 | JobType.MINUTELY: next_minutely_occurrence, 168 | JobType.HOURLY: next_hourly_occurrence, 169 | JobType.DAILY: next_daily_occurrence, 170 | } 171 | 172 | 173 | def are_times_unique( 174 | timelist: list[dt.time], 175 | ) -> bool: 176 | r""" 177 | Check if list contains distinct `datetime.time`\ s. 178 | 179 | Parameters 180 | ---------- 181 | timelist : list[datetime.time] 182 | List of time objects. 183 | 184 | Returns 185 | ------- 186 | boolean 187 | ``True`` if list entries are not equivalent with tzinfo offset. 188 | """ 189 | ref = dt.datetime(year=1970, month=1, day=1) 190 | collection = { 191 | ref.replace( 192 | hour=time.hour, 193 | minute=time.minute, 194 | second=time.second, 195 | microsecond=time.microsecond, 196 | ) 197 | + (time.utcoffset() or dt.timedelta()) 198 | for time in timelist 199 | } 200 | return len(collection) == len(timelist) 201 | 202 | 203 | def are_weekday_times_unique(weekday_list: list[Weekday], tzinfo: Optional[dt.tzinfo]) -> bool: 204 | """ 205 | Check if list contains distinct weekday times. 206 | 207 | .. warning:: Both arguments are expected to be either timezone aware or not 208 | - no internal checks. 209 | 210 | Parameters 211 | ---------- 212 | weekday_list : list[Weekday] 213 | List of weekday objects. 214 | 215 | Returns 216 | ------- 217 | boolean 218 | ``True`` if list entries are not equivalent with timezone offset. 219 | """ 220 | ref = dt.datetime(year=1970, month=1, day=1, tzinfo=tzinfo) 221 | collection = { 222 | next_weekday_time_occurrence(ref.astimezone(day.time.tzinfo), day, day.time) 223 | for day in weekday_list 224 | } 225 | return len(collection) == len(weekday_list) 226 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/tests/__init__.py -------------------------------------------------------------------------------- /tests/asyncio/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/tests/asyncio/__init__.py -------------------------------------------------------------------------------- /tests/asyncio/test_async_job.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import copy 3 | import datetime as dt 4 | from typing import Any 5 | 6 | import pytest 7 | 8 | from scheduler.asyncio.job import Job 9 | from scheduler.base.definition import JobType 10 | from scheduler.base.scheduler import LOGGER 11 | 12 | from ..helpers import T_2021_5_26__3_55, T_2021_5_26__3_55_UTC, job_args, job_args_utc 13 | 14 | async_job_args = copy.deepcopy(job_args) 15 | for ele in async_job_args: 16 | ele.pop("weight") 17 | 18 | async_job_args_utc = copy.deepcopy(job_args_utc) 19 | for ele in async_job_args_utc: 20 | ele.pop("weight") 21 | 22 | 23 | async def foo() -> None: 24 | await asyncio.sleep(0.01) 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_async_job() -> None: 29 | job = Job( 30 | JobType.CYCLIC, 31 | [dt.timedelta(seconds=0.01)], 32 | foo, 33 | max_attempts=2, 34 | ) 35 | assert job.attempts == 0 36 | 37 | await job._exec(LOGGER) 38 | assert job.attempts == 1 39 | 40 | await job._exec(LOGGER) 41 | assert job.attempts == 2 42 | 43 | assert job.has_attempts_remaining == False 44 | 45 | 46 | job_reprs = ( 47 | [ 48 | "scheduler.asyncio.job.Job(, [datetime.timedelta(seconds=3600)], , (), {}, 1, True, datetime.datetime(2021, 5, 26, 3, 55), None, True, None, None)", 50 | ], 51 | [ 52 | "scheduler.asyncio.job.Job(, [datetime.time(0, 0, 20)], , (), {'msg': 'foobar'}, 20, False, datetime.datetime(2021, 5, 26, 3, 54, 15)," 55 | " datetime.datetime(2021, 5, 26, 4, 5), False, None, None)" 56 | ), 57 | ], 58 | [ 59 | "scheduler.asyncio.job.Job(, [datetime.time(7, 5)], , (), {}, 7, True, datetime.datetime(2021, 5, 26, 3, 55), None, True, None, None)", 61 | ], 62 | ) 63 | 64 | job_reprs_utc = ( 65 | [ 66 | "scheduler.asyncio.job.Job(, [datetime.timedelta(seconds=3600)], , (), {}, 0, False, datetime.datetime(2021, 5, 26, 3, 54, 59, 999990" 69 | ", tzinfo=datetime.timezone.utc), None, True, None, datetime.timezone.utc)" 70 | ), 71 | ], 72 | [ 73 | ( 74 | "scheduler.asyncio.job.Job(, [datetime.time(0, 5, tzinfo=datetime.timezone.utc)]," 75 | " , (), {}, 0, False, datetime.datetime(2021, 5, 26, 3, 55," 76 | " tzinfo=datetime.timezone.utc), datetime.datetime(2021, 5, 26, 23, 55, " 77 | "tzinfo=datetime.timezone.utc), False, None, datetime.timezone.utc)" 78 | ) 79 | ], 80 | [ 81 | ( 82 | "scheduler.asyncio.job.Job(, [Monday(time=datetime.time(0, 0, " 83 | "tzinfo=datetime.timezone.utc))], , (), {}, 0, False, datetime.datetime(2021, 5, 25, 3, 55, " 87 | "tzinfo=datetime.timezone.utc), None, True, None, datetime.timezone.utc)" 88 | ), 89 | ], 90 | [ 91 | ( 92 | "scheduler.asyncio.job.Job(, [Wednesday(time=datetime.time(0, 0, " 93 | "tzinfo=datetime.timezone.utc)), Tuesday(time=datetime.time(23, 45, 59, " 94 | "tzinfo=datetime.timezone.utc))], " 95 | ", (), {'end': 'FOO\\n'}, 1, True, " 96 | "datetime.datetime(2021, 6, 2, 3, 55, tzinfo=datetime.timezone.utc)," 97 | " datetime.datetime(2021, 7, 25, 3, 55, tzinfo=datetime.timezone.utc)," 98 | " False, None, datetime.timezone.utc)" 99 | ) 100 | ], 101 | ) 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "kwargs, result", 106 | [(args, reprs) for args, reprs in zip(async_job_args, job_reprs)] 107 | + [(args, reprs) for args, reprs in zip(async_job_args_utc, job_reprs_utc)], 108 | ) 109 | def test_async_job_repr( 110 | kwargs: dict[str, Any], 111 | result: list[str], 112 | ) -> None: 113 | rep = repr(Job(**kwargs)) 114 | for substring in result: 115 | assert substring in rep 116 | rep = rep.replace(substring, "", 1) 117 | 118 | # result is broken into substring at every address. Address string is 12 long 119 | assert len(rep) == (len(result) - 1) * 12 120 | 121 | 122 | @pytest.mark.parametrize( 123 | "patch_datetime_now, job_kwargs, results", 124 | [ 125 | ( 126 | [T_2021_5_26__3_55] * 3, 127 | async_job_args, 128 | [ 129 | "ONCE, foo(), at=2021-05-26 04:55:00, tz=None, in=1:00:00, #0/1", 130 | "MINUTELY, bar(..), at=2021-05-26 03:54:15, tz=None, in=-0:00:45, #0/20", 131 | "DAILY, foo(), at=2021-05-26 07:05:00, tz=None, in=3:10:00, #0/7", 132 | ], 133 | ), 134 | ( 135 | [T_2021_5_26__3_55_UTC] * 4, 136 | async_job_args_utc, 137 | [ 138 | "CYCLIC, foo(), at=2021-05-26 03:54:59, tz=UTC, in=-0:00:00, #0/inf", 139 | "HOURLY, print(?), at=2021-05-26 03:55:00, tz=UTC, in=0:00:00, #0/inf", 140 | "WEEKLY, bar(..), at=2021-05-25 03:55:00, tz=UTC, in=-1 day, #0/inf", 141 | "ONCE, print(?), at=2021-06-08 23:45:59, tz=UTC, in=13 days, #0/1", 142 | ], 143 | ), 144 | ], 145 | indirect=["patch_datetime_now"], 146 | ) 147 | def test_job_str( 148 | patch_datetime_now: Any, 149 | job_kwargs: tuple[dict[str, Any], ...], 150 | results: list[str], 151 | ) -> None: 152 | for kwargs, result in zip(job_kwargs, results): 153 | assert result == str(Job(**kwargs)) 154 | -------------------------------------------------------------------------------- /tests/asyncio/test_async_scheduler_cyclic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for scheduler.asyncio.scheduler considering asyncio.sleep behaviour 3 | 4 | Heavy use of monkey-patching with the following behaviour: 5 | 6 | * `dt.datetime.now()` increments the returned datetime by one second with each call 7 | * `dt.datetime.last_now()` gives the most recent value returned by `dt.datetime.now()` 8 | * `asyncio.sleep(delay)` will never sleep real time, but simply block until the next 9 | event loop is executed, effectively behaving as `asyncio.sleep(0)` 10 | 11 | Author: Jendrik A. Potyka, Fabian A. Preiss 12 | """ 13 | 14 | import asyncio 15 | import datetime as dt 16 | import logging 17 | from asyncio.selector_events import BaseSelectorEventLoop 18 | from typing import Any, NoReturn 19 | 20 | import pytest 21 | 22 | from scheduler.asyncio.scheduler import Scheduler 23 | 24 | from ..helpers import T_2021_5_26__3_55, fail 25 | 26 | samples_secondly = [T_2021_5_26__3_55 + dt.timedelta(seconds=x) for x in range(12)] 27 | async_real_sleep = asyncio.sleep 28 | 29 | 30 | async def fake_sleep(delay: float, result: None = None) -> None: 31 | """Fake asyncio.sleep, depends on monkeypatch `patch_datetime_now`""" 32 | t_start = dt.datetime.last_now() 33 | while True: 34 | await asyncio.tasks.__sleep0() 35 | if (dt.datetime.last_now() - t_start).total_seconds() >= delay: 36 | break 37 | else: 38 | _ = dt.datetime.now() 39 | return result 40 | 41 | 42 | async def bar() -> None: 43 | ... 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "patch_datetime_now", 48 | [samples_secondly], 49 | indirect=["patch_datetime_now"], 50 | ) 51 | def test_async_scheduler_cyclic1s( 52 | monkeypatch: pytest.MonkeyPatch, patch_datetime_now: Any, event_loop: BaseSelectorEventLoop 53 | ) -> None: 54 | async def main() -> None: 55 | with monkeypatch.context() as m: 56 | m.setattr(asyncio, "sleep", fake_sleep) 57 | schedule = Scheduler() 58 | 59 | # schedule.__schedule calls: now, async: [now, async_sleep] 60 | cyclic_job = schedule.cyclic(dt.timedelta(seconds=1), bar) 61 | assert dt.datetime.last_now() == samples_secondly[0] 62 | assert cyclic_job.datetime == samples_secondly[1] 63 | assert cyclic_job.attempts == 0 64 | 65 | await asyncio.sleep(0) 66 | assert dt.datetime.last_now() == samples_secondly[1] 67 | assert cyclic_job.attempts == 0 68 | 69 | await asyncio.sleep(0) 70 | assert dt.datetime.last_now() == samples_secondly[2] 71 | assert cyclic_job.attempts == 1 72 | 73 | await asyncio.sleep(0) 74 | assert dt.datetime.last_now() == samples_secondly[3] 75 | assert cyclic_job.attempts == 2 76 | 77 | await asyncio.sleep(0) 78 | assert dt.datetime.last_now() == samples_secondly[4] 79 | assert cyclic_job.attempts == 3 80 | 81 | schedule.delete_jobs() 82 | await asyncio.sleep(0) 83 | assert dt.datetime.last_now() == samples_secondly[4] 84 | assert cyclic_job.attempts == 3 85 | 86 | event_loop.run_until_complete(main()) 87 | 88 | 89 | # NOTE: In the following test `sch.delete_jobs()` is run to suppress 90 | # the asyncio Warning "Task was destroyed but it is pending!" during testing 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "patch_datetime_now", 95 | [samples_secondly], 96 | indirect=["patch_datetime_now"], 97 | ) 98 | def test_async_scheduler_cyclic2s( 99 | monkeypatch: pytest.MonkeyPatch, patch_datetime_now: Any, event_loop: BaseSelectorEventLoop 100 | ) -> None: 101 | async def main() -> None: 102 | with monkeypatch.context() as m: 103 | m.setattr(asyncio, "sleep", fake_sleep) 104 | schedule = Scheduler() 105 | 106 | # schedule.__schedule calls: now, async: [now, async_sleep] 107 | cyclic_job = schedule.cyclic(dt.timedelta(seconds=2), bar, max_attempts=3) 108 | assert dt.datetime.last_now() == samples_secondly[0] 109 | assert cyclic_job.datetime == samples_secondly[2] 110 | assert cyclic_job.attempts == 0 111 | 112 | await asyncio.sleep(0) 113 | assert dt.datetime.last_now() == samples_secondly[1] 114 | assert cyclic_job.attempts == 0 115 | 116 | await asyncio.sleep(0) 117 | assert dt.datetime.last_now() == samples_secondly[2] 118 | assert cyclic_job.attempts == 0 119 | 120 | await asyncio.sleep(0) 121 | assert dt.datetime.last_now() == samples_secondly[3] 122 | assert cyclic_job.attempts == 1 123 | 124 | await asyncio.sleep(0) 125 | assert dt.datetime.last_now() == samples_secondly[4] 126 | assert cyclic_job.attempts == 1 127 | 128 | await asyncio.sleep(0) 129 | assert dt.datetime.last_now() == samples_secondly[5] 130 | assert cyclic_job.attempts == 2 131 | schedule.delete_jobs() 132 | 133 | event_loop.run_until_complete(main()) 134 | 135 | 136 | async def async_fail() -> NoReturn: 137 | fail() 138 | 139 | 140 | @pytest.mark.parametrize( 141 | "patch_datetime_now", 142 | [samples_secondly], 143 | indirect=["patch_datetime_now"], 144 | ) 145 | def test_asyncio_fail( 146 | monkeypatch: pytest.MonkeyPatch, 147 | patch_datetime_now: Any, 148 | event_loop: BaseSelectorEventLoop, 149 | caplog: pytest.LogCaptureFixture, 150 | ) -> None: 151 | caplog.set_level(logging.DEBUG, logger="scheduler") 152 | 153 | async def main() -> None: 154 | with monkeypatch.context() as m: 155 | m.setattr(asyncio, "sleep", fake_sleep) 156 | schedule = Scheduler() 157 | cyclic_job = schedule.cyclic(dt.timedelta(seconds=1), async_fail) 158 | assert dt.datetime.last_now() == samples_secondly[0] 159 | assert cyclic_job.datetime == samples_secondly[1] 160 | assert cyclic_job.attempts == 0 161 | 162 | RECORD = ( 163 | "scheduler", 164 | logging.ERROR, 165 | "Unhandled exception in `%r`!" % (cyclic_job,), 166 | ) 167 | 168 | await asyncio.sleep(0) 169 | assert dt.datetime.last_now() == samples_secondly[1] 170 | assert cyclic_job.attempts == 0 171 | 172 | await asyncio.sleep(0) 173 | assert dt.datetime.last_now() == samples_secondly[2] 174 | assert cyclic_job.attempts == 1 175 | assert cyclic_job.failed_attempts == 1 176 | assert caplog.record_tuples == [RECORD] 177 | 178 | await asyncio.sleep(0) 179 | assert cyclic_job.attempts == 2 180 | assert cyclic_job.failed_attempts == 2 181 | assert caplog.record_tuples == [RECORD, RECORD] 182 | 183 | event_loop.run_until_complete(main()) 184 | -------------------------------------------------------------------------------- /tests/asyncio/test_async_scheduler_repr.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime as dt 3 | from typing import Any, Optional 4 | 5 | import pytest 6 | 7 | from scheduler.asyncio.job import Job 8 | from scheduler.asyncio.scheduler import Scheduler 9 | 10 | from ..helpers import ( 11 | T_2021_5_26__3_55, 12 | T_2021_5_26__3_55_UTC, 13 | job_args, 14 | job_args_utc, 15 | utc, 16 | ) 17 | 18 | patch_samples = [T_2021_5_26__3_55] * 7 19 | patch_samples_utc = [T_2021_5_26__3_55_UTC] * 11 20 | 21 | async_job_args = copy.deepcopy(job_args) 22 | for ele in async_job_args: 23 | ele.pop("weight") 24 | 25 | async_job_args_utc = copy.deepcopy(job_args_utc) 26 | for ele in async_job_args_utc: 27 | ele.pop("weight") 28 | 29 | async_sch_repr = ( 30 | "scheduler.asyncio.scheduler.Scheduler(None, jobs={", 31 | "})", 32 | ) 33 | async_sch_repr_utc = ( 34 | "scheduler.asyncio.scheduler.Scheduler(datetime.timezone.utc, jobs={", 35 | "})", 36 | ) 37 | 38 | async_job_reprs = ( 39 | [ 40 | "scheduler.asyncio.job.Job(, [datetime.timedelta(seconds=3600)], , (), {}, 1, True, datetime.datetime(2021, 5, 26, 3, 55), None, True, None, None)", 42 | ], 43 | [ 44 | "scheduler.asyncio.job.Job(, [datetime.time(0, 0, 20)], , (), {'msg': 'foobar'}, 20, False, datetime.datetime(2021, 5, 26, 3, 54, 15)," 47 | " datetime.datetime(2021, 5, 26, 4, 5), False, None, None)" 48 | ), 49 | ], 50 | [ 51 | "scheduler.asyncio.job.Job(, [datetime.time(7, 5)], , (), {}, 7, True, datetime.datetime(2021, 5, 26, 3, 55), None, True, None, None)", 53 | ], 54 | ) 55 | 56 | async_job_reprs_utc = ( 57 | [ 58 | "scheduler.asyncio.job.Job(, [datetime.timedelta(seconds=3600)], , (), {}, 0, False, datetime.datetime(2021, 5, 26, 3, 54, 59, 999990" 61 | ", tzinfo=datetime.timezone.utc), None, True, None, datetime.timezone.utc)" 62 | ), 63 | ], 64 | [ 65 | ( 66 | "scheduler.asyncio.job.Job(, [datetime.time(0, 5, tzinfo=datetime.timezone.utc)]," 67 | " , (), {}, 0, False, datetime.datetime(2021, 5, 26, 3, 55," 68 | " tzinfo=datetime.timezone.utc), datetime.datetime(2021, 5, 26, 23, 55, " 69 | "tzinfo=datetime.timezone.utc), False, None, datetime.timezone.utc)" 70 | ) 71 | ], 72 | [ 73 | ( 74 | "scheduler.asyncio.job.Job(, [Monday(time=datetime.time(0, 0, " 75 | "tzinfo=datetime.timezone.utc))], , (), {}, 0, False, datetime.datetime(2021, 5, 25, 3, 55, " 79 | "tzinfo=datetime.timezone.utc), None, True, None, datetime.timezone.utc)" 80 | ), 81 | ], 82 | [ 83 | ( 84 | "scheduler.asyncio.job.Job(, [Wednesday(time=datetime.time(0, 0, " 85 | "tzinfo=datetime.timezone.utc)), Tuesday(time=datetime.time(23, 45, 59, " 86 | "tzinfo=datetime.timezone.utc))], " 87 | ", (), {'end': 'FOO\\n'}, 1, True, " 88 | "datetime.datetime(2021, 6, 2, 3, 55, tzinfo=datetime.timezone.utc)," 89 | " datetime.datetime(2021, 7, 25, 3, 55, tzinfo=datetime.timezone.utc)," 90 | " False, None, datetime.timezone.utc)" 91 | ) 92 | ], 93 | ) 94 | 95 | 96 | @pytest.mark.asyncio 97 | @pytest.mark.parametrize( 98 | "patch_datetime_now, job_kwargs, tzinfo, j_results, s_results", 99 | [ 100 | (patch_samples, async_job_args, None, async_job_reprs, async_sch_repr), 101 | (patch_samples_utc, async_job_args_utc, utc, async_job_reprs_utc, async_sch_repr_utc), 102 | ], 103 | indirect=["patch_datetime_now"], 104 | ) 105 | async def test_async_scheduler_repr( 106 | patch_datetime_now: Any, 107 | job_kwargs: tuple[dict[str, Any], ...], 108 | tzinfo: Optional[dt.tzinfo], 109 | j_results: str, 110 | s_results: str, 111 | ) -> None: 112 | jobs = [Job(**kwargs) for kwargs in job_kwargs] 113 | 114 | sch = Scheduler(tzinfo=tzinfo) 115 | for job in jobs: 116 | sch._jobs[job] = None 117 | rep = repr(sch) 118 | n_j_addr = 0 # number of address strings in jobs 119 | for j_result in j_results: 120 | n_j_addr += len(j_result) - 1 121 | for substring in j_result: 122 | assert substring in rep 123 | rep = rep.replace(substring, "", 1) 124 | for substring in s_results: 125 | assert substring in rep 126 | rep = rep.replace(substring, "", 1) 127 | 128 | # ", " separators between jobs: (len(j_results) - 1) * 2 129 | assert len(rep) == n_j_addr * 12 + (len(j_results) - 1) * 2 130 | -------------------------------------------------------------------------------- /tests/asyncio/test_async_scheduler_str.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime as dt 3 | from typing import Any, Optional 4 | 5 | import pytest 6 | 7 | from scheduler.asyncio.job import Job 8 | from scheduler.asyncio.scheduler import Scheduler 9 | 10 | from ..helpers import ( 11 | T_2021_5_26__3_55, 12 | T_2021_5_26__3_55_UTC, 13 | job_args, 14 | job_args_utc, 15 | utc, 16 | ) 17 | 18 | patch_samples = [T_2021_5_26__3_55] * 4 19 | patch_samples_utc = [T_2021_5_26__3_55_UTC] * 5 20 | 21 | async_job_args = copy.deepcopy(job_args) 22 | for ele in async_job_args: 23 | ele.pop("weight") 24 | ele["alias"] = "test" 25 | 26 | async_job_args_utc = copy.deepcopy(job_args_utc) 27 | for ele in async_job_args_utc: 28 | ele.pop("weight") 29 | 30 | table = ( 31 | "tzinfo=None, #jobs=3\n" 32 | "\n" 33 | "type function / alias due at due in attempts\n" 34 | "-------- ---------------- ------------------- --------- -------------\n" 35 | "MINUTELY test 2021-05-26 03:54:15 -0:00:45 0/20\n" 36 | "ONCE test 2021-05-26 04:55:00 1:00:00 0/1\n" 37 | "DAILY test 2021-05-26 07:05:00 3:10:00 0/7\n" 38 | ) 39 | 40 | table_utc = ( 41 | "tzinfo=UTC, #jobs=4\n" 42 | "\n" 43 | "type function / alias due at tzinfo due in attempts\n" 44 | "-------- ---------------- ------------------- ------------ --------- -------------\n" 45 | "WEEKLY bar(..) 2021-05-25 03:55:00 UTC -1 day 0/inf\n" 46 | "CYCLIC foo() 2021-05-26 03:54:59 UTC -0:00:00 0/inf\n" 47 | "HOURLY print(?) 2021-05-26 03:55:00 UTC 0:00:00 0/inf\n" 48 | "ONCE print(?) 2021-06-08 23:45:59 UTC 13 days 0/1\n" 49 | ) 50 | 51 | 52 | @pytest.mark.asyncio 53 | @pytest.mark.parametrize( 54 | "patch_datetime_now, job_kwargs, tzinfo, res", 55 | [ 56 | (patch_samples, async_job_args, None, table), 57 | (patch_samples_utc, async_job_args_utc, utc, table_utc), 58 | ], 59 | indirect=["patch_datetime_now"], 60 | ) 61 | async def test_async_scheduler_str( 62 | patch_datetime_now: Any, 63 | job_kwargs: tuple[dict[str, Any], ...], 64 | tzinfo: Optional[dt.tzinfo], 65 | res: str, 66 | ) -> None: 67 | jobs = [Job(**kwargs) for kwargs in job_kwargs] 68 | 69 | sch = Scheduler(tzinfo=tzinfo) 70 | for job in jobs: 71 | sch._jobs[job] = None 72 | assert str(sch) == res 73 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from collections.abc import Iterator 3 | from typing import Optional 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def one() -> int: 10 | return 1 11 | 12 | 13 | @pytest.fixture 14 | def two(one: int) -> tuple[int, int]: 15 | return one, 2 16 | 17 | 18 | @pytest.fixture 19 | def patch_datetime_now(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: 20 | class DatetimePatch(dt.datetime): 21 | _it: Iterator["DatetimePatch"] = iter(request.param) 22 | cached_time: Optional["DatetimePatch"] = None 23 | 24 | @classmethod 25 | def now(cls, tz: Optional[dt.tzinfo] = None) -> "DatetimePatch": 26 | time = next(cls._it) 27 | cls.cached_time = time 28 | return time 29 | 30 | @classmethod 31 | def last_now(cls) -> Optional["DatetimePatch"]: 32 | return cls.cached_time 33 | 34 | def __repr__(self) -> str: 35 | s = super().__repr__() 36 | return s.replace("DatetimePatch", "datetime.datetime") 37 | 38 | monkeypatch.setattr(dt, "datetime", DatetimePatch) 39 | -------------------------------------------------------------------------------- /tests/test_jobtimer.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import SchedulerError 8 | from scheduler.base.definition import JobType 9 | from scheduler.base.job_timer import JobTimer 10 | from scheduler.base.job_util import sane_timing_types 11 | from scheduler.base.timingtype import TimingJobTimerUnion, TimingJobUnion 12 | 13 | from .helpers import CYCLIC_TYPE_ERROR_MSG, T_2021_5_26__3_55, utc 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "job_type, timing, start, target, next_target", 18 | ( 19 | [ 20 | JobType.WEEKLY, 21 | trigger.Thursday(), 22 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39, tzinfo=utc), 23 | dt.datetime(year=2021, month=5, day=27, tzinfo=utc), 24 | dt.datetime(year=2021, month=6, day=3, tzinfo=utc), 25 | ], 26 | [ 27 | JobType.WEEKLY, 28 | trigger.Friday(dt.time(hour=1, minute=1, tzinfo=utc)), 29 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39, tzinfo=utc), 30 | dt.datetime(year=2021, month=5, day=28, hour=1, minute=1, tzinfo=utc), 31 | dt.datetime(year=2021, month=6, day=4, hour=1, minute=1, tzinfo=utc), 32 | ], 33 | [ 34 | JobType.DAILY, 35 | dt.time(hour=12, minute=1, tzinfo=utc), 36 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39, tzinfo=utc), 37 | dt.datetime(year=2021, month=5, day=26, hour=12, minute=1, tzinfo=utc), 38 | dt.datetime(year=2021, month=5, day=27, hour=12, minute=1, tzinfo=utc), 39 | ], 40 | [ 41 | JobType.HOURLY, 42 | dt.time(minute=1, tzinfo=utc), 43 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39, tzinfo=utc), 44 | dt.datetime(year=2021, month=5, day=26, hour=12, minute=1, tzinfo=utc), 45 | dt.datetime(year=2021, month=5, day=26, hour=13, minute=1, tzinfo=utc), 46 | ], 47 | [ 48 | JobType.MINUTELY, 49 | dt.time(second=1, tzinfo=utc), 50 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39, tzinfo=utc), 51 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39, second=1, tzinfo=utc), 52 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=40, second=1, tzinfo=utc), 53 | ], 54 | [ 55 | JobType.CYCLIC, 56 | dt.timedelta(hours=3, seconds=27), 57 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39), 58 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39) 59 | + dt.timedelta(hours=3, seconds=27), 60 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39) 61 | + 2 * dt.timedelta(hours=3, seconds=27), 62 | ], 63 | ), 64 | ) 65 | def test_JobTimer_calc_next_exec( 66 | job_type: JobType, 67 | timing: TimingJobTimerUnion, 68 | start: dt.datetime, 69 | target: dt.datetime, 70 | next_target: dt.datetime, 71 | ) -> None: 72 | timer = JobTimer(job_type, timing, start) 73 | 74 | assert timer.datetime == target 75 | assert timer.timedelta(start) == target - start 76 | 77 | timer.calc_next_exec() 78 | assert timer.datetime == next_target 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "delta_m, offset_m, skip, res_delta_m", 83 | ( 84 | [20, 21, True, 41], 85 | [1, 21, True, 22], 86 | [1, 1, True, 2], 87 | [20, 20, True, 40], 88 | [20, 1, True, 21], 89 | [20, 21, False, 40], 90 | [1, 21, False, 2], 91 | [1, 1, False, 2], 92 | [20, 20, False, 40], 93 | [20, 1, False, 40], 94 | ), 95 | ) 96 | def test_skip(delta_m: int, offset_m: int, skip: bool, res_delta_m: int) -> None: 97 | delta = dt.timedelta(minutes=delta_m) 98 | offset = dt.timedelta(minutes=offset_m) 99 | res_delta = dt.timedelta(minutes=res_delta_m) 100 | 101 | jet = JobTimer( 102 | JobType.CYCLIC, 103 | timing=delta, 104 | start=T_2021_5_26__3_55, 105 | skip_missing=skip, 106 | ) 107 | assert jet.datetime == T_2021_5_26__3_55 + delta 108 | 109 | jet.calc_next_exec(T_2021_5_26__3_55 + offset) 110 | assert jet.datetime == T_2021_5_26__3_55 + res_delta 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "job_type, timing, err", 115 | ( 116 | [JobType.CYCLIC, [dt.timedelta()], None], 117 | [JobType.CYCLIC, [dt.timedelta(), dt.timedelta()], CYCLIC_TYPE_ERROR_MSG], 118 | [JobType.WEEKLY, [trigger.Monday()], None], 119 | [JobType.DAILY, [dt.time()], None], 120 | [JobType.DAILY, [dt.time(), dt.time()], None], 121 | [JobType.HOURLY, [dt.time()], None], 122 | [JobType.HOURLY, [dt.time(), dt.time()], None], 123 | [JobType.MINUTELY, [dt.time()], None], 124 | [JobType.MINUTELY, [dt.time(), dt.time()], None], 125 | # [JobType.CYCLIC, (dt.timedelta(), dt.timedelta()), CYCLIC_TYPE_ERROR_MSG], 126 | # [JobType.WEEKLY, dt.time(), WEEKLY_TYPE_ERROR_MSG], 127 | # [JobType.WEEKLY, [trigger.Monday(), dt.time()], WEEKLY_TYPE_ERROR_MSG], 128 | # [JobType.DAILY, (dt.time(), dt.time()), DAILY_TYPE_ERROR_MSG], 129 | # [JobType.HOURLY, (dt.time(), dt.time()), HOURLY_TYPE_ERROR_MSG], 130 | # [JobType.MINUTELY, (dt.time(), dt.time()), MINUTELY_TYPE_ERROR_MSG], 131 | ), 132 | ) 133 | def test_sane_timing_types(job_type: JobType, timing: TimingJobUnion, err: Optional[str]) -> None: 134 | if err: 135 | with pytest.raises(SchedulerError, match=err): 136 | sane_timing_types(job_type, timing) 137 | else: 138 | sane_timing_types(job_type, timing) 139 | -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | from scheduler.trigger import ( 2 | Friday, 3 | Monday, 4 | Saturday, 5 | Sunday, 6 | Thursday, 7 | Tuesday, 8 | Wednesday, 9 | weekday, 10 | ) 11 | 12 | from .helpers import samples, samples_utc 13 | 14 | 15 | def test_trigger_misc() -> None: 16 | for sample in samples + samples_utc: 17 | for day, wkday in zip( 18 | range(7), (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday) 19 | ): 20 | 21 | res = weekday(value=day, time=sample.time()) 22 | assert isinstance(res, wkday) 23 | assert res.value == day 24 | assert res.time == sample.time() 25 | -------------------------------------------------------------------------------- /tests/test_prioritization.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Callable 3 | 4 | import pytest 5 | 6 | import scheduler 7 | from scheduler.prioritization import ( 8 | constant_weight_prioritization, 9 | linear_priority_function, 10 | random_priority_function, 11 | ) 12 | from scheduler.threading.job import Job 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "timedelta, executions", 17 | [ 18 | [dt.timedelta(seconds=0), 1], 19 | [dt.timedelta(seconds=100), 0], 20 | ], 21 | ) 22 | @pytest.mark.parametrize( 23 | "priority_function", 24 | [ 25 | constant_weight_prioritization, 26 | linear_priority_function, 27 | ], 28 | ) 29 | def test_deprecated_prioritization( 30 | timedelta: dt.timedelta, 31 | executions: int, 32 | priority_function: Callable[ 33 | [float, Job, int, int], 34 | float, 35 | ], 36 | ) -> None: 37 | schedule = scheduler.Scheduler(max_exec=3, priority_function=priority_function) 38 | schedule.once( 39 | dt.datetime.now() + timedelta, 40 | print, 41 | ) 42 | assert schedule.exec_jobs() == executions 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "timedelta, weight, executions", 47 | [ 48 | [dt.timedelta(seconds=0), 1, 1], 49 | [dt.timedelta(seconds=0), 0, 0], 50 | [dt.timedelta(seconds=100), 1, 1], 51 | ], 52 | ) 53 | def test_deprecated_rnd_prioritization( 54 | timedelta: dt.timedelta, weight: int, executions: int 55 | ) -> None: 56 | schedule = scheduler.Scheduler(max_exec=3, priority_function=random_priority_function) 57 | schedule.once(dt.datetime.now() + timedelta, print, weight=weight) 58 | assert schedule.exec_jobs() == executions 59 | -------------------------------------------------------------------------------- /tests/test_readme.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import doctest 3 | import sys 4 | from typing import Any 5 | 6 | import pytest 7 | 8 | from .helpers import T_2021_5_26__3_55 9 | 10 | 11 | # NOTE: We cannot test for the full table, as some Jobs depend on the time of execution 12 | # e.g. a Job supposed to run on Weekday.MONDAY. The ordering between the Jobs scheduled 13 | # at 0:09:59 can be guaranteed though, as they differ on the milliseconds level. 14 | # NOTE: Currently when updating the example in the README.md file, the changes should be applied 15 | # manually in this file as well. 16 | # NOTE: The same example and doctest can be found in `doc/examples/general_job_scheduling.rst`, 17 | # however here the test is more granular, wheras in `doc/examples` the focus is more on 18 | # readability and additional comments. 19 | @pytest.mark.parametrize( 20 | "patch_datetime_now", 21 | [[T_2021_5_26__3_55 + dt.timedelta(microseconds=x) for x in range(17)]], 22 | indirect=["patch_datetime_now"], 23 | ) 24 | def test_general_readme(patch_datetime_now: Any) -> None: 25 | r""" 26 | >>> import datetime as dt 27 | >>> from scheduler import Scheduler 28 | >>> from scheduler.trigger import Monday, Tuesday 29 | 30 | >>> def foo(): 31 | ... print("foo") 32 | 33 | >>> schedule = Scheduler() 34 | 35 | >>> schedule.cyclic(dt.timedelta(minutes=10), foo) # doctest:+ELLIPSIS 36 | scheduler.Job(, [datetime.timedelta(seconds=600)], , (), {}, 0, 1, True, datetime.datetime(...), None, False, None, None) 37 | 38 | >>> schedule.minutely(dt.time(second=15), foo) # doctest:+ELLIPSIS 39 | scheduler.Job(, [datetime.time(0, 0, 15)], , (), {}, 0, 1, True, datetime.datetime(...), None, False, None, None) 40 | 41 | >>> schedule.hourly(dt.time(minute=30, second=15), foo) # doctest:+ELLIPSIS 42 | scheduler.Job(, [datetime.time(0, 30, 15)], , (), {}, 0, 1, True, datetime.datetime(...), None, False, None, None) 43 | 44 | >>> schedule.daily(dt.time(hour=16, minute=30), foo) # doctest:+ELLIPSIS 45 | scheduler.Job(, [datetime.time(16, 30)], , (), {}, 0, 1, True, datetime.datetime(...), None, False, None, None) 46 | 47 | >>> schedule.weekly(Monday(), foo) # doctest:+ELLIPSIS 48 | scheduler.Job(, [Monday(time=datetime.time(0, 0))], , (), {}, 0, 1, True, datetime.datetime(...), None, False, None, None) 49 | 50 | >>> schedule.weekly(Monday(dt.time(hour=16, minute=30)), foo) # doctest:+ELLIPSIS 51 | scheduler.Job(, [Monday(time=datetime.time(16, 30))], , (), {}, 0, 1, True, datetime.datetime(...), None, False, None, None) 52 | 53 | >>> schedule.once(dt.timedelta(minutes=10), foo) # doctest:+ELLIPSIS 54 | scheduler.Job(, [datetime.timedelta(seconds=600)], , (), {}, 1, 1, True, datetime.datetime(...), None, False, None, None) 55 | 56 | >>> schedule.once(Tuesday(), foo) # doctest:+ELLIPSIS 57 | scheduler.Job(, [Tuesday(time=datetime.time(0, 0))], , (), {}, 1, 1, True, datetime.datetime(...), None, False, None, None) 58 | 59 | >>> schedule.once(dt.datetime(year=2022, month=2, day=15, minute=45), foo) # doctest:+ELLIPSIS 60 | scheduler.Job(, [datetime.timedelta(0)], , (), {}, 1, 1, False, datetime.datetime(2022, 2, 15, 0, 45), None, False, None, None) 61 | 62 | 63 | >>> print(schedule) # doctest:+ELLIPSIS 64 | max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=9 65 | 66 | type function / alias due at due in attempts weight 67 | -------- ---------------- ------------------- --------- ------------- ------ 68 | MINUTELY foo() 2021-05-26 03:55:15 0:00:14 0/inf 1 69 | CYCLIC foo() 2021-05-26 04:05:00 0:09:59 0/inf 1 70 | ONCE foo() 2021-05-26 04:05:00 0:09:59 0/1 1 71 | HOURLY foo() 2021-05-26 04:30:15 0:35:14 0/inf 1 72 | DAILY foo() 2021-05-26 16:30:00 12:34:59 0/inf 1 73 | WEEKLY foo() 2021-05-31 00:00:00 4 days 0/inf 1 74 | WEEKLY foo() 2021-05-31 16:30:00 5 days 0/inf 1 75 | ONCE foo() 2021-06-01 00:00:00 5 days 0/1 1 76 | ONCE foo() 2022-02-15 00:45:00 264 days 0/1 1 77 | 78 | 79 | 80 | >>> import time 81 | >>> while True: # doctest:+SKIP 82 | ... schedule.exec_jobs() 83 | ... time.sleep(1) 84 | """ 85 | DP = doctest.DocTestParser() 86 | assert test_general_readme.__doc__ 87 | dt_readme = DP.get_doctest(test_general_readme.__doc__, globals(), "README", None, None) 88 | DTR = doctest.DocTestRunner() 89 | if sys.version_info < (3, 13): 90 | assert doctest.TestResults(failed=0, attempted=16) == DTR.run(dt_readme) 91 | else: 92 | assert doctest.TestResults(failed=0, attempted=17, skipped=1) == DTR.run(dt_readme) 93 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Optional, Union 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler.base.scheduler_util import str_cutoff 8 | from scheduler.error import SchedulerError 9 | from scheduler.trigger.core import Weekday, _Weekday 10 | from scheduler.util import ( 11 | days_to_weekday, 12 | next_daily_occurrence, 13 | next_hourly_occurrence, 14 | next_minutely_occurrence, 15 | next_weekday_time_occurrence, 16 | ) 17 | 18 | err_msg = r"Weekday enumeration interval: \[0,6\] <=> \[Monday, Sunday\]" 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "wkdy_src, wkdy_dest, days, err_msg", 23 | ( 24 | [trigger.Monday(), trigger.Thursday(), 3, None], 25 | [trigger.Wednesday(), trigger.Sunday(), 4, None], 26 | [trigger.Friday(), trigger.Friday(), 7, None], 27 | [trigger.Saturday(), trigger.Thursday(), 5, None], 28 | [trigger.Sunday(), trigger.Saturday(), 6, None], 29 | [3, 8, 0, err_msg], 30 | [4, -1, 0, err_msg], 31 | [8, 4, 0, err_msg], 32 | [-1, 2, 0, err_msg], 33 | ), 34 | ) 35 | def test_days_to_weekday( 36 | wkdy_src: Union[int, _Weekday], wkdy_dest: int, days: int, err_msg: Optional[str] 37 | ) -> None: 38 | if err_msg: 39 | assert isinstance(wkdy_src, int) 40 | with pytest.raises(SchedulerError, match=err_msg): 41 | days_to_weekday(wkdy_src, wkdy_dest) 42 | else: 43 | assert isinstance(wkdy_src, Weekday) 44 | assert isinstance(wkdy_dest, Weekday) 45 | 46 | assert days_to_weekday(wkdy_src.value, wkdy_dest.value) == days 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "now, wkdy, timestamp, target", 51 | ( 52 | [ 53 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39), 54 | trigger.Friday(), 55 | dt.time(hour=0, minute=0), 56 | dt.datetime(year=2021, month=5, day=28), 57 | ], 58 | [ 59 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39), 60 | trigger.Wednesday(), 61 | dt.time(hour=12, minute=3, second=1), 62 | dt.datetime(year=2021, month=5, day=26, hour=12, minute=3, second=1), 63 | ], 64 | [ 65 | dt.datetime(year=2021, month=5, day=26, hour=11, minute=39, tzinfo=dt.timezone.utc), 66 | trigger.Thursday(), 67 | dt.time(hour=12, minute=3, second=1, tzinfo=dt.timezone.utc), 68 | dt.datetime( 69 | year=2021, 70 | month=5, 71 | day=27, 72 | hour=12, 73 | minute=3, 74 | second=1, 75 | tzinfo=dt.timezone.utc, 76 | ), 77 | ], 78 | [ 79 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 80 | trigger.Wednesday(), 81 | dt.time(hour=2), 82 | dt.datetime(year=2021, month=6, day=16, hour=2), 83 | ], 84 | ), 85 | ) 86 | def test_next_weekday_time_occurrence( 87 | now: dt.datetime, wkdy: _Weekday, timestamp: dt.time, target: dt.datetime 88 | ) -> None: 89 | assert next_weekday_time_occurrence(now, wkdy, timestamp) == target 90 | 91 | 92 | @pytest.mark.parametrize( 93 | "now, target_time, target_datetime", 94 | ( 95 | [ 96 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 97 | dt.time(hour=2), 98 | dt.datetime(year=2021, month=6, day=16, hour=2), 99 | ], 100 | [ 101 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 102 | dt.time(hour=13, second=45), 103 | dt.datetime(year=2021, month=6, day=16, hour=13, second=45), 104 | ], 105 | [ 106 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 107 | dt.time(second=45), 108 | dt.datetime(year=2021, month=6, day=17, second=45), 109 | ], 110 | ), 111 | ) 112 | def test_next_daily_occurence( 113 | now: dt.datetime, target_time: dt.time, target_datetime: dt.datetime 114 | ) -> None: 115 | assert next_daily_occurrence(now, target_time) == target_datetime 116 | 117 | 118 | @pytest.mark.parametrize( 119 | "now, target_time, target_datetime", 120 | ( 121 | [ 122 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 123 | dt.time(minute=7, second=3), 124 | dt.datetime(year=2021, month=6, day=16, hour=2, minute=7, second=3), 125 | ], 126 | [ 127 | dt.datetime(year=2021, month=6, day=16, hour=23, minute=53, second=45), 128 | dt.time(minute=7, second=3), 129 | dt.datetime(year=2021, month=6, day=17, hour=0, minute=7, second=3), 130 | ], 131 | [ 132 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 133 | dt.time(hour=4), 134 | dt.datetime(year=2021, month=6, day=16, hour=2), 135 | ], 136 | ), 137 | ) 138 | def test_next_hourly_occurence( 139 | now: dt.datetime, target_time: dt.time, target_datetime: dt.datetime 140 | ) -> None: 141 | assert next_hourly_occurrence(now, target_time) == target_datetime 142 | 143 | 144 | @pytest.mark.parametrize( 145 | "now, target_time, target_datetime", 146 | ( 147 | [ 148 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 149 | dt.time(second=3), 150 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=54, second=3), 151 | ], 152 | [ 153 | dt.datetime(year=2021, month=6, day=16, hour=23, minute=59, second=45), 154 | dt.time(second=44), 155 | dt.datetime(year=2021, month=6, day=17, hour=0, minute=0, second=44), 156 | ], 157 | [ 158 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=53, second=45), 159 | dt.time(hour=4, minute=8, second=25), 160 | dt.datetime(year=2021, month=6, day=16, hour=1, minute=54, second=25), 161 | ], 162 | ), 163 | ) 164 | def test_next_minutely_occurence( 165 | now: dt.datetime, target_time: dt.time, target_datetime: dt.datetime 166 | ) -> None: 167 | assert next_minutely_occurrence(now, target_time) == target_datetime 168 | 169 | 170 | @pytest.mark.parametrize( 171 | "string, max_length, cut_tail, result, err", 172 | [ 173 | ("abcdefg", 10, False, "abcdefg", None), 174 | ("abcdefg", 4, False, "#efg", None), 175 | ("abcdefg", 2, True, "a#", None), 176 | ("abcdefg", 1, True, "#", None), 177 | ("abcdefg", 0, True, "", "max_length < 1 not allowed"), 178 | ], 179 | ) 180 | def test_str_cutoff( 181 | string: str, max_length: int, cut_tail: bool, result: str, err: Optional[str] 182 | ) -> None: 183 | if err: 184 | with pytest.raises(ValueError, match=err): 185 | str_cutoff(string, max_length, cut_tail) 186 | else: 187 | assert str_cutoff(string, max_length, cut_tail) == result 188 | -------------------------------------------------------------------------------- /tests/threading/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/tests/threading/__init__.py -------------------------------------------------------------------------------- /tests/threading/job/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/tests/threading/job/__init__.py -------------------------------------------------------------------------------- /tests/threading/job/test_job_init.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import SchedulerError 8 | from scheduler.base.definition import JobType 9 | from scheduler.base.timingtype import TimingJobUnion 10 | from scheduler.threading.job import Job 11 | 12 | from ...helpers import _TZ_ERROR_MSG, START_STOP_ERROR, TZ_ERROR_MSG, utc 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "job_type, timing, start, stop, tzinfo, err", 17 | ( 18 | [JobType.WEEKLY, [trigger.Monday()], None, None, None, None], 19 | [ 20 | JobType.WEEKLY, 21 | [trigger.Monday(), trigger.Thursday()], 22 | None, 23 | None, 24 | None, 25 | None, 26 | ], 27 | [JobType.WEEKLY, [trigger.Monday(dt.time(tzinfo=utc))], None, None, utc, None], 28 | [ 29 | JobType.DAILY, 30 | [dt.time(tzinfo=utc)], 31 | dt.datetime.now(utc), 32 | None, 33 | utc, 34 | None, 35 | ], 36 | [ 37 | JobType.DAILY, 38 | [dt.time(tzinfo=utc)], 39 | dt.datetime.now(utc), 40 | None, 41 | utc, 42 | None, 43 | ], 44 | [ 45 | JobType.DAILY, 46 | [dt.time(tzinfo=None)], 47 | dt.datetime.now(utc), 48 | None, 49 | utc, 50 | TZ_ERROR_MSG, 51 | ], 52 | [ 53 | JobType.DAILY, 54 | [dt.time(tzinfo=None)], 55 | None, 56 | None, 57 | utc, 58 | TZ_ERROR_MSG, 59 | ], 60 | [ 61 | JobType.DAILY, 62 | [dt.time(tzinfo=None)], 63 | None, 64 | dt.datetime.now(utc), 65 | utc, 66 | TZ_ERROR_MSG, 67 | ], 68 | [ 69 | JobType.DAILY, 70 | [dt.time()], 71 | dt.datetime.now(utc), 72 | None, 73 | None, 74 | _TZ_ERROR_MSG.format("start"), 75 | ], 76 | [ 77 | JobType.DAILY, 78 | [dt.time()], 79 | None, 80 | dt.datetime.now(utc), 81 | None, 82 | _TZ_ERROR_MSG.format("stop"), 83 | ], 84 | [ 85 | JobType.WEEKLY, 86 | [trigger.Monday(dt.time(tzinfo=utc))], 87 | dt.datetime.now(utc), 88 | dt.datetime.now(utc) - dt.timedelta(hours=1), 89 | utc, 90 | START_STOP_ERROR, 91 | ], 92 | ), 93 | ) 94 | def test_job_init( 95 | job_type: JobType, 96 | timing: TimingJobUnion, 97 | start: Optional[dt.datetime], 98 | stop: Optional[dt.datetime], 99 | tzinfo: Optional[dt.tzinfo], 100 | err: Optional[str], 101 | ) -> None: 102 | if err: 103 | with pytest.raises(SchedulerError, match=err): 104 | Job( 105 | job_type=job_type, 106 | timing=timing, 107 | handle=lambda: None, 108 | kwargs={}, 109 | max_attempts=1, 110 | weight=20, 111 | start=start, 112 | stop=stop, 113 | tzinfo=tzinfo, 114 | ) 115 | else: 116 | Job( 117 | job_type=job_type, 118 | timing=timing, 119 | handle=lambda: None, 120 | kwargs={}, 121 | max_attempts=1, 122 | weight=20, 123 | start=start, 124 | stop=stop, 125 | tzinfo=tzinfo, 126 | ) 127 | -------------------------------------------------------------------------------- /tests/threading/job/test_job_misc.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler.base.definition import JobType 8 | from scheduler.base.timingtype import TimingJobUnion 9 | from scheduler.threading.job import Job 10 | 11 | from ...helpers import ( 12 | T_2021_5_26__3_55, 13 | T_2021_5_26__3_55_UTC, 14 | foo, 15 | samples_minutes_utc, 16 | samples_weeks_utc, 17 | utc, 18 | utc2, 19 | ) 20 | 21 | 22 | def test_misc_properties(recwarn: pytest.WarningsRecorder) -> None: 23 | job = Job( 24 | job_type=JobType.CYCLIC, 25 | timing=[dt.timedelta()], 26 | handle=foo, 27 | args=(8, "bar"), 28 | kwargs={"abc": 123}, 29 | tags={"test", "misc"}, 30 | weight=1 / 3, 31 | delay=False, 32 | start=T_2021_5_26__3_55_UTC, 33 | stop=T_2021_5_26__3_55_UTC + dt.timedelta(seconds=1), 34 | skip_missing=True, 35 | tzinfo=utc2, 36 | ) 37 | assert job.type == JobType.CYCLIC 38 | assert job.handle == foo 39 | assert job.args == (8, "bar") 40 | assert job.kwargs == {"abc": 123} 41 | assert job.tags == {"test", "misc"} 42 | assert job.weight == 1 / 3 43 | assert job.start == T_2021_5_26__3_55_UTC 44 | assert job.stop == T_2021_5_26__3_55_UTC + dt.timedelta(seconds=1) 45 | assert job.tzinfo == utc 46 | assert job.skip_missing == True 47 | assert job._tzinfo == utc2 48 | 49 | assert job.delay == False 50 | warn = recwarn.pop(DeprecationWarning) 51 | assert ( 52 | str(warn.message) 53 | == "Using the `delay` property is deprecated and will be removed in the next minor release." 54 | ) 55 | 56 | 57 | @pytest.mark.parametrize( 58 | "start_1, start_2, tzinfo, result", 59 | ( 60 | [T_2021_5_26__3_55, T_2021_5_26__3_55, None, False], 61 | [T_2021_5_26__3_55, T_2021_5_26__3_55 + dt.timedelta(hours=1), None, True], 62 | [T_2021_5_26__3_55, T_2021_5_26__3_55 + dt.timedelta(hours=-1), None, False], 63 | [T_2021_5_26__3_55_UTC, T_2021_5_26__3_55_UTC, utc, False], 64 | [ 65 | T_2021_5_26__3_55_UTC, 66 | T_2021_5_26__3_55_UTC + dt.timedelta(hours=1), 67 | utc, 68 | True, 69 | ], 70 | [ 71 | T_2021_5_26__3_55_UTC, 72 | T_2021_5_26__3_55_UTC + dt.timedelta(hours=-1), 73 | utc, 74 | False, 75 | ], 76 | ), 77 | ) 78 | def test_job__lt__( 79 | start_1: dt.datetime, 80 | start_2: dt.datetime, 81 | tzinfo: Optional[dt.tzinfo], 82 | result: bool, 83 | ) -> None: 84 | job_1 = Job( 85 | job_type=JobType.CYCLIC, 86 | timing=[dt.timedelta()], 87 | handle=lambda: None, 88 | start=start_1, 89 | tzinfo=tzinfo, 90 | ) 91 | job_2 = Job( 92 | job_type=JobType.CYCLIC, 93 | timing=[dt.timedelta()], 94 | handle=lambda: None, 95 | start=start_2, 96 | tzinfo=tzinfo, 97 | ) 98 | assert (job_1 < job_2) == result 99 | 100 | 101 | @pytest.mark.parametrize( 102 | "job_type, timing, base, offset, tzinfo, patch_datetime_now", 103 | ( 104 | [ 105 | JobType.CYCLIC, 106 | [dt.timedelta(minutes=2)], 107 | T_2021_5_26__3_55_UTC, 108 | dt.timedelta(minutes=2, seconds=8), 109 | utc, 110 | samples_minutes_utc, 111 | ], 112 | [ 113 | JobType.CYCLIC, 114 | [dt.timedelta(weeks=2)], 115 | T_2021_5_26__3_55_UTC, 116 | dt.timedelta(minutes=2, seconds=8), 117 | utc, 118 | samples_minutes_utc, 119 | ], 120 | [ 121 | JobType.WEEKLY, 122 | [trigger.Sunday(dt.time(tzinfo=utc))], 123 | T_2021_5_26__3_55_UTC, 124 | dt.timedelta(minutes=2, seconds=8), 125 | utc, 126 | samples_weeks_utc, 127 | ], 128 | ), 129 | indirect=["patch_datetime_now"], 130 | ) 131 | def test_start_with_no_delay( 132 | job_type: JobType, 133 | timing: TimingJobUnion, 134 | base: dt.datetime, 135 | offset: dt.timedelta, 136 | tzinfo: dt.tzinfo, 137 | patch_datetime_now: Any, 138 | ) -> None: 139 | job = Job( 140 | job_type=job_type, 141 | timing=timing, 142 | handle=lambda: None, 143 | start=base + offset, 144 | delay=False, 145 | tzinfo=tzinfo, 146 | ) 147 | 148 | assert job.datetime == base + offset 149 | assert job.timedelta() == offset 150 | -------------------------------------------------------------------------------- /tests/threading/job/test_job_repr.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from scheduler.threading.job import Job 6 | 7 | from ...helpers import job_args, job_args_utc, job_reprs, job_reprs_utc 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "kwargs, result", 12 | [(args, reprs) for args, reprs in zip(job_args, job_reprs)] 13 | + [(args, reprs) for args, reprs in zip(job_args_utc, job_reprs_utc)], 14 | ) 15 | def test_job_repr( 16 | kwargs: dict[str, Any], 17 | result: str, 18 | ) -> None: 19 | rep = repr(Job(**kwargs)) 20 | for substring in result: 21 | assert substring in rep 22 | rep = rep.replace(substring, "", 1) 23 | 24 | # result is broken into substring at every address. Address string is 12 long 25 | assert len(rep) == (len(result) - 1) * 12 26 | -------------------------------------------------------------------------------- /tests/threading/job/test_job_str.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from scheduler.threading.job import Job 6 | 7 | from ...helpers import T_2021_5_26__3_55, T_2021_5_26__3_55_UTC, job_args, job_args_utc 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "patch_datetime_now, job_kwargs, results", 12 | [ 13 | ( 14 | [T_2021_5_26__3_55] * 3, 15 | job_args, 16 | [ 17 | "ONCE, foo(), at=2021-05-26 04:55:00, tz=None, in=1:00:00, #0/1, w=1", 18 | "MINUTELY, bar(..), at=2021-05-26 03:54:15, tz=None, in=-0:00:45, #0/20, w=0", 19 | "DAILY, foo(), at=2021-05-26 07:05:00, tz=None, in=3:10:00, #0/7, w=1", 20 | ], 21 | ), 22 | ( 23 | [T_2021_5_26__3_55_UTC] * 4, 24 | job_args_utc, 25 | [ 26 | "CYCLIC, foo(), at=2021-05-26 03:54:59, tz=UTC, in=-0:00:00, #0/inf, w=0.333", 27 | "HOURLY, print(?), at=2021-05-26 03:55:00, tz=UTC, in=0:00:00, #0/inf, w=20", 28 | "WEEKLY, bar(..), at=2021-05-25 03:55:00, tz=UTC, in=-1 day, #0/inf, w=1", 29 | "ONCE, print(?), at=2021-06-08 23:45:59, tz=UTC, in=13 days, #0/1, w=1", 30 | ], 31 | ), 32 | ], 33 | indirect=["patch_datetime_now"], 34 | ) 35 | def test_job_str( 36 | patch_datetime_now: Any, 37 | job_kwargs: tuple[dict[str, Any], ...], 38 | results: list[str], 39 | ) -> None: 40 | for kwargs, result in zip(job_kwargs, results): 41 | job = Job(**kwargs) 42 | assert result == str(job) 43 | -------------------------------------------------------------------------------- /tests/threading/scheduler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigonIO/scheduler/691a2174bd5cc33d5395673f16314d277712367c/tests/threading/scheduler/__init__.py -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_cyclic.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import Scheduler, SchedulerError 8 | 9 | from ...helpers import CYCLIC_TYPE_ERROR_MSG, foo, samples_days, samples_seconds, utc 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "timing, counts, patch_datetime_now, tzinfo, err_msg", 14 | ( 15 | [dt.timedelta(seconds=4), [1, 2, 2, 2, 3, 3, 3], samples_seconds, None, None], 16 | [dt.timedelta(seconds=5), [1, 1, 2, 2, 2, 3, 3], samples_seconds, None, None], 17 | [dt.timedelta(seconds=5), [1, 1, 2, 2, 2, 3, 3], samples_seconds, utc, None], 18 | [dt.timedelta(days=2), [0, 0, 1, 2, 2, 2, 2], samples_days, None, None], 19 | [dt.time(hour=2), [], samples_days, None, CYCLIC_TYPE_ERROR_MSG], 20 | [trigger.Monday(), [], samples_days, None, CYCLIC_TYPE_ERROR_MSG], 21 | ), 22 | indirect=["patch_datetime_now"], 23 | ) 24 | def test_cyclic( 25 | timing: dt.timedelta, 26 | counts: list[int], 27 | patch_datetime_now: Any, 28 | tzinfo: dt.tzinfo, 29 | err_msg: Optional[str], 30 | ) -> None: 31 | sch = Scheduler(tzinfo=tzinfo) 32 | 33 | if err_msg: 34 | with pytest.raises(SchedulerError, match=err_msg): 35 | job = sch.cyclic(timing=timing, handle=foo) 36 | else: 37 | job = sch.cyclic(timing=timing, handle=foo) 38 | for count in counts: 39 | sch.exec_jobs() 40 | assert job.attempts == count 41 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_cyclic_fail.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import logging 3 | from typing import Any 4 | 5 | import pytest 6 | 7 | from scheduler import Scheduler 8 | 9 | from ...helpers import fail, samples_seconds 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "timing, counts, patch_datetime_now", 14 | ([dt.timedelta(seconds=4), [1, 2], samples_seconds],), 15 | indirect=["patch_datetime_now"], 16 | ) 17 | def test_threading_fail( 18 | timing: dt.timedelta, 19 | counts: list[int], 20 | patch_datetime_now: Any, 21 | caplog: pytest.LogCaptureFixture, 22 | ) -> None: 23 | caplog.set_level(logging.DEBUG, logger="scheduler") 24 | 25 | sch = Scheduler() 26 | job = sch.cyclic(timing=timing, handle=fail) 27 | RECORD = ( 28 | "scheduler", 29 | logging.ERROR, 30 | "Unhandled exception in `%r`!" % (job,), 31 | ) 32 | 33 | for count in counts: 34 | sch.exec_jobs() 35 | assert job.attempts == count 36 | assert job.failed_attempts == count 37 | 38 | assert caplog.record_tuples == [RECORD, RECORD] 39 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_daily.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import Scheduler, SchedulerError 8 | 9 | from ...helpers import ( 10 | DAILY_TYPE_ERROR_MSG, 11 | TZ_ERROR_MSG, 12 | foo, 13 | samples_days, 14 | samples_days_utc, 15 | utc, 16 | ) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "timing, counts, patch_datetime_now, tzinfo, err_msg", 21 | ( 22 | [ 23 | dt.time(hour=4, minute=30), 24 | [1, 2, 3, 4, 5, 6, 6, 6], 25 | samples_days, 26 | None, 27 | None, 28 | ], 29 | [ 30 | dt.time(hour=16, minute=45), 31 | [1, 1, 2, 3, 4, 5, 6, 6], 32 | samples_days, 33 | None, 34 | None, 35 | ], 36 | [ 37 | dt.time(hour=14, tzinfo=utc), 38 | [1, 1, 2, 3, 4, 5, 6, 6], 39 | samples_days_utc, 40 | utc, 41 | None, 42 | ], 43 | [dt.time(hour=2), [], samples_days_utc, utc, TZ_ERROR_MSG], 44 | [trigger.Monday(), [], samples_days, None, DAILY_TYPE_ERROR_MSG], 45 | ), 46 | indirect=["patch_datetime_now"], 47 | ) 48 | def test_daily( 49 | timing: dt.timedelta, 50 | counts: list[int], 51 | patch_datetime_now: Any, 52 | tzinfo: Optional[dt.tzinfo], 53 | err_msg: Optional[str], 54 | ) -> None: 55 | sch = Scheduler(tzinfo=tzinfo) 56 | 57 | if err_msg: 58 | with pytest.raises(SchedulerError, match=err_msg): 59 | job = sch.daily(timing=timing, handle=foo) 60 | else: 61 | job = sch.daily(timing=timing, handle=foo) 62 | for count in counts: 63 | sch.exec_jobs() 64 | assert job.attempts == count 65 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_dead_lock.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import time 3 | 4 | from scheduler import Scheduler 5 | from scheduler.threading.job import Job 6 | 7 | tags = {"to_delete"} 8 | n_execs = {0: 2, 1: 2, 2: 2, 3: 0} 9 | 10 | 11 | def useful() -> None: 12 | ... 13 | 14 | 15 | def scheduler_in_handle(scheduler: Scheduler, counter: dict[str, int]) -> None: 16 | _ = str(scheduler) 17 | assert len(scheduler.get_jobs()) == 2 18 | assert len(scheduler.get_jobs(tags=tags)) == 2 19 | assert len(scheduler.jobs) == 2 20 | 21 | if counter["val"] == 2: 22 | rnd_job = scheduler.jobs.pop() 23 | scheduler.delete_job(rnd_job) 24 | scheduler.delete_jobs(tags=tags) 25 | assert len(scheduler.jobs) == 0 26 | 27 | 28 | def test_dead_lock() -> None: 29 | counter = {"val": 0} 30 | 31 | schedule = Scheduler() 32 | schedule.cyclic(dt.timedelta(seconds=0.01), useful, tags=tags) 33 | schedule.cyclic( 34 | dt.timedelta(seconds=0.01), 35 | scheduler_in_handle, 36 | tags=tags, 37 | args=(schedule, counter), 38 | ) 39 | 40 | for i in range(4): 41 | time.sleep(0.01) 42 | counter["val"] = i 43 | assert n_execs[i] == schedule.exec_jobs() 44 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_delete_jobs.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import random 3 | from typing import Optional 4 | 5 | import pytest 6 | 7 | from scheduler import Scheduler, SchedulerError 8 | from scheduler.base.definition import JobType 9 | from scheduler.threading.job import Job 10 | 11 | from ...helpers import DELETE_NOT_SCHEDULED_ERROR, foo 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "n_jobs", 16 | [ 17 | 1, 18 | 2, 19 | 3, 20 | 10, 21 | ], 22 | ) 23 | def test_delete_job(n_jobs: int) -> None: 24 | sch = Scheduler() 25 | assert len(sch.jobs) == 0 26 | 27 | jobs = [] 28 | for _ in range(n_jobs): 29 | jobs.append(sch.once(dt.datetime.now(), foo)) 30 | assert len(sch.jobs) == n_jobs 31 | 32 | job = random.choice(jobs) 33 | sch.delete_job(job) 34 | assert job not in sch.jobs 35 | assert len(sch.jobs) == n_jobs - 1 36 | 37 | # test error if the job is not scheduled 38 | with pytest.raises(SchedulerError, match=DELETE_NOT_SCHEDULED_ERROR): 39 | sch.delete_job(job) 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "empty_set", 44 | [ 45 | False, 46 | True, 47 | ], 48 | ) 49 | @pytest.mark.parametrize( 50 | "any_tag", 51 | [ 52 | None, 53 | False, 54 | True, 55 | ], 56 | ) 57 | @pytest.mark.parametrize( 58 | "n_jobs", 59 | [ 60 | 0, 61 | 1, 62 | 2, 63 | 3, 64 | 10, 65 | ], 66 | ) 67 | def test_delete_jobs(n_jobs: int, any_tag: Optional[bool], empty_set: bool) -> None: 68 | sch = Scheduler() 69 | assert len(sch.jobs) == 0 70 | 71 | for _ in range(n_jobs): 72 | sch.once(dt.datetime.now(), foo) 73 | assert len(sch.jobs) == n_jobs 74 | 75 | if empty_set: 76 | if any_tag is None: 77 | num_del = sch.delete_jobs() 78 | else: 79 | num_del = sch.delete_jobs(any_tag=any_tag) 80 | else: 81 | if any_tag is None: 82 | num_del = sch.delete_jobs(tags=set()) 83 | else: 84 | num_del = sch.delete_jobs(tags=set(), any_tag=any_tag) 85 | 86 | assert len(sch.jobs) == 0 87 | assert num_del == n_jobs 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "job_tags, delete_tags, any_tag, n_deleted", 92 | [ 93 | [[{"a", "b"}, {"1", "2", "3"}, {"a", "1"}], {"a", "1"}, True, 3], 94 | [[{"a", "b"}, {"1", "2", "3"}, {"a", "2"}], {"b", "1"}, True, 2], 95 | [[{"a", "b"}, {"1", "2", "3"}, {"b", "1"}], {"3"}, True, 1], 96 | [[{"a", "b"}, {"1", "2", "3"}, {"b", "2"}], {"2", "3"}, True, 2], 97 | [[{"a", "b"}, {"1", "2", "3"}, {"a", "1"}], {"a", "1"}, False, 1], 98 | [[{"a", "b"}, {"1", "2", "3"}, {"a", "2"}], {"b", "1"}, False, 0], 99 | [[{"a", "b"}, {"1", "2", "3"}, {"b", "1"}], {"1", "3"}, False, 1], 100 | [[{"a", "b"}, {"1", "2", "3"}, {"b", "2"}], {"2", "3"}, False, 1], 101 | ], 102 | ) 103 | def test_delete_tagged_jobs( 104 | job_tags: list[set[str]], delete_tags: set[str], any_tag: bool, n_deleted: int 105 | ) -> None: 106 | sch = Scheduler() 107 | 108 | for tags in job_tags: 109 | sch.once(dt.timedelta(), lambda: None, tags=tags) 110 | 111 | assert sch.delete_jobs(tags=delete_tags, any_tag=any_tag) == n_deleted 112 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_exec_jobs.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import pytest 4 | 5 | from scheduler import Scheduler 6 | 7 | from ...helpers import foo 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "n_jobs", 12 | [ 13 | 0, 14 | 1, 15 | 2, 16 | 3, 17 | 10, 18 | ], 19 | ) 20 | def test_exec_all_jobs(n_jobs: int) -> None: 21 | sch = Scheduler() 22 | 23 | assert len(sch.jobs) == 0 24 | for _ in range(n_jobs): 25 | sch.once(dt.datetime.now(), foo) 26 | assert len(sch.jobs) == n_jobs 27 | 28 | exec_job_count = sch.exec_jobs(force_exec_all=True) 29 | assert exec_job_count == n_jobs 30 | assert len(sch.jobs) == 0 31 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_get_jobs.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import random 3 | from typing import Optional 4 | 5 | import pytest 6 | 7 | from scheduler import Scheduler, SchedulerError 8 | from scheduler.base.definition import JobType 9 | from scheduler.threading.job import Job 10 | 11 | from ...helpers import foo 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "empty_set", 16 | [ 17 | False, 18 | True, 19 | ], 20 | ) 21 | @pytest.mark.parametrize( 22 | "any_tag", 23 | [ 24 | None, 25 | False, 26 | True, 27 | ], 28 | ) 29 | @pytest.mark.parametrize( 30 | "n_jobs", 31 | [ 32 | 0, 33 | 1, 34 | 2, 35 | 3, 36 | 10, 37 | ], 38 | ) 39 | def test_get_all_jobs(n_jobs: int, any_tag: Optional[bool], empty_set: bool) -> None: 40 | sch = Scheduler() 41 | assert len(sch.jobs) == 0 42 | 43 | for _ in range(n_jobs): 44 | sch.once(dt.datetime.now(), foo) 45 | assert len(sch.jobs) == n_jobs 46 | 47 | if empty_set: 48 | if any_tag is None: 49 | jobs = sch.get_jobs() 50 | else: 51 | jobs = sch.get_jobs(any_tag=any_tag) 52 | else: 53 | if any_tag is None: 54 | jobs = sch.get_jobs(tags=set()) 55 | else: 56 | jobs = sch.get_jobs(tags=set(), any_tag=any_tag) 57 | 58 | assert len(jobs) == n_jobs 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "job_tags, select_tags, any_tag, returned", 63 | [ 64 | [ 65 | [{"a", "b"}, {"1", "2", "3"}, {"a", "1"}], 66 | {"a", "1"}, 67 | True, 68 | [True, True, True], 69 | ], 70 | [ 71 | [{"a", "b"}, {"1", "2", "3"}, {"a", "2"}], 72 | {"b", "1"}, 73 | True, 74 | [True, True, False], 75 | ], 76 | [ 77 | [{"a", "b"}, {"1", "2", "3"}, {"b", "1"}], 78 | {"3"}, 79 | True, 80 | [False, True, False], 81 | ], 82 | [ 83 | [{"a", "b"}, {"1", "2", "3"}, {"b", "2"}], 84 | {"2", "3"}, 85 | True, 86 | [False, True, True], 87 | ], 88 | [ 89 | [{"a", "b"}, {"1", "2", "3"}, {"a", "1"}], 90 | {"a", "1"}, 91 | False, 92 | [False, False, True], 93 | ], 94 | [ 95 | [{"a", "b"}, {"1", "2", "3"}, {"a", "2"}], 96 | {"b", "1"}, 97 | False, 98 | [False, False, False], 99 | ], 100 | [ 101 | [{"a", "b"}, {"1", "2", "3"}, {"b", "1"}], 102 | {"1", "3"}, 103 | False, 104 | [False, True, False], 105 | ], 106 | [ 107 | [{"a", "b"}, {"1", "2", "3"}, {"b", "2"}], 108 | {"2", "3"}, 109 | False, 110 | [False, True, False], 111 | ], 112 | ], 113 | ) 114 | def test_get_tagged_jobs( 115 | job_tags: list[set[str]], select_tags: set[str], any_tag: bool, returned: list[bool] 116 | ) -> None: 117 | sch = Scheduler() 118 | 119 | jobs = [sch.once(dt.timedelta(), lambda: None, tags=tags) for tags in job_tags] 120 | 121 | res = sch.get_jobs(tags=select_tags, any_tag=any_tag) 122 | for job, ret in zip(jobs, returned): 123 | if ret: 124 | assert job in res 125 | else: 126 | assert job not in res 127 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_hourly.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import Scheduler, SchedulerError 8 | 9 | from ...helpers import ( 10 | HOURLY_TYPE_ERROR_MSG, 11 | TZ_ERROR_MSG, 12 | foo, 13 | samples_hours, 14 | samples_hours_utc, 15 | utc, 16 | ) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "timing, counts, patch_datetime_now, tzinfo, err_msg", 21 | ( 22 | [dt.time(minute=0), [1, 2, 3, 4, 5, 6, 6], samples_hours, None, None], 23 | [dt.time(minute=39), [1, 2, 3, 4, 5, 5, 5], samples_hours, None, None], 24 | [ 25 | [dt.time(minute=39), dt.time(hour=1, minute=39, tzinfo=utc)], 26 | [], 27 | samples_hours, 28 | None, 29 | TZ_ERROR_MSG, 30 | ], 31 | [ 32 | dt.time(minute=47, tzinfo=utc), 33 | [1, 2, 3, 4, 5, 5, 5], 34 | samples_hours_utc, 35 | utc, 36 | None, 37 | ], 38 | [dt.time(hour=2), [], samples_hours_utc, utc, TZ_ERROR_MSG], 39 | [trigger.Monday(), [], samples_hours, None, HOURLY_TYPE_ERROR_MSG], 40 | ), 41 | indirect=["patch_datetime_now"], 42 | ) 43 | def test_hourly( 44 | timing: dt.time, 45 | counts: list[int], 46 | patch_datetime_now: Any, 47 | tzinfo: Optional[dt.tzinfo], 48 | err_msg: Optional[str], 49 | ) -> None: 50 | sch = Scheduler(tzinfo=tzinfo) 51 | 52 | if err_msg: 53 | with pytest.raises(SchedulerError, match=err_msg): 54 | job = sch.hourly(timing=timing, handle=foo) 55 | else: 56 | job = sch.hourly(timing=timing, handle=foo) 57 | attempts = [] 58 | for _ in counts: 59 | sch.exec_jobs() 60 | attempts.append(job.attempts) 61 | assert attempts == counts 62 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_init.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Callable, Optional 3 | 4 | import pytest 5 | 6 | from scheduler import Scheduler, SchedulerError 7 | from scheduler.base.definition import JobType 8 | from scheduler.threading.job import Job 9 | 10 | from ...helpers import TZ_ERROR_MSG, foo, utc 11 | 12 | 13 | def priority_function_dummy(seconds: float, job: Job, max_exec: int, job_count: int) -> float: 14 | _ = seconds 15 | _ = job 16 | _ = max_exec 17 | _ = job_count 18 | 19 | return 1 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "max_exec, tzinfo, priority_function, jobs, err", 24 | ( 25 | [0, None, None, None, None], 26 | [10, utc, priority_function_dummy, None, None], 27 | [ 28 | 1, 29 | utc, 30 | priority_function_dummy, 31 | {Job(JobType.CYCLIC, [dt.timedelta(seconds=1)], foo, tzinfo=utc)}, 32 | None, 33 | ], 34 | [ 35 | 1, 36 | utc, 37 | priority_function_dummy, 38 | {Job(JobType.CYCLIC, [dt.timedelta(seconds=1)], foo, tzinfo=None)}, 39 | TZ_ERROR_MSG, 40 | ], 41 | ), 42 | ) 43 | def test_sch_init( 44 | max_exec: int, 45 | tzinfo: dt.tzinfo, 46 | priority_function: Callable[ 47 | [float, Job, int, int], 48 | float, 49 | ], 50 | jobs: set[Job], 51 | err: Optional[str], 52 | ) -> None: 53 | if err: 54 | with pytest.raises(SchedulerError, match=err): 55 | Scheduler( 56 | max_exec=max_exec, 57 | tzinfo=tzinfo, 58 | priority_function=priority_function, 59 | jobs=jobs, 60 | ) 61 | else: 62 | Scheduler( 63 | max_exec=max_exec, 64 | tzinfo=tzinfo, 65 | priority_function=priority_function, 66 | jobs=jobs, 67 | ) 68 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_minutely.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import Scheduler, SchedulerError 8 | 9 | from ...helpers import ( 10 | DUPLICATE_EFFECTIVE_TIME, 11 | MINUTELY_TYPE_ERROR_MSG, 12 | TZ_ERROR_MSG, 13 | foo, 14 | samples_half_minutes, 15 | samples_minutes, 16 | samples_minutes_utc, 17 | utc, 18 | ) 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "timing, counts, patch_datetime_now, tzinfo, err_msg", 23 | ( 24 | [dt.time(second=0), [1, 2, 3, 4, 5, 5, 5], samples_minutes, None, None], 25 | [dt.time(second=39), [1, 2, 3, 4, 5, 6, 6], samples_minutes, None, None], 26 | [ 27 | [dt.time(second=5), dt.time(second=30)], 28 | [1, 1, 2, 3, 4, 4, 5, 6, 7], 29 | samples_half_minutes, 30 | None, 31 | None, 32 | ], 33 | [ 34 | [dt.time(second=5), dt.time(minute=1, second=5)], 35 | [], 36 | samples_half_minutes, 37 | None, 38 | DUPLICATE_EFFECTIVE_TIME, 39 | ], 40 | [ 41 | dt.time(second=47, tzinfo=utc), 42 | [1, 2, 3, 4, 5, 5, 5], 43 | samples_minutes_utc, 44 | utc, 45 | None, 46 | ], 47 | [dt.time(hour=2), [], samples_minutes_utc, utc, TZ_ERROR_MSG], 48 | [trigger.Monday(), [], samples_minutes, None, MINUTELY_TYPE_ERROR_MSG], 49 | ), 50 | indirect=["patch_datetime_now"], 51 | ) 52 | def test_minutely( 53 | timing: dt.time, 54 | counts: list[int], 55 | patch_datetime_now: Any, 56 | tzinfo: Optional[dt.tzinfo], 57 | err_msg: Optional[str], 58 | ) -> None: 59 | sch = Scheduler(tzinfo=tzinfo) 60 | 61 | if err_msg: 62 | with pytest.raises(SchedulerError, match=err_msg): 63 | job = sch.minutely(timing=timing, handle=foo) 64 | else: 65 | job = sch.minutely(timing=timing, handle=foo) 66 | attempts = [] 67 | for _ in counts: 68 | sch.exec_jobs() 69 | attempts.append(job.attempts) 70 | assert attempts == counts 71 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_once.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import Scheduler, SchedulerError 8 | from scheduler.base.timingtype import TimingOnceUnion 9 | 10 | from ...helpers import ONCE_TYPE_ERROR_MSG, TZ_ERROR_MSG, foo, samples, samples_utc, utc 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "timing, counts, patch_datetime_now, tzinfo, err_msg", 15 | ( 16 | [ 17 | dt.timedelta(seconds=8), 18 | [0, 1, 1, 1, 1, 1, 1, 1], 19 | samples, 20 | None, 21 | None, 22 | ], 23 | [ 24 | dt.timedelta(seconds=9.5), 25 | [0, 0, 1, 1, 1, 1, 1, 1], 26 | samples_utc, 27 | utc, 28 | None, 29 | ], 30 | [ 31 | dt.timedelta(days=10), 32 | [0, 0, 0, 0, 0, 0, 0, 1], 33 | samples, 34 | None, 35 | None, 36 | ], 37 | [ 38 | trigger.Thursday(), 39 | [0, 0, 0, 0, 0, 1, 1, 1], 40 | samples, 41 | None, 42 | None, 43 | ], 44 | [ 45 | dt.time(hour=5, minute=57, tzinfo=utc), 46 | [0, 0, 0, 0, 1, 1, 1, 1], 47 | samples_utc, 48 | utc, 49 | None, 50 | ], 51 | [ 52 | trigger.Thursday(dt.time(hour=3, minute=57, tzinfo=utc)), 53 | [0, 0, 0, 0, 0, 1, 1, 1], 54 | samples_utc, 55 | utc, 56 | None, 57 | ], 58 | [ 59 | trigger.Thursday(dt.time(hour=3, minute=57, tzinfo=None)), 60 | [0, 0, 0, 0, 0, 1, 1, 1], 61 | samples_utc, 62 | utc, 63 | TZ_ERROR_MSG, 64 | ], 65 | [[dt.time(), dt.timedelta()], [], samples, None, ONCE_TYPE_ERROR_MSG], 66 | ), 67 | indirect=["patch_datetime_now"], 68 | ) 69 | def test_once( 70 | timing: TimingOnceUnion, 71 | counts: list[int], 72 | patch_datetime_now: Any, 73 | tzinfo: Optional[dt.tzinfo], 74 | err_msg: Optional[str], 75 | ) -> None: 76 | sch = Scheduler(tzinfo=tzinfo) 77 | 78 | if err_msg: 79 | with pytest.raises(SchedulerError, match=err_msg): 80 | job = sch.once(timing=timing, handle=foo) 81 | else: 82 | job = sch.once(timing=timing, handle=foo) 83 | for count in counts: 84 | sch.exec_jobs() 85 | assert job.attempts == count 86 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_repr.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | from scheduler.threading.job import Job 7 | from scheduler.threading.scheduler import Scheduler 8 | 9 | from ...helpers import ( 10 | T_2021_5_26__3_55, 11 | T_2021_5_26__3_55_UTC, 12 | job_args, 13 | job_args_utc, 14 | job_reprs, 15 | job_reprs_utc, 16 | utc, 17 | ) 18 | 19 | patch_samples = [T_2021_5_26__3_55] * 7 20 | patch_samples_utc = [T_2021_5_26__3_55_UTC] * 11 21 | 22 | sch_repr = ( 23 | "scheduler.Scheduler(0, None, , jobs={", 25 | "})", 26 | ) 27 | sch_repr_utc = ( 28 | "scheduler.Scheduler(0, datetime.timezone.utc, , jobs={", 30 | "})", 31 | ) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "patch_datetime_now, job_kwargs, tzinfo, j_results, s_results", 36 | [ 37 | (patch_samples, job_args, None, job_reprs, sch_repr), 38 | (patch_samples_utc, job_args_utc, utc, job_reprs_utc, sch_repr_utc), 39 | ], 40 | indirect=["patch_datetime_now"], 41 | ) 42 | def test_sch_repr( 43 | patch_datetime_now: Any, 44 | job_kwargs: tuple[dict[str, Any], ...], 45 | tzinfo: Optional[dt.tzinfo], 46 | j_results: list[str], 47 | s_results: tuple[str], 48 | ) -> None: 49 | jobs = [Job(**kwargs) for kwargs in job_kwargs] 50 | sch = Scheduler(tzinfo=tzinfo, jobs=jobs) 51 | rep = repr(sch) 52 | n_j_addr = 0 # number of address strings in jobs 53 | for j_result in j_results: 54 | n_j_addr += len(j_result) - 1 55 | for substring in j_result: 56 | assert substring in rep 57 | rep = rep.replace(substring, "", 1) 58 | for substring in s_results: 59 | assert substring in rep 60 | rep = rep.replace(substring, "", 1) 61 | 62 | # Addr str of priority_function: 12 63 | # ", " separators between jobs: (len(j_results) - 1) * 2 64 | assert len(rep) == 12 + n_j_addr * 12 + (len(j_results) - 1) * 2 65 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_skip_missing.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any 3 | 4 | import pytest 5 | 6 | from scheduler.base.definition import JobType 7 | from scheduler.threading.job import Job 8 | from scheduler.threading.scheduler import Scheduler 9 | 10 | from ...helpers import sample_seconds_interference_lag, samples_days 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "patch_datetime_now, counts, job", 15 | [ 16 | ( 17 | samples_days[:1] + samples_days, 18 | [1, 1, 2, 2, 3, 4, 5, 5, 5, 5], 19 | Job( 20 | JobType.DAILY, 21 | [ 22 | samples_days[0].time(), 23 | (samples_days[0] + dt.timedelta(microseconds=10)).time(), 24 | ], 25 | print, 26 | start=samples_days[0] - dt.timedelta(days=1), 27 | skip_missing=True, 28 | ), 29 | ), 30 | ( 31 | samples_days[:1] + samples_days, 32 | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 33 | Job( 34 | JobType.DAILY, 35 | [ 36 | samples_days[0].time(), 37 | (samples_days[0] + dt.timedelta(microseconds=10)).time(), 38 | ], 39 | print, 40 | start=samples_days[0] - dt.timedelta(days=1), 41 | skip_missing=False, 42 | ), 43 | ), 44 | ( 45 | sample_seconds_interference_lag, 46 | [0, 1, 1, 1, 1, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 6, 6], 47 | Job( 48 | JobType.CYCLIC, 49 | [dt.timedelta(seconds=4)], 50 | print, 51 | start=sample_seconds_interference_lag[0], 52 | skip_missing=False, 53 | ), 54 | ), 55 | ( 56 | sample_seconds_interference_lag, 57 | [0, 1, 1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 4, 4, 5, 5, 5, 5], 58 | Job( 59 | JobType.CYCLIC, 60 | [dt.timedelta(seconds=4)], 61 | print, 62 | start=sample_seconds_interference_lag[0], 63 | skip_missing=True, 64 | ), 65 | ), 66 | ], 67 | indirect=["patch_datetime_now"], 68 | ) 69 | def test_sch_skip_missing(patch_datetime_now: Any, counts: list[int], job: Job) -> None: 70 | sch = Scheduler(jobs={job}) 71 | attempts = [] 72 | for _ in counts: 73 | sch.exec_jobs() 74 | attempts.append(job.attempts) 75 | 76 | assert attempts == counts 77 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_start_stop.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | from scheduler import Scheduler, SchedulerError 7 | 8 | from ...helpers import START_STOP_ERROR, foo, samples_seconds 9 | 10 | # Attention: t1 will be the second datetime object in the sample 11 | # because with start the Job do not require a dt.datetime.now() in its init 12 | # So we consume it with a _ 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "timing, counts, patch_datetime_now, start, stop, err_msg", 17 | ( 18 | [ 19 | dt.timedelta(seconds=4), 20 | [], 21 | samples_seconds, 22 | dt.datetime(2021, 5, 26, 3, 55), 23 | dt.datetime(2021, 5, 26, 3, 55), 24 | START_STOP_ERROR, 25 | ], 26 | [ 27 | dt.timedelta(seconds=2.75), 28 | [0, 1, 2, 2, 2, 2], 29 | samples_seconds, 30 | dt.datetime(2021, 5, 26, 3, 55, 5), 31 | dt.datetime(2021, 5, 26, 3, 55, 13), 32 | None, 33 | ], 34 | [ 35 | dt.timedelta(seconds=3), 36 | [0, 1, 2, 2, 2, 2], 37 | samples_seconds, 38 | dt.datetime(2021, 5, 26, 3, 55, 5), 39 | dt.datetime(2021, 5, 26, 3, 55, 13), 40 | None, 41 | ], 42 | [ 43 | dt.timedelta(seconds=5), 44 | [1, 1, 2, 2, 2, 2, 2, 2], 45 | samples_seconds, 46 | dt.datetime(2021, 5, 26, 3, 55, 0), 47 | dt.datetime(2021, 5, 26, 3, 55, 14), 48 | None, 49 | ], 50 | [ 51 | dt.timedelta(seconds=5), 52 | [1, 1, 2, 2, 2, 3, 3, 3], 53 | samples_seconds, 54 | dt.datetime(2021, 5, 26, 3, 55, 0), 55 | dt.datetime(2021, 5, 26, 3, 55, 15), 56 | None, 57 | ], 58 | [ 59 | dt.timedelta(seconds=5), 60 | [], 61 | samples_seconds, 62 | None, 63 | dt.datetime(2021, 5, 26, 3, 54), 64 | START_STOP_ERROR, 65 | ], 66 | [ 67 | dt.timedelta(seconds=5), 68 | [0, 0, 0], 69 | samples_seconds, 70 | None, 71 | dt.datetime(2021, 5, 26, 3, 55, 3), 72 | None, 73 | ], 74 | ), 75 | indirect=["patch_datetime_now"], 76 | ) 77 | def test_start_stop( 78 | timing: dt.timedelta, 79 | counts: list[int], 80 | patch_datetime_now: Any, 81 | start: Optional[dt.datetime], 82 | stop: Optional[dt.datetime], 83 | err_msg: Optional[str], 84 | ) -> None: 85 | sch = Scheduler() 86 | 87 | if start: 88 | _ = dt.datetime.now() 89 | 90 | if err_msg: 91 | with pytest.raises(SchedulerError, match=err_msg): 92 | job = sch.cyclic(timing=timing, handle=foo, start=start, stop=stop) 93 | else: 94 | job = sch.cyclic(timing=timing, handle=foo, start=start, stop=stop) 95 | 96 | if start is not None: 97 | assert job.start == start 98 | assert job.stop == stop 99 | 100 | for count in counts: 101 | sch.exec_jobs() 102 | assert job.attempts == count 103 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_str.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | from scheduler.threading.job import Job 7 | from scheduler.threading.scheduler import Scheduler 8 | 9 | from ...helpers import ( 10 | T_2021_5_26__3_55, 11 | T_2021_5_26__3_55_UTC, 12 | job_args, 13 | job_args_utc, 14 | utc, 15 | ) 16 | 17 | patch_samples = [T_2021_5_26__3_55] * 7 18 | patch_samples_utc = [T_2021_5_26__3_55_UTC] * 11 19 | 20 | table = ( 21 | "max_exec=inf, tzinfo=None, priority_function=linear_priority_function, #jobs=3\n" 22 | "\n" 23 | "type function / alias due at due in attempts weight\n" 24 | "-------- ---------------- ------------------- --------- ------------- ------\n" 25 | "MINUTELY bar(..) 2021-05-26 03:54:15 -0:00:45 0/20 0\n" 26 | "ONCE foo() 2021-05-26 04:55:00 1:00:00 0/1 1\n" 27 | "DAILY foo() 2021-05-26 07:05:00 3:10:00 0/7 1\n" 28 | ) 29 | 30 | table_utc = ( 31 | "max_exec=inf, tzinfo=UTC, priority_function=linear_priority_function, #jobs=4\n" 32 | "\n" 33 | "type function / alias due at tzinfo due in attempts weight\n" 34 | "-------- ---------------- ------------------- ------------ --------- ------------- ------\n" 35 | "WEEKLY bar(..) 2021-05-25 03:55:00 UTC -1 day 0/inf 1\n" 36 | "CYCLIC foo() 2021-05-26 03:54:59 UTC -0:00:00 0/inf 0.333#\n" 37 | "HOURLY print(?) 2021-05-26 03:55:00 UTC 0:00:00 0/inf 20\n" 38 | "ONCE print(?) 2021-06-08 23:45:59 UTC 13 days 0/1 1\n" 39 | ) 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "patch_datetime_now, job_kwargs, tzinfo, res", 44 | [ 45 | (patch_samples, job_args, None, table), 46 | (patch_samples_utc, job_args_utc, utc, table_utc), 47 | ], 48 | indirect=["patch_datetime_now"], 49 | ) 50 | def test_sch_str( 51 | patch_datetime_now: Any, 52 | job_kwargs: tuple[dict[str, Any], ...], 53 | tzinfo: Optional[dt.tzinfo], 54 | res: str, 55 | ) -> None: 56 | jobs = [Job(**kwargs) for kwargs in job_kwargs] 57 | sch = Scheduler(tzinfo=tzinfo, jobs=jobs) 58 | assert str(sch) == res 59 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_threading.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import threading 3 | import time 4 | 5 | import pytest 6 | 7 | from scheduler import Scheduler 8 | 9 | 10 | def wrap_sleep(secs: float) -> None: 11 | time.sleep(secs) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "duration", 16 | ( 17 | 0.000001, 18 | 0.0001, 19 | 0.01, 20 | 0.02, 21 | ), 22 | ) 23 | def test_thread_safety(duration: float) -> None: 24 | sch = Scheduler() 25 | sch.cyclic(dt.timedelta(), wrap_sleep, kwargs={"secs": duration}, skip_missing=True) 26 | thread_1 = threading.Thread(target=sch.exec_jobs) 27 | thread_2 = threading.Thread(target=sch.exec_jobs) 28 | thread_1.daemon = True 29 | thread_2.daemon = True 30 | start_time = time.perf_counter() 31 | thread_1.start() 32 | thread_2.start() 33 | thread_1.join() 34 | thread_2.join() 35 | total_time = time.perf_counter() - start_time 36 | assert total_time > duration * 2 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "n_threads, max_exec, n_jobs, res_n_exec", 41 | [ 42 | (1, 0, 2, [2]), # no threading 43 | (2, 0, 10, [10]), 44 | (0, 0, 10, [10]), 45 | (3, 0, 10, [10]), 46 | (3, 3, 10, [3, 3, 3, 1]), 47 | (3, 2, 10, [2, 2, 2, 2, 2]), 48 | ], 49 | ) 50 | def test_worker_count(n_threads: int, max_exec: int, n_jobs: int, res_n_exec: list[int]) -> None: 51 | sch = Scheduler(n_threads=n_threads, max_exec=max_exec) 52 | 53 | for _ in range(n_jobs): 54 | sch.once(dt.timedelta(), lambda: None) 55 | 56 | results = [] 57 | for _ in range(len(res_n_exec)): 58 | results.append(sch.exec_jobs()) 59 | 60 | assert results == res_n_exec 61 | 62 | 63 | @pytest.mark.parametrize( 64 | "job_sleep, n_threads, max_exec, n_jobs, res_n_exec", 65 | [ 66 | (0.0005, 1, 0, 2, [2, 2, 2, 2]), # simple case: no threading 67 | (0.0005, 2, 0, 2, [2, 2, 2, 2]), # simple case: 2 threads, 2 slow jobs 68 | (0.0005, 2, 0, 4, [4, 4, 4, 4]), # 2 threads, 4 slow jobs 69 | (0.0005, 4, 0, 2, [2, 2, 2, 2]), # 4 threads, 2 slow jobs 70 | (0.0005, 4, 2, 3, [2, 2, 2, 2]), # 4 threads, exec limit, 3 slow jobs 71 | (0.0005, 4, 4, 3, [3, 3, 3, 3]), # 4 threads, exec limit, 3 slow jobs 72 | ], 73 | ) 74 | def test_threading_slow_jobs( 75 | job_sleep: float, 76 | n_threads: int, 77 | max_exec: int, 78 | n_jobs: int, 79 | res_n_exec: list[int], 80 | recwarn: pytest.WarningsRecorder, 81 | ) -> None: 82 | sch = Scheduler(n_threads=n_threads, max_exec=max_exec) 83 | 84 | for _ in range(n_jobs): 85 | sch.cyclic( 86 | dt.timedelta(), 87 | wrap_sleep, 88 | kwargs={"secs": job_sleep}, 89 | delay=False, 90 | ) 91 | warn = recwarn.pop(DeprecationWarning) 92 | assert ( 93 | str(warn.message) 94 | == "Using the `delay` argument is deprecated and will be removed in the next minor release." 95 | ) 96 | 97 | results = [] 98 | for _ in range(len(res_n_exec)): 99 | results.append(sch.exec_jobs()) 100 | assert results == res_n_exec 101 | -------------------------------------------------------------------------------- /tests/threading/scheduler/test_sch_weekly.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import scheduler.trigger as trigger 7 | from scheduler import Scheduler, SchedulerError 8 | 9 | from ...helpers import ( 10 | DUPLICATE_EFFECTIVE_TIME, 11 | TZ_ERROR_MSG, 12 | WEEKLY_TYPE_ERROR_MSG, 13 | foo, 14 | samples_weeks, 15 | samples_weeks_utc, 16 | utc, 17 | ) 18 | 19 | MONDAY_23_UTC = trigger.Monday(dt.time(hour=23, tzinfo=dt.timezone.utc)) 20 | MONDAY_23_UTC_AS_SUNDAY = trigger.Sunday( 21 | dt.time( 22 | hour=23, 23 | minute=30, 24 | tzinfo=dt.timezone(-dt.timedelta(hours=23, minutes=30)), 25 | ) 26 | ) 27 | MONDAY_23_UTC_AS_TUESDAY = trigger.Tuesday( 28 | dt.time(hour=1, tzinfo=dt.timezone(dt.timedelta(hours=2))), 29 | ) 30 | FRIDAY_4 = trigger.Friday(dt.time(hour=4, tzinfo=None)) 31 | FRIDAY_4_UTC = trigger.Friday(dt.time(hour=4, tzinfo=utc)) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "timing, counts, patch_datetime_now, tzinfo, err_msg", 36 | ( 37 | [ 38 | trigger.Friday(), 39 | [1, 2, 2, 2, 3, 3, 4, 4], 40 | samples_weeks, 41 | None, 42 | None, 43 | ], 44 | [ 45 | FRIDAY_4_UTC, 46 | [1, 1, 2, 2, 2, 3, 3, 4], 47 | samples_weeks_utc, 48 | utc, 49 | None, 50 | ], 51 | [ 52 | trigger.Sunday(), 53 | [1, 1, 1, 2, 2, 3, 3, 3], 54 | samples_weeks, 55 | None, 56 | None, 57 | ], 58 | [ 59 | [trigger.Wednesday(), trigger.Wednesday()], 60 | [], 61 | samples_weeks, 62 | None, 63 | DUPLICATE_EFFECTIVE_TIME, 64 | ], 65 | [ 66 | [MONDAY_23_UTC_AS_SUNDAY, MONDAY_23_UTC], 67 | [], 68 | samples_weeks_utc, 69 | utc, 70 | DUPLICATE_EFFECTIVE_TIME, 71 | ], 72 | [ 73 | [MONDAY_23_UTC, MONDAY_23_UTC_AS_TUESDAY], 74 | [], 75 | samples_weeks_utc, 76 | utc, 77 | DUPLICATE_EFFECTIVE_TIME, 78 | ], 79 | [ 80 | [MONDAY_23_UTC_AS_SUNDAY, MONDAY_23_UTC_AS_TUESDAY], 81 | [], 82 | samples_weeks_utc, 83 | utc, 84 | DUPLICATE_EFFECTIVE_TIME, 85 | ], 86 | [ 87 | [trigger.Wednesday(), trigger.Sunday()], 88 | [1, 2, 2, 3, 4, 5, 6, 6], 89 | samples_weeks_utc, 90 | None, 91 | None, 92 | ], 93 | [ 94 | [FRIDAY_4_UTC, FRIDAY_4], 95 | [], 96 | samples_weeks_utc, 97 | utc, 98 | TZ_ERROR_MSG, 99 | ], 100 | [dt.time(), [], samples_weeks, None, WEEKLY_TYPE_ERROR_MSG], 101 | [dt.timedelta(), [], samples_weeks, None, WEEKLY_TYPE_ERROR_MSG], 102 | ), 103 | indirect=["patch_datetime_now"], 104 | ) 105 | def test_weekly( 106 | timing: dt.timedelta, 107 | counts: list[int], 108 | patch_datetime_now: Any, 109 | tzinfo: Optional[dt.tzinfo], 110 | err_msg: Optional[str], 111 | ) -> None: 112 | sch = Scheduler(tzinfo=tzinfo) 113 | 114 | if err_msg: 115 | with pytest.raises(SchedulerError, match=err_msg): 116 | job = sch.weekly(timing=timing, handle=foo) 117 | else: 118 | job = sch.weekly(timing=timing, handle=foo) 119 | for count in counts: 120 | sch.exec_jobs() 121 | assert job.attempts == count 122 | --------------------------------------------------------------------------------