├── .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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------