├── .github └── workflows │ └── ci.yml ├── .gitignore ├── AUTHORS.rst ├── HISTORY.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ └── custom.css ├── _templates │ └── sidebarintro.html ├── background-execution.rst ├── changelog.rst ├── conf.py ├── development.rst ├── examples.rst ├── exception-handling.rst ├── faq.rst ├── index.rst ├── installation.rst ├── logging.rst ├── multiple-schedulers.rst ├── parallel-execution.rst ├── reference.rst └── timezones.rst ├── pyproject.toml ├── requirements-dev.txt ├── schedule ├── __init__.py └── py.typed ├── setup.cfg ├── setup.py ├── test_schedule.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 6 16 | matrix: 17 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: pip install tox tox-gh-actions 26 | - name: Tests 27 | run: tox 28 | - name: Coveralls 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | COVERALLS_FLAG_NAME: python-${{ matrix.python-version }} 32 | COVERALLS_PARALLEL: true 33 | run: | 34 | pip3 install coveralls 35 | coveralls --service=github 36 | coveralls: 37 | # Notify coveralls that the built has finished so they can 38 | # combine the results and post a comment with the summary. 39 | name: coverage push 40 | needs: test 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Set up Python 3.11 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: 3.11 47 | - name: Finished 48 | run: | 49 | pip3 install coveralls 50 | coveralls --finish --service=github 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | docs: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v3 58 | - name: Set up Python 3.11 59 | uses: actions/setup-python@v4 60 | with: 61 | python-version: 3.11 62 | - name: Install dependencies 63 | run: pip install tox 64 | - name: Check docs 65 | run: tox -e docs 66 | 67 | formatting: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v3 71 | - name: Set up Python 3.11 72 | uses: actions/setup-python@v4 73 | with: 74 | python-version: 3.11 75 | - name: Install dependencies 76 | run: pip install tox 77 | - name: Check formatting 78 | run: tox -e format 79 | 80 | setuppy: 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v3 84 | - name: Set up Python 3.11 85 | uses: actions/setup-python@v4 86 | with: 87 | python-version: 3.11 88 | - name: Install dependencies 89 | run: pip install tox 90 | - name: Check docs 91 | run: tox -e setuppy 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | MANIFEST 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | env 39 | env3 40 | __pycache__ 41 | venv 42 | 43 | .cache 44 | docs/_build 45 | 46 | # For Idea (e.g. PyCharm) users 47 | .idea 48 | *.iml 49 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Thanks to all the wonderful folks who have contributed to schedule over the years: 2 | 3 | - mattss 4 | - mrhwick 5 | - cfrco 6 | - matrixise 7 | - abultman 8 | - mplewis 9 | - WoLfulus 10 | - dylwhich 11 | - fkromer 12 | - alaingilbert 13 | - Zerrossetto 14 | - yetingsky 15 | - schnepp 16 | - grampajoe 17 | - gilbsgilbs 18 | - Nathan Wailes 19 | - Connor Skees 20 | - qmorek 21 | - aisk 22 | - MichaelCorleoneLi 23 | - sijmenhuizenga 24 | - eladbi 25 | - chankeypathak 26 | - vubon 27 | - gaguirregabiria 28 | - rhagenaars 29 | - Skenvy 30 | - zcking 31 | - Martin Thoma 32 | - ebllg 33 | - fredthomsen 34 | - biggerfisch 35 | - sosolidkk 36 | - rudSarkar 37 | - chrimaho 38 | - jweijers 39 | - Akuli 40 | - NaelsonDouglas 41 | - SergBobrovsky 42 | - CPickens42 43 | - emollier 44 | - sunpro108 45 | - kurtasov 46 | - AnezeR 47 | - a-detiste 48 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | 1.2.2 (2024-05-25) 7 | ++++++++++++++++++ 8 | 9 | - Fix bugs in cross-timezone scheduling (#601, #602, #604, #623) 10 | - Add support for python 3.12 (#606) 11 | - Remove dependency on old mock (#622) Thanks @a-detiste! 12 | 13 | 14 | 1.2.1 (2023-11-01) 15 | ++++++++++++++++++ 16 | 17 | - Fix bug where schedule was off when using .at with timezone (#583) Thanks @AnezeR! 18 | 19 | 20 | 1.2.0 (2023-04-10) 21 | ++++++++++++++++++ 22 | 23 | - Dropped support for Python 3.6, add support for Python 3.10 and 3.11. 24 | - Add timezone support for .at(). See #517. Thanks @chrimaho! 25 | - Get next run by tag (#463) Thanks @jweijers! 26 | - Add py.typed file. See #521. Thanks @Akuli! 27 | 28 | - Fix the re pattern of the 'days'. See #506 Thanks @sunpro108! 29 | - Fix test_until_time failure when run early. See #563. Thanks @emollier! 30 | - Fix crash repr on partially constructed job. See #569. Thanks @CPickens42! 31 | - Code cleanup and modernization. See #567, #536. Thanks @masa-08 and @SergBobrovsky! 32 | - Documentation improvements and fix typos. See #469, #479, #493, #519, #520. Thanks to @NaelsonDouglas, @chrimaho, @rudSarkar 33 | 34 | 1.1.0 (2021-04-09) 35 | ++++++++++++++++++ 36 | 37 | - Added @repeat() decorator. See #148. Thanks @rhagenaars! 38 | - Added execute .until(). See #195. Thanks @fredthomsen! 39 | - Added job retrieval filtered by tags using get_jobs('tag'). See #419. Thanks @skenvy! 40 | - Added type annotations. See #427. Thanks @martinthoma! 41 | 42 | - Bugfix: str() of job when there is no __name__. See #430. Thanks @biggerfisch! 43 | - Improved error messages. See #280, #439. Thanks @connorskees and @sosolidkk! 44 | - Improved logging. See #193. Thanks @zcking! 45 | - Documentation improvements and fix typos. See #424, #435, #436, #453, #437, #448. Thanks @ebllg! 46 | 47 | 1.0.0 (2021-01-20) 48 | ++++++++++++++++++ 49 | 50 | Depending on your configuration, the following bugfixes might change schedule's behaviour: 51 | 52 | - Fix: idle_seconds crashes when no jobs are scheduled. See #401. Thanks @yoonghm! 53 | - Fix: day.at('HH:MM:SS') where HMS=now+10s doesn't run today. See #331. Thanks @qmorek! 54 | - Fix: hour.at('MM:SS'), the seconds are set to 00. See #290. Thanks @eladbi! 55 | - Fix: Long-running jobs skip a day when they finish in the next day #404. Thanks @4379711! 56 | 57 | Other changes: 58 | 59 | - Dropped Python 2.7 and 3.5 support, added 3.8 and 3.9 support. See #409 60 | - Fix RecursionError when the job is passed to the do function as an arg. See #190. Thanks @connorskees! 61 | - Fix DeprecationWarning of 'collections'. See #296. Thanks @gaguirregabiria! 62 | - Replaced Travis with Github Actions for automated testing 63 | - Revamp and extend documentation. See #395 64 | - Improved tests. Thanks @connorskees and @Jamim! 65 | - Changed log messages to DEBUG level. Thanks @aisk! 66 | 67 | 68 | 0.6.0 (2019-01-20) 69 | ++++++++++++++++++ 70 | 71 | - Make at() accept timestamps with 1 second precision (#267). Thanks @NathanWailes! 72 | - Introduce proper exception hierarchy (#271). Thanks @ConnorSkees! 73 | 74 | 75 | 0.5.0 (2017-11-16) 76 | ++++++++++++++++++ 77 | 78 | - Keep partially scheduled jobs from breaking the scheduler (#125) 79 | - Add support for random intervals (Thanks @grampajoe and @gilbsgilbs) 80 | 81 | 82 | 0.4.3 (2017-06-10) 83 | ++++++++++++++++++ 84 | 85 | - Improve docs & clean up docstrings 86 | 87 | 88 | 0.4.2 (2016-11-29) 89 | ++++++++++++++++++ 90 | 91 | - Publish to PyPI as a universal (py2/py3) wheel 92 | 93 | 94 | 0.4.0 (2016-11-28) 95 | ++++++++++++++++++ 96 | 97 | - Add proper HTML (Sphinx) docs available at https://schedule.readthedocs.io/ 98 | - CI builds now run against Python 2.7 and 3.5 (3.3 and 3.4 should work fine but are untested) 99 | - Fixed an issue with ``run_all()`` and having more than one job that deletes itself in the same iteration. Thanks @alaingilbert. 100 | - Add ability to tag jobs and to cancel jobs by tag. Thanks @Zerrossetto. 101 | - Improve schedule docs. Thanks @Zerrossetto. 102 | - Additional docs fixes by @fkromer and @yetingsky. 103 | 104 | 0.3.2 (2015-07-02) 105 | ++++++++++++++++++ 106 | 107 | - Fixed issues where scheduling a job with a functools.partial as the job function fails. Thanks @dylwhich. 108 | - Fixed an issue where scheduling a job to run every >= 2 days would cause the initial execution to happen one day early. Thanks @WoLfulus for identifying this and providing a fix. 109 | - Added a FAQ item to describe how to schedule a job that runs only once. 110 | 111 | 0.3.1 (2014-09-03) 112 | ++++++++++++++++++ 113 | 114 | - Fixed an issue with unicode handling in setup.py that was causing trouble on Python 3 and Debian (https://github.com/dbader/schedule/issues/27). Thanks to @waghanza for reporting it. 115 | - Added an FAQ item to describe how to deal with job functions that throw exceptions. Thanks @mplewis. 116 | 117 | 0.3.0 (2014-06-14) 118 | ++++++++++++++++++ 119 | 120 | - Added support for scheduling jobs on specific weekdays. Example: ``schedule.every().tuesday.do(job)`` or ``schedule.every().wednesday.at("13:15").do(job)`` (Thanks @abultman.) 121 | - Run tests against Python 2.7 and 3.4. Python 3.3 should continue to work but we're not actively testing it on CI anymore. 122 | 123 | 0.2.1 (2013-11-20) 124 | ++++++++++++++++++ 125 | 126 | - Fixed history (no code changes). 127 | 128 | 0.2.0 (2013-11-09) 129 | ++++++++++++++++++ 130 | 131 | - This release introduces two new features in a backwards compatible way: 132 | - Allow jobs to cancel repeated execution: Jobs can be cancelled by calling ``schedule.cancel_job()`` or by returning ``schedule.CancelJob`` from the job function. (Thanks to @cfrco and @matrixise.) 133 | - Updated ``at_time()`` to allow running jobs at a particular time every hour. Example: ``every().hour.at(':15').do(job)`` will run ``job`` 15 minutes after every full hour. (Thanks @mattss.) 134 | - Refactored unit tests to mock ``datetime`` in a cleaner way. (Thanks @matts.) 135 | 136 | 0.1.11 (2013-07-30) 137 | +++++++++++++++++++ 138 | 139 | - Fixed an issue with ``next_run()`` throwing a ``ValueError`` exception when the job queue is empty. Thanks to @dpagano for pointing this out and thanks to @mrhwick for quickly providing a fix. 140 | 141 | 0.1.10 (2013-06-07) 142 | +++++++++++++++++++ 143 | 144 | - Fixed issue with ``at_time`` jobs not running on the same day the job is created (Thanks to @mattss) 145 | 146 | 0.1.9 (2013-05-27) 147 | ++++++++++++++++++ 148 | 149 | - Added ``schedule.next_run()`` 150 | - Added ``schedule.idle_seconds()`` 151 | - Args passed into ``do()`` are forwarded to the job function at call time 152 | - Increased test coverage to 100% 153 | 154 | 155 | 0.1.8 (2013-05-21) 156 | ++++++++++++++++++ 157 | 158 | - Changed default ``delay_seconds`` for ``schedule.run_all()`` to 0 (from 60) 159 | - Increased test coverage 160 | 161 | 0.1.7 (2013-05-20) 162 | ++++++++++++++++++ 163 | 164 | - API change: renamed ``schedule.run_all_jobs()`` to ``schedule.run_all()`` 165 | - API change: renamed ``schedule.run_pending_jobs()`` to ``schedule.run_pending()`` 166 | - API change: renamed ``schedule.clear_all_jobs()`` to ``schedule.clear()`` 167 | - Added ``schedule.jobs`` 168 | 169 | 0.1.6 (2013-05-20) 170 | ++++++++++++++++++ 171 | 172 | - Fix packaging 173 | - README fixes 174 | 175 | 0.1.4 (2013-05-20) 176 | ++++++++++++++++++ 177 | 178 | - API change: renamed ``schedule.tick()`` to ``schedule.run_pending_jobs()`` 179 | - Updated README and ``setup.py`` packaging 180 | 181 | 0.1.0 (2013-05-19) 182 | ++++++++++++++++++ 183 | 184 | - Initial release 185 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Daniel Bader (http://dbader.org) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include HISTORY.rst 3 | include LICENSE.txt 4 | 5 | include test_schedule.py 6 | 7 | recursive-exclude * __pycache__ 8 | recursive-exclude * *.py[co] 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | `schedule `__ 2 | =============================================== 3 | 4 | 5 | .. image:: https://github.com/dbader/schedule/workflows/Tests/badge.svg 6 | :target: https://github.com/dbader/schedule/actions?query=workflow%3ATests+branch%3Amaster 7 | 8 | .. image:: https://coveralls.io/repos/dbader/schedule/badge.svg?branch=master 9 | :target: https://coveralls.io/r/dbader/schedule 10 | 11 | .. image:: https://img.shields.io/pypi/v/schedule.svg 12 | :target: https://pypi.python.org/pypi/schedule 13 | 14 | Python job scheduling for humans. Run Python functions (or any other callable) periodically using a friendly syntax. 15 | 16 | - A simple to use API for scheduling jobs, made for humans. 17 | - In-process scheduler for periodic jobs. No extra processes needed! 18 | - Very lightweight and no external dependencies. 19 | - Excellent test coverage. 20 | - Tested on Python and 3.7, 3.8, 3.9, 3.10, 3.11, 3.12 21 | 22 | Usage 23 | ----- 24 | 25 | .. code-block:: bash 26 | 27 | $ pip install schedule 28 | 29 | .. code-block:: python 30 | 31 | import schedule 32 | import time 33 | 34 | def job(): 35 | print("I'm working...") 36 | 37 | schedule.every(10).seconds.do(job) 38 | schedule.every(10).minutes.do(job) 39 | schedule.every().hour.do(job) 40 | schedule.every().day.at("10:30").do(job) 41 | schedule.every(5).to(10).minutes.do(job) 42 | schedule.every().monday.do(job) 43 | schedule.every().wednesday.at("13:15").do(job) 44 | schedule.every().day.at("12:42", "Europe/Amsterdam").do(job) 45 | schedule.every().minute.at(":17").do(job) 46 | 47 | def job_with_argument(name): 48 | print(f"I am {name}") 49 | 50 | schedule.every(10).seconds.do(job_with_argument, name="Peter") 51 | 52 | while True: 53 | schedule.run_pending() 54 | time.sleep(1) 55 | 56 | Documentation 57 | ------------- 58 | 59 | Schedule's documentation lives at `schedule.readthedocs.io `_. 60 | 61 | 62 | Meta 63 | ---- 64 | 65 | Daniel Bader - `@dbader_org `_ - mail@dbader.org 66 | 67 | Inspired by `Adam Wiggins' `_ article `"Rethinking Cron" `_ and the `clockwork `_ Ruby module. 68 | 69 | Distributed under the MIT license. See `LICENSE.txt `_ for more information. 70 | 71 | https://github.com/dbader/schedule 72 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/schedule.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/schedule.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/schedule" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/schedule" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | .toctree-l1 { 2 | padding-bottom: 4px; 3 | } -------------------------------------------------------------------------------- /docs/_templates/sidebarintro.html: -------------------------------------------------------------------------------- 1 |

📰 Useful Links

2 | 7 | 8 |

🐍 More Python

9 | 10 |

11 | 16 | -------------------------------------------------------------------------------- /docs/background-execution.rst: -------------------------------------------------------------------------------- 1 | Run in the background 2 | ===================== 3 | 4 | Out of the box it is not possible to run the schedule in the background. 5 | However, you can create a thread yourself and use it to run jobs without blocking the main thread. 6 | This is an example of how you could do this: 7 | 8 | .. code-block:: python 9 | 10 | import threading 11 | import time 12 | 13 | import schedule 14 | 15 | 16 | def run_continuously(interval=1): 17 | """Continuously run, while executing pending jobs at each 18 | elapsed time interval. 19 | @return cease_continuous_run: threading. Event which can 20 | be set to cease continuous run. Please note that it is 21 | *intended behavior that run_continuously() does not run 22 | missed jobs*. For example, if you've registered a job that 23 | should run every minute and you set a continuous run 24 | interval of one hour then your job won't be run 60 times 25 | at each interval but only once. 26 | """ 27 | cease_continuous_run = threading.Event() 28 | 29 | class ScheduleThread(threading.Thread): 30 | @classmethod 31 | def run(cls): 32 | while not cease_continuous_run.is_set(): 33 | schedule.run_pending() 34 | time.sleep(interval) 35 | 36 | continuous_thread = ScheduleThread() 37 | continuous_thread.start() 38 | return cease_continuous_run 39 | 40 | 41 | def background_job(): 42 | print('Hello from the background thread') 43 | 44 | 45 | schedule.every().second.do(background_job) 46 | 47 | # Start the background thread 48 | stop_run_continuously = run_continuously() 49 | 50 | # Do some other things... 51 | time.sleep(10) 52 | 53 | # Stop the background thread 54 | stop_run_continuously.set() 55 | 56 | 57 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # schedule documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Nov 7 15:14:48 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # (schedule modules lives up one level from docs/) 20 | # 21 | import os 22 | import sys 23 | 24 | sys.path.insert(0, os.path.abspath("..")) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.todo", 38 | "sphinx.ext.coverage", 39 | "sphinx.ext.viewcode", 40 | # 'sphinx.ext.githubpages', # This breaks the ReadTheDocs build 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = ".rst" 51 | 52 | # The encoding of source files. 53 | # 54 | # source_encoding = 'utf-8-sig' 55 | 56 | # The master toctree document. 57 | master_doc = "index" 58 | 59 | # General information about the project. 60 | project = u"schedule" 61 | copyright = u'2020, Daniel Bader' 62 | author = u'Daniel Bader' 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # 68 | # The short X.Y version. 69 | version = u"1.2.2" 70 | # The full version, including alpha/beta/rc tags. 71 | release = u"1.2.2" 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | # 76 | # This is also used if you do content translation via gettext catalogs. 77 | # Usually you set "language" from the command line for these cases. 78 | language = "en" 79 | 80 | # There are two options for replacing |today|: either, you set today to some 81 | # non-false value, then it is used: 82 | # 83 | # today = '' 84 | # 85 | # Else, today_fmt is used as the format for a strftime call. 86 | # 87 | # today_fmt = '%B %d, %Y' 88 | 89 | # List of patterns, relative to source directory, that match files and 90 | # directories to ignore when looking for source files. 91 | # This patterns also effect to html_static_path and html_extra_path 92 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 93 | 94 | # The reST default role (used for this markup: `text`) to use for all 95 | # documents. 96 | # 97 | # default_role = None 98 | 99 | # If true, '()' will be appended to :func: etc. cross-reference text. 100 | # 101 | # add_function_parentheses = True 102 | 103 | # If true, the current module name will be prepended to all description 104 | # unit titles (such as .. function::). 105 | # 106 | # add_module_names = True 107 | 108 | # If true, sectionauthor and moduleauthor directives will be shown in the 109 | # output. They are ignored by default. 110 | # 111 | # show_authors = False 112 | 113 | # The name of the Pygments (syntax highlighting) style to use. 114 | # pygments_style = 'flask_theme_support.FlaskyStyle' 115 | 116 | # A list of ignored prefixes for module index sorting. 117 | # modindex_common_prefix = [] 118 | 119 | # If true, keep warnings as "system message" paragraphs in the built documents. 120 | # keep_warnings = False 121 | 122 | # If true, `todo` and `todoList` produce output, else they produce nothing. 123 | todo_include_todos = True 124 | 125 | 126 | # -- Options for HTML output ---------------------------------------------- 127 | 128 | # The theme to use for HTML and HTML Help pages. See the documentation for 129 | # a list of builtin themes. 130 | # 131 | html_theme = "alabaster" 132 | 133 | # Theme options are theme-specific and customize the look and feel of a theme 134 | # further. For a list of options available for each theme, see the 135 | # documentation. 136 | # 137 | html_theme_options = { 138 | "show_powered_by": False, 139 | "github_user": "dbader", 140 | "github_repo": "schedule", 141 | "github_banner": True, 142 | "github_button": True, 143 | "github_type": "star", 144 | "show_related": False, 145 | } 146 | 147 | # Add any paths that contain custom themes here, relative to this directory. 148 | # html_theme_path = [] 149 | 150 | # The name for this set of Sphinx documents. 151 | # " v documentation" by default. 152 | # 153 | # html_title = u'schedule v0.4.0' 154 | 155 | # A shorter title for the navigation bar. Default is the same as html_title. 156 | # 157 | # html_short_title = None 158 | 159 | # The name of an image file (relative to this directory) to place at the top 160 | # of the sidebar. 161 | # 162 | # html_logo = None 163 | 164 | # The name of an image file (relative to this directory) to use as a favicon of 165 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 166 | # pixels large. 167 | # 168 | # html_favicon = None 169 | 170 | # Add any paths that contain custom static files (such as style sheets) here, 171 | # relative to this directory. They are copied after the builtin static files, 172 | # so a file named "default.css" will overwrite the builtin "default.css". 173 | html_static_path = ["_static"] 174 | 175 | # Add any extra paths that contain custom files (such as robots.txt or 176 | # .htaccess) here, relative to this directory. These files are copied 177 | # directly to the root of the documentation. 178 | # 179 | # html_extra_path = [] 180 | 181 | # If not None, a 'Last updated on:' timestamp is inserted at every page 182 | # bottom, using the given strftime format. 183 | # The empty string is equivalent to '%b %d, %Y'. 184 | # 185 | # html_last_updated_fmt = None 186 | 187 | # If true, SmartyPants will be used to convert quotes and dashes to 188 | # typographically correct entities. 189 | # 190 | html_use_smartypants = True 191 | 192 | # Custom sidebar templates, maps document names to template names. 193 | # 194 | html_sidebars = { 195 | "**": [ 196 | "about.html", 197 | "globaltoc.html", 198 | "sidebarintro.html", 199 | "relations.html", 200 | "searchbox.html", 201 | ], 202 | } 203 | 204 | # Additional templates that should be rendered to pages, maps page names to 205 | # template names. 206 | # 207 | # html_additional_pages = {} 208 | 209 | # If false, no module index is generated. 210 | # 211 | # html_domain_indices = True 212 | 213 | # If false, no index is generated. 214 | # 215 | # html_use_index = True 216 | 217 | # If true, the index is split into individual pages for each letter. 218 | # 219 | # html_split_index = False 220 | 221 | # If true, links to the reST sources are added to the pages. 222 | # 223 | html_show_sourcelink = False 224 | 225 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 226 | # 227 | html_show_sphinx = False 228 | 229 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 230 | # 231 | html_show_copyright = True 232 | 233 | # If true, an OpenSearch description file will be output, and all pages will 234 | # contain a tag referring to it. The value of this option must be the 235 | # base URL from which the finished HTML is served. 236 | # 237 | # html_use_opensearch = '' 238 | 239 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 240 | # html_file_suffix = None 241 | 242 | # Language to be used for generating the HTML full-text search index. 243 | # Sphinx supports the following languages: 244 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 245 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' 246 | # 247 | # html_search_language = 'en' 248 | 249 | # A dictionary with options for the search language support, empty by default. 250 | # 'ja' uses this config value. 251 | # 'zh' user can custom change `jieba` dictionary path. 252 | # 253 | # html_search_options = {'type': 'default'} 254 | 255 | # The name of a javascript file (relative to the configuration directory) that 256 | # implements a search results scorer. If empty, the default will be used. 257 | # 258 | # html_search_scorer = 'scorer.js' 259 | 260 | # Output file base name for HTML help builder. 261 | htmlhelp_basename = "scheduledoc" 262 | 263 | # -- Options for LaTeX output --------------------------------------------- 264 | 265 | latex_elements = { 266 | # The paper size ('letterpaper' or 'a4paper'). 267 | # 268 | # 'papersize': 'letterpaper', 269 | # The font size ('10pt', '11pt' or '12pt'). 270 | # 271 | # 'pointsize': '10pt', 272 | # Additional stuff for the LaTeX preamble. 273 | # 274 | # 'preamble': '', 275 | # Latex figure (float) alignment 276 | # 277 | # 'figure_align': 'htbp', 278 | } 279 | 280 | # Grouping the document tree into LaTeX files. List of tuples 281 | # (source start file, target name, title, 282 | # author, documentclass [howto, manual, or own class]). 283 | latex_documents = [ 284 | ( 285 | master_doc, 286 | "schedule.tex", 287 | u"schedule Documentation", 288 | u"Daniel Bader", 289 | "manual", 290 | ), 291 | ] 292 | 293 | # The name of an image file (relative to this directory) to place at the top of 294 | # the title page. 295 | # 296 | # latex_logo = None 297 | 298 | # For "manual" documents, if this is true, then toplevel headings are parts, 299 | # not chapters. 300 | # 301 | # latex_use_parts = False 302 | 303 | # If true, show page references after internal links. 304 | # 305 | # latex_show_pagerefs = False 306 | 307 | # If true, show URL addresses after external links. 308 | # 309 | # latex_show_urls = False 310 | 311 | # Documents to append as an appendix to all manuals. 312 | # 313 | # latex_appendices = [] 314 | 315 | # It false, will not define \strong, \code, itleref, \crossref ... but only 316 | # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added 317 | # packages. 318 | # 319 | # latex_keep_old_macro_names = True 320 | 321 | # If false, no module index is generated. 322 | # 323 | # latex_domain_indices = True 324 | 325 | 326 | # -- Options for manual page output --------------------------------------- 327 | 328 | # One entry per manual page. List of tuples 329 | # (source start file, name, description, authors, manual section). 330 | man_pages = [(master_doc, "schedule", u"schedule Documentation", [author], 1)] 331 | 332 | # If true, show URL addresses after external links. 333 | # 334 | # man_show_urls = False 335 | 336 | 337 | # -- Options for Texinfo output ------------------------------------------- 338 | 339 | # Grouping the document tree into Texinfo files. List of tuples 340 | # (source start file, target name, title, author, 341 | # dir menu entry, description, category) 342 | texinfo_documents = [ 343 | ( 344 | master_doc, 345 | "schedule", 346 | u"schedule Documentation", 347 | author, 348 | "schedule", 349 | "One line description of project.", 350 | "Miscellaneous", 351 | ), 352 | ] 353 | 354 | # Documents to append as an appendix to all manuals. 355 | # 356 | # texinfo_appendices = [] 357 | 358 | # If false, no module index is generated. 359 | # 360 | # texinfo_domain_indices = True 361 | 362 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 363 | # 364 | # texinfo_show_urls = 'footnote' 365 | 366 | # If true, do not generate a @detailmenu in the "Top" node's menu. 367 | # 368 | # texinfo_no_detailmenu = False 369 | 370 | autodoc_member_order = "bysource" 371 | 372 | # We're pulling in some external images like CI badges. 373 | suppress_warnings = ["image.nonlocal_uri"] 374 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | These instructions are geared towards people who want to help develop this library. 5 | 6 | Preparing for development 7 | ------------------------- 8 | All required tooling and libraries can be installed using the ``requirements-dev.txt`` file: 9 | 10 | .. code-block:: bash 11 | 12 | pip install -r requirements-dev.txt 13 | 14 | 15 | Running tests 16 | ------------- 17 | 18 | ``pytest`` is used to run tests. Run all tests with coverage and formatting checks: 19 | 20 | .. code-block:: bash 21 | 22 | py.test test_schedule.py --flake8 schedule -v --cov schedule --cov-report term-missing 23 | 24 | Formatting the code 25 | ------------------- 26 | This project uses `black formatter `_. 27 | To format the code, run: 28 | 29 | .. code-block:: bash 30 | 31 | black . 32 | 33 | Make sure you use version 20.8b1 of black. 34 | 35 | Compiling documentation 36 | ----------------------- 37 | 38 | The documentation is written in `reStructuredText `_. 39 | It is processed using `Sphinx `_ using the `alabaster `_ theme. 40 | After installing the development requirements it is just a matter of running: 41 | 42 | .. code-block:: bash 43 | 44 | cd docs 45 | make html 46 | 47 | The resulting html can be found in ``docs/_build/html`` 48 | 49 | Publish a new version 50 | --------------------- 51 | 52 | Update the ``HISTORY.rst`` and ``AUTHORS.rst`` files. 53 | Bump the version in ``setup.py`` and ``docs/conf.py``. 54 | Merge these changes into master. Finally: 55 | 56 | .. code-block:: bash 57 | 58 | git tag X.Y.Z -m "Release X.Y.Z" 59 | git push --tags 60 | 61 | pip install --upgrade setuptools twine wheel 62 | python3 -m build --wheel 63 | # For https://test.pypi.org/project/schedule/ 64 | twine upload --repository schedule-test dist/* 65 | # For https://pypi.org/project/schedule/ 66 | twine upload --repository schedule dist/* 67 | 68 | This project follows `semantic versioning `_.` 69 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Eager to get started? This page gives a good introduction to Schedule. 5 | It assumes you already have Schedule installed. If you do not, head over to :doc:`installation`. 6 | 7 | Run a job every x minute 8 | ~~~~~~~~~~~~~~~~~~~~~~~~ 9 | 10 | .. code-block:: python 11 | 12 | import schedule 13 | import time 14 | 15 | def job(): 16 | print("I'm working...") 17 | 18 | # Run job every 3 second/minute/hour/day/week, 19 | # Starting 3 second/minute/hour/day/week from now 20 | schedule.every(3).seconds.do(job) 21 | schedule.every(3).minutes.do(job) 22 | schedule.every(3).hours.do(job) 23 | schedule.every(3).days.do(job) 24 | schedule.every(3).weeks.do(job) 25 | 26 | # Run job every minute at the 23rd second 27 | schedule.every().minute.at(":23").do(job) 28 | 29 | # Run job every hour at the 42nd minute 30 | schedule.every().hour.at(":42").do(job) 31 | 32 | # Run jobs every 5th hour, 20 minutes and 30 seconds in. 33 | # If current time is 02:00, first execution is at 06:20:30 34 | schedule.every(5).hours.at("20:30").do(job) 35 | 36 | # Run job every day at specific HH:MM and next HH:MM:SS 37 | schedule.every().day.at("10:30").do(job) 38 | schedule.every().day.at("10:30:42").do(job) 39 | schedule.every().day.at("12:42", "Europe/Amsterdam").do(job) 40 | 41 | # Run job on a specific day of the week 42 | schedule.every().monday.do(job) 43 | schedule.every().wednesday.at("13:15").do(job) 44 | 45 | while True: 46 | schedule.run_pending() 47 | time.sleep(1) 48 | 49 | Use a decorator to schedule a job 50 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 51 | 52 | Use the ``@repeat`` to schedule a function. 53 | Pass it an interval using the same syntax as above while omitting the ``.do()``. 54 | 55 | .. code-block:: python 56 | 57 | from schedule import every, repeat, run_pending 58 | import time 59 | 60 | @repeat(every(10).minutes) 61 | def job(): 62 | print("I am a scheduled job") 63 | 64 | while True: 65 | run_pending() 66 | time.sleep(1) 67 | 68 | The ``@repeat`` decorator does not work on non-static class methods. 69 | 70 | Pass arguments to a job 71 | ~~~~~~~~~~~~~~~~~~~~~~~ 72 | 73 | ``do()`` passes extra arguments to the job function 74 | 75 | .. code-block:: python 76 | 77 | import schedule 78 | 79 | def greet(name): 80 | print('Hello', name) 81 | 82 | schedule.every(2).seconds.do(greet, name='Alice') 83 | schedule.every(4).seconds.do(greet, name='Bob') 84 | 85 | from schedule import every, repeat 86 | 87 | @repeat(every().second, "World") 88 | @repeat(every().day, "Mars") 89 | def hello(planet): 90 | print("Hello", planet) 91 | 92 | 93 | Cancel a job 94 | ~~~~~~~~~~~~ 95 | To remove a job from the scheduler, use the ``schedule.cancel_job(job)`` method 96 | 97 | .. code-block:: python 98 | 99 | import schedule 100 | 101 | def some_task(): 102 | print('Hello world') 103 | 104 | job = schedule.every().day.at('22:30').do(some_task) 105 | schedule.cancel_job(job) 106 | 107 | 108 | Run a job once 109 | ~~~~~~~~~~~~~~ 110 | 111 | Return ``schedule.CancelJob`` from a job to remove it from the scheduler. 112 | 113 | .. code-block:: python 114 | 115 | import schedule 116 | import time 117 | 118 | def job_that_executes_once(): 119 | # Do some work that only needs to happen once... 120 | return schedule.CancelJob 121 | 122 | schedule.every().day.at('22:30').do(job_that_executes_once) 123 | 124 | while True: 125 | schedule.run_pending() 126 | time.sleep(1) 127 | 128 | 129 | Get all jobs 130 | ~~~~~~~~~~~~ 131 | To retrieve all jobs from the scheduler, use ``schedule.get_jobs()`` 132 | 133 | .. code-block:: python 134 | 135 | import schedule 136 | 137 | def hello(): 138 | print('Hello world') 139 | 140 | schedule.every().second.do(hello) 141 | 142 | all_jobs = schedule.get_jobs() 143 | 144 | 145 | Cancel all jobs 146 | ~~~~~~~~~~~~~~~ 147 | To remove all jobs from the scheduler, use ``schedule.clear()`` 148 | 149 | .. code-block:: python 150 | 151 | import schedule 152 | 153 | def greet(name): 154 | print('Hello {}'.format(name)) 155 | 156 | schedule.every().second.do(greet) 157 | 158 | schedule.clear() 159 | 160 | 161 | Get several jobs, filtered by tags 162 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 163 | 164 | You can retrieve a group of jobs from the scheduler, selecting them by a unique identifier. 165 | 166 | .. code-block:: python 167 | 168 | import schedule 169 | 170 | def greet(name): 171 | print('Hello {}'.format(name)) 172 | 173 | schedule.every().day.do(greet, 'Andrea').tag('daily-tasks', 'friend') 174 | schedule.every().hour.do(greet, 'John').tag('hourly-tasks', 'friend') 175 | schedule.every().hour.do(greet, 'Monica').tag('hourly-tasks', 'customer') 176 | schedule.every().day.do(greet, 'Derek').tag('daily-tasks', 'guest') 177 | 178 | friends = schedule.get_jobs('friend') 179 | 180 | Will return a list of every job tagged as ``friend``. 181 | 182 | 183 | Cancel several jobs, filtered by tags 184 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 185 | 186 | You can cancel the scheduling of a group of jobs selecting them by a unique identifier. 187 | 188 | .. code-block:: python 189 | 190 | import schedule 191 | 192 | def greet(name): 193 | print('Hello {}'.format(name)) 194 | 195 | schedule.every().day.do(greet, 'Andrea').tag('daily-tasks', 'friend') 196 | schedule.every().hour.do(greet, 'John').tag('hourly-tasks', 'friend') 197 | schedule.every().hour.do(greet, 'Monica').tag('hourly-tasks', 'customer') 198 | schedule.every().day.do(greet, 'Derek').tag('daily-tasks', 'guest') 199 | 200 | schedule.clear('daily-tasks') 201 | 202 | Will prevent every job tagged as ``daily-tasks`` from running again. 203 | 204 | 205 | Run a job at random intervals 206 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 207 | 208 | .. code-block:: python 209 | 210 | def my_job(): 211 | print('Foo') 212 | 213 | # Run every 5 to 10 seconds. 214 | schedule.every(5).to(10).seconds.do(my_job) 215 | 216 | ``every(A).to(B).seconds`` executes the job function every N seconds such that A <= N <= B. 217 | 218 | 219 | Run a job until a certain time 220 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 221 | 222 | .. code-block:: python 223 | 224 | import schedule 225 | from datetime import datetime, timedelta, time 226 | 227 | def job(): 228 | print('Boo') 229 | 230 | # run job until a 18:30 today 231 | schedule.every(1).hours.until("18:30").do(job) 232 | 233 | # run job until a 2030-01-01 18:33 today 234 | schedule.every(1).hours.until("2030-01-01 18:33").do(job) 235 | 236 | # Schedule a job to run for the next 8 hours 237 | schedule.every(1).hours.until(timedelta(hours=8)).do(job) 238 | 239 | # Run my_job until today 11:33:42 240 | schedule.every(1).hours.until(time(11, 33, 42)).do(job) 241 | 242 | # run job until a specific datetime 243 | schedule.every(1).hours.until(datetime(2020, 5, 17, 11, 36, 20)).do(job) 244 | 245 | The ``until`` method sets the jobs deadline. The job will not run after the deadline. 246 | 247 | Time until the next execution 248 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 249 | Use ``schedule.idle_seconds()`` to get the number of seconds until the next job is scheduled to run. 250 | The returned value is negative if the next scheduled jobs was scheduled to run in the past. 251 | Returns ``None`` if no jobs are scheduled. 252 | 253 | .. code-block:: python 254 | 255 | import schedule 256 | import time 257 | 258 | def job(): 259 | print('Hello') 260 | 261 | schedule.every(5).seconds.do(job) 262 | 263 | while 1: 264 | n = schedule.idle_seconds() 265 | if n is None: 266 | # no more jobs 267 | break 268 | elif n > 0: 269 | # sleep exactly the right amount of time 270 | time.sleep(n) 271 | schedule.run_pending() 272 | 273 | 274 | Run all jobs now, regardless of their scheduling 275 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 276 | To run all jobs regardless if they are scheduled to run or not, use ``schedule.run_all()``. 277 | Jobs are re-scheduled after finishing, just like they would if they were executed using ``run_pending()``. 278 | 279 | .. code-block:: python 280 | 281 | import schedule 282 | 283 | def job_1(): 284 | print('Foo') 285 | 286 | def job_2(): 287 | print('Bar') 288 | 289 | schedule.every().monday.at("12:40").do(job_1) 290 | schedule.every().tuesday.at("16:40").do(job_2) 291 | 292 | schedule.run_all() 293 | 294 | # Add the delay_seconds argument to run the jobs with a number 295 | # of seconds delay in between. 296 | schedule.run_all(delay_seconds=10) 297 | -------------------------------------------------------------------------------- /docs/exception-handling.rst: -------------------------------------------------------------------------------- 1 | Exception Handling 2 | ################## 3 | 4 | Schedule doesn't catch exceptions that happen during job execution. Therefore any exceptions thrown during job execution will bubble up and interrupt schedule's run_xyz function. 5 | 6 | If you want to guard against exceptions you can wrap your job function 7 | in a decorator like this: 8 | 9 | .. code-block:: python 10 | 11 | import functools 12 | 13 | def catch_exceptions(cancel_on_failure=False): 14 | def catch_exceptions_decorator(job_func): 15 | @functools.wraps(job_func) 16 | def wrapper(*args, **kwargs): 17 | try: 18 | return job_func(*args, **kwargs) 19 | except: 20 | import traceback 21 | print(traceback.format_exc()) 22 | if cancel_on_failure: 23 | return schedule.CancelJob 24 | return wrapper 25 | return catch_exceptions_decorator 26 | 27 | @catch_exceptions(cancel_on_failure=True) 28 | def bad_task(): 29 | return 1 / 0 30 | 31 | schedule.every(5).minutes.do(bad_task) 32 | 33 | Another option would be to subclass Schedule like @mplewis did in `this example `_. 34 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | Frequently asked questions on the usage of schedule. 5 | Did you get here using an 'old' link and expected to see more questions? 6 | 7 | AttributeError: 'module' object has no attribute 'every' 8 | -------------------------------------------------------- 9 | 10 | I'm getting 11 | 12 | .. code-block:: text 13 | 14 | AttributeError: 'module' object has no attribute 'every' 15 | 16 | when I try to use schedule. 17 | 18 | This happens if your code imports the wrong ``schedule`` module. 19 | Make sure you don't have a ``schedule.py`` file in your project that overrides the ``schedule`` module provided by this library. 20 | 21 | 22 | ModuleNotFoundError: No module named 'schedule' 23 | ----------------------------------------------- 24 | 25 | It seems python can't find the schedule package. Let's check some common causes. 26 | 27 | Did you install schedule? If not, follow :doc:`installation`. Validate installation: 28 | 29 | * Did you install using pip? Run ``pip3 list | grep schedule``. This should return ``schedule 0.6.0`` (or a higher version number) 30 | * Did you install using apt? Run ``dpkg -l | grep python3-schedule``. This should return something along the lines of ``python3-schedule 0.3.2-1.1 Job scheduling for humans (Python 3)`` (or a higher version number) 31 | 32 | Are you used python 3 to install Schedule, and are running the script using python 3? 33 | For example, if you installed schedule using a version of pip that uses Python 2, and your code runs in Python 3, the package won't be found. 34 | In this case the solution is to install Schedule using pip3: ``pip3 install schedule``. 35 | 36 | Are you using virtualenv? Check that you are running the script inside the same virtualenv where you installed schedule. 37 | 38 | Is this problem occurring when running the program from inside and IDE like PyCharm or VSCode? 39 | Try to run your program from a commandline outside of the IDE. 40 | If it works there, the problem is with your IDE configuration. 41 | It might be that your IDE uses a different Python interpreter installation. 42 | 43 | Still having problems? Use Google and StackOverflow before submitting an issue. 44 | 45 | ModuleNotFoundError: ModuleNotFoundError: No module named 'pytz' 46 | ---------------------------------------------------------------- 47 | 48 | This error happens when you try to set a timezone in ``.at()`` without having the `pytz `_ package installed. 49 | Pytz is a required dependency when working with timezones. 50 | To resolve this issue, install the ``pytz`` module by running ``pip install pytz``. 51 | 52 | Does schedule support time zones? 53 | --------------------------------- 54 | Yes! See :doc:`Timezones `. 55 | 56 | What if my task throws an exception? 57 | ------------------------------------ 58 | See :doc:`Exception Handling `. 59 | 60 | How can I run a job only once? 61 | ------------------------------ 62 | See :doc:`Examples `. 63 | 64 | How can I cancel several jobs at once? 65 | -------------------------------------- 66 | See :doc:`Examples `. 67 | 68 | How to execute jobs in parallel? 69 | -------------------------------- 70 | See :doc:`Parallel Execution `. 71 | 72 | How to continuously run the scheduler without blocking the main thread? 73 | ----------------------------------------------------------------------- 74 | :doc:`Background Execution`. 75 | 76 | Another question? 77 | ----------------- 78 | If you are left with an unanswered question, `browse the issue tracker `_ to see if your question has been asked before. 79 | Feel free to create a new issue if that's not the case. Thank you 😃 -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | schedule 2 | ======== 3 | 4 | 5 | .. image:: https://github.com/dbader/schedule/workflows/Tests/badge.svg 6 | :target: https://github.com/dbader/schedule/actions?query=workflow%3ATests+branch%3Amaster 7 | 8 | .. image:: https://coveralls.io/repos/dbader/schedule/badge.svg?branch=master 9 | :target: https://coveralls.io/r/dbader/schedule 10 | 11 | .. image:: https://img.shields.io/pypi/v/schedule.svg 12 | :target: https://pypi.python.org/pypi/schedule 13 | 14 | Python job scheduling for humans. Run Python functions (or any other callable) periodically using a friendly syntax. 15 | 16 | - A simple to use API for scheduling jobs, made for humans. 17 | - In-process scheduler for periodic jobs. No extra processes needed! 18 | - Very lightweight and no external dependencies. 19 | - Excellent test coverage. 20 | - Tested on Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12 21 | 22 | 23 | :doc:`Example ` 24 | ------------------------- 25 | 26 | .. code-block:: bash 27 | 28 | $ pip install schedule 29 | 30 | .. code-block:: python 31 | 32 | import schedule 33 | import time 34 | 35 | def job(): 36 | print("I'm working...") 37 | 38 | schedule.every(10).minutes.do(job) 39 | schedule.every().hour.do(job) 40 | schedule.every().day.at("10:30").do(job) 41 | schedule.every().monday.do(job) 42 | schedule.every().wednesday.at("13:15").do(job) 43 | schedule.every().day.at("12:42", "Europe/Amsterdam").do(job) 44 | schedule.every().minute.at(":17").do(job) 45 | 46 | while True: 47 | schedule.run_pending() 48 | time.sleep(1) 49 | 50 | 51 | More :doc:`examples` 52 | 53 | When **not** to use Schedule 54 | ---------------------------- 55 | Let's be honest, Schedule is not a 'one size fits all' scheduling library. 56 | This library is designed to be a simple solution for simple scheduling problems. 57 | You should probably look somewhere else if you need: 58 | 59 | * Job persistence (remember schedule between restarts) 60 | * Exact timing (sub-second precision execution) 61 | * Concurrent execution (multiple threads) 62 | * Localization (workdays or holidays) 63 | 64 | 65 | **Schedule does not account for the time it takes for the job function to execute.** 66 | To guarantee a stable execution schedule you need to move long-running jobs off the main-thread (where the scheduler runs). 67 | See :doc:`parallel-execution` for a sample implementation. 68 | 69 | 70 | Read More 71 | --------- 72 | .. toctree:: 73 | :maxdepth: 2 74 | 75 | installation 76 | examples 77 | background-execution 78 | parallel-execution 79 | timezones 80 | exception-handling 81 | logging 82 | multiple-schedulers 83 | faq 84 | reference 85 | development 86 | 87 | .. toctree:: 88 | :maxdepth: 1 89 | 90 | changelog 91 | 92 | 93 | Issues 94 | ------ 95 | 96 | If you encounter any problems, please `file an issue `_ along with a detailed description. 97 | Please also use the search feature in the issue tracker beforehand to avoid creating duplicates. Thank you 😃 98 | 99 | About Schedule 100 | -------------- 101 | 102 | Created by `Daniel Bader `__ - `@dbader_org `_ 103 | 104 | Inspired by `Adam Wiggins' `_ article `"Rethinking Cron" `_ and the `clockwork `_ Ruby module. 105 | 106 | 107 | Distributed under the MIT license. See ``LICENSE.txt`` for more information. 108 | 109 | .. include:: ../AUTHORS.rst 110 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | 5 | Python version support 6 | ###################### 7 | 8 | We recommend using the latest version of Python. 9 | Schedule is tested on Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12 10 | 11 | Want to use Schedule on earlier Python versions? See the History. 12 | 13 | 14 | Dependencies 15 | ############ 16 | 17 | Schedule has 1 optional dependency: 18 | 19 | Only when you use ``.at()`` with a timezone, you must have `pytz `_ installed. 20 | 21 | Installation instructions 22 | ######################### 23 | 24 | Problems? Check out :doc:`faq`. 25 | 26 | PIP (preferred) 27 | *************** 28 | The recommended way to install this package is to use pip. 29 | Use the following command to install it: 30 | 31 | .. code-block:: bash 32 | 33 | $ pip install schedule 34 | 35 | Schedule is now installed. 36 | Check out the :doc:`examples ` or go to the :doc:`the documentation overview `. 37 | 38 | 39 | Using another package manager 40 | ***************************** 41 | Schedule is available through some linux package managers. 42 | These packages are not maintained by the maintainers of this project. 43 | It cannot be guarantee that these packages are up-to-date (and will stay up-to-date) with the latest released version. 44 | If you don't mind having an old version, you can use it. 45 | 46 | Ubuntu 47 | ------- 48 | 49 | **OUTDATED!** At the time of writing, the packages for 20.04LTS and below use version 0.3.2 (2015). 50 | 51 | .. code-block:: bash 52 | 53 | $ apt-get install python3-schedule 54 | 55 | See `package page `__. 56 | 57 | Debian 58 | ------ 59 | 60 | **OUTDATED!** At the time of writing, the packages for buster and below use version 0.3.2 (2015). 61 | 62 | .. code-block:: bash 63 | 64 | $ apt-get install python3 schedule 65 | 66 | See `package page `__. 67 | 68 | Arch 69 | ---- 70 | 71 | On the Arch Linux User repository (AUR) the package is available using the name `python-schedule`. 72 | See the package page `here `__. 73 | For yay users, run: 74 | 75 | .. code-block:: bash 76 | 77 | $ yay -S python-schedule 78 | 79 | Conda (Anaconda) 80 | ---------------- 81 | 82 | Schedule is `published `__ in conda (the Anaconda package manager). 83 | 84 | For installation instructions, visit `the conda-forge Schedule repo `__. 85 | The release of Schedule on conda is maintained by the `conda-forge project `__. 86 | 87 | Install manually 88 | ************************** 89 | If you don't have access to a package manager or need more control, you can manually copy the library into your project. 90 | This is easy as the schedule library consists of a single sourcefile MIT licenced. 91 | However, this method is highly discouraged as you won't receive automatic updates. 92 | 93 | 1. Go to the `Github repo `_. 94 | 2. Open file `schedule/__init__.py` and copy the code. 95 | 3. In your project, create a packaged named `schedule` and paste the code in a file named `__init__.py`. 96 | -------------------------------------------------------------------------------- /docs/logging.rst: -------------------------------------------------------------------------------- 1 | Logging 2 | ======= 3 | 4 | Schedule logs messages to the Python logger named ``schedule`` at ``DEBUG`` level. 5 | To receive logs from Schedule, set the logging level to ``DEBUG``. 6 | 7 | .. code-block:: python 8 | 9 | import schedule 10 | import logging 11 | 12 | logging.basicConfig() 13 | schedule_logger = logging.getLogger('schedule') 14 | schedule_logger.setLevel(level=logging.DEBUG) 15 | 16 | def job(): 17 | print("Hello, Logs") 18 | 19 | schedule.every().second.do(job) 20 | 21 | schedule.run_all() 22 | 23 | schedule.clear() 24 | 25 | This will result in the following log messages: 26 | 27 | .. code-block:: text 28 | 29 | DEBUG:schedule:Running *all* 1 jobs with 0s delay in between 30 | DEBUG:schedule:Running job Job(interval=1, unit=seconds, do=job, args=(), kwargs={}) 31 | Hello, Logs 32 | DEBUG:schedule:Deleting *all* jobs 33 | 34 | 35 | Customize logging 36 | ----------------- 37 | The easiest way to add reusable logging to jobs is to implement a decorator that handles logging. 38 | As an example, below code adds the ``print_elapsed_time`` decorator: 39 | 40 | .. code-block:: python 41 | 42 | import functools 43 | import time 44 | import schedule 45 | 46 | # This decorator can be applied to any job function to log the elapsed time of each job 47 | def print_elapsed_time(func): 48 | @functools.wraps(func) 49 | def wrapper(*args, **kwargs): 50 | start_timestamp = time.time() 51 | print('LOG: Running job "%s"' % func.__name__) 52 | result = func(*args, **kwargs) 53 | print('LOG: Job "%s" completed in %d seconds' % (func.__name__, time.time() - start_timestamp)) 54 | return result 55 | 56 | return wrapper 57 | 58 | 59 | @print_elapsed_time 60 | def job(): 61 | print('Hello, Logs') 62 | time.sleep(5) 63 | 64 | schedule.every().second.do(job) 65 | 66 | schedule.run_all() 67 | 68 | This outputs: 69 | 70 | .. code-block:: text 71 | 72 | LOG: Running job "job" 73 | Hello, Logs 74 | LOG: Job "job" completed in 5 seconds 75 | -------------------------------------------------------------------------------- /docs/multiple-schedulers.rst: -------------------------------------------------------------------------------- 1 | Multiple schedulers 2 | ################### 3 | 4 | You can run as many jobs from a single scheduler as you wish. 5 | However, for larger installations it might be desirable to have multiple schedulers. 6 | This is supported: 7 | 8 | .. code-block:: python 9 | 10 | import time 11 | import schedule 12 | 13 | def fooJob(): 14 | print("Foo") 15 | 16 | def barJob(): 17 | print("Bar") 18 | 19 | # Create a new scheduler 20 | scheduler1 = schedule.Scheduler() 21 | 22 | # Add jobs to the created scheduler 23 | scheduler1.every().hour.do(fooJob) 24 | scheduler1.every().hour.do(barJob) 25 | 26 | # Create as many schedulers as you need 27 | scheduler2 = schedule.Scheduler() 28 | scheduler2.every().second.do(fooJob) 29 | scheduler2.every().second.do(barJob) 30 | 31 | while True: 32 | # run_pending needs to be called on every scheduler 33 | scheduler1.run_pending() 34 | scheduler2.run_pending() 35 | time.sleep(1) 36 | -------------------------------------------------------------------------------- /docs/parallel-execution.rst: -------------------------------------------------------------------------------- 1 | Parallel execution 2 | ========================== 3 | 4 | *I am trying to execute 50 items every 10 seconds, but from the my logs it says it executes every item in 10 second schedule serially, is there a work around?* 5 | 6 | By default, schedule executes all jobs serially. The reasoning behind this is that it would be difficult to find a model for parallel execution that makes everyone happy. 7 | 8 | You can work around this limitation by running each of the jobs in its own thread: 9 | 10 | .. code-block:: python 11 | 12 | import threading 13 | import time 14 | import schedule 15 | 16 | def job(): 17 | print("I'm running on thread %s" % threading.current_thread()) 18 | 19 | def run_threaded(job_func): 20 | job_thread = threading.Thread(target=job_func) 21 | job_thread.start() 22 | 23 | schedule.every(10).seconds.do(run_threaded, job) 24 | schedule.every(10).seconds.do(run_threaded, job) 25 | schedule.every(10).seconds.do(run_threaded, job) 26 | schedule.every(10).seconds.do(run_threaded, job) 27 | schedule.every(10).seconds.do(run_threaded, job) 28 | 29 | 30 | while 1: 31 | schedule.run_pending() 32 | time.sleep(1) 33 | 34 | If you want tighter control on the number of threads use a shared jobqueue and one or more worker threads: 35 | 36 | .. code-block:: python 37 | 38 | import time 39 | import threading 40 | import schedule 41 | import queue 42 | 43 | def job(): 44 | print("I'm working") 45 | 46 | 47 | def worker_main(): 48 | while 1: 49 | job_func = jobqueue.get() 50 | job_func() 51 | jobqueue.task_done() 52 | 53 | jobqueue = queue.Queue() 54 | 55 | schedule.every(10).seconds.do(jobqueue.put, job) 56 | schedule.every(10).seconds.do(jobqueue.put, job) 57 | schedule.every(10).seconds.do(jobqueue.put, job) 58 | schedule.every(10).seconds.do(jobqueue.put, job) 59 | schedule.every(10).seconds.do(jobqueue.put, job) 60 | 61 | worker_thread = threading.Thread(target=worker_main) 62 | worker_thread.start() 63 | 64 | while 1: 65 | schedule.run_pending() 66 | time.sleep(1) 67 | 68 | This model also makes sense for a distributed application where the workers are separate processes that receive jobs from a distributed work queue. I like using beanstalkd with the beanstalkc Python library. 69 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. module:: schedule 5 | 6 | This part of the documentation covers all the interfaces of schedule. 7 | 8 | Main Interface 9 | -------------- 10 | 11 | .. autodata:: default_scheduler 12 | .. autodata:: jobs 13 | 14 | .. autofunction:: every 15 | .. autofunction:: run_pending 16 | .. autofunction:: run_all 17 | .. autofunction:: get_jobs 18 | .. autofunction:: clear 19 | .. autofunction:: cancel_job 20 | .. autofunction:: next_run 21 | .. autofunction:: idle_seconds 22 | 23 | 24 | Classes 25 | ------- 26 | 27 | .. autoclass:: schedule.Scheduler 28 | :members: 29 | :undoc-members: 30 | 31 | .. autoclass:: schedule.Job 32 | :members: 33 | :undoc-members: 34 | 35 | 36 | Exceptions 37 | ---------- 38 | 39 | .. autoexception:: schedule.CancelJob -------------------------------------------------------------------------------- /docs/timezones.rst: -------------------------------------------------------------------------------- 1 | Timezone & Daylight Saving Time 2 | =============================== 3 | 4 | Timezone in .at() 5 | ~~~~~~~~~~~~~~~~~ 6 | 7 | Schedule supports setting the job execution time in another timezone using the ``.at`` method. 8 | 9 | **To work with timezones** `pytz `_ **must be installed!** Get it: 10 | 11 | .. code-block:: bash 12 | 13 | pip install pytz 14 | 15 | Timezones are only available in the ``.at`` function, like so: 16 | 17 | .. code-block:: python 18 | 19 | # Pass a timezone as a string 20 | schedule.every().day.at("12:42", "Europe/Amsterdam").do(job) 21 | 22 | # Pass an pytz timezone object 23 | from pytz import timezone 24 | schedule.every().friday.at("12:42", timezone("Africa/Lagos")).do(job) 25 | 26 | Schedule uses the timezone to calculate the next runtime in local time. 27 | All datetimes inside the library are stored `naive `_. 28 | This causes the ``next_run`` and ``last_run`` to always be in Pythons local timezone. 29 | 30 | Daylight Saving Time 31 | ~~~~~~~~~~~~~~~~~~~~ 32 | Scheduling jobs that do not specify a timezone do **not** take clock-changes into account. 33 | Timezone unaware jobs will use naive local times to calculate the next run. 34 | For example, a job that is set to run every 4 hours might execute after 3 realtime hours when DST goes into effect. 35 | 36 | But when passing a timezone to ``.at()``, DST **is** taken into account. 37 | The job will run at the specified time, even when the clock changes. 38 | 39 | Example clock moves forward: 40 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 41 | When a job is scheduled in the gap that occurs when the clock moves forward, the job is scheduled after the gap. 42 | 43 | A job is scheduled ``.at("02:30", "Europe/Berlin")``. 44 | When the clock moves from ``02:00`` to ``03:00``, the job will run once at ``03:30``. 45 | The day after it will return to normal and run at ``02:30``. 46 | 47 | A job is scheduled ``.at("01:00", "Europe/London")``. 48 | When the clock moves from ``01:00`` to ``02:00``, the job will run once at ``02:00``. 49 | 50 | Example clock moves backwards: 51 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 52 | A job is scheduled ``.at("02:30", "Europe/Berlin")``. 53 | When the clock moves from ``02:00`` to ``03:00``, the job will run once at ``02:30``. 54 | It will run only at the first time the clock hits ``02:30``, but not the second time. 55 | The day after, it will return to normal and run at ``02:30``. 56 | 57 | Example scheduling across timezones 58 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 59 | Let's say we are in ``Europe/Berlin`` and local datetime is ``2022 march 20, 10:00:00``. 60 | At this moment daylight saving time is not in effect in Berlin (UTC+1). 61 | 62 | We schedule a job to run every day at 10:30:00 in America/New_York. 63 | At this time, daylight saving time is in effect in New York (UTC-4). 64 | 65 | .. code-block:: python 66 | 67 | s = every().day.at("10:30", "America/New_York").do(job) 68 | 69 | Because of the 5 hour time difference between Berlin and New York the job should effectively run at ``15:30:00``. 70 | So the next run in Berlin time is ``2022 march 20, 15:30:00``: 71 | 72 | .. code-block:: python 73 | 74 | print(s.next_run) 75 | # 2022-03-20 15:30:00 76 | 77 | print(repr(s)) 78 | # Every 1 day at 10:30:00 do job() (last run: [never], next run: 2022-03-20 15:30:00) 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "schedule" 7 | description = "Job scheduling for humans." 8 | dynamic = ["version", "classifiers", "keywords", "authors"] 9 | readme = "README.rst" 10 | license = {text = "MIT License"} 11 | 12 | requires-python = ">= 3.7" 13 | dependencies = [] 14 | 15 | maintainers = [ 16 | {name = "Sijmen Huizenga"} 17 | ] 18 | 19 | [project.optional-dependencies] 20 | timezone = ["pytz"] 21 | 22 | [project.urls] 23 | Documentation = "https://schedule.readthedocs.io" 24 | Repository = "https://github.com/dbader/schedule.git" 25 | Issues = "https://github.com/dbader/schedule/issues" 26 | Changelog = "https://github.com/dbader/schedule/blob/master/HISTORY.rst" 27 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | docutils 2 | Pygments 3 | pytest 4 | pytest-cov 5 | pytest-flake8 6 | Sphinx 7 | black==20.8b1 8 | click==8.0.4 9 | mypy 10 | pytz 11 | types-pytz -------------------------------------------------------------------------------- /schedule/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python job scheduling for humans. 3 | 4 | github.com/dbader/schedule 5 | 6 | An in-process scheduler for periodic jobs that uses the builder pattern 7 | for configuration. Schedule lets you run Python functions (or any other 8 | callable) periodically at pre-determined intervals using a simple, 9 | human-friendly syntax. 10 | 11 | Inspired by Addam Wiggins' article "Rethinking Cron" [1] and the 12 | "clockwork" Ruby module [2][3]. 13 | 14 | Features: 15 | - A simple to use API for scheduling jobs. 16 | - Very lightweight and no external dependencies. 17 | - Excellent test coverage. 18 | - Tested on Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12 19 | 20 | Usage: 21 | >>> import schedule 22 | >>> import time 23 | 24 | >>> def job(message='stuff'): 25 | >>> print("I'm working on:", message) 26 | 27 | >>> schedule.every(10).minutes.do(job) 28 | >>> schedule.every(5).to(10).days.do(job) 29 | >>> schedule.every().hour.do(job, message='things') 30 | >>> schedule.every().day.at("10:30").do(job) 31 | 32 | >>> while True: 33 | >>> schedule.run_pending() 34 | >>> time.sleep(1) 35 | 36 | [1] https://adam.herokuapp.com/past/2010/4/13/rethinking_cron/ 37 | [2] https://github.com/Rykian/clockwork 38 | [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ 39 | """ 40 | 41 | from collections.abc import Hashable 42 | import datetime 43 | import functools 44 | import logging 45 | import random 46 | import re 47 | import time 48 | from typing import Set, List, Optional, Callable, Union 49 | 50 | logger = logging.getLogger("schedule") 51 | 52 | 53 | class ScheduleError(Exception): 54 | """Base schedule exception""" 55 | 56 | pass 57 | 58 | 59 | class ScheduleValueError(ScheduleError): 60 | """Base schedule value error""" 61 | 62 | pass 63 | 64 | 65 | class IntervalError(ScheduleValueError): 66 | """An improper interval was used""" 67 | 68 | pass 69 | 70 | 71 | class CancelJob: 72 | """ 73 | Can be returned from a job to unschedule itself. 74 | """ 75 | 76 | pass 77 | 78 | 79 | class Scheduler: 80 | """ 81 | Objects instantiated by the :class:`Scheduler ` are 82 | factories to create jobs, keep record of scheduled jobs and 83 | handle their execution. 84 | """ 85 | 86 | def __init__(self) -> None: 87 | self.jobs: List[Job] = [] 88 | 89 | def run_pending(self) -> None: 90 | """ 91 | Run all jobs that are scheduled to run. 92 | 93 | Please note that it is *intended behavior that run_pending() 94 | does not run missed jobs*. For example, if you've registered a job 95 | that should run every minute and you only call run_pending() 96 | in one hour increments then your job won't be run 60 times in 97 | between but only once. 98 | """ 99 | runnable_jobs = (job for job in self.jobs if job.should_run) 100 | for job in sorted(runnable_jobs): 101 | self._run_job(job) 102 | 103 | def run_all(self, delay_seconds: int = 0) -> None: 104 | """ 105 | Run all jobs regardless if they are scheduled to run or not. 106 | 107 | A delay of `delay` seconds is added between each job. This helps 108 | distribute system load generated by the jobs more evenly 109 | over time. 110 | 111 | :param delay_seconds: A delay added between every executed job 112 | """ 113 | logger.debug( 114 | "Running *all* %i jobs with %is delay in between", 115 | len(self.jobs), 116 | delay_seconds, 117 | ) 118 | for job in self.jobs[:]: 119 | self._run_job(job) 120 | time.sleep(delay_seconds) 121 | 122 | def get_jobs(self, tag: Optional[Hashable] = None) -> List["Job"]: 123 | """ 124 | Gets scheduled jobs marked with the given tag, or all jobs 125 | if tag is omitted. 126 | 127 | :param tag: An identifier used to identify a subset of 128 | jobs to retrieve 129 | """ 130 | if tag is None: 131 | return self.jobs[:] 132 | else: 133 | return [job for job in self.jobs if tag in job.tags] 134 | 135 | def clear(self, tag: Optional[Hashable] = None) -> None: 136 | """ 137 | Deletes scheduled jobs marked with the given tag, or all jobs 138 | if tag is omitted. 139 | 140 | :param tag: An identifier used to identify a subset of 141 | jobs to delete 142 | """ 143 | if tag is None: 144 | logger.debug("Deleting *all* jobs") 145 | del self.jobs[:] 146 | else: 147 | logger.debug('Deleting all jobs tagged "%s"', tag) 148 | self.jobs[:] = (job for job in self.jobs if tag not in job.tags) 149 | 150 | def cancel_job(self, job: "Job") -> None: 151 | """ 152 | Delete a scheduled job. 153 | 154 | :param job: The job to be unscheduled 155 | """ 156 | try: 157 | logger.debug('Cancelling job "%s"', str(job)) 158 | self.jobs.remove(job) 159 | except ValueError: 160 | logger.debug('Cancelling not-scheduled job "%s"', str(job)) 161 | 162 | def every(self, interval: int = 1) -> "Job": 163 | """ 164 | Schedule a new periodic job. 165 | 166 | :param interval: A quantity of a certain time unit 167 | :return: An unconfigured :class:`Job ` 168 | """ 169 | job = Job(interval, self) 170 | return job 171 | 172 | def _run_job(self, job: "Job") -> None: 173 | ret = job.run() 174 | if isinstance(ret, CancelJob) or ret is CancelJob: 175 | self.cancel_job(job) 176 | 177 | def get_next_run( 178 | self, tag: Optional[Hashable] = None 179 | ) -> Optional[datetime.datetime]: 180 | """ 181 | Datetime when the next job should run. 182 | 183 | :param tag: Filter the next run for the given tag parameter 184 | 185 | :return: A :class:`~datetime.datetime` object 186 | or None if no jobs scheduled 187 | """ 188 | if not self.jobs: 189 | return None 190 | jobs_filtered = self.get_jobs(tag) 191 | if not jobs_filtered: 192 | return None 193 | return min(jobs_filtered).next_run 194 | 195 | next_run = property(get_next_run) 196 | 197 | @property 198 | def idle_seconds(self) -> Optional[float]: 199 | """ 200 | :return: Number of seconds until 201 | :meth:`next_run ` 202 | or None if no jobs are scheduled 203 | """ 204 | if not self.next_run: 205 | return None 206 | return (self.next_run - datetime.datetime.now()).total_seconds() 207 | 208 | 209 | class Job: 210 | """ 211 | A periodic job as used by :class:`Scheduler`. 212 | 213 | :param interval: A quantity of a certain time unit 214 | :param scheduler: The :class:`Scheduler ` instance that 215 | this job will register itself with once it has 216 | been fully configured in :meth:`Job.do()`. 217 | 218 | Every job runs at a given fixed time interval that is defined by: 219 | 220 | * a :meth:`time unit ` 221 | * a quantity of `time units` defined by `interval` 222 | 223 | A job is usually created and returned by :meth:`Scheduler.every` 224 | method, which also defines its `interval`. 225 | """ 226 | 227 | def __init__(self, interval: int, scheduler: Optional[Scheduler] = None): 228 | self.interval: int = interval # pause interval * unit between runs 229 | self.latest: Optional[int] = None # upper limit to the interval 230 | self.job_func: Optional[functools.partial] = None # the job job_func to run 231 | 232 | # time units, e.g. 'minutes', 'hours', ... 233 | self.unit: Optional[str] = None 234 | 235 | # optional time at which this job runs 236 | self.at_time: Optional[datetime.time] = None 237 | 238 | # optional time zone of the self.at_time field. Only relevant when at_time is not None 239 | self.at_time_zone = None 240 | 241 | # datetime of the last run 242 | self.last_run: Optional[datetime.datetime] = None 243 | 244 | # datetime of the next run 245 | self.next_run: Optional[datetime.datetime] = None 246 | 247 | # Weekday to run the job at. Only relevant when unit is 'weeks'. 248 | # For example, when asking 'every week on tuesday' the start_day is 'tuesday'. 249 | self.start_day: Optional[str] = None 250 | 251 | # optional time of final run 252 | self.cancel_after: Optional[datetime.datetime] = None 253 | 254 | self.tags: Set[Hashable] = set() # unique set of tags for the job 255 | self.scheduler: Optional[Scheduler] = scheduler # scheduler to register with 256 | 257 | def __lt__(self, other) -> bool: 258 | """ 259 | PeriodicJobs are sortable based on the scheduled time they 260 | run next. 261 | """ 262 | return self.next_run < other.next_run 263 | 264 | def __str__(self) -> str: 265 | if hasattr(self.job_func, "__name__"): 266 | job_func_name = self.job_func.__name__ # type: ignore 267 | else: 268 | job_func_name = repr(self.job_func) 269 | 270 | return ("Job(interval={}, unit={}, do={}, args={}, kwargs={})").format( 271 | self.interval, 272 | self.unit, 273 | job_func_name, 274 | "()" if self.job_func is None else self.job_func.args, 275 | "{}" if self.job_func is None else self.job_func.keywords, 276 | ) 277 | 278 | def __repr__(self): 279 | def format_time(t): 280 | return t.strftime("%Y-%m-%d %H:%M:%S") if t else "[never]" 281 | 282 | def is_repr(j): 283 | return not isinstance(j, Job) 284 | 285 | timestats = "(last run: %s, next run: %s)" % ( 286 | format_time(self.last_run), 287 | format_time(self.next_run), 288 | ) 289 | 290 | if hasattr(self.job_func, "__name__"): 291 | job_func_name = self.job_func.__name__ 292 | else: 293 | job_func_name = repr(self.job_func) 294 | 295 | if self.job_func is not None: 296 | args = [repr(x) if is_repr(x) else str(x) for x in self.job_func.args] 297 | kwargs = ["%s=%s" % (k, repr(v)) for k, v in self.job_func.keywords.items()] 298 | call_repr = job_func_name + "(" + ", ".join(args + kwargs) + ")" 299 | else: 300 | call_repr = "[None]" 301 | 302 | if self.at_time is not None: 303 | return "Every %s %s at %s do %s %s" % ( 304 | self.interval, 305 | self.unit[:-1] if self.interval == 1 else self.unit, 306 | self.at_time, 307 | call_repr, 308 | timestats, 309 | ) 310 | else: 311 | fmt = ( 312 | "Every %(interval)s " 313 | + ("to %(latest)s " if self.latest is not None else "") 314 | + "%(unit)s do %(call_repr)s %(timestats)s" 315 | ) 316 | 317 | return fmt % dict( 318 | interval=self.interval, 319 | latest=self.latest, 320 | unit=(self.unit[:-1] if self.interval == 1 else self.unit), 321 | call_repr=call_repr, 322 | timestats=timestats, 323 | ) 324 | 325 | @property 326 | def second(self): 327 | if self.interval != 1: 328 | raise IntervalError("Use seconds instead of second") 329 | return self.seconds 330 | 331 | @property 332 | def seconds(self): 333 | self.unit = "seconds" 334 | return self 335 | 336 | @property 337 | def minute(self): 338 | if self.interval != 1: 339 | raise IntervalError("Use minutes instead of minute") 340 | return self.minutes 341 | 342 | @property 343 | def minutes(self): 344 | self.unit = "minutes" 345 | return self 346 | 347 | @property 348 | def hour(self): 349 | if self.interval != 1: 350 | raise IntervalError("Use hours instead of hour") 351 | return self.hours 352 | 353 | @property 354 | def hours(self): 355 | self.unit = "hours" 356 | return self 357 | 358 | @property 359 | def day(self): 360 | if self.interval != 1: 361 | raise IntervalError("Use days instead of day") 362 | return self.days 363 | 364 | @property 365 | def days(self): 366 | self.unit = "days" 367 | return self 368 | 369 | @property 370 | def week(self): 371 | if self.interval != 1: 372 | raise IntervalError("Use weeks instead of week") 373 | return self.weeks 374 | 375 | @property 376 | def weeks(self): 377 | self.unit = "weeks" 378 | return self 379 | 380 | @property 381 | def monday(self): 382 | if self.interval != 1: 383 | raise IntervalError( 384 | "Scheduling .monday() jobs is only allowed for weekly jobs. " 385 | "Using .monday() on a job scheduled to run every 2 or more weeks " 386 | "is not supported." 387 | ) 388 | self.start_day = "monday" 389 | return self.weeks 390 | 391 | @property 392 | def tuesday(self): 393 | if self.interval != 1: 394 | raise IntervalError( 395 | "Scheduling .tuesday() jobs is only allowed for weekly jobs. " 396 | "Using .tuesday() on a job scheduled to run every 2 or more weeks " 397 | "is not supported." 398 | ) 399 | self.start_day = "tuesday" 400 | return self.weeks 401 | 402 | @property 403 | def wednesday(self): 404 | if self.interval != 1: 405 | raise IntervalError( 406 | "Scheduling .wednesday() jobs is only allowed for weekly jobs. " 407 | "Using .wednesday() on a job scheduled to run every 2 or more weeks " 408 | "is not supported." 409 | ) 410 | self.start_day = "wednesday" 411 | return self.weeks 412 | 413 | @property 414 | def thursday(self): 415 | if self.interval != 1: 416 | raise IntervalError( 417 | "Scheduling .thursday() jobs is only allowed for weekly jobs. " 418 | "Using .thursday() on a job scheduled to run every 2 or more weeks " 419 | "is not supported." 420 | ) 421 | self.start_day = "thursday" 422 | return self.weeks 423 | 424 | @property 425 | def friday(self): 426 | if self.interval != 1: 427 | raise IntervalError( 428 | "Scheduling .friday() jobs is only allowed for weekly jobs. " 429 | "Using .friday() on a job scheduled to run every 2 or more weeks " 430 | "is not supported." 431 | ) 432 | self.start_day = "friday" 433 | return self.weeks 434 | 435 | @property 436 | def saturday(self): 437 | if self.interval != 1: 438 | raise IntervalError( 439 | "Scheduling .saturday() jobs is only allowed for weekly jobs. " 440 | "Using .saturday() on a job scheduled to run every 2 or more weeks " 441 | "is not supported." 442 | ) 443 | self.start_day = "saturday" 444 | return self.weeks 445 | 446 | @property 447 | def sunday(self): 448 | if self.interval != 1: 449 | raise IntervalError( 450 | "Scheduling .sunday() jobs is only allowed for weekly jobs. " 451 | "Using .sunday() on a job scheduled to run every 2 or more weeks " 452 | "is not supported." 453 | ) 454 | self.start_day = "sunday" 455 | return self.weeks 456 | 457 | def tag(self, *tags: Hashable): 458 | """ 459 | Tags the job with one or more unique identifiers. 460 | 461 | Tags must be hashable. Duplicate tags are discarded. 462 | 463 | :param tags: A unique list of ``Hashable`` tags. 464 | :return: The invoked job instance 465 | """ 466 | if not all(isinstance(tag, Hashable) for tag in tags): 467 | raise TypeError("Tags must be hashable") 468 | self.tags.update(tags) 469 | return self 470 | 471 | def at(self, time_str: str, tz: Optional[str] = None): 472 | """ 473 | Specify a particular time that the job should be run at. 474 | 475 | :param time_str: A string in one of the following formats: 476 | 477 | - For daily jobs -> `HH:MM:SS` or `HH:MM` 478 | - For hourly jobs -> `MM:SS` or `:MM` 479 | - For minute jobs -> `:SS` 480 | 481 | The format must make sense given how often the job is 482 | repeating; for example, a job that repeats every minute 483 | should not be given a string in the form `HH:MM:SS`. The 484 | difference between `:MM` and `:SS` is inferred from the 485 | selected time-unit (e.g. `every().hour.at(':30')` vs. 486 | `every().minute.at(':30')`). 487 | 488 | :param tz: The timezone that this timestamp refers to. Can be 489 | a string that can be parsed by pytz.timezone(), or a pytz.BaseTzInfo object 490 | 491 | :return: The invoked job instance 492 | """ 493 | if self.unit not in ("days", "hours", "minutes") and not self.start_day: 494 | raise ScheduleValueError( 495 | "Invalid unit (valid units are `days`, `hours`, and `minutes`)" 496 | ) 497 | 498 | if tz is not None: 499 | import pytz 500 | 501 | if isinstance(tz, str): 502 | self.at_time_zone = pytz.timezone(tz) # type: ignore 503 | elif isinstance(tz, pytz.BaseTzInfo): 504 | self.at_time_zone = tz 505 | else: 506 | raise ScheduleValueError( 507 | "Timezone must be string or pytz.timezone object" 508 | ) 509 | 510 | if not isinstance(time_str, str): 511 | raise TypeError("at() should be passed a string") 512 | if self.unit == "days" or self.start_day: 513 | if not re.match(r"^[0-2]\d:[0-5]\d(:[0-5]\d)?$", time_str): 514 | raise ScheduleValueError( 515 | "Invalid time format for a daily job (valid format is HH:MM(:SS)?)" 516 | ) 517 | if self.unit == "hours": 518 | if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str): 519 | raise ScheduleValueError( 520 | "Invalid time format for an hourly job (valid format is (MM)?:SS)" 521 | ) 522 | 523 | if self.unit == "minutes": 524 | if not re.match(r"^:[0-5]\d$", time_str): 525 | raise ScheduleValueError( 526 | "Invalid time format for a minutely job (valid format is :SS)" 527 | ) 528 | time_values = time_str.split(":") 529 | hour: Union[str, int] 530 | minute: Union[str, int] 531 | second: Union[str, int] 532 | if len(time_values) == 3: 533 | hour, minute, second = time_values 534 | elif len(time_values) == 2 and self.unit == "minutes": 535 | hour = 0 536 | minute = 0 537 | _, second = time_values 538 | elif len(time_values) == 2 and self.unit == "hours" and len(time_values[0]): 539 | hour = 0 540 | minute, second = time_values 541 | else: 542 | hour, minute = time_values 543 | second = 0 544 | if self.unit == "days" or self.start_day: 545 | hour = int(hour) 546 | if not (0 <= hour <= 23): 547 | raise ScheduleValueError( 548 | "Invalid number of hours ({} is not between 0 and 23)" 549 | ) 550 | elif self.unit == "hours": 551 | hour = 0 552 | elif self.unit == "minutes": 553 | hour = 0 554 | minute = 0 555 | hour = int(hour) 556 | minute = int(minute) 557 | second = int(second) 558 | self.at_time = datetime.time(hour, minute, second) 559 | return self 560 | 561 | def to(self, latest: int): 562 | """ 563 | Schedule the job to run at an irregular (randomized) interval. 564 | 565 | The job's interval will randomly vary from the value given 566 | to `every` to `latest`. The range defined is inclusive on 567 | both ends. For example, `every(A).to(B).seconds` executes 568 | the job function every N seconds such that A <= N <= B. 569 | 570 | :param latest: Maximum interval between randomized job runs 571 | :return: The invoked job instance 572 | """ 573 | self.latest = latest 574 | return self 575 | 576 | def until( 577 | self, 578 | until_time: Union[datetime.datetime, datetime.timedelta, datetime.time, str], 579 | ): 580 | """ 581 | Schedule job to run until the specified moment. 582 | 583 | The job is canceled whenever the next run is calculated and it turns out the 584 | next run is after the until_time. The job is also canceled right before it runs, 585 | if the current time is after until_time. This latter case can happen when the 586 | the job was scheduled to run before until_time, but runs after until_time. 587 | 588 | If until_time is a moment in the past, ScheduleValueError is thrown. 589 | 590 | :param until_time: A moment in the future representing the latest time a job can 591 | be run. If only a time is supplied, the date is set to today. 592 | The following formats are accepted: 593 | 594 | - datetime.datetime 595 | - datetime.timedelta 596 | - datetime.time 597 | - String in one of the following formats: "%Y-%m-%d %H:%M:%S", 598 | "%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M:%S", "%H:%M" 599 | as defined by strptime() behaviour. If an invalid string format is passed, 600 | ScheduleValueError is thrown. 601 | 602 | :return: The invoked job instance 603 | """ 604 | 605 | if isinstance(until_time, datetime.datetime): 606 | self.cancel_after = until_time 607 | elif isinstance(until_time, datetime.timedelta): 608 | self.cancel_after = datetime.datetime.now() + until_time 609 | elif isinstance(until_time, datetime.time): 610 | self.cancel_after = datetime.datetime.combine( 611 | datetime.datetime.now(), until_time 612 | ) 613 | elif isinstance(until_time, str): 614 | cancel_after = self._decode_datetimestr( 615 | until_time, 616 | [ 617 | "%Y-%m-%d %H:%M:%S", 618 | "%Y-%m-%d %H:%M", 619 | "%Y-%m-%d", 620 | "%H:%M:%S", 621 | "%H:%M", 622 | ], 623 | ) 624 | if cancel_after is None: 625 | raise ScheduleValueError("Invalid string format for until()") 626 | if "-" not in until_time: 627 | # the until_time is a time-only format. Set the date to today 628 | now = datetime.datetime.now() 629 | cancel_after = cancel_after.replace( 630 | year=now.year, month=now.month, day=now.day 631 | ) 632 | self.cancel_after = cancel_after 633 | else: 634 | raise TypeError( 635 | "until() takes a string, datetime.datetime, datetime.timedelta, " 636 | "datetime.time parameter" 637 | ) 638 | if self.cancel_after < datetime.datetime.now(): 639 | raise ScheduleValueError( 640 | "Cannot schedule a job to run until a time in the past" 641 | ) 642 | return self 643 | 644 | def do(self, job_func: Callable, *args, **kwargs): 645 | """ 646 | Specifies the job_func that should be called every time the 647 | job runs. 648 | 649 | Any additional arguments are passed on to job_func when 650 | the job runs. 651 | 652 | :param job_func: The function to be scheduled 653 | :return: The invoked job instance 654 | """ 655 | self.job_func = functools.partial(job_func, *args, **kwargs) 656 | functools.update_wrapper(self.job_func, job_func) 657 | self._schedule_next_run() 658 | if self.scheduler is None: 659 | raise ScheduleError( 660 | "Unable to a add job to schedule. " 661 | "Job is not associated with an scheduler" 662 | ) 663 | self.scheduler.jobs.append(self) 664 | return self 665 | 666 | @property 667 | def should_run(self) -> bool: 668 | """ 669 | :return: ``True`` if the job should be run now. 670 | """ 671 | assert self.next_run is not None, "must run _schedule_next_run before" 672 | return datetime.datetime.now() >= self.next_run 673 | 674 | def run(self): 675 | """ 676 | Run the job and immediately reschedule it. 677 | If the job's deadline is reached (configured using .until()), the job is not 678 | run and CancelJob is returned immediately. If the next scheduled run exceeds 679 | the job's deadline, CancelJob is returned after the execution. In this latter 680 | case CancelJob takes priority over any other returned value. 681 | 682 | :return: The return value returned by the `job_func`, or CancelJob if the job's 683 | deadline is reached. 684 | 685 | """ 686 | if self._is_overdue(datetime.datetime.now()): 687 | logger.debug("Cancelling job %s", self) 688 | return CancelJob 689 | 690 | logger.debug("Running job %s", self) 691 | ret = self.job_func() 692 | self.last_run = datetime.datetime.now() 693 | self._schedule_next_run() 694 | 695 | if self._is_overdue(self.next_run): 696 | logger.debug("Cancelling job %s", self) 697 | return CancelJob 698 | return ret 699 | 700 | def _schedule_next_run(self) -> None: 701 | """ 702 | Compute the instant when this job should run next. 703 | """ 704 | if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): 705 | raise ScheduleValueError( 706 | "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " 707 | "`days`, and `weeks`)" 708 | ) 709 | if self.latest is not None: 710 | if not (self.latest >= self.interval): 711 | raise ScheduleError("`latest` is greater than `interval`") 712 | interval = random.randint(self.interval, self.latest) 713 | else: 714 | interval = self.interval 715 | 716 | # Do all computation in the context of the requested timezone 717 | now = datetime.datetime.now(self.at_time_zone) 718 | 719 | next_run = now 720 | 721 | if self.start_day is not None: 722 | if self.unit != "weeks": 723 | raise ScheduleValueError("`unit` should be 'weeks'") 724 | next_run = _move_to_next_weekday(next_run, self.start_day) 725 | 726 | if self.at_time is not None: 727 | next_run = self._move_to_at_time(next_run) 728 | 729 | period = datetime.timedelta(**{self.unit: interval}) 730 | if interval != 1: 731 | next_run += period 732 | 733 | while next_run <= now: 734 | next_run += period 735 | 736 | next_run = self._correct_utc_offset( 737 | next_run, fixate_time=(self.at_time is not None) 738 | ) 739 | 740 | # To keep the api consistent with older versions, we have to set the 'next_run' to a naive timestamp in the local timezone. 741 | # Because we want to stay backwards compatible with older versions. 742 | if self.at_time_zone is not None: 743 | # Convert back to the local timezone 744 | next_run = next_run.astimezone() 745 | 746 | next_run = next_run.replace(tzinfo=None) 747 | 748 | self.next_run = next_run 749 | 750 | def _move_to_at_time(self, moment: datetime.datetime) -> datetime.datetime: 751 | """ 752 | Takes a datetime and moves the time-component to the job's at_time. 753 | """ 754 | if self.at_time is None: 755 | return moment 756 | 757 | kwargs = {"second": self.at_time.second, "microsecond": 0} 758 | 759 | if self.unit == "days" or self.start_day is not None: 760 | kwargs["hour"] = self.at_time.hour 761 | 762 | if self.unit in ["days", "hours"] or self.start_day is not None: 763 | kwargs["minute"] = self.at_time.minute 764 | 765 | moment = moment.replace(**kwargs) # type: ignore 766 | 767 | # When we set the time elements, we might end up in a different UTC-offset than the current offset. 768 | # This happens when we cross into or out of daylight saving time. 769 | moment = self._correct_utc_offset(moment, fixate_time=True) 770 | 771 | return moment 772 | 773 | def _correct_utc_offset( 774 | self, moment: datetime.datetime, fixate_time: bool 775 | ) -> datetime.datetime: 776 | """ 777 | Given a datetime, corrects any mistakes in the utc offset. 778 | This is similar to pytz' normalize, but adds the ability to attempt 779 | keeping the time-component at the same hour/minute/second. 780 | """ 781 | if self.at_time_zone is None: 782 | return moment 783 | # Normalize corrects the utc-offset to match the timezone 784 | # For example: When a date&time&offset does not exist within a timezone, 785 | # the normalization will change the utc-offset to where it is valid. 786 | # It does this while keeping the moment in time the same, by moving the 787 | # time component opposite of the utc-change. 788 | offset_before_normalize = moment.utcoffset() 789 | moment = self.at_time_zone.normalize(moment) 790 | offset_after_normalize = moment.utcoffset() 791 | 792 | if offset_before_normalize == offset_after_normalize: 793 | # There was no change in the utc-offset, datetime didn't change. 794 | return moment 795 | 796 | # The utc-offset and time-component has changed 797 | 798 | if not fixate_time: 799 | # No need to fixate the time. 800 | return moment 801 | 802 | offset_diff = offset_after_normalize - offset_before_normalize 803 | 804 | # Adjust the time to reset the date-time to have the same HH:mm components 805 | moment -= offset_diff 806 | 807 | # Check if moving the timestamp back by the utc-offset-difference made it end up 808 | # in a moment that does not exist within the current timezone/utc-offset 809 | re_normalized_offset = self.at_time_zone.normalize(moment).utcoffset() 810 | if re_normalized_offset != offset_after_normalize: 811 | # We ended up in a DST Gap. The requested 'at' time does not exist 812 | # within the current timezone/utc-offset. As a best effort, we will 813 | # schedule the job 1 offset later than possible. 814 | # For example, if 02:23 does not exist (because DST moves from 02:00 815 | # to 03:00), this will schedule the job at 03:23. 816 | moment += offset_diff 817 | return moment 818 | 819 | def _is_overdue(self, when: datetime.datetime): 820 | return self.cancel_after is not None and when > self.cancel_after 821 | 822 | def _decode_datetimestr( 823 | self, datetime_str: str, formats: List[str] 824 | ) -> Optional[datetime.datetime]: 825 | for f in formats: 826 | try: 827 | return datetime.datetime.strptime(datetime_str, f) 828 | except ValueError: 829 | pass 830 | return None 831 | 832 | 833 | # The following methods are shortcuts for not having to 834 | # create a Scheduler instance: 835 | 836 | #: Default :class:`Scheduler ` object 837 | default_scheduler = Scheduler() 838 | 839 | #: Default :class:`Jobs ` list 840 | jobs = default_scheduler.jobs # todo: should this be a copy, e.g. jobs()? 841 | 842 | 843 | def every(interval: int = 1) -> Job: 844 | """Calls :meth:`every ` on the 845 | :data:`default scheduler instance `. 846 | """ 847 | return default_scheduler.every(interval) 848 | 849 | 850 | def run_pending() -> None: 851 | """Calls :meth:`run_pending ` on the 852 | :data:`default scheduler instance `. 853 | """ 854 | default_scheduler.run_pending() 855 | 856 | 857 | def run_all(delay_seconds: int = 0) -> None: 858 | """Calls :meth:`run_all ` on the 859 | :data:`default scheduler instance `. 860 | """ 861 | default_scheduler.run_all(delay_seconds=delay_seconds) 862 | 863 | 864 | def get_jobs(tag: Optional[Hashable] = None) -> List[Job]: 865 | """Calls :meth:`get_jobs ` on the 866 | :data:`default scheduler instance `. 867 | """ 868 | return default_scheduler.get_jobs(tag) 869 | 870 | 871 | def clear(tag: Optional[Hashable] = None) -> None: 872 | """Calls :meth:`clear ` on the 873 | :data:`default scheduler instance `. 874 | """ 875 | default_scheduler.clear(tag) 876 | 877 | 878 | def cancel_job(job: Job) -> None: 879 | """Calls :meth:`cancel_job ` on the 880 | :data:`default scheduler instance `. 881 | """ 882 | default_scheduler.cancel_job(job) 883 | 884 | 885 | def next_run(tag: Optional[Hashable] = None) -> Optional[datetime.datetime]: 886 | """Calls :meth:`next_run ` on the 887 | :data:`default scheduler instance `. 888 | """ 889 | return default_scheduler.get_next_run(tag) 890 | 891 | 892 | def idle_seconds() -> Optional[float]: 893 | """Calls :meth:`idle_seconds ` on the 894 | :data:`default scheduler instance `. 895 | """ 896 | return default_scheduler.idle_seconds 897 | 898 | 899 | def repeat(job, *args, **kwargs): 900 | """ 901 | Decorator to schedule a new periodic job. 902 | 903 | Any additional arguments are passed on to the decorated function 904 | when the job runs. 905 | 906 | :param job: a :class:`Jobs ` 907 | """ 908 | 909 | def _schedule_decorator(decorated_function): 910 | job.do(decorated_function, *args, **kwargs) 911 | return decorated_function 912 | 913 | return _schedule_decorator 914 | 915 | 916 | def _move_to_next_weekday(moment: datetime.datetime, weekday: str): 917 | """ 918 | Move the given timestamp to the nearest given weekday. May be this week 919 | or next week. If the timestamp is already at the given weekday, it is not 920 | moved. 921 | """ 922 | weekday_index = _weekday_index(weekday) 923 | 924 | days_ahead = weekday_index - moment.weekday() 925 | if days_ahead < 0: 926 | # Target day already happened this week, move to next week 927 | days_ahead += 7 928 | return moment + datetime.timedelta(days=days_ahead) 929 | 930 | 931 | def _weekday_index(day: str) -> int: 932 | weekdays = ( 933 | "monday", 934 | "tuesday", 935 | "wednesday", 936 | "thursday", 937 | "friday", 938 | "saturday", 939 | "sunday", 940 | ) 941 | if day not in weekdays: 942 | raise ScheduleValueError( 943 | "Invalid start day (valid start days are {})".format(weekdays) 944 | ) 945 | return weekdays.index(day) 946 | -------------------------------------------------------------------------------- /schedule/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbader/schedule/82a43db1b938d8fdf60103bd41f329e06c8d3651/schedule/py.typed -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files=schedule 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | from setuptools import setup 3 | 4 | 5 | SCHEDULE_VERSION = "1.2.2" 6 | SCHEDULE_DOWNLOAD_URL = "https://github.com/dbader/schedule/tarball/" + SCHEDULE_VERSION 7 | 8 | 9 | def read_file(filename): 10 | """ 11 | Read a utf8 encoded text file and return its contents. 12 | """ 13 | with codecs.open(filename, "r", "utf8") as f: 14 | return f.read() 15 | 16 | 17 | setup( 18 | name="schedule", 19 | packages=["schedule"], 20 | package_data={"schedule": ["py.typed"]}, 21 | version=SCHEDULE_VERSION, 22 | description="Job scheduling for humans.", 23 | long_description=read_file("README.rst"), 24 | license="MIT", 25 | author="Daniel Bader", 26 | author_email="mail@dbader.org", 27 | url="https://github.com/dbader/schedule", 28 | download_url=SCHEDULE_DOWNLOAD_URL, 29 | keywords=[ 30 | "schedule", 31 | "periodic", 32 | "jobs", 33 | "scheduling", 34 | "clockwork", 35 | "cron", 36 | "scheduler", 37 | "job scheduling", 38 | ], 39 | classifiers=[ 40 | "Intended Audience :: Developers", 41 | "License :: OSI Approved :: MIT License", 42 | "Development Status :: 5 - Production/Stable", 43 | "Operating System :: OS Independent", 44 | "Programming Language :: Python", 45 | "Programming Language :: Python :: 3", 46 | "Programming Language :: Python :: 3.7", 47 | "Programming Language :: Python :: 3.8", 48 | "Programming Language :: Python :: 3.9", 49 | "Programming Language :: Python :: 3.10", 50 | "Programming Language :: Python :: 3.11", 51 | "Programming Language :: Python :: 3.12", 52 | "Natural Language :: English", 53 | ], 54 | python_requires=">=3.7", 55 | ) 56 | -------------------------------------------------------------------------------- /test_schedule.py: -------------------------------------------------------------------------------- 1 | """Unit tests for schedule.py""" 2 | 3 | import datetime 4 | import functools 5 | from unittest import mock, TestCase 6 | import os 7 | import time 8 | 9 | # Silence "missing docstring", "method could be a function", 10 | # "class already defined", and "too many public methods" messages: 11 | # pylint: disable-msg=R0201,C0111,E0102,R0904,R0901 12 | 13 | import schedule 14 | from schedule import ( 15 | every, 16 | repeat, 17 | ScheduleError, 18 | ScheduleValueError, 19 | IntervalError, 20 | ) 21 | 22 | # POSIX TZ string format 23 | TZ_BERLIN = "CET-1CEST,M3.5.0,M10.5.0/3" 24 | TZ_AUCKLAND = "NZST-12NZDT,M9.5.0,M4.1.0/3" 25 | TZ_CHATHAM = "<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45" 26 | TZ_UTC = "UTC0" 27 | 28 | # Set timezone to Europe/Berlin (CEST) to ensure global reproducibility 29 | os.environ["TZ"] = TZ_BERLIN 30 | time.tzset() 31 | 32 | 33 | def make_mock_job(name=None): 34 | job = mock.Mock() 35 | job.__name__ = name or "job" 36 | return job 37 | 38 | 39 | class mock_datetime: 40 | """ 41 | Monkey-patch datetime for predictable results 42 | """ 43 | 44 | def __init__(self, year, month, day, hour, minute, second=0, zone=None, fold=0): 45 | self.year = year 46 | self.month = month 47 | self.day = day 48 | self.hour = hour 49 | self.minute = minute 50 | self.second = second 51 | self.zone = zone 52 | self.fold = fold 53 | self.original_datetime = None 54 | self.original_zone = None 55 | 56 | def __enter__(self): 57 | class MockDate(datetime.datetime): 58 | @classmethod 59 | def today(cls): 60 | return cls(self.year, self.month, self.day) 61 | 62 | @classmethod 63 | def now(cls, tz=None): 64 | mock_date = cls( 65 | self.year, 66 | self.month, 67 | self.day, 68 | self.hour, 69 | self.minute, 70 | self.second, 71 | fold=self.fold, 72 | ) 73 | if tz: 74 | return mock_date.astimezone(tz) 75 | return mock_date 76 | 77 | self.original_datetime = datetime.datetime 78 | datetime.datetime = MockDate 79 | 80 | self.original_zone = os.environ.get("TZ") 81 | if self.zone: 82 | os.environ["TZ"] = self.zone 83 | time.tzset() 84 | 85 | return MockDate( 86 | self.year, self.month, self.day, self.hour, self.minute, self.second 87 | ) 88 | 89 | def __exit__(self, *args, **kwargs): 90 | datetime.datetime = self.original_datetime 91 | if self.original_zone: 92 | os.environ["TZ"] = self.original_zone 93 | time.tzset() 94 | 95 | 96 | class SchedulerTests(TestCase): 97 | def setUp(self): 98 | schedule.clear() 99 | 100 | def make_tz_mock_job(self, name=None): 101 | try: 102 | import pytz 103 | except ModuleNotFoundError: 104 | self.skipTest("pytz unavailable") 105 | return 106 | return make_mock_job(name) 107 | 108 | def test_time_units(self): 109 | assert every().seconds.unit == "seconds" 110 | assert every().minutes.unit == "minutes" 111 | assert every().hours.unit == "hours" 112 | assert every().days.unit == "days" 113 | assert every().weeks.unit == "weeks" 114 | 115 | job_instance = schedule.Job(interval=2) 116 | # without a context manager, it incorrectly raises an error because 117 | # it is not callable 118 | with self.assertRaises(IntervalError): 119 | job_instance.minute 120 | with self.assertRaises(IntervalError): 121 | job_instance.hour 122 | with self.assertRaises(IntervalError): 123 | job_instance.day 124 | with self.assertRaises(IntervalError): 125 | job_instance.week 126 | with self.assertRaisesRegex( 127 | IntervalError, 128 | ( 129 | r"Scheduling \.monday\(\) jobs is only allowed for weekly jobs\. " 130 | r"Using \.monday\(\) on a job scheduled to run every 2 or more " 131 | r"weeks is not supported\." 132 | ), 133 | ): 134 | job_instance.monday 135 | with self.assertRaisesRegex( 136 | IntervalError, 137 | ( 138 | r"Scheduling \.tuesday\(\) jobs is only allowed for weekly jobs\. " 139 | r"Using \.tuesday\(\) on a job scheduled to run every 2 or more " 140 | r"weeks is not supported\." 141 | ), 142 | ): 143 | job_instance.tuesday 144 | with self.assertRaisesRegex( 145 | IntervalError, 146 | ( 147 | r"Scheduling \.wednesday\(\) jobs is only allowed for weekly jobs\. " 148 | r"Using \.wednesday\(\) on a job scheduled to run every 2 or more " 149 | r"weeks is not supported\." 150 | ), 151 | ): 152 | job_instance.wednesday 153 | with self.assertRaisesRegex( 154 | IntervalError, 155 | ( 156 | r"Scheduling \.thursday\(\) jobs is only allowed for weekly jobs\. " 157 | r"Using \.thursday\(\) on a job scheduled to run every 2 or more " 158 | r"weeks is not supported\." 159 | ), 160 | ): 161 | job_instance.thursday 162 | with self.assertRaisesRegex( 163 | IntervalError, 164 | ( 165 | r"Scheduling \.friday\(\) jobs is only allowed for weekly jobs\. " 166 | r"Using \.friday\(\) on a job scheduled to run every 2 or more " 167 | r"weeks is not supported\." 168 | ), 169 | ): 170 | job_instance.friday 171 | with self.assertRaisesRegex( 172 | IntervalError, 173 | ( 174 | r"Scheduling \.saturday\(\) jobs is only allowed for weekly jobs\. " 175 | r"Using \.saturday\(\) on a job scheduled to run every 2 or more " 176 | r"weeks is not supported\." 177 | ), 178 | ): 179 | job_instance.saturday 180 | with self.assertRaisesRegex( 181 | IntervalError, 182 | ( 183 | r"Scheduling \.sunday\(\) jobs is only allowed for weekly jobs\. " 184 | r"Using \.sunday\(\) on a job scheduled to run every 2 or more " 185 | r"weeks is not supported\." 186 | ), 187 | ): 188 | job_instance.sunday 189 | 190 | # test an invalid unit 191 | job_instance.unit = "foo" 192 | self.assertRaises(ScheduleValueError, job_instance.at, "1:0:0") 193 | self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) 194 | 195 | # test start day exists but unit is not 'weeks' 196 | job_instance.unit = "days" 197 | job_instance.start_day = 1 198 | self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) 199 | 200 | # test weeks with an invalid start day 201 | job_instance.unit = "weeks" 202 | job_instance.start_day = "bar" 203 | self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) 204 | 205 | # test a valid unit with invalid hours/minutes/seconds 206 | job_instance.unit = "days" 207 | self.assertRaises(ScheduleValueError, job_instance.at, "25:00:00") 208 | self.assertRaises(ScheduleValueError, job_instance.at, "00:61:00") 209 | self.assertRaises(ScheduleValueError, job_instance.at, "00:00:61") 210 | 211 | # test invalid time format 212 | self.assertRaises(ScheduleValueError, job_instance.at, "25:0:0") 213 | self.assertRaises(ScheduleValueError, job_instance.at, "0:61:0") 214 | self.assertRaises(ScheduleValueError, job_instance.at, "0:0:61") 215 | 216 | # test self.latest >= self.interval 217 | job_instance.latest = 1 218 | self.assertRaises(ScheduleError, job_instance._schedule_next_run) 219 | job_instance.latest = 3 220 | self.assertRaises(ScheduleError, job_instance._schedule_next_run) 221 | 222 | def test_next_run_with_tag(self): 223 | with mock_datetime(2014, 6, 28, 12, 0): 224 | job1 = every(5).seconds.do(make_mock_job(name="job1")).tag("tag1") 225 | job2 = every(2).hours.do(make_mock_job(name="job2")).tag("tag1", "tag2") 226 | job3 = ( 227 | every(1) 228 | .minutes.do(make_mock_job(name="job3")) 229 | .tag("tag1", "tag3", "tag2") 230 | ) 231 | assert schedule.next_run("tag1") == job1.next_run 232 | assert schedule.default_scheduler.get_next_run("tag2") == job3.next_run 233 | assert schedule.next_run("tag3") == job3.next_run 234 | assert schedule.next_run("tag4") is None 235 | 236 | def test_singular_time_units_match_plural_units(self): 237 | assert every().second.unit == every().seconds.unit 238 | assert every().minute.unit == every().minutes.unit 239 | assert every().hour.unit == every().hours.unit 240 | assert every().day.unit == every().days.unit 241 | assert every().week.unit == every().weeks.unit 242 | 243 | def test_time_range(self): 244 | with mock_datetime(2014, 6, 28, 12, 0): 245 | mock_job = make_mock_job() 246 | 247 | # Choose a sample size large enough that it's unlikely the 248 | # same value will be chosen each time. 249 | minutes = set( 250 | [ 251 | every(5).to(30).minutes.do(mock_job).next_run.minute 252 | for i in range(100) 253 | ] 254 | ) 255 | 256 | assert len(minutes) > 1 257 | assert min(minutes) >= 5 258 | assert max(minutes) <= 30 259 | 260 | def test_time_range_repr(self): 261 | mock_job = make_mock_job() 262 | 263 | with mock_datetime(2014, 6, 28, 12, 0): 264 | job_repr = repr(every(5).to(30).minutes.do(mock_job)) 265 | 266 | assert job_repr.startswith("Every 5 to 30 minutes do job()") 267 | 268 | def test_at_time(self): 269 | mock_job = make_mock_job() 270 | assert every().day.at("10:30").do(mock_job).next_run.hour == 10 271 | assert every().day.at("10:30").do(mock_job).next_run.minute == 30 272 | assert every().day.at("20:59").do(mock_job).next_run.minute == 59 273 | assert every().day.at("10:30:50").do(mock_job).next_run.second == 50 274 | 275 | self.assertRaises(ScheduleValueError, every().day.at, "2:30:000001") 276 | self.assertRaises(ScheduleValueError, every().day.at, "::2") 277 | self.assertRaises(ScheduleValueError, every().day.at, ".2") 278 | self.assertRaises(ScheduleValueError, every().day.at, "2") 279 | self.assertRaises(ScheduleValueError, every().day.at, ":2") 280 | self.assertRaises(ScheduleValueError, every().day.at, " 2:30:00") 281 | self.assertRaises(ScheduleValueError, every().day.at, "59:59") 282 | self.assertRaises(ScheduleValueError, every().do, lambda: 0) 283 | self.assertRaises(TypeError, every().day.at, 2) 284 | 285 | # without a context manager, it incorrectly raises an error because 286 | # it is not callable 287 | with self.assertRaises(IntervalError): 288 | every(interval=2).second 289 | with self.assertRaises(IntervalError): 290 | every(interval=2).minute 291 | with self.assertRaises(IntervalError): 292 | every(interval=2).hour 293 | with self.assertRaises(IntervalError): 294 | every(interval=2).day 295 | with self.assertRaises(IntervalError): 296 | every(interval=2).week 297 | with self.assertRaises(IntervalError): 298 | every(interval=2).monday 299 | with self.assertRaises(IntervalError): 300 | every(interval=2).tuesday 301 | with self.assertRaises(IntervalError): 302 | every(interval=2).wednesday 303 | with self.assertRaises(IntervalError): 304 | every(interval=2).thursday 305 | with self.assertRaises(IntervalError): 306 | every(interval=2).friday 307 | with self.assertRaises(IntervalError): 308 | every(interval=2).saturday 309 | with self.assertRaises(IntervalError): 310 | every(interval=2).sunday 311 | 312 | def test_until_time(self): 313 | mock_job = make_mock_job() 314 | # Check argument parsing 315 | with mock_datetime(2020, 1, 1, 10, 0, 0) as m: 316 | assert every().day.until(datetime.datetime(3000, 1, 1, 20, 30)).do( 317 | mock_job 318 | ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 0) 319 | assert every().day.until(datetime.datetime(3000, 1, 1, 20, 30, 50)).do( 320 | mock_job 321 | ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 50) 322 | assert every().day.until(datetime.time(12, 30)).do( 323 | mock_job 324 | ).cancel_after == m.replace(hour=12, minute=30, second=0, microsecond=0) 325 | assert every().day.until(datetime.time(12, 30, 50)).do( 326 | mock_job 327 | ).cancel_after == m.replace(hour=12, minute=30, second=50, microsecond=0) 328 | 329 | assert every().day.until( 330 | datetime.timedelta(days=40, hours=5, minutes=12, seconds=42) 331 | ).do(mock_job).cancel_after == datetime.datetime(2020, 2, 10, 15, 12, 42) 332 | 333 | assert every().day.until("10:30").do(mock_job).cancel_after == m.replace( 334 | hour=10, minute=30, second=0, microsecond=0 335 | ) 336 | assert every().day.until("10:30:50").do(mock_job).cancel_after == m.replace( 337 | hour=10, minute=30, second=50, microsecond=0 338 | ) 339 | assert every().day.until("3000-01-01 10:30").do( 340 | mock_job 341 | ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 0) 342 | assert every().day.until("3000-01-01 10:30:50").do( 343 | mock_job 344 | ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50) 345 | assert every().day.until(datetime.datetime(3000, 1, 1, 10, 30, 50)).do( 346 | mock_job 347 | ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50) 348 | 349 | # Invalid argument types 350 | self.assertRaises(TypeError, every().day.until, 123) 351 | self.assertRaises(ScheduleValueError, every().day.until, "123") 352 | self.assertRaises(ScheduleValueError, every().day.until, "01-01-3000") 353 | 354 | # Using .until() with moments in the passed 355 | self.assertRaises( 356 | ScheduleValueError, 357 | every().day.until, 358 | datetime.datetime(2019, 12, 31, 23, 59), 359 | ) 360 | self.assertRaises( 361 | ScheduleValueError, every().day.until, datetime.timedelta(minutes=-1) 362 | ) 363 | one_hour_ago = datetime.datetime.now() - datetime.timedelta(hours=1) 364 | self.assertRaises(ScheduleValueError, every().day.until, one_hour_ago) 365 | 366 | # Unschedule job after next_run passes the deadline 367 | schedule.clear() 368 | with mock_datetime(2020, 1, 1, 11, 35, 10): 369 | mock_job.reset_mock() 370 | every(5).seconds.until(datetime.time(11, 35, 20)).do(mock_job) 371 | with mock_datetime(2020, 1, 1, 11, 35, 15): 372 | schedule.run_pending() 373 | assert mock_job.call_count == 1 374 | assert len(schedule.jobs) == 1 375 | with mock_datetime(2020, 1, 1, 11, 35, 20): 376 | schedule.run_all() 377 | assert mock_job.call_count == 2 378 | assert len(schedule.jobs) == 0 379 | 380 | # Unschedule job because current execution time has passed deadline 381 | schedule.clear() 382 | with mock_datetime(2020, 1, 1, 11, 35, 10): 383 | mock_job.reset_mock() 384 | every(5).seconds.until(datetime.time(11, 35, 20)).do(mock_job) 385 | with mock_datetime(2020, 1, 1, 11, 35, 50): 386 | schedule.run_pending() 387 | assert mock_job.call_count == 0 388 | assert len(schedule.jobs) == 0 389 | 390 | def test_weekday_at_todady(self): 391 | mock_job = make_mock_job() 392 | 393 | # This date is a wednesday 394 | with mock_datetime(2020, 11, 25, 22, 38, 5): 395 | job = every().wednesday.at("22:38:10").do(mock_job) 396 | assert job.next_run.hour == 22 397 | assert job.next_run.minute == 38 398 | assert job.next_run.second == 10 399 | assert job.next_run.year == 2020 400 | assert job.next_run.month == 11 401 | assert job.next_run.day == 25 402 | 403 | job = every().wednesday.at("22:39").do(mock_job) 404 | assert job.next_run.hour == 22 405 | assert job.next_run.minute == 39 406 | assert job.next_run.second == 00 407 | assert job.next_run.year == 2020 408 | assert job.next_run.month == 11 409 | assert job.next_run.day == 25 410 | 411 | def test_at_time_hour(self): 412 | with mock_datetime(2010, 1, 6, 12, 20): 413 | mock_job = make_mock_job() 414 | assert every().hour.at(":30").do(mock_job).next_run.hour == 12 415 | assert every().hour.at(":30").do(mock_job).next_run.minute == 30 416 | assert every().hour.at(":30").do(mock_job).next_run.second == 0 417 | assert every().hour.at(":10").do(mock_job).next_run.hour == 13 418 | assert every().hour.at(":10").do(mock_job).next_run.minute == 10 419 | assert every().hour.at(":10").do(mock_job).next_run.second == 0 420 | assert every().hour.at(":00").do(mock_job).next_run.hour == 13 421 | assert every().hour.at(":00").do(mock_job).next_run.minute == 0 422 | assert every().hour.at(":00").do(mock_job).next_run.second == 0 423 | 424 | self.assertRaises(ScheduleValueError, every().hour.at, "2:30:00") 425 | self.assertRaises(ScheduleValueError, every().hour.at, "::2") 426 | self.assertRaises(ScheduleValueError, every().hour.at, ".2") 427 | self.assertRaises(ScheduleValueError, every().hour.at, "2") 428 | self.assertRaises(ScheduleValueError, every().hour.at, " 2:30") 429 | self.assertRaises(ScheduleValueError, every().hour.at, "61:00") 430 | self.assertRaises(ScheduleValueError, every().hour.at, "00:61") 431 | self.assertRaises(ScheduleValueError, every().hour.at, "01:61") 432 | self.assertRaises(TypeError, every().hour.at, 2) 433 | 434 | # test the 'MM:SS' format 435 | assert every().hour.at("30:05").do(mock_job).next_run.hour == 12 436 | assert every().hour.at("30:05").do(mock_job).next_run.minute == 30 437 | assert every().hour.at("30:05").do(mock_job).next_run.second == 5 438 | assert every().hour.at("10:25").do(mock_job).next_run.hour == 13 439 | assert every().hour.at("10:25").do(mock_job).next_run.minute == 10 440 | assert every().hour.at("10:25").do(mock_job).next_run.second == 25 441 | assert every().hour.at("00:40").do(mock_job).next_run.hour == 13 442 | assert every().hour.at("00:40").do(mock_job).next_run.minute == 0 443 | assert every().hour.at("00:40").do(mock_job).next_run.second == 40 444 | 445 | def test_at_time_minute(self): 446 | with mock_datetime(2010, 1, 6, 12, 20, 30): 447 | mock_job = make_mock_job() 448 | assert every().minute.at(":40").do(mock_job).next_run.hour == 12 449 | assert every().minute.at(":40").do(mock_job).next_run.minute == 20 450 | assert every().minute.at(":40").do(mock_job).next_run.second == 40 451 | assert every().minute.at(":10").do(mock_job).next_run.hour == 12 452 | assert every().minute.at(":10").do(mock_job).next_run.minute == 21 453 | assert every().minute.at(":10").do(mock_job).next_run.second == 10 454 | 455 | self.assertRaises(ScheduleValueError, every().minute.at, "::2") 456 | self.assertRaises(ScheduleValueError, every().minute.at, ".2") 457 | self.assertRaises(ScheduleValueError, every().minute.at, "2") 458 | self.assertRaises(ScheduleValueError, every().minute.at, "2:30:00") 459 | self.assertRaises(ScheduleValueError, every().minute.at, "2:30") 460 | self.assertRaises(ScheduleValueError, every().minute.at, " :30") 461 | self.assertRaises(TypeError, every().minute.at, 2) 462 | 463 | def test_next_run_time(self): 464 | with mock_datetime(2010, 1, 6, 12, 15): 465 | mock_job = make_mock_job() 466 | assert schedule.next_run() is None 467 | assert every().minute.do(mock_job).next_run.minute == 16 468 | assert every(5).minutes.do(mock_job).next_run.minute == 20 469 | assert every().hour.do(mock_job).next_run.hour == 13 470 | assert every().day.do(mock_job).next_run.day == 7 471 | assert every().day.at("09:00").do(mock_job).next_run.day == 7 472 | assert every().day.at("12:30").do(mock_job).next_run.day == 6 473 | assert every().week.do(mock_job).next_run.day == 13 474 | assert every().monday.do(mock_job).next_run.day == 11 475 | assert every().tuesday.do(mock_job).next_run.day == 12 476 | assert every().wednesday.do(mock_job).next_run.day == 13 477 | assert every().thursday.do(mock_job).next_run.day == 7 478 | assert every().friday.do(mock_job).next_run.day == 8 479 | assert every().saturday.do(mock_job).next_run.day == 9 480 | assert every().sunday.do(mock_job).next_run.day == 10 481 | assert ( 482 | every().minute.until(datetime.time(12, 17)).do(mock_job).next_run.minute 483 | == 16 484 | ) 485 | 486 | def test_next_run_time_day_end(self): 487 | mock_job = make_mock_job() 488 | # At day 1, schedule job to run at daily 23:30 489 | with mock_datetime(2010, 12, 1, 23, 0, 0): 490 | job = every().day.at("23:30").do(mock_job) 491 | # first occurrence same day 492 | assert job.next_run.day == 1 493 | assert job.next_run.hour == 23 494 | 495 | # Running the job 01:00 on day 2, afterwards the job should be 496 | # scheduled at 23:30 the same day. This simulates a job that started 497 | # on day 1 at 23:30 and took 1,5 hours to finish 498 | with mock_datetime(2010, 12, 2, 1, 0, 0): 499 | job.run() 500 | assert job.next_run.day == 2 501 | assert job.next_run.hour == 23 502 | 503 | # Run the job at 23:30 on day 2, afterwards the job should be 504 | # scheduled at 23:30 the next day 505 | with mock_datetime(2010, 12, 2, 23, 30, 0): 506 | job.run() 507 | assert job.next_run.day == 3 508 | assert job.next_run.hour == 23 509 | 510 | def test_next_run_time_hour_end(self): 511 | try: 512 | import pytz 513 | except ModuleNotFoundError: 514 | self.skipTest("pytz unavailable") 515 | 516 | self.tst_next_run_time_hour_end(None, 0) 517 | 518 | def test_next_run_time_hour_end_london(self): 519 | try: 520 | import pytz 521 | except ModuleNotFoundError: 522 | self.skipTest("pytz unavailable") 523 | 524 | self.tst_next_run_time_hour_end("Europe/London", 0) 525 | 526 | def test_next_run_time_hour_end_katmandu(self): 527 | try: 528 | import pytz 529 | except ModuleNotFoundError: 530 | self.skipTest("pytz unavailable") 531 | 532 | # 12:00 in Berlin is 15:45 in Kathmandu 533 | # this test schedules runs at :10 minutes, so job runs at 534 | # 16:10 in Kathmandu, which is 13:25 in Berlin 535 | # in local time we don't run at :10, but at :25, offset of 15 minutes 536 | self.tst_next_run_time_hour_end("Asia/Kathmandu", 15) 537 | 538 | def tst_next_run_time_hour_end(self, tz, offsetMinutes): 539 | mock_job = make_mock_job() 540 | 541 | # So a job scheduled to run at :10 in Kathmandu, runs always 25 minutes 542 | with mock_datetime(2010, 10, 10, 12, 0, 0): 543 | job = every().hour.at(":10", tz).do(mock_job) 544 | assert job.next_run.hour == 12 545 | assert job.next_run.minute == 10 + offsetMinutes 546 | 547 | with mock_datetime(2010, 10, 10, 13, 0, 0): 548 | job.run() 549 | assert job.next_run.hour == 13 550 | assert job.next_run.minute == 10 + offsetMinutes 551 | 552 | with mock_datetime(2010, 10, 10, 13, 30, 0): 553 | job.run() 554 | assert job.next_run.hour == 14 555 | assert job.next_run.minute == 10 + offsetMinutes 556 | 557 | def test_next_run_time_minute_end(self): 558 | self.tst_next_run_time_minute_end(None) 559 | 560 | def test_next_run_time_minute_end_london(self): 561 | try: 562 | import pytz 563 | except ModuleNotFoundError: 564 | self.skipTest("pytz unavailable") 565 | 566 | self.tst_next_run_time_minute_end("Europe/London") 567 | 568 | def test_next_run_time_minute_end_katmhandu(self): 569 | try: 570 | import pytz 571 | except ModuleNotFoundError: 572 | self.skipTest("pytz unavailable") 573 | 574 | self.tst_next_run_time_minute_end("Asia/Kathmandu") 575 | 576 | def tst_next_run_time_minute_end(self, tz): 577 | mock_job = make_mock_job() 578 | with mock_datetime(2010, 10, 10, 10, 10, 0): 579 | job = every().minute.at(":15", tz).do(mock_job) 580 | assert job.next_run.minute == 10 581 | assert job.next_run.second == 15 582 | 583 | with mock_datetime(2010, 10, 10, 10, 10, 59): 584 | job.run() 585 | assert job.next_run.minute == 11 586 | assert job.next_run.second == 15 587 | 588 | with mock_datetime(2010, 10, 10, 10, 12, 14): 589 | job.run() 590 | assert job.next_run.minute == 12 591 | assert job.next_run.second == 15 592 | 593 | with mock_datetime(2010, 10, 10, 10, 12, 16): 594 | job.run() 595 | assert job.next_run.minute == 13 596 | assert job.next_run.second == 15 597 | 598 | def test_tz(self): 599 | mock_job = self.make_tz_mock_job() 600 | with mock_datetime(2022, 2, 1, 23, 15): 601 | # Current Berlin time: feb-1 23:15 (local) 602 | # Current India time: feb-2 03:45 603 | # Expected to run India time: feb-2 06:30 604 | # Next run Berlin time: feb-2 02:00 605 | next = every().day.at("06:30", "Asia/Kolkata").do(mock_job).next_run 606 | assert next.day == 2 607 | assert next.hour == 2 608 | assert next.minute == 0 609 | 610 | def test_tz_daily_midnight(self): 611 | mock_job = self.make_tz_mock_job() 612 | with mock_datetime(2023, 4, 14, 4, 50): 613 | # Current Berlin time: april-14 04:50 (local) (during daylight saving) 614 | # Current US/Central time: april-13 21:50 615 | # Expected to run US/Central time: april-14 00:00 616 | # Next run Berlin time: april-14 07:00 617 | next = every().day.at("00:00", "US/Central").do(mock_job).next_run 618 | assert next.day == 14 619 | assert next.hour == 7 620 | assert next.minute == 0 621 | 622 | def test_tz_daily_half_hour_offset(self): 623 | mock_job = self.make_tz_mock_job() 624 | with mock_datetime(2022, 4, 8, 10, 0): 625 | # Current Berlin time: 10:00 (local) (during daylight saving) 626 | # Current NY time: 04:00 627 | # Expected to run NY time: 10:30 628 | # Next run Berlin time: 16:30 629 | next = every().day.at("10:30", "America/New_York").do(mock_job).next_run 630 | assert next.hour == 16 631 | assert next.minute == 30 632 | 633 | def test_tz_daily_dst(self): 634 | mock_job = self.make_tz_mock_job() 635 | import pytz 636 | 637 | with mock_datetime(2022, 3, 20, 10, 0): 638 | # Current Berlin time: 10:00 (local) (NOT during daylight saving) 639 | # Current NY time: 04:00 (during daylight saving) 640 | # Expected to run NY time: 10:30 641 | # Next run Berlin time: 15:30 642 | tz = pytz.timezone("America/New_York") 643 | next = every().day.at("10:30", tz).do(mock_job).next_run 644 | assert next.hour == 15 645 | assert next.minute == 30 646 | 647 | def test_tz_daily_dst_skip_hour(self): 648 | mock_job = self.make_tz_mock_job() 649 | # Test the DST-case that is described in the documentation 650 | with mock_datetime(2023, 3, 26, 1, 30): 651 | # Current Berlin time: 01:30 (NOT during daylight saving) 652 | # Expected to run: 02:30 - this time doesn't exist 653 | # because clock moves from 02:00 to 03:00 654 | # Next run: 03:30 655 | job = every().day.at("02:30", "Europe/Berlin").do(mock_job) 656 | assert job.next_run.day == 26 657 | assert job.next_run.hour == 3 658 | assert job.next_run.minute == 30 659 | with mock_datetime(2023, 3, 27, 1, 30): 660 | # the next day the job shall again run at 02:30 661 | job.run() 662 | assert job.next_run.day == 27 663 | assert job.next_run.hour == 2 664 | assert job.next_run.minute == 30 665 | 666 | def test_tz_daily_dst_overlap_hour(self): 667 | mock_job = self.make_tz_mock_job() 668 | # Test the DST-case that is described in the documentation 669 | with mock_datetime(2023, 10, 29, 1, 30): 670 | # Current Berlin time: 01:30 (during daylight saving) 671 | # Expected to run: 02:30 - this time exists twice 672 | # because clock moves from 03:00 to 02:00 673 | # Next run should be at the first occurrence of 02:30 674 | job = every().day.at("02:30", "Europe/Berlin").do(mock_job) 675 | assert job.next_run.day == 29 676 | assert job.next_run.hour == 2 677 | assert job.next_run.minute == 30 678 | with mock_datetime(2023, 10, 29, 2, 35): 679 | # After the job runs, the next run should be scheduled on the next day at 02:30 680 | job.run() 681 | assert job.next_run.day == 30 682 | assert job.next_run.hour == 2 683 | assert job.next_run.minute == 30 684 | 685 | def test_tz_daily_exact_future_scheduling(self): 686 | mock_job = self.make_tz_mock_job() 687 | with mock_datetime(2022, 3, 20, 10, 0): 688 | # Current Berlin time: 10:00 (local) (NOT during daylight saving) 689 | # Current Krasnoyarsk time: 16:00 690 | # Expected to run Krasnoyarsk time: mar-21 11:00 691 | # Next run Berlin time: mar-21 05:00 692 | # Expected idle seconds: 68400 693 | schedule.clear() 694 | every().day.at("11:00", "Asia/Krasnoyarsk").do(mock_job) 695 | expected_delta = ( 696 | datetime.datetime(2022, 3, 21, 5, 0) - datetime.datetime.now() 697 | ) 698 | assert schedule.idle_seconds() == expected_delta.total_seconds() 699 | 700 | def test_tz_daily_utc(self): 701 | mock_job = self.make_tz_mock_job() 702 | with mock_datetime(2023, 9, 18, 10, 59, 0, TZ_AUCKLAND): 703 | # Testing issue #598 704 | # Current Auckland time: 10:59 (local) (NOT during daylight saving) 705 | # Current UTC time: 21:59 (17 september) 706 | # Expected to run UTC time: sept-18 00:00 707 | # Next run Auckland time: sept-18 12:00 708 | schedule.clear() 709 | next = every().day.at("00:00", "UTC").do(mock_job).next_run 710 | assert next.day == 18 711 | assert next.hour == 12 712 | assert next.minute == 0 713 | 714 | # Test that .day.at() and .monday.at() are equivalent in this case 715 | schedule.clear() 716 | next = every().monday.at("00:00", "UTC").do(mock_job).next_run 717 | assert next.day == 18 718 | assert next.hour == 12 719 | assert next.minute == 0 720 | 721 | def test_tz_daily_issue_592(self): 722 | mock_job = self.make_tz_mock_job() 723 | with mock_datetime(2023, 7, 15, 13, 0, 0, TZ_UTC): 724 | # Testing issue #592 725 | # Current UTC time: 13:00 726 | # Expected to run US East time: 9:45 (daylight saving active) 727 | # Next run UTC time: july-15 13:45 728 | schedule.clear() 729 | next = every().day.at("09:45", "US/Eastern").do(mock_job).next_run 730 | assert next.day == 15 731 | assert next.hour == 13 732 | assert next.minute == 45 733 | 734 | def test_tz_daily_exact_seconds_precision(self): 735 | mock_job = self.make_tz_mock_job() 736 | with mock_datetime(2023, 10, 19, 15, 0, 0, TZ_UTC): 737 | # Testing issue #603 738 | # Current UTC: oktober-19 15:00 739 | # Current Amsterdam: oktober-19 17:00 (daylight saving active) 740 | # Expected run Amsterdam: oktober-20 00:00:20 (daylight saving active) 741 | # Next run UTC time: oktober-19 22:00:20 742 | schedule.clear() 743 | next = every().day.at("00:00:20", "Europe/Amsterdam").do(mock_job).next_run 744 | assert next.day == 19 745 | assert next.hour == 22 746 | assert next.minute == 00 747 | assert next.second == 20 748 | 749 | def test_tz_weekly_sunday_conversion(self): 750 | mock_job = self.make_tz_mock_job() 751 | with mock_datetime(2023, 10, 22, 23, 0, 0, TZ_UTC): 752 | # Current UTC: sunday 22-okt 23:00 753 | # Current Amsterdam: monday 23-okt 01:00 (daylight saving active) 754 | # Expected run Amsterdam: sunday 29 oktober 23:00 (daylight saving NOT active) 755 | # Next run UTC time: oktober-29 22:00 756 | schedule.clear() 757 | next = every().sunday.at("23:00", "Europe/Amsterdam").do(mock_job).next_run 758 | assert next.day == 29 759 | assert next.hour == 22 760 | assert next.minute == 00 761 | 762 | def test_tz_daily_new_year_offset(self): 763 | mock_job = self.make_tz_mock_job() 764 | with mock_datetime(2023, 12, 31, 23, 0, 0): 765 | # Current Berlin time: dec-31 23:00 (local) 766 | # Current Sydney time: jan-1 09:00 (next day) 767 | # Expected to run Sydney time: jan-1 12:00 768 | # Next run Berlin time: jan-1 02:00 769 | next = every().day.at("12:00", "Australia/Sydney").do(mock_job).next_run 770 | assert next.day == 1 771 | assert next.hour == 2 772 | assert next.minute == 0 773 | 774 | def test_tz_daily_end_year_cross_continent(self): 775 | mock_job = self.make_tz_mock_job() 776 | with mock_datetime(2023, 12, 31, 23, 50): 777 | # End of the year in Berlin 778 | # Current Berlin time: dec-31 23:50 779 | # Current Tokyo time: jan-1 07:50 (next day) 780 | # Expected to run Tokyo time: jan-1 09:00 781 | # Next run Berlin time: jan-1 01:00 782 | next = every().day.at("09:00", "Asia/Tokyo").do(mock_job).next_run 783 | assert next.day == 1 784 | assert next.hour == 1 785 | assert next.minute == 0 786 | 787 | def test_tz_daily_end_month_offset(self): 788 | mock_job = self.make_tz_mock_job() 789 | with mock_datetime(2023, 2, 28, 23, 50): 790 | # End of the month (non-leap year) in Berlin 791 | # Current Berlin time: feb-28 23:50 792 | # Current Sydney time: mar-1 09:50 (next day) 793 | # Expected to run Sydney time: mar-1 10:00 794 | # Next run Berlin time: mar-1 00:00 795 | next = every().day.at("10:00", "Australia/Sydney").do(mock_job).next_run 796 | assert next.day == 1 797 | assert next.hour == 0 798 | assert next.minute == 0 799 | 800 | def test_tz_daily_leap_year(self): 801 | mock_job = self.make_tz_mock_job() 802 | with mock_datetime(2024, 2, 28, 23, 50): 803 | # End of the month (leap year) in Berlin 804 | # Current Berlin time: feb-28 23:50 805 | # Current Dubai time: feb-29 02:50 806 | # Expected to run Dubai time: feb-29 04:00 807 | # Next run Berlin time: feb-29 01:00 808 | next = every().day.at("04:00", "Asia/Dubai").do(mock_job).next_run 809 | assert next.month == 2 810 | assert next.day == 29 811 | assert next.hour == 1 812 | assert next.minute == 0 813 | 814 | def test_tz_daily_issue_605(self): 815 | mock_job = self.make_tz_mock_job() 816 | with mock_datetime(2023, 9, 18, 10, 00, 0, TZ_AUCKLAND): 817 | schedule.clear() 818 | # Testing issue #605 819 | # Current time: Monday 18 September 10:00 NZST 820 | # Current time UTC: Sunday 17 September 22:00 821 | # We expect the job to run at 23:00 on Sunday 17 September NZST 822 | # That is an expected idle time of 1 hour 823 | # Expected next run in NZST: 2023-09-18 11:00:00 824 | next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run 825 | assert round(schedule.idle_seconds() / 3600) == 1 826 | assert next.day == 18 827 | assert next.hour == 11 828 | assert next.minute == 0 829 | 830 | def test_tz_daily_dst_starting_point(self): 831 | mock_job = self.make_tz_mock_job() 832 | with mock_datetime(2023, 3, 26, 1, 30): 833 | # Daylight Saving Time starts in Berlin 834 | # In Berlin, 26 March 2023, 02:00:00 clocks were turned forward 1 hour 835 | # In London, 26 March 2023, 01:00:00 clocks were turned forward 1 hour 836 | # Current Berlin time: 26 March 01:30 (UTC+1) 837 | # Current London time: 26 March 00:30 (UTC+0) 838 | # Expected London time: 26 March 02:00 (UTC+1) 839 | # Expected Berlin time: 26 March 03:00 (UTC+2) 840 | next = every().day.at("01:00", "Europe/London").do(mock_job).next_run 841 | assert next.day == 26 842 | assert next.hour == 3 843 | assert next.minute == 0 844 | 845 | def test_tz_daily_dst_ending_point(self): 846 | mock_job = self.make_tz_mock_job() 847 | with mock_datetime(2023, 10, 29, 2, 30, fold=1): 848 | # Daylight Saving Time ends in Berlin 849 | # Current Berlin time: oct-29 02:30 (after moving back to 02:00 due to DST end) 850 | # Current Istanbul time: oct-29 04:30 851 | # Expected to run Istanbul time: oct-29 06:00 852 | # Next run Berlin time: oct-29 04:00 853 | next = every().day.at("06:00", "Europe/Istanbul").do(mock_job).next_run 854 | assert next.hour == 4 855 | assert next.minute == 0 856 | 857 | def test_tz_daily_issue_608_pre_dst(self): 858 | mock_job = self.make_tz_mock_job() 859 | with mock_datetime(2023, 9, 18, 10, 00, 0, TZ_AUCKLAND): 860 | # See ticket #608 861 | # Testing timezone conversion the week before daylight saving comes into effect 862 | # Current time: Monday 18 September 10:00 NZST 863 | # Current time UTC: Sunday 17 September 22:00 864 | # Expected next run in NZST: 2023-09-18 11:00:00 865 | schedule.clear() 866 | next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run 867 | assert next.day == 18 868 | assert next.hour == 11 869 | assert next.minute == 0 870 | 871 | def test_tz_daily_issue_608_post_dst(self): 872 | mock_job = self.make_tz_mock_job() 873 | with mock_datetime(2024, 4, 8, 10, 00, 0, TZ_AUCKLAND): 874 | # See ticket #608 875 | # Testing timezone conversion the week after daylight saving ends 876 | # Current time: Monday 8 April 10:00 NZST 877 | # Current time UTC: Sunday 7 April 22:00 878 | # Expected next run in NZDT: 2023-04-08 11:00:00 879 | schedule.clear() 880 | next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run 881 | assert next.day == 8 882 | assert next.hour == 11 883 | assert next.minute == 0 884 | 885 | def test_tz_daily_issue_608_mid_dst(self): 886 | mock_job = self.make_tz_mock_job() 887 | with mock_datetime(2023, 9, 25, 10, 00, 0, TZ_AUCKLAND): 888 | # See ticket #608 889 | # Testing timezone conversion during the week after daylight saving comes into effect 890 | # Current time: Monday 25 September 10:00 NZDT 891 | # Current time UTC: Sunday 24 September 21:00 892 | # Expected next run in UTC: 2023-09-24 23:00 893 | # Expected next run in NZDT: 2023-09-25 12:00 894 | schedule.clear() 895 | next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run 896 | assert next.month == 9 897 | assert next.day == 25 898 | assert next.hour == 12 899 | assert next.minute == 0 900 | 901 | def test_tz_daily_issue_608_before_dst_end(self): 902 | mock_job = self.make_tz_mock_job() 903 | with mock_datetime(2024, 4, 1, 10, 00, 0, TZ_AUCKLAND): 904 | # See ticket #608 905 | # Testing timezone conversion during the week before daylight saving ends 906 | # Current time: Monday 1 April 10:00 NZDT 907 | # Current time UTC: Friday 31 March 21:00 908 | # Expected next run in UTC: 2023-03-31 23:00 909 | # Expected next run in NZDT: 2024-04-01 12:00 910 | schedule.clear() 911 | next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run 912 | assert next.month == 4 913 | assert next.day == 1 914 | assert next.hour == 12 915 | assert next.minute == 0 916 | 917 | def test_tz_hourly_intermediate_conversion(self): 918 | mock_job = self.make_tz_mock_job() 919 | with mock_datetime(2024, 5, 4, 14, 37, 22, TZ_CHATHAM): 920 | # Crurent time: 14:37:22 New Zealand, Chatham Islands (UTC +12:45) 921 | # Current time: 3 may, 23:22:22 Canada, Newfoundland (UTC -2:30) 922 | # Exected next run in Newfoundland: 4 may, 09:14:45 923 | # Expected next run in Chatham: 5 may, 00:29:45 924 | schedule.clear() 925 | next = ( 926 | schedule.every(10) 927 | .hours.at("14:45", "Canada/Newfoundland") 928 | .do(mock_job) 929 | .next_run 930 | ) 931 | assert next.day == 5 932 | assert next.hour == 0 933 | assert next.minute == 29 934 | assert next.second == 45 935 | 936 | def test_tz_minutes_year_round(self): 937 | mock_job = self.make_tz_mock_job() 938 | # Test a full year of scheduling across timezones, where one timezone 939 | # is in the northern hemisphere and the other in the southern hemisphere 940 | # These two timezones are also a bit exotic (not the usual UTC+1, UTC-1) 941 | # Local timezone: Newfoundland, Canada: UTC-2:30 / DST UTC-3:30 942 | # Remote timezone: Chatham Islands, New Zealand: UTC+12:45 / DST UTC+13:45 943 | schedule.clear() 944 | job = schedule.every(20).minutes.at(":13", "Canada/Newfoundland").do(mock_job) 945 | with mock_datetime(2024, 9, 29, 2, 20, 0, TZ_CHATHAM): 946 | # First run, nothing special, no utc-offset change 947 | # Current time: 29 sept, 02:20:00 Chatham 948 | # Current time: 28 sept, 11:05:00 Newfoundland 949 | # Expected time: 28 sept, 11:20:13 Newfoundland 950 | # Expected time: 29 sept, 02:40:13 Chatham 951 | job.run() 952 | assert job.next_run.day == 29 953 | assert job.next_run.hour == 2 954 | assert job.next_run.minute == 40 955 | assert job.next_run.second == 13 956 | with mock_datetime(2024, 9, 29, 2, 40, 14, TZ_CHATHAM): 957 | # next-schedule happens 1 second behind schedule 958 | job.run() 959 | # On 29 Sep, 02:45 2024, in Chatham, the clock is moved +1 hour 960 | # Thus, the next run happens AFTER the local timezone exits DST 961 | # Current time: 29 sept, 02:40:14 Chatham (UTC +12:45) 962 | # Current time: 28 sept, 11:25:14 Newfoundland (UTC -2:30) 963 | # Expected time: 28 sept, 11:45:13 Newfoundland (UTC -2:30) 964 | # Expected time: 29 sept, 04:00:13 Chatham (UTC +13:45) 965 | assert job.next_run.day == 29 966 | assert job.next_run.hour == 4 967 | assert job.next_run.minute == 00 968 | assert job.next_run.second == 13 969 | with mock_datetime(2024, 11, 3, 2, 23, 55, TZ_CHATHAM, fold=0): 970 | # Time is right before Newfoundland exits DST 971 | # Local time will move 1 hour back at 03:00 972 | 973 | job.run() 974 | # There are no timezone switches yet, nothing special going on: 975 | # Current time: 3 Nov, 02:23:55 Chatham 976 | # Expected time: 3 Nov, 02:43:13 Chatham 977 | assert job.next_run.day == 3 978 | assert job.next_run.hour == 2 979 | assert job.next_run.minute == 43 # Within the fold, first occurrence 980 | assert job.next_run.second == 13 981 | with mock_datetime(2024, 11, 3, 2, 23, 55, TZ_CHATHAM, fold=1): 982 | # Time is during the fold. Local time has moved back 1 hour, this is 983 | # the second occurrence of the 02:23 time. 984 | 985 | job.run() 986 | # Current time: 3 Nov, 02:23:55 Chatham 987 | # Expected time: 3 Nov, 02:43:13 Chatham 988 | assert job.next_run.day == 3 989 | assert job.next_run.hour == 2 990 | assert job.next_run.minute == 43 991 | assert job.next_run.second == 13 992 | with mock_datetime(2025, 3, 9, 19, 00, 00, TZ_CHATHAM): 993 | # Time is right before Newfoundland enters DST 994 | # At 02:00, the remote clock will move forward 1 hour 995 | 996 | job.run() 997 | # Current time: 9 March, 19:00:00 Chatham (UTC +13:45) 998 | # Current time: 9 March, 01:45:00 Newfoundland (UTC -3:30) 999 | # Expected time: 9 March, 03:05:13 Newfoundland (UTC -2:30) 1000 | # Expected time 9 March, 19:20:13 Chatham (UTC +13:45) 1001 | 1002 | assert job.next_run.day == 9 1003 | assert job.next_run.hour == 19 1004 | assert job.next_run.minute == 20 1005 | assert job.next_run.second == 13 1006 | with mock_datetime(2025, 4, 7, 17, 55, 00, TZ_CHATHAM): 1007 | # Time is within the few hours before Catham exits DST 1008 | # At 03:45, the local clock moves back 1 hour 1009 | 1010 | job.run() 1011 | # Current time: 7 April, 17:55:00 Chatham 1012 | # Current time: 7 April, 02:40:00 Newfoundland 1013 | # Expected time: 7 April, 03:00:13 Newfoundland 1014 | # Expected time 7 April, 18:15:13 Chatham 1015 | assert job.next_run.day == 7 1016 | assert job.next_run.hour == 18 1017 | assert job.next_run.minute == 15 1018 | assert job.next_run.second == 13 1019 | with mock_datetime(2025, 4, 7, 18, 55, 00, TZ_CHATHAM): 1020 | # Schedule the next run exactly when the clock moved backwards 1021 | # Curren time is before the clock-move, next run is after the clock change 1022 | 1023 | job.run() 1024 | # Current time: 7 April, 18:55:00 Chatham 1025 | # Current time: 7 April, 03:40:00 Newfoundland 1026 | # Expected time: 7 April, 03:00:13 Newfoundland (clock moved back) 1027 | # Expected time 7 April, 19:15:13 Chatham 1028 | assert job.next_run.day == 7 1029 | assert job.next_run.hour == 19 1030 | assert job.next_run.minute == 15 1031 | assert job.next_run.second == 13 1032 | with mock_datetime(2025, 4, 7, 19, 15, 13, TZ_CHATHAM): 1033 | # Schedule during the fold in the remote timezone 1034 | 1035 | job.run() 1036 | # Current time: 7 April, 19:15:13 Chatham 1037 | # Current time: 7 April, 03:00:13 Newfoundland (fold) 1038 | # Expected time: 7 April, 03:20:13 Newfoundland (fold) 1039 | # Expected time: 7 April, 19:35:13 Chatham 1040 | assert job.next_run.day == 7 1041 | assert job.next_run.hour == 19 1042 | assert job.next_run.minute == 35 1043 | assert job.next_run.second == 13 1044 | 1045 | def test_tz_weekly_large_interval_forward(self): 1046 | mock_job = self.make_tz_mock_job() 1047 | # Testing scheduling large intervals that skip over clock move forward 1048 | with mock_datetime(2024, 3, 28, 11, 0, 0, TZ_BERLIN): 1049 | # At March 31st 2024, 02:00:00 clocks were turned forward 1 hour 1050 | schedule.clear() 1051 | next = ( 1052 | schedule.every(7) 1053 | .days.at("11:00", "Europe/Berlin") 1054 | .do(mock_job) 1055 | .next_run 1056 | ) 1057 | assert next.month == 4 1058 | assert next.day == 4 1059 | assert next.hour == 11 1060 | assert next.minute == 0 1061 | assert next.second == 0 1062 | 1063 | def test_tz_weekly_large_interval_backward(self): 1064 | mock_job = self.make_tz_mock_job() 1065 | import pytz 1066 | 1067 | # Testing scheduling large intervals that skip over clock move back 1068 | with mock_datetime(2024, 10, 25, 11, 0, 0, TZ_BERLIN): 1069 | # At March 31st 2024, 02:00:00 clocks were turned forward 1 hour 1070 | schedule.clear() 1071 | next = ( 1072 | schedule.every(7) 1073 | .days.at("11:00", "Europe/Berlin") 1074 | .do(mock_job) 1075 | .next_run 1076 | ) 1077 | assert next.month == 11 1078 | assert next.day == 1 1079 | assert next.hour == 11 1080 | assert next.minute == 0 1081 | assert next.second == 0 1082 | 1083 | def test_tz_daily_skip_dst_change(self): 1084 | mock_job = self.make_tz_mock_job() 1085 | with mock_datetime(2024, 11, 3, 10, 0): 1086 | # At 3 November 2024, 02:00:00 clocks are turned backward 1 hour 1087 | # The job skips the whole DST change becaus it runs at 14:00 1088 | # Current time Berlin: 3 Nov, 10:00 1089 | # Current time Anchorage: 3 Nov, 00:00 (UTC-08:00) 1090 | # Expected time Anchorage: 3 Nov, 14:00 (UTC-09:00) 1091 | # Expected time Berlin: 4 Nov, 00:00 1092 | schedule.clear() 1093 | next = ( 1094 | schedule.every() 1095 | .day.at("14:00", "America/Anchorage") 1096 | .do(mock_job) 1097 | .next_run 1098 | ) 1099 | assert next.day == 4 1100 | assert next.hour == 0 1101 | assert next.minute == 00 1102 | 1103 | def test_tz_daily_different_simultaneous_dst_change(self): 1104 | mock_job = self.make_tz_mock_job() 1105 | 1106 | # TZ_BERLIN_EXTRA is the same as Berlin, but during summer time 1107 | # moves the clock 2 hours forward instead of 1 1108 | # This is a fictional timezone 1109 | TZ_BERLIN_EXTRA = "CET-01CEST-03,M3.5.0,M10.5.0/3" 1110 | with mock_datetime(2024, 3, 31, 0, 0, 0, TZ_BERLIN_EXTRA): 1111 | # In Berlin at March 31 2024, 02:00:00 clocks were turned forward 1 hour 1112 | # In Berlin Extra, the clocks move forward 2 hour at the same time 1113 | # Current time Berlin Extra: 31 Mar, 00:00 (UTC+01:00) 1114 | # Current time Berlin: 31 Mar, 00:00 (UTC+01:00) 1115 | # Expected time Berlin: 31 Mar, 10:00 (UTC+02:00) 1116 | # Expected time Berlin Extra: 31 Mar, 11:00 (UTC+03:00) 1117 | schedule.clear() 1118 | next = ( 1119 | schedule.every().day.at("10:00", "Europe/Berlin").do(mock_job).next_run 1120 | ) 1121 | assert next.day == 31 1122 | assert next.hour == 11 1123 | assert next.minute == 00 1124 | 1125 | def test_tz_daily_opposite_dst_change(self): 1126 | mock_job = self.make_tz_mock_job() 1127 | 1128 | # TZ_BERLIN_INVERTED changes in the opposite direction of Berlin 1129 | # This is a fictional timezone 1130 | TZ_BERLIN_INVERTED = "CET-1CEST,M10.5.0/3,M3.5.0" 1131 | with mock_datetime(2024, 3, 31, 0, 0, 0, TZ_BERLIN_INVERTED): 1132 | # In Berlin at March 31 2024, 02:00:00 clocks were turned forward 1 hour 1133 | # In Berlin Inverted, the clocks move back 1 hour at the same time 1134 | # Current time Berlin Inverted: 31 Mar, 00:00 (UTC+02:00) 1135 | # Current time Berlin: 31 Mar, 00:00 (UTC+01:00) 1136 | # Expected time Berlin: 31 Mar, 10:00 (UTC+02:00) +9 hour 1137 | # Expected time Berlin Inverted: 31 Mar, 09:00 (UTC+01:00) 1138 | schedule.clear() 1139 | next = ( 1140 | schedule.every().day.at("10:00", "Europe/Berlin").do(mock_job).next_run 1141 | ) 1142 | assert next.day == 31 1143 | assert next.hour == 9 1144 | assert next.minute == 00 1145 | 1146 | def test_tz_invalid_timezone_exceptions(self): 1147 | mock_job = self.make_tz_mock_job() 1148 | import pytz 1149 | 1150 | with self.assertRaises(pytz.exceptions.UnknownTimeZoneError): 1151 | every().day.at("10:30", "FakeZone").do(mock_job) 1152 | 1153 | with self.assertRaises(ScheduleValueError): 1154 | every().day.at("10:30", 43).do(mock_job) 1155 | 1156 | def test_align_utc_offset_no_timezone(self): 1157 | job = schedule.every().day.at("10:00").do(make_mock_job()) 1158 | now = datetime.datetime(2024, 5, 11, 10, 30, 55, 0) 1159 | aligned_time = job._correct_utc_offset(now, fixate_time=True) 1160 | self.assertEqual(now, aligned_time) 1161 | 1162 | def setup_utc_offset_test(self): 1163 | try: 1164 | import pytz 1165 | except ModuleNotFoundError: 1166 | self.skipTest("pytz unavailable") 1167 | job = ( 1168 | schedule.every() 1169 | .day.at("10:00", "Europe/Berlin") 1170 | .do(make_mock_job("tz-test")) 1171 | ) 1172 | tz = pytz.timezone("Europe/Berlin") 1173 | return (job, tz) 1174 | 1175 | def test_align_utc_offset_no_change(self): 1176 | (job, tz) = self.setup_utc_offset_test() 1177 | now = tz.localize(datetime.datetime(2023, 3, 26, 1, 30)) 1178 | aligned_time = job._correct_utc_offset(now, fixate_time=False) 1179 | self.assertEqual(now, aligned_time) 1180 | 1181 | def test_align_utc_offset_with_dst_gap(self): 1182 | (job, tz) = self.setup_utc_offset_test() 1183 | # Non-existent time in Berlin timezone 1184 | gap_time = tz.localize(datetime.datetime(2024, 3, 31, 2, 30, 0)) 1185 | aligned_time = job._correct_utc_offset(gap_time, fixate_time=True) 1186 | 1187 | assert aligned_time.utcoffset() == datetime.timedelta(hours=2) 1188 | assert aligned_time.day == 31 1189 | assert aligned_time.hour == 3 1190 | assert aligned_time.minute == 30 1191 | 1192 | def test_align_utc_offset_with_dst_fold(self): 1193 | (job, tz) = self.setup_utc_offset_test() 1194 | # This time exists twice, this is the first occurance 1195 | overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30)) 1196 | aligned_time = job._correct_utc_offset(overlap_time, fixate_time=False) 1197 | # Since the time exists twice, no fixate_time flag should yield the first occurrence 1198 | first_occurrence = tz.localize(datetime.datetime(2024, 10, 27, 2, 30, fold=0)) 1199 | self.assertEqual(first_occurrence, aligned_time) 1200 | 1201 | def test_align_utc_offset_with_dst_fold_fixate_1(self): 1202 | (job, tz) = self.setup_utc_offset_test() 1203 | # This time exists twice, this is the 1st occurance 1204 | overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 1, 30), is_dst=True) 1205 | overlap_time += datetime.timedelta( 1206 | hours=1 1207 | ) # puts it at 02:30+02:00 (Which exists once) 1208 | 1209 | aligned_time = job._correct_utc_offset(overlap_time, fixate_time=True) 1210 | # The time should not have moved, because the original time is valid 1211 | assert aligned_time.utcoffset() == datetime.timedelta(hours=2) 1212 | assert aligned_time.hour == 2 1213 | assert aligned_time.minute == 30 1214 | assert aligned_time.day == 27 1215 | 1216 | def test_align_utc_offset_with_dst_fold_fixate_2(self): 1217 | (job, tz) = self.setup_utc_offset_test() 1218 | # 02:30 exists twice, this is the 2nd occurance 1219 | overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30), is_dst=False) 1220 | # The time 2024-10-27 02:30:00+01:00 exists once 1221 | 1222 | aligned_time = job._correct_utc_offset(overlap_time, fixate_time=True) 1223 | # The time was valid, should not have been moved 1224 | assert aligned_time.utcoffset() == datetime.timedelta(hours=1) 1225 | assert aligned_time.hour == 2 1226 | assert aligned_time.minute == 30 1227 | assert aligned_time.day == 27 1228 | 1229 | def test_align_utc_offset_after_fold_fixate(self): 1230 | (job, tz) = self.setup_utc_offset_test() 1231 | # This time is 30 minutes after a folded hour. 1232 | duplicate_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30)) 1233 | duplicate_time += datetime.timedelta(hours=1) 1234 | 1235 | aligned_time = job._correct_utc_offset(duplicate_time, fixate_time=False) 1236 | 1237 | assert aligned_time.utcoffset() == datetime.timedelta(hours=1) 1238 | assert aligned_time.hour == 3 1239 | assert aligned_time.minute == 30 1240 | assert aligned_time.day == 27 1241 | 1242 | def test_daylight_saving_time(self): 1243 | mock_job = make_mock_job() 1244 | # 27 March 2022, 02:00:00 clocks were turned forward 1 hour 1245 | with mock_datetime(2022, 3, 27, 0, 0): 1246 | assert every(4).hours.do(mock_job).next_run.hour == 4 1247 | 1248 | # Sunday, 30 October 2022, 03:00:00 clocks were turned backward 1 hour 1249 | with mock_datetime(2022, 10, 30, 0, 0): 1250 | assert every(4).hours.do(mock_job).next_run.hour == 4 1251 | 1252 | def test_move_to_next_weekday_today(self): 1253 | monday = datetime.datetime(2024, 5, 13, 10, 27, 54) 1254 | tuesday = schedule._move_to_next_weekday(monday, "monday") 1255 | assert tuesday.day == 13 # today! Time didn't change. 1256 | assert tuesday.hour == 10 1257 | assert tuesday.minute == 27 1258 | 1259 | def test_move_to_next_weekday_tommorrow(self): 1260 | monday = datetime.datetime(2024, 5, 13, 10, 27, 54) 1261 | tuesday = schedule._move_to_next_weekday(monday, "tuesday") 1262 | assert tuesday.day == 14 # 1 day ahead 1263 | assert tuesday.hour == 10 1264 | assert tuesday.minute == 27 1265 | 1266 | def test_move_to_next_weekday_nextweek(self): 1267 | wednesday = datetime.datetime(2024, 5, 15, 10, 27, 54) 1268 | tuesday = schedule._move_to_next_weekday(wednesday, "tuesday") 1269 | assert tuesday.day == 21 # next week monday 1270 | assert tuesday.hour == 10 1271 | assert tuesday.minute == 27 1272 | 1273 | def test_run_all(self): 1274 | mock_job = make_mock_job() 1275 | every().minute.do(mock_job) 1276 | every().hour.do(mock_job) 1277 | every().day.at("11:00").do(mock_job) 1278 | schedule.run_all() 1279 | assert mock_job.call_count == 3 1280 | 1281 | def test_run_all_with_decorator(self): 1282 | mock_job = make_mock_job() 1283 | 1284 | @repeat(every().minute) 1285 | def job1(): 1286 | mock_job() 1287 | 1288 | @repeat(every().hour) 1289 | def job2(): 1290 | mock_job() 1291 | 1292 | @repeat(every().day.at("11:00")) 1293 | def job3(): 1294 | mock_job() 1295 | 1296 | schedule.run_all() 1297 | assert mock_job.call_count == 3 1298 | 1299 | def test_run_all_with_decorator_args(self): 1300 | mock_job = make_mock_job() 1301 | 1302 | @repeat(every().minute, 1, 2, "three", foo=23, bar={}) 1303 | def job(*args, **kwargs): 1304 | mock_job(*args, **kwargs) 1305 | 1306 | schedule.run_all() 1307 | mock_job.assert_called_once_with(1, 2, "three", foo=23, bar={}) 1308 | 1309 | def test_run_all_with_decorator_defaultargs(self): 1310 | mock_job = make_mock_job() 1311 | 1312 | @repeat(every().minute) 1313 | def job(nothing=None): 1314 | mock_job(nothing) 1315 | 1316 | schedule.run_all() 1317 | mock_job.assert_called_once_with(None) 1318 | 1319 | def test_job_func_args_are_passed_on(self): 1320 | mock_job = make_mock_job() 1321 | every().second.do(mock_job, 1, 2, "three", foo=23, bar={}) 1322 | schedule.run_all() 1323 | mock_job.assert_called_once_with(1, 2, "three", foo=23, bar={}) 1324 | 1325 | def test_to_string(self): 1326 | def job_fun(): 1327 | pass 1328 | 1329 | s = str(every().minute.do(job_fun, "foo", bar=23)) 1330 | assert s == ( 1331 | "Job(interval=1, unit=minutes, do=job_fun, " 1332 | "args=('foo',), kwargs={'bar': 23})" 1333 | ) 1334 | assert "job_fun" in s 1335 | assert "foo" in s 1336 | assert "{'bar': 23}" in s 1337 | 1338 | def test_to_repr(self): 1339 | def job_fun(): 1340 | pass 1341 | 1342 | s = repr(every().minute.do(job_fun, "foo", bar=23)) 1343 | assert s.startswith( 1344 | "Every 1 minute do job_fun('foo', bar=23) (last run: [never], next run: " 1345 | ) 1346 | assert "job_fun" in s 1347 | assert "foo" in s 1348 | assert "bar=23" in s 1349 | 1350 | # test repr when at_time is not None 1351 | s2 = repr(every().day.at("00:00").do(job_fun, "foo", bar=23)) 1352 | assert s2.startswith( 1353 | ( 1354 | "Every 1 day at 00:00:00 do job_fun('foo', " 1355 | "bar=23) (last run: [never], next run: " 1356 | ) 1357 | ) 1358 | 1359 | # Ensure Job.__repr__ does not throw exception on a partially-composed Job 1360 | s3 = repr(schedule.every(10)) 1361 | assert s3 == "Every 10 None do [None] (last run: [never], next run: [never])" 1362 | 1363 | def test_to_string_lambda_job_func(self): 1364 | assert len(str(every().minute.do(lambda: 1))) > 1 1365 | assert len(str(every().day.at("10:30").do(lambda: 1))) > 1 1366 | 1367 | def test_repr_functools_partial_job_func(self): 1368 | def job_fun(arg): 1369 | pass 1370 | 1371 | job_fun = functools.partial(job_fun, "foo") 1372 | job_repr = repr(every().minute.do(job_fun, bar=True, somekey=23)) 1373 | assert "functools.partial" in job_repr 1374 | assert "bar=True" in job_repr 1375 | assert "somekey=23" in job_repr 1376 | 1377 | def test_to_string_functools_partial_job_func(self): 1378 | def job_fun(arg): 1379 | pass 1380 | 1381 | job_fun = functools.partial(job_fun, "foo") 1382 | job_str = str(every().minute.do(job_fun, bar=True, somekey=23)) 1383 | assert "functools.partial" in job_str 1384 | assert "bar=True" in job_str 1385 | assert "somekey=23" in job_str 1386 | 1387 | def test_run_pending(self): 1388 | """Check that run_pending() runs pending jobs. 1389 | We do this by overriding datetime.datetime with mock objects 1390 | that represent increasing system times. 1391 | 1392 | Please note that it is *intended behavior that run_pending() does not 1393 | run missed jobs*. For example, if you've registered a job that 1394 | should run every minute and you only call run_pending() in one hour 1395 | increments then your job won't be run 60 times in between but 1396 | only once. 1397 | """ 1398 | mock_job = make_mock_job() 1399 | 1400 | with mock_datetime(2010, 1, 6, 12, 15): 1401 | every().minute.do(mock_job) 1402 | every().hour.do(mock_job) 1403 | every().day.do(mock_job) 1404 | every().sunday.do(mock_job) 1405 | schedule.run_pending() 1406 | assert mock_job.call_count == 0 1407 | 1408 | with mock_datetime(2010, 1, 6, 12, 16): 1409 | schedule.run_pending() 1410 | assert mock_job.call_count == 1 1411 | 1412 | with mock_datetime(2010, 1, 6, 13, 16): 1413 | mock_job.reset_mock() 1414 | schedule.run_pending() 1415 | assert mock_job.call_count == 2 1416 | 1417 | with mock_datetime(2010, 1, 7, 13, 16): 1418 | mock_job.reset_mock() 1419 | schedule.run_pending() 1420 | assert mock_job.call_count == 3 1421 | 1422 | with mock_datetime(2010, 1, 10, 13, 16): 1423 | mock_job.reset_mock() 1424 | schedule.run_pending() 1425 | assert mock_job.call_count == 4 1426 | 1427 | def test_run_every_weekday_at_specific_time_today(self): 1428 | mock_job = make_mock_job() 1429 | with mock_datetime(2010, 1, 6, 13, 16): # january 6 2010 == Wednesday 1430 | every().wednesday.at("14:12").do(mock_job) 1431 | schedule.run_pending() 1432 | assert mock_job.call_count == 0 1433 | 1434 | with mock_datetime(2010, 1, 6, 14, 16): 1435 | schedule.run_pending() 1436 | assert mock_job.call_count == 1 1437 | 1438 | def test_run_every_weekday_at_specific_time_past_today(self): 1439 | mock_job = make_mock_job() 1440 | with mock_datetime(2010, 1, 6, 13, 16): 1441 | every().wednesday.at("13:15").do(mock_job) 1442 | schedule.run_pending() 1443 | assert mock_job.call_count == 0 1444 | 1445 | with mock_datetime(2010, 1, 13, 13, 14): 1446 | schedule.run_pending() 1447 | assert mock_job.call_count == 0 1448 | 1449 | with mock_datetime(2010, 1, 13, 13, 16): 1450 | schedule.run_pending() 1451 | assert mock_job.call_count == 1 1452 | 1453 | def test_run_every_n_days_at_specific_time(self): 1454 | mock_job = make_mock_job() 1455 | with mock_datetime(2010, 1, 6, 11, 29): 1456 | every(2).days.at("11:30").do(mock_job) 1457 | schedule.run_pending() 1458 | assert mock_job.call_count == 0 1459 | 1460 | with mock_datetime(2010, 1, 6, 11, 31): 1461 | schedule.run_pending() 1462 | assert mock_job.call_count == 0 1463 | 1464 | with mock_datetime(2010, 1, 7, 11, 31): 1465 | schedule.run_pending() 1466 | assert mock_job.call_count == 0 1467 | 1468 | with mock_datetime(2010, 1, 8, 11, 29): 1469 | schedule.run_pending() 1470 | assert mock_job.call_count == 0 1471 | 1472 | with mock_datetime(2010, 1, 8, 11, 31): 1473 | schedule.run_pending() 1474 | assert mock_job.call_count == 1 1475 | 1476 | with mock_datetime(2010, 1, 10, 11, 31): 1477 | schedule.run_pending() 1478 | assert mock_job.call_count == 2 1479 | 1480 | def test_next_run_property(self): 1481 | original_datetime = datetime.datetime 1482 | with mock_datetime(2010, 1, 6, 13, 16): 1483 | hourly_job = make_mock_job("hourly") 1484 | daily_job = make_mock_job("daily") 1485 | every().day.do(daily_job) 1486 | every().hour.do(hourly_job) 1487 | assert len(schedule.jobs) == 2 1488 | # Make sure the hourly job is first 1489 | assert schedule.next_run() == original_datetime(2010, 1, 6, 14, 16) 1490 | 1491 | def test_idle_seconds(self): 1492 | assert schedule.default_scheduler.next_run is None 1493 | assert schedule.idle_seconds() is None 1494 | 1495 | mock_job = make_mock_job() 1496 | with mock_datetime(2020, 12, 9, 21, 46): 1497 | job = every().hour.do(mock_job) 1498 | assert schedule.idle_seconds() == 60 * 60 1499 | schedule.cancel_job(job) 1500 | assert schedule.next_run() is None 1501 | assert schedule.idle_seconds() is None 1502 | 1503 | def test_cancel_job(self): 1504 | def stop_job(): 1505 | return schedule.CancelJob 1506 | 1507 | mock_job = make_mock_job() 1508 | 1509 | every().second.do(stop_job) 1510 | mj = every().second.do(mock_job) 1511 | assert len(schedule.jobs) == 2 1512 | 1513 | schedule.run_all() 1514 | assert len(schedule.jobs) == 1 1515 | assert schedule.jobs[0] == mj 1516 | 1517 | schedule.cancel_job("Not a job") 1518 | assert len(schedule.jobs) == 1 1519 | schedule.default_scheduler.cancel_job("Not a job") 1520 | assert len(schedule.jobs) == 1 1521 | 1522 | schedule.cancel_job(mj) 1523 | assert len(schedule.jobs) == 0 1524 | 1525 | def test_cancel_jobs(self): 1526 | def stop_job(): 1527 | return schedule.CancelJob 1528 | 1529 | every().second.do(stop_job) 1530 | every().second.do(stop_job) 1531 | every().second.do(stop_job) 1532 | assert len(schedule.jobs) == 3 1533 | 1534 | schedule.run_all() 1535 | assert len(schedule.jobs) == 0 1536 | 1537 | def test_tag_type_enforcement(self): 1538 | job1 = every().second.do(make_mock_job(name="job1")) 1539 | self.assertRaises(TypeError, job1.tag, {}) 1540 | self.assertRaises(TypeError, job1.tag, 1, "a", []) 1541 | job1.tag(0, "a", True) 1542 | assert len(job1.tags) == 3 1543 | 1544 | def test_get_by_tag(self): 1545 | every().second.do(make_mock_job()).tag("job1", "tag1") 1546 | every().second.do(make_mock_job()).tag("job2", "tag2", "tag4") 1547 | every().second.do(make_mock_job()).tag("job3", "tag3", "tag4") 1548 | 1549 | # Test None input yields all 3 1550 | jobs = schedule.get_jobs() 1551 | assert len(jobs) == 3 1552 | assert {"job1", "job2", "job3"}.issubset( 1553 | {*jobs[0].tags, *jobs[1].tags, *jobs[2].tags} 1554 | ) 1555 | 1556 | # Test each 1:1 tag:job 1557 | jobs = schedule.get_jobs("tag1") 1558 | assert len(jobs) == 1 1559 | assert "job1" in jobs[0].tags 1560 | 1561 | # Test multiple jobs found. 1562 | jobs = schedule.get_jobs("tag4") 1563 | assert len(jobs) == 2 1564 | assert "job1" not in {*jobs[0].tags, *jobs[1].tags} 1565 | 1566 | # Test no tag. 1567 | jobs = schedule.get_jobs("tag5") 1568 | assert len(jobs) == 0 1569 | schedule.clear() 1570 | assert len(schedule.jobs) == 0 1571 | 1572 | def test_clear_by_tag(self): 1573 | every().second.do(make_mock_job(name="job1")).tag("tag1") 1574 | every().second.do(make_mock_job(name="job2")).tag("tag1", "tag2") 1575 | every().second.do(make_mock_job(name="job3")).tag( 1576 | "tag3", "tag3", "tag3", "tag2" 1577 | ) 1578 | assert len(schedule.jobs) == 3 1579 | schedule.run_all() 1580 | assert len(schedule.jobs) == 3 1581 | schedule.clear("tag3") 1582 | assert len(schedule.jobs) == 2 1583 | schedule.clear("tag1") 1584 | assert len(schedule.jobs) == 0 1585 | every().second.do(make_mock_job(name="job1")) 1586 | every().second.do(make_mock_job(name="job2")) 1587 | every().second.do(make_mock_job(name="job3")) 1588 | schedule.clear() 1589 | assert len(schedule.jobs) == 0 1590 | 1591 | def test_misconfigured_job_wont_break_scheduler(self): 1592 | """ 1593 | Ensure an interrupted job definition chain won't break 1594 | the scheduler instance permanently. 1595 | """ 1596 | scheduler = schedule.Scheduler() 1597 | scheduler.every() 1598 | scheduler.every(10).seconds 1599 | scheduler.run_pending() 1600 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3{7,8,9,10,11,12}{,-pytz} 3 | skip_missing_interpreters = true 4 | 5 | 6 | [gh-actions] 7 | python = 8 | 3.7: py37, py37-pytz 9 | 3.8: py38, py38-pytz 10 | 3.9: py39, py39-pytz 11 | 3.10: py310, py310-pytz 12 | 3.11: py311, py311-pytz 13 | 3.12: py312, py312-pytz 14 | 15 | [testenv] 16 | deps = 17 | pytest 18 | pytest-cov 19 | mypy 20 | types-pytz 21 | pytz: pytz 22 | commands = 23 | py.test test_schedule.py schedule -v --cov schedule --cov-report term-missing 24 | python -m mypy -p schedule --install-types --non-interactive 25 | 26 | [testenv:docs] 27 | changedir = docs 28 | deps = -rrequirements-dev.txt 29 | commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 30 | 31 | [testenv:format] 32 | deps = -rrequirements-dev.txt 33 | commands = black --check . 34 | 35 | [testenv:setuppy] 36 | deps = -rrequirements-dev.txt 37 | commands = 38 | python setup.py check --strict --metadata --restructuredtext 39 | --------------------------------------------------------------------------------