././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1618051629.2829413 schedule-1.1.0/0000755000175000017500000000000000000000000012642 5ustar00sijmensijmen././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1618051572.0 schedule-1.1.0/AUTHORS.rst0000644000175000017500000000300300000000000014515 0ustar00sijmensijmenThanks to all the wonderful folks who have contributed to schedule over the years: - mattss - mrhwick - cfrco - matrixise - abultman - mplewis - WoLfulus - dylwhich - fkromer - alaingilbert - Zerrossetto - yetingsky - schnepp - grampajoe - gilbsgilbs - Nathan Wailes - Connor Skees - qmorek - aisk - MichaelCorleoneLi - sijmenhuizenga - eladbi - chankeypathak - vubon - gaguirregabiria - rhagenaars - Skenvy - zcking - Martin Thoma - ebllg - fredthomsen - biggerfisch - sosolidkk ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1618051572.0 schedule-1.1.0/HISTORY.rst0000644000175000017500000001312700000000000014541 0ustar00sijmensijmen.. :changelog: History ------- 1.1.0 (2021-04-09) ++++++++++++++++++ - Added @repeat() decorator. See #148. Thanks @rhagenaars! - Added execute .until(). See #195. Thanks @fredthomsen! - Added job retrieval filtered by tags using get_jobs('tag'). See #419. Thanks @skenvy! - Added type annotations. See #427. Thanks @martinthoma! - Bugfix: str() of job when there is no __name__. See #430. Thanks @biggerfisch! - Improved error messages. See #280, #439. Thanks @connorskees and @sosolidkk! - Improved logging. See #193. Thanks @zcking! - Documentation improvements and fix typos. See #424, #435, #436, #453, #437, #448. Thanks @ebllg! 1.0.0 (2021-01-20) ++++++++++++++++++ Depending on your configuration, the following bugfixes might change schedule's behaviour: - Fix: idle_seconds crashes when no jobs are scheduled. See #401. Thanks @yoonghm! - Fix: day.at('HH:MM:SS') where HMS=now+10s doesn't run today. See #331. Thanks @qmorek! - Fix: hour.at('MM:SS'), the seconds are set to 00. See #290. Thanks @eladbi! - Fix: Long-running jobs skip a day when they finish in the next day #404. Thanks @4379711! Other changes: - Dropped Python 2.7 and 3.5 support, added 3.8 and 3.9 support. See #409 - Fix RecursionError when the job is passed to the do function as an arg. See #190. Thanks @connorskees! - Fix DeprecationWarning of 'collections'. See #296. Thanks @gaguirregabiria! - Replaced Travis with Github Actions for automated testing - Revamp and extend documentation. See #395 - Improved tests. Thanks @connorskees and @Jamim! - Changed log messages to DEBUG level. Thanks @aisk! 0.6.0 (2019-01-20) ++++++++++++++++++ - Make at() accept timestamps with 1 second precision (#267). Thanks @NathanWailes! - Introduce proper exception hierarchy (#271). Thanks @ConnorSkees! 0.5.0 (2017-11-16) ++++++++++++++++++ - Keep partially scheduled jobs from breaking the scheduler (#125) - Add support for random intervals (Thanks @grampajoe and @gilbsgilbs) 0.4.3 (2017-06-10) ++++++++++++++++++ - Improve docs & clean up docstrings 0.4.2 (2016-11-29) ++++++++++++++++++ - Publish to PyPI as a universal (py2/py3) wheel 0.4.0 (2016-11-28) ++++++++++++++++++ - Add proper HTML (Sphinx) docs available at https://schedule.readthedocs.io/ - CI builds now run against Python 2.7 and 3.5 (3.3 and 3.4 should work fine but are untested) - Fixed an issue with ``run_all()`` and having more than one job that deletes itself in the same iteration. Thanks @alaingilbert. - Add ability to tag jobs and to cancel jobs by tag. Thanks @Zerrossetto. - Improve schedule docs. Thanks @Zerrossetto. - Additional docs fixes by @fkromer and @yetingsky. 0.3.2 (2015-07-02) ++++++++++++++++++ - Fixed issues where scheduling a job with a functools.partial as the job function fails. Thanks @dylwhich. - 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. - Added a FAQ item to describe how to schedule a job that runs only once. 0.3.1 (2014-09-03) ++++++++++++++++++ - 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. - Added an FAQ item to describe how to deal with job functions that throw exceptions. Thanks @mplewis. 0.3.0 (2014-06-14) ++++++++++++++++++ - 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.) - 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. 0.2.1 (2013-11-20) ++++++++++++++++++ - Fixed history (no code changes). 0.2.0 (2013-11-09) ++++++++++++++++++ - This release introduces two new features in a backwards compatible way: - 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.) - 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.) - Refactored unit tests to mock ``datetime`` in a cleaner way. (Thanks @matts.) 0.1.11 (2013-07-30) +++++++++++++++++++ - 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. 0.1.10 (2013-06-07) +++++++++++++++++++ - Fixed issue with ``at_time`` jobs not running on the same day the job is created (Thanks to @mattss) 0.1.9 (2013-05-27) ++++++++++++++++++ - Added ``schedule.next_run()`` - Added ``schedule.idle_seconds()`` - Args passed into ``do()`` are forwarded to the job function at call time - Increased test coverage to 100% 0.1.8 (2013-05-21) ++++++++++++++++++ - Changed default ``delay_seconds`` for ``schedule.run_all()`` to 0 (from 60) - Increased test coverage 0.1.7 (2013-05-20) ++++++++++++++++++ - API change: renamed ``schedule.run_all_jobs()`` to ``schedule.run_all()`` - API change: renamed ``schedule.run_pending_jobs()`` to ``schedule.run_pending()`` - API change: renamed ``schedule.clear_all_jobs()`` to ``schedule.clear()`` - Added ``schedule.jobs`` 0.1.6 (2013-05-20) ++++++++++++++++++ - Fix packaging - README fixes 0.1.4 (2013-05-20) ++++++++++++++++++ - API change: renamed ``schedule.tick()`` to ``schedule.run_pending_jobs()`` - Updated README and ``setup.py`` packaging 0.1.0 (2013-05-19) ++++++++++++++++++ - Initial release ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1605371601.0 schedule-1.1.0/LICENSE.txt0000644000175000017500000000211300000000000014462 0ustar00sijmensijmenThe MIT License (MIT) Copyright (c) 2013 Daniel Bader (http://dbader.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1605371601.0 schedule-1.1.0/MANIFEST.in0000644000175000017500000000022300000000000014375 0ustar00sijmensijmeninclude README.rst include HISTORY.rst include LICENSE.txt include test_schedule.py recursive-exclude * __pycache__ recursive-exclude * *.py[co] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1618051629.2829413 schedule-1.1.0/PKG-INFO0000644000175000017500000000670700000000000013751 0ustar00sijmensijmenMetadata-Version: 1.2 Name: schedule Version: 1.1.0 Summary: Job scheduling for humans. Home-page: https://github.com/dbader/schedule Author: Daniel Bader Author-email: mail@dbader.org License: MIT Download-URL: https://github.com/dbader/schedule/tarball/1.1.0 Description: `schedule `__ =============================================== .. image:: https://github.com/dbader/schedule/workflows/Tests/badge.svg :target: https://github.com/dbader/schedule/actions?query=workflow%3ATests+branch%3Amaster .. image:: https://coveralls.io/repos/dbader/schedule/badge.svg?branch=master :target: https://coveralls.io/r/dbader/schedule .. image:: https://img.shields.io/pypi/v/schedule.svg :target: https://pypi.python.org/pypi/schedule Python job scheduling for humans. Run Python functions (or any other callable) periodically using a friendly syntax. - A simple to use API for scheduling jobs, made for humans. - In-process scheduler for periodic jobs. No extra processes needed! - Very lightweight and no external dependencies. - Excellent test coverage. - Tested on Python and 3.6, 3.7, 3.8, 3.9 Usage ----- .. code-block:: bash $ pip install schedule .. code-block:: python import schedule import time def job(): print("I'm working...") schedule.every(10).seconds.do(job) schedule.every(10).minutes.do(job) schedule.every().hour.do(job) schedule.every().day.at("10:30").do(job) schedule.every(5).to(10).minutes.do(job) schedule.every().monday.do(job) schedule.every().wednesday.at("13:15").do(job) schedule.every().minute.at(":17").do(job) while True: schedule.run_pending() time.sleep(1) Documentation ------------- Schedule's documentation lives at `schedule.readthedocs.io `_. Meta ---- Daniel Bader - `@dbader_org `_ - mail@dbader.org Inspired by `Adam Wiggins' `_ article `"Rethinking Cron" `_ and the `clockwork `_ Ruby module. Distributed under the MIT license. See `LICENSE.txt `_ for more information. https://github.com/dbader/schedule Keywords: schedule,periodic,jobs,scheduling,clockwork,cron,scheduler,job scheduling Platform: UNKNOWN Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Development Status :: 5 - Production/Stable Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Natural Language :: English Requires-Python: >=3.6 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1613320477.0 schedule-1.1.0/README.rst0000644000175000017500000000403600000000000014334 0ustar00sijmensijmen`schedule `__ =============================================== .. image:: https://github.com/dbader/schedule/workflows/Tests/badge.svg :target: https://github.com/dbader/schedule/actions?query=workflow%3ATests+branch%3Amaster .. image:: https://coveralls.io/repos/dbader/schedule/badge.svg?branch=master :target: https://coveralls.io/r/dbader/schedule .. image:: https://img.shields.io/pypi/v/schedule.svg :target: https://pypi.python.org/pypi/schedule Python job scheduling for humans. Run Python functions (or any other callable) periodically using a friendly syntax. - A simple to use API for scheduling jobs, made for humans. - In-process scheduler for periodic jobs. No extra processes needed! - Very lightweight and no external dependencies. - Excellent test coverage. - Tested on Python and 3.6, 3.7, 3.8, 3.9 Usage ----- .. code-block:: bash $ pip install schedule .. code-block:: python import schedule import time def job(): print("I'm working...") schedule.every(10).seconds.do(job) schedule.every(10).minutes.do(job) schedule.every().hour.do(job) schedule.every().day.at("10:30").do(job) schedule.every(5).to(10).minutes.do(job) schedule.every().monday.do(job) schedule.every().wednesday.at("13:15").do(job) schedule.every().minute.at(":17").do(job) while True: schedule.run_pending() time.sleep(1) Documentation ------------- Schedule's documentation lives at `schedule.readthedocs.io `_. Meta ---- Daniel Bader - `@dbader_org `_ - mail@dbader.org Inspired by `Adam Wiggins' `_ article `"Rethinking Cron" `_ and the `clockwork `_ Ruby module. Distributed under the MIT license. See `LICENSE.txt `_ for more information. https://github.com/dbader/schedule ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1618051629.2829413 schedule-1.1.0/schedule/0000755000175000017500000000000000000000000014436 5ustar00sijmensijmen././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1616854412.0 schedule-1.1.0/schedule/__init__.py0000644000175000017500000007001000000000000016545 0ustar00sijmensijmen""" Python job scheduling for humans. github.com/dbader/schedule An in-process scheduler for periodic jobs that uses the builder pattern for configuration. Schedule lets you run Python functions (or any other callable) periodically at pre-determined intervals using a simple, human-friendly syntax. Inspired by Addam Wiggins' article "Rethinking Cron" [1] and the "clockwork" Ruby module [2][3]. Features: - A simple to use API for scheduling jobs. - Very lightweight and no external dependencies. - Excellent test coverage. - Tested on Python 3.6, 3.7, 3.8, 3.9 Usage: >>> import schedule >>> import time >>> def job(message='stuff'): >>> print("I'm working on:", message) >>> schedule.every(10).minutes.do(job) >>> schedule.every(5).to(10).days.do(job) >>> schedule.every().hour.do(job, message='things') >>> schedule.every().day.at("10:30").do(job) >>> while True: >>> schedule.run_pending() >>> time.sleep(1) [1] https://adam.herokuapp.com/past/2010/4/13/rethinking_cron/ [2] https://github.com/Rykian/clockwork [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ """ from collections.abc import Hashable import datetime import functools import logging import random import re import time from typing import Set, List, Optional, Callable, Union logger = logging.getLogger("schedule") class ScheduleError(Exception): """Base schedule exception""" pass class ScheduleValueError(ScheduleError): """Base schedule value error""" pass class IntervalError(ScheduleValueError): """An improper interval was used""" pass class CancelJob(object): """ Can be returned from a job to unschedule itself. """ pass class Scheduler(object): """ Objects instantiated by the :class:`Scheduler ` are factories to create jobs, keep record of scheduled jobs and handle their execution. """ def __init__(self) -> None: self.jobs: List[Job] = [] def run_pending(self) -> None: """ Run all jobs that are scheduled to run. Please note that it is *intended behavior that run_pending() does not run missed jobs*. For example, if you've registered a job that should run every minute and you only call run_pending() in one hour increments then your job won't be run 60 times in between but only once. """ runnable_jobs = (job for job in self.jobs if job.should_run) for job in sorted(runnable_jobs): self._run_job(job) def run_all(self, delay_seconds: int = 0) -> None: """ Run all jobs regardless if they are scheduled to run or not. A delay of `delay` seconds is added between each job. This helps distribute system load generated by the jobs more evenly over time. :param delay_seconds: A delay added between every executed job """ logger.debug( "Running *all* %i jobs with %is delay in between", len(self.jobs), delay_seconds, ) for job in self.jobs[:]: self._run_job(job) time.sleep(delay_seconds) def get_jobs(self, tag: Optional[Hashable] = None) -> List["Job"]: """ Gets scheduled jobs marked with the given tag, or all jobs if tag is omitted. :param tag: An identifier used to identify a subset of jobs to retrieve """ if tag is None: return self.jobs[:] else: return [job for job in self.jobs if tag in job.tags] def clear(self, tag: Optional[Hashable] = None) -> None: """ Deletes scheduled jobs marked with the given tag, or all jobs if tag is omitted. :param tag: An identifier used to identify a subset of jobs to delete """ if tag is None: logger.debug("Deleting *all* jobs") del self.jobs[:] else: logger.debug('Deleting all jobs tagged "%s"', tag) self.jobs[:] = (job for job in self.jobs if tag not in job.tags) def cancel_job(self, job: "Job") -> None: """ Delete a scheduled job. :param job: The job to be unscheduled """ try: logger.debug('Cancelling job "%s"', str(job)) self.jobs.remove(job) except ValueError: logger.debug('Cancelling not-scheduled job "%s"', str(job)) def every(self, interval: int = 1) -> "Job": """ Schedule a new periodic job. :param interval: A quantity of a certain time unit :return: An unconfigured :class:`Job ` """ job = Job(interval, self) return job def _run_job(self, job: "Job") -> None: ret = job.run() if isinstance(ret, CancelJob) or ret is CancelJob: self.cancel_job(job) @property def next_run(self) -> Optional[datetime.datetime]: """ Datetime when the next job should run. :return: A :class:`~datetime.datetime` object or None if no jobs scheduled """ if not self.jobs: return None return min(self.jobs).next_run @property def idle_seconds(self) -> Optional[float]: """ :return: Number of seconds until :meth:`next_run ` or None if no jobs are scheduled """ if not self.next_run: return None return (self.next_run - datetime.datetime.now()).total_seconds() class Job(object): """ A periodic job as used by :class:`Scheduler`. :param interval: A quantity of a certain time unit :param scheduler: The :class:`Scheduler ` instance that this job will register itself with once it has been fully configured in :meth:`Job.do()`. Every job runs at a given fixed time interval that is defined by: * a :meth:`time unit ` * a quantity of `time units` defined by `interval` A job is usually created and returned by :meth:`Scheduler.every` method, which also defines its `interval`. """ def __init__(self, interval: int, scheduler: Scheduler = None): self.interval: int = interval # pause interval * unit between runs self.latest: Optional[int] = None # upper limit to the interval self.job_func: Optional[functools.partial] = None # the job job_func to run # time units, e.g. 'minutes', 'hours', ... self.unit: Optional[str] = None # optional time at which this job runs self.at_time: Optional[datetime.time] = None # datetime of the last run self.last_run: Optional[datetime.datetime] = None # datetime of the next run self.next_run: Optional[datetime.datetime] = None # timedelta between runs, only valid for self.period: Optional[datetime.timedelta] = None # Specific day of the week to start on self.start_day: Optional[str] = None # optional time of final run self.cancel_after: Optional[datetime.datetime] = None self.tags: Set[Hashable] = set() # unique set of tags for the job self.scheduler: Optional[Scheduler] = scheduler # scheduler to register with def __lt__(self, other) -> bool: """ PeriodicJobs are sortable based on the scheduled time they run next. """ return self.next_run < other.next_run def __str__(self) -> str: if hasattr(self.job_func, "__name__"): job_func_name = self.job_func.__name__ # type: ignore else: job_func_name = repr(self.job_func) return ("Job(interval={}, unit={}, do={}, args={}, kwargs={})").format( self.interval, self.unit, job_func_name, "()" if self.job_func is None else self.job_func.args, "{}" if self.job_func is None else self.job_func.keywords, ) def __repr__(self): def format_time(t): return t.strftime("%Y-%m-%d %H:%M:%S") if t else "[never]" def is_repr(j): return not isinstance(j, Job) timestats = "(last run: %s, next run: %s)" % ( format_time(self.last_run), format_time(self.next_run), ) if hasattr(self.job_func, "__name__"): job_func_name = self.job_func.__name__ else: job_func_name = repr(self.job_func) args = [repr(x) if is_repr(x) else str(x) for x in self.job_func.args] kwargs = ["%s=%s" % (k, repr(v)) for k, v in self.job_func.keywords.items()] call_repr = job_func_name + "(" + ", ".join(args + kwargs) + ")" if self.at_time is not None: return "Every %s %s at %s do %s %s" % ( self.interval, self.unit[:-1] if self.interval == 1 else self.unit, self.at_time, call_repr, timestats, ) else: fmt = ( "Every %(interval)s " + ("to %(latest)s " if self.latest is not None else "") + "%(unit)s do %(call_repr)s %(timestats)s" ) return fmt % dict( interval=self.interval, latest=self.latest, unit=(self.unit[:-1] if self.interval == 1 else self.unit), call_repr=call_repr, timestats=timestats, ) @property def second(self): if self.interval != 1: raise IntervalError("Use seconds instead of second") return self.seconds @property def seconds(self): self.unit = "seconds" return self @property def minute(self): if self.interval != 1: raise IntervalError("Use minutes instead of minute") return self.minutes @property def minutes(self): self.unit = "minutes" return self @property def hour(self): if self.interval != 1: raise IntervalError("Use hours instead of hour") return self.hours @property def hours(self): self.unit = "hours" return self @property def day(self): if self.interval != 1: raise IntervalError("Use days instead of day") return self.days @property def days(self): self.unit = "days" return self @property def week(self): if self.interval != 1: raise IntervalError("Use weeks instead of week") return self.weeks @property def weeks(self): self.unit = "weeks" return self @property def monday(self): if self.interval != 1: raise IntervalError( "Scheduling .monday() jobs is only allowed for weekly jobs. " "Using .monday() on a job scheduled to run every 2 or more weeks " "is not supported." ) self.start_day = "monday" return self.weeks @property def tuesday(self): if self.interval != 1: raise IntervalError( "Scheduling .tuesday() jobs is only allowed for weekly jobs. " "Using .tuesday() on a job scheduled to run every 2 or more weeks " "is not supported." ) self.start_day = "tuesday" return self.weeks @property def wednesday(self): if self.interval != 1: raise IntervalError( "Scheduling .wednesday() jobs is only allowed for weekly jobs. " "Using .wednesday() on a job scheduled to run every 2 or more weeks " "is not supported." ) self.start_day = "wednesday" return self.weeks @property def thursday(self): if self.interval != 1: raise IntervalError( "Scheduling .thursday() jobs is only allowed for weekly jobs. " "Using .thursday() on a job scheduled to run every 2 or more weeks " "is not supported." ) self.start_day = "thursday" return self.weeks @property def friday(self): if self.interval != 1: raise IntervalError( "Scheduling .friday() jobs is only allowed for weekly jobs. " "Using .friday() on a job scheduled to run every 2 or more weeks " "is not supported." ) self.start_day = "friday" return self.weeks @property def saturday(self): if self.interval != 1: raise IntervalError( "Scheduling .saturday() jobs is only allowed for weekly jobs. " "Using .saturday() on a job scheduled to run every 2 or more weeks " "is not supported." ) self.start_day = "saturday" return self.weeks @property def sunday(self): if self.interval != 1: raise IntervalError( "Scheduling .sunday() jobs is only allowed for weekly jobs. " "Using .sunday() on a job scheduled to run every 2 or more weeks " "is not supported." ) self.start_day = "sunday" return self.weeks def tag(self, *tags: Hashable): """ Tags the job with one or more unique identifiers. Tags must be hashable. Duplicate tags are discarded. :param tags: A unique list of ``Hashable`` tags. :return: The invoked job instance """ if not all(isinstance(tag, Hashable) for tag in tags): raise TypeError("Tags must be hashable") self.tags.update(tags) return self def at(self, time_str): """ Specify a particular time that the job should be run at. :param time_str: A string in one of the following formats: - For daily jobs -> `HH:MM:SS` or `HH:MM` - For hourly jobs -> `MM:SS` or `:MM` - For minute jobs -> `:SS` The format must make sense given how often the job is repeating; for example, a job that repeats every minute should not be given a string in the form `HH:MM:SS`. The difference between `:MM` and :SS` is inferred from the selected time-unit (e.g. `every().hour.at(':30')` vs. `every().minute.at(':30')`). :return: The invoked job instance """ if self.unit not in ("days", "hours", "minutes") and not self.start_day: raise ScheduleValueError( "Invalid unit (valid units are `days`, `hours`, and `minutes`)" ) if not isinstance(time_str, str): raise TypeError("at() should be passed a string") if self.unit == "days" or self.start_day: if not re.match(r"^([0-2]\d:)?[0-5]\d:[0-5]\d$", time_str): raise ScheduleValueError( "Invalid time format for a daily job (valid format is HH:MM(:SS)?)" ) if self.unit == "hours": if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str): raise ScheduleValueError( "Invalid time format for an hourly job (valid format is (MM)?:SS)" ) if self.unit == "minutes": if not re.match(r"^:[0-5]\d$", time_str): raise ScheduleValueError( "Invalid time format for a minutely job (valid format is :SS)" ) time_values = time_str.split(":") hour: Union[str, int] minute: Union[str, int] second: Union[str, int] if len(time_values) == 3: hour, minute, second = time_values elif len(time_values) == 2 and self.unit == "minutes": hour = 0 minute = 0 _, second = time_values elif len(time_values) == 2 and self.unit == "hours" and len(time_values[0]): hour = 0 minute, second = time_values else: hour, minute = time_values second = 0 if self.unit == "days" or self.start_day: hour = int(hour) if not (0 <= hour <= 23): raise ScheduleValueError( "Invalid number of hours ({} is not between 0 and 23)" ) elif self.unit == "hours": hour = 0 elif self.unit == "minutes": hour = 0 minute = 0 minute = int(minute) second = int(second) self.at_time = datetime.time(hour, minute, second) return self def to(self, latest: int): """ Schedule the job to run at an irregular (randomized) interval. The job's interval will randomly vary from the value given to `every` to `latest`. The range defined is inclusive on both ends. For example, `every(A).to(B).seconds` executes the job function every N seconds such that A <= N <= B. :param latest: Maximum interval between randomized job runs :return: The invoked job instance """ self.latest = latest return self def until( self, until_time: Union[datetime.datetime, datetime.timedelta, datetime.time, str], ): """ Schedule job to run until the specified moment. The job is canceled whenever the next run is calculated and it turns out the next run is after the until_time. The job is also canceled right before it runs, if the current time is after until_time. This latter case can happen when the the job was scheduled to run before until_time, but runs after until_time. If until_time is a moment in the past, ScheduleValueError is thrown. :param until_time: A moment in the future representing the latest time a job can be run. If only a time is supplied, the date is set to today. The following formats are accepted: - datetime.datetime - datetime.timedelta - datetime.time - String in one of the following formats: "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M:%S", "%H:%M" as defined by strptime() behaviour. If an invalid string format is passed, ScheduleValueError is thrown. :return: The invoked job instance """ if isinstance(until_time, datetime.datetime): self.cancel_after = until_time elif isinstance(until_time, datetime.timedelta): self.cancel_after = datetime.datetime.now() + until_time elif isinstance(until_time, datetime.time): self.cancel_after = datetime.datetime.combine( datetime.datetime.now(), until_time ) elif isinstance(until_time, str): cancel_after = self._decode_datetimestr( until_time, [ "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M:%S", "%H:%M", ], ) if cancel_after is None: raise ScheduleValueError("Invalid string format for until()") if "-" not in until_time: # the until_time is a time-only format. Set the date to today now = datetime.datetime.now() cancel_after = cancel_after.replace( year=now.year, month=now.month, day=now.day ) self.cancel_after = cancel_after else: raise TypeError( "until() takes a string, datetime.datetime, datetime.timedelta, " "datetime.time parameter" ) if self.cancel_after < datetime.datetime.now(): raise ScheduleValueError( "Cannot schedule a job to run until a time in the past" ) return self def do(self, job_func: Callable, *args, **kwargs): """ Specifies the job_func that should be called every time the job runs. Any additional arguments are passed on to job_func when the job runs. :param job_func: The function to be scheduled :return: The invoked job instance """ self.job_func = functools.partial(job_func, *args, **kwargs) functools.update_wrapper(self.job_func, job_func) self._schedule_next_run() if self.scheduler is None: raise ScheduleError( "Unable to a add job to schedule. " "Job is not associated with an scheduler" ) self.scheduler.jobs.append(self) return self @property def should_run(self) -> bool: """ :return: ``True`` if the job should be run now. """ assert self.next_run is not None, "must run _schedule_next_run before" return datetime.datetime.now() >= self.next_run def run(self): """ Run the job and immediately reschedule it. If the job's deadline is reached (configured using .until()), the job is not run and CancelJob is returned immediately. If the next scheduled run exceeds the job's deadline, CancelJob is returned after the execution. In this latter case CancelJob takes priority over any other returned value. :return: The return value returned by the `job_func`, or CancelJob if the job's deadline is reached. """ if self._is_overdue(datetime.datetime.now()): logger.debug("Cancelling job %s", self) return CancelJob logger.debug("Running job %s", self) ret = self.job_func() self.last_run = datetime.datetime.now() self._schedule_next_run() if self._is_overdue(self.next_run): logger.debug("Cancelling job %s", self) return CancelJob return ret def _schedule_next_run(self) -> None: """ Compute the instant when this job should run next. """ if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): raise ScheduleValueError( "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " "`days`, and `weeks`)" ) if self.latest is not None: if not (self.latest >= self.interval): raise ScheduleError("`latest` is greater than `interval`") interval = random.randint(self.interval, self.latest) else: interval = self.interval self.period = datetime.timedelta(**{self.unit: interval}) self.next_run = datetime.datetime.now() + self.period if self.start_day is not None: if self.unit != "weeks": raise ScheduleValueError("`unit` should be 'weeks'") weekdays = ( "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", ) if self.start_day not in weekdays: raise ScheduleValueError( "Invalid start day (valid start days are {})".format(weekdays) ) weekday = weekdays.index(self.start_day) days_ahead = weekday - self.next_run.weekday() if days_ahead <= 0: # Target day already happened this week days_ahead += 7 self.next_run += datetime.timedelta(days_ahead) - self.period if self.at_time is not None: if self.unit not in ("days", "hours", "minutes") and self.start_day is None: raise ScheduleValueError("Invalid unit without specifying start day") kwargs = {"second": self.at_time.second, "microsecond": 0} if self.unit == "days" or self.start_day is not None: kwargs["hour"] = self.at_time.hour if self.unit in ["days", "hours"] or self.start_day is not None: kwargs["minute"] = self.at_time.minute self.next_run = self.next_run.replace(**kwargs) # type: ignore # Make sure we run at the specified time *today* (or *this hour*) # as well. This accounts for when a job takes so long it finished # in the next period. if not self.last_run or (self.next_run - self.last_run) > self.period: now = datetime.datetime.now() if ( self.unit == "days" and self.at_time > now.time() and self.interval == 1 ): self.next_run = self.next_run - datetime.timedelta(days=1) elif self.unit == "hours" and ( self.at_time.minute > now.minute or ( self.at_time.minute == now.minute and self.at_time.second > now.second ) ): self.next_run = self.next_run - datetime.timedelta(hours=1) elif self.unit == "minutes" and self.at_time.second > now.second: self.next_run = self.next_run - datetime.timedelta(minutes=1) if self.start_day is not None and self.at_time is not None: # Let's see if we will still make that time we specified today if (self.next_run - datetime.datetime.now()).days >= 7: self.next_run -= self.period def _is_overdue(self, when: datetime.datetime): return self.cancel_after is not None and when > self.cancel_after def _decode_datetimestr( self, datetime_str: str, formats: List[str] ) -> Optional[datetime.datetime]: for f in formats: try: return datetime.datetime.strptime(datetime_str, f) except ValueError: pass return None # The following methods are shortcuts for not having to # create a Scheduler instance: #: Default :class:`Scheduler ` object default_scheduler = Scheduler() #: Default :class:`Jobs ` list jobs = default_scheduler.jobs # todo: should this be a copy, e.g. jobs()? def every(interval: int = 1) -> Job: """Calls :meth:`every ` on the :data:`default scheduler instance `. """ return default_scheduler.every(interval) def run_pending() -> None: """Calls :meth:`run_pending ` on the :data:`default scheduler instance `. """ default_scheduler.run_pending() def run_all(delay_seconds: int = 0) -> None: """Calls :meth:`run_all ` on the :data:`default scheduler instance `. """ default_scheduler.run_all(delay_seconds=delay_seconds) def get_jobs(tag: Optional[Hashable] = None) -> List[Job]: """Calls :meth:`get_jobs ` on the :data:`default scheduler instance `. """ return default_scheduler.get_jobs(tag) def clear(tag: Optional[Hashable] = None) -> None: """Calls :meth:`clear ` on the :data:`default scheduler instance `. """ default_scheduler.clear(tag) def cancel_job(job: Job) -> None: """Calls :meth:`cancel_job ` on the :data:`default scheduler instance `. """ default_scheduler.cancel_job(job) def next_run() -> Optional[datetime.datetime]: """Calls :meth:`next_run ` on the :data:`default scheduler instance `. """ return default_scheduler.next_run def idle_seconds() -> Optional[float]: """Calls :meth:`idle_seconds ` on the :data:`default scheduler instance `. """ return default_scheduler.idle_seconds def repeat(job, *args, **kwargs): """ Decorator to schedule a new periodic job. Any additional arguments are passed on to the decorated function when the job runs. :param job: a :class:`Jobs ` """ def _schedule_decorator(decorated_function): job.do(decorated_function, *args, **kwargs) return decorated_function return _schedule_decorator ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1618051629.2829413 schedule-1.1.0/schedule.egg-info/0000755000175000017500000000000000000000000016130 5ustar00sijmensijmen././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1618051629.0 schedule-1.1.0/schedule.egg-info/PKG-INFO0000644000175000017500000000670700000000000017237 0ustar00sijmensijmenMetadata-Version: 1.2 Name: schedule Version: 1.1.0 Summary: Job scheduling for humans. Home-page: https://github.com/dbader/schedule Author: Daniel Bader Author-email: mail@dbader.org License: MIT Download-URL: https://github.com/dbader/schedule/tarball/1.1.0 Description: `schedule `__ =============================================== .. image:: https://github.com/dbader/schedule/workflows/Tests/badge.svg :target: https://github.com/dbader/schedule/actions?query=workflow%3ATests+branch%3Amaster .. image:: https://coveralls.io/repos/dbader/schedule/badge.svg?branch=master :target: https://coveralls.io/r/dbader/schedule .. image:: https://img.shields.io/pypi/v/schedule.svg :target: https://pypi.python.org/pypi/schedule Python job scheduling for humans. Run Python functions (or any other callable) periodically using a friendly syntax. - A simple to use API for scheduling jobs, made for humans. - In-process scheduler for periodic jobs. No extra processes needed! - Very lightweight and no external dependencies. - Excellent test coverage. - Tested on Python and 3.6, 3.7, 3.8, 3.9 Usage ----- .. code-block:: bash $ pip install schedule .. code-block:: python import schedule import time def job(): print("I'm working...") schedule.every(10).seconds.do(job) schedule.every(10).minutes.do(job) schedule.every().hour.do(job) schedule.every().day.at("10:30").do(job) schedule.every(5).to(10).minutes.do(job) schedule.every().monday.do(job) schedule.every().wednesday.at("13:15").do(job) schedule.every().minute.at(":17").do(job) while True: schedule.run_pending() time.sleep(1) Documentation ------------- Schedule's documentation lives at `schedule.readthedocs.io `_. Meta ---- Daniel Bader - `@dbader_org `_ - mail@dbader.org Inspired by `Adam Wiggins' `_ article `"Rethinking Cron" `_ and the `clockwork `_ Ruby module. Distributed under the MIT license. See `LICENSE.txt `_ for more information. https://github.com/dbader/schedule Keywords: schedule,periodic,jobs,scheduling,clockwork,cron,scheduler,job scheduling Platform: UNKNOWN Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Development Status :: 5 - Production/Stable Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Natural Language :: English Requires-Python: >=3.6 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1618051629.0 schedule-1.1.0/schedule.egg-info/SOURCES.txt0000644000175000017500000000036300000000000020016 0ustar00sijmensijmenAUTHORS.rst HISTORY.rst LICENSE.txt MANIFEST.in README.rst setup.cfg setup.py test_schedule.py schedule/__init__.py schedule.egg-info/PKG-INFO schedule.egg-info/SOURCES.txt schedule.egg-info/dependency_links.txt schedule.egg-info/top_level.txt././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1618051629.0 schedule-1.1.0/schedule.egg-info/dependency_links.txt0000644000175000017500000000000100000000000022176 0ustar00sijmensijmen ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1618051629.0 schedule-1.1.0/schedule.egg-info/top_level.txt0000644000175000017500000000001100000000000020652 0ustar00sijmensijmenschedule ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1618051629.2829413 schedule-1.1.0/setup.cfg0000644000175000017500000000013500000000000014462 0ustar00sijmensijmen[mypy] files = schedule ignore_missing_imports = True [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1618051572.0 schedule-1.1.0/setup.py0000644000175000017500000000264200000000000014360 0ustar00sijmensijmenimport codecs from setuptools import setup SCHEDULE_VERSION = "1.1.0" SCHEDULE_DOWNLOAD_URL = "https://github.com/dbader/schedule/tarball/" + SCHEDULE_VERSION def read_file(filename): """ Read a utf8 encoded text file and return its contents. """ with codecs.open(filename, "r", "utf8") as f: return f.read() setup( name="schedule", packages=["schedule"], version=SCHEDULE_VERSION, description="Job scheduling for humans.", long_description=read_file("README.rst"), license="MIT", author="Daniel Bader", author_email="mail@dbader.org", url="https://github.com/dbader/schedule", download_url=SCHEDULE_DOWNLOAD_URL, keywords=[ "schedule", "periodic", "jobs", "scheduling", "clockwork", "cron", "scheduler", "job scheduling", ], classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Development Status :: 5 - Production/Stable", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Natural Language :: English", ], python_requires=">=3.6", ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1615114164.0 schedule-1.1.0/test_schedule.py0000644000175000017500000007616100000000000016062 0ustar00sijmensijmen"""Unit tests for schedule.py""" import datetime import functools import mock import unittest # Silence "missing docstring", "method could be a function", # "class already defined", and "too many public methods" messages: # pylint: disable-msg=R0201,C0111,E0102,R0904,R0901 import schedule from schedule import ( every, repeat, ScheduleError, ScheduleValueError, IntervalError, ) def make_mock_job(name=None): job = mock.Mock() job.__name__ = name or "job" return job class mock_datetime(object): """ Monkey-patch datetime for predictable results """ def __init__(self, year, month, day, hour, minute, second=0): self.year = year self.month = month self.day = day self.hour = hour self.minute = minute self.second = second def __enter__(self): class MockDate(datetime.datetime): @classmethod def today(cls): return cls(self.year, self.month, self.day) @classmethod def now(cls): return cls( self.year, self.month, self.day, self.hour, self.minute, self.second, ) self.original_datetime = datetime.datetime datetime.datetime = MockDate return MockDate( self.year, self.month, self.day, self.hour, self.minute, self.second ) def __exit__(self, *args, **kwargs): datetime.datetime = self.original_datetime class SchedulerTests(unittest.TestCase): def setUp(self): schedule.clear() def test_time_units(self): assert every().seconds.unit == "seconds" assert every().minutes.unit == "minutes" assert every().hours.unit == "hours" assert every().days.unit == "days" assert every().weeks.unit == "weeks" job_instance = schedule.Job(interval=2) # without a context manager, it incorrectly raises an error because # it is not callable with self.assertRaises(IntervalError): job_instance.minute with self.assertRaises(IntervalError): job_instance.hour with self.assertRaises(IntervalError): job_instance.day with self.assertRaises(IntervalError): job_instance.week with self.assertRaisesRegex( IntervalError, ( r"Scheduling \.monday\(\) jobs is only allowed for weekly jobs\. " r"Using \.monday\(\) on a job scheduled to run every 2 or more " r"weeks is not supported\." ), ): job_instance.monday with self.assertRaisesRegex( IntervalError, ( r"Scheduling \.tuesday\(\) jobs is only allowed for weekly jobs\. " r"Using \.tuesday\(\) on a job scheduled to run every 2 or more " r"weeks is not supported\." ), ): job_instance.tuesday with self.assertRaisesRegex( IntervalError, ( r"Scheduling \.wednesday\(\) jobs is only allowed for weekly jobs\. " r"Using \.wednesday\(\) on a job scheduled to run every 2 or more " r"weeks is not supported\." ), ): job_instance.wednesday with self.assertRaisesRegex( IntervalError, ( r"Scheduling \.thursday\(\) jobs is only allowed for weekly jobs\. " r"Using \.thursday\(\) on a job scheduled to run every 2 or more " r"weeks is not supported\." ), ): job_instance.thursday with self.assertRaisesRegex( IntervalError, ( r"Scheduling \.friday\(\) jobs is only allowed for weekly jobs\. " r"Using \.friday\(\) on a job scheduled to run every 2 or more " r"weeks is not supported\." ), ): job_instance.friday with self.assertRaisesRegex( IntervalError, ( r"Scheduling \.saturday\(\) jobs is only allowed for weekly jobs\. " r"Using \.saturday\(\) on a job scheduled to run every 2 or more " r"weeks is not supported\." ), ): job_instance.saturday with self.assertRaisesRegex( IntervalError, ( r"Scheduling \.sunday\(\) jobs is only allowed for weekly jobs\. " r"Using \.sunday\(\) on a job scheduled to run every 2 or more " r"weeks is not supported\." ), ): job_instance.sunday # test an invalid unit job_instance.unit = "foo" self.assertRaises(ScheduleValueError, job_instance.at, "1:0:0") self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) # test start day exists but unit is not 'weeks' job_instance.unit = "days" job_instance.start_day = 1 self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) # test weeks with an invalid start day job_instance.unit = "weeks" job_instance.start_day = "bar" self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) # test a valid unit with invalid hours/minutes/seconds job_instance.unit = "days" self.assertRaises(ScheduleValueError, job_instance.at, "25:00:00") self.assertRaises(ScheduleValueError, job_instance.at, "00:61:00") self.assertRaises(ScheduleValueError, job_instance.at, "00:00:61") # test invalid time format self.assertRaises(ScheduleValueError, job_instance.at, "25:0:0") self.assertRaises(ScheduleValueError, job_instance.at, "0:61:0") self.assertRaises(ScheduleValueError, job_instance.at, "0:0:61") # test (very specific) seconds with unspecified start_day job_instance.unit = "seconds" job_instance.at_time = datetime.datetime.now() job_instance.start_day = None self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) # test self.latest >= self.interval job_instance.latest = 1 self.assertRaises(ScheduleError, job_instance._schedule_next_run) job_instance.latest = 3 self.assertRaises(ScheduleError, job_instance._schedule_next_run) def test_singular_time_units_match_plural_units(self): assert every().second.unit == every().seconds.unit assert every().minute.unit == every().minutes.unit assert every().hour.unit == every().hours.unit assert every().day.unit == every().days.unit assert every().week.unit == every().weeks.unit def test_time_range(self): with mock_datetime(2014, 6, 28, 12, 0): mock_job = make_mock_job() # Choose a sample size large enough that it's unlikely the # same value will be chosen each time. minutes = set( [ every(5).to(30).minutes.do(mock_job).next_run.minute for i in range(100) ] ) assert len(minutes) > 1 assert min(minutes) >= 5 assert max(minutes) <= 30 def test_time_range_repr(self): mock_job = make_mock_job() with mock_datetime(2014, 6, 28, 12, 0): job_repr = repr(every(5).to(30).minutes.do(mock_job)) assert job_repr.startswith("Every 5 to 30 minutes do job()") def test_at_time(self): mock_job = make_mock_job() assert every().day.at("10:30").do(mock_job).next_run.hour == 10 assert every().day.at("10:30").do(mock_job).next_run.minute == 30 assert every().day.at("10:30:50").do(mock_job).next_run.second == 50 self.assertRaises(ScheduleValueError, every().day.at, "2:30:000001") self.assertRaises(ScheduleValueError, every().day.at, "::2") self.assertRaises(ScheduleValueError, every().day.at, ".2") self.assertRaises(ScheduleValueError, every().day.at, "2") self.assertRaises(ScheduleValueError, every().day.at, ":2") self.assertRaises(ScheduleValueError, every().day.at, " 2:30:00") self.assertRaises(ScheduleValueError, every().do, lambda: 0) self.assertRaises(TypeError, every().day.at, 2) # without a context manager, it incorrectly raises an error because # it is not callable with self.assertRaises(IntervalError): every(interval=2).second with self.assertRaises(IntervalError): every(interval=2).minute with self.assertRaises(IntervalError): every(interval=2).hour with self.assertRaises(IntervalError): every(interval=2).day with self.assertRaises(IntervalError): every(interval=2).week with self.assertRaises(IntervalError): every(interval=2).monday with self.assertRaises(IntervalError): every(interval=2).tuesday with self.assertRaises(IntervalError): every(interval=2).wednesday with self.assertRaises(IntervalError): every(interval=2).thursday with self.assertRaises(IntervalError): every(interval=2).friday with self.assertRaises(IntervalError): every(interval=2).saturday with self.assertRaises(IntervalError): every(interval=2).sunday def test_until_time(self): mock_job = make_mock_job() # Check argument parsing with mock_datetime(2020, 1, 1, 10, 0, 0) as m: assert every().day.until(datetime.datetime(3000, 1, 1, 20, 30)).do( mock_job ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 0) assert every().day.until(datetime.datetime(3000, 1, 1, 20, 30, 50)).do( mock_job ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 50) assert every().day.until(datetime.time(12, 30)).do( mock_job ).cancel_after == m.replace(hour=12, minute=30, second=0, microsecond=0) assert every().day.until(datetime.time(12, 30, 50)).do( mock_job ).cancel_after == m.replace(hour=12, minute=30, second=50, microsecond=0) assert every().day.until( datetime.timedelta(days=40, hours=5, minutes=12, seconds=42) ).do(mock_job).cancel_after == datetime.datetime(2020, 2, 10, 15, 12, 42) assert every().day.until("10:30").do(mock_job).cancel_after == m.replace( hour=10, minute=30, second=0, microsecond=0 ) assert every().day.until("10:30:50").do(mock_job).cancel_after == m.replace( hour=10, minute=30, second=50, microsecond=0 ) assert every().day.until("3000-01-01 10:30").do( mock_job ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 0) assert every().day.until("3000-01-01 10:30:50").do( mock_job ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50) assert every().day.until(datetime.datetime(3000, 1, 1, 10, 30, 50)).do( mock_job ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50) # Invalid argument types self.assertRaises(TypeError, every().day.until, 123) self.assertRaises(ScheduleValueError, every().day.until, "123") self.assertRaises(ScheduleValueError, every().day.until, "01-01-3000") # Using .until() with moments in the passed self.assertRaises( ScheduleValueError, every().day.until, datetime.datetime(2019, 12, 31, 23, 59), ) self.assertRaises( ScheduleValueError, every().day.until, datetime.timedelta(minutes=-1) ) self.assertRaises(ScheduleValueError, every().day.until, datetime.time(hour=5)) # Unschedule job after next_run passes the deadline schedule.clear() with mock_datetime(2020, 1, 1, 11, 35, 10): mock_job.reset_mock() every(5).seconds.until(datetime.time(11, 35, 20)).do(mock_job) with mock_datetime(2020, 1, 1, 11, 35, 15): schedule.run_pending() assert mock_job.call_count == 1 assert len(schedule.jobs) == 1 with mock_datetime(2020, 1, 1, 11, 35, 20): schedule.run_all() assert mock_job.call_count == 2 assert len(schedule.jobs) == 0 # Unschedule job because current execution time has passed deadline schedule.clear() with mock_datetime(2020, 1, 1, 11, 35, 10): mock_job.reset_mock() every(5).seconds.until(datetime.time(11, 35, 20)).do(mock_job) with mock_datetime(2020, 1, 1, 11, 35, 50): schedule.run_pending() assert mock_job.call_count == 0 assert len(schedule.jobs) == 0 def test_weekday_at_todady(self): mock_job = make_mock_job() # This date is a wednesday with mock_datetime(2020, 11, 25, 22, 38, 5): job = every().wednesday.at("22:38:10").do(mock_job) assert job.next_run.hour == 22 assert job.next_run.minute == 38 assert job.next_run.second == 10 assert job.next_run.year == 2020 assert job.next_run.month == 11 assert job.next_run.day == 25 job = every().wednesday.at("22:39").do(mock_job) assert job.next_run.hour == 22 assert job.next_run.minute == 39 assert job.next_run.second == 00 assert job.next_run.year == 2020 assert job.next_run.month == 11 assert job.next_run.day == 25 def test_at_time_hour(self): with mock_datetime(2010, 1, 6, 12, 20): mock_job = make_mock_job() assert every().hour.at(":30").do(mock_job).next_run.hour == 12 assert every().hour.at(":30").do(mock_job).next_run.minute == 30 assert every().hour.at(":30").do(mock_job).next_run.second == 0 assert every().hour.at(":10").do(mock_job).next_run.hour == 13 assert every().hour.at(":10").do(mock_job).next_run.minute == 10 assert every().hour.at(":10").do(mock_job).next_run.second == 0 assert every().hour.at(":00").do(mock_job).next_run.hour == 13 assert every().hour.at(":00").do(mock_job).next_run.minute == 0 assert every().hour.at(":00").do(mock_job).next_run.second == 0 self.assertRaises(ScheduleValueError, every().hour.at, "2:30:00") self.assertRaises(ScheduleValueError, every().hour.at, "::2") self.assertRaises(ScheduleValueError, every().hour.at, ".2") self.assertRaises(ScheduleValueError, every().hour.at, "2") self.assertRaises(ScheduleValueError, every().hour.at, " 2:30") self.assertRaises(ScheduleValueError, every().hour.at, "61:00") self.assertRaises(ScheduleValueError, every().hour.at, "00:61") self.assertRaises(ScheduleValueError, every().hour.at, "01:61") self.assertRaises(TypeError, every().hour.at, 2) # test the 'MM:SS' format assert every().hour.at("30:05").do(mock_job).next_run.hour == 12 assert every().hour.at("30:05").do(mock_job).next_run.minute == 30 assert every().hour.at("30:05").do(mock_job).next_run.second == 5 assert every().hour.at("10:25").do(mock_job).next_run.hour == 13 assert every().hour.at("10:25").do(mock_job).next_run.minute == 10 assert every().hour.at("10:25").do(mock_job).next_run.second == 25 assert every().hour.at("00:40").do(mock_job).next_run.hour == 13 assert every().hour.at("00:40").do(mock_job).next_run.minute == 0 assert every().hour.at("00:40").do(mock_job).next_run.second == 40 def test_at_time_minute(self): with mock_datetime(2010, 1, 6, 12, 20, 30): mock_job = make_mock_job() assert every().minute.at(":40").do(mock_job).next_run.hour == 12 assert every().minute.at(":40").do(mock_job).next_run.minute == 20 assert every().minute.at(":40").do(mock_job).next_run.second == 40 assert every().minute.at(":10").do(mock_job).next_run.hour == 12 assert every().minute.at(":10").do(mock_job).next_run.minute == 21 assert every().minute.at(":10").do(mock_job).next_run.second == 10 self.assertRaises(ScheduleValueError, every().minute.at, "::2") self.assertRaises(ScheduleValueError, every().minute.at, ".2") self.assertRaises(ScheduleValueError, every().minute.at, "2") self.assertRaises(ScheduleValueError, every().minute.at, "2:30:00") self.assertRaises(ScheduleValueError, every().minute.at, "2:30") self.assertRaises(ScheduleValueError, every().minute.at, " :30") self.assertRaises(TypeError, every().minute.at, 2) def test_next_run_time(self): with mock_datetime(2010, 1, 6, 12, 15): mock_job = make_mock_job() assert schedule.next_run() is None assert every().minute.do(mock_job).next_run.minute == 16 assert every(5).minutes.do(mock_job).next_run.minute == 20 assert every().hour.do(mock_job).next_run.hour == 13 assert every().day.do(mock_job).next_run.day == 7 assert every().day.at("09:00").do(mock_job).next_run.day == 7 assert every().day.at("12:30").do(mock_job).next_run.day == 6 assert every().week.do(mock_job).next_run.day == 13 assert every().monday.do(mock_job).next_run.day == 11 assert every().tuesday.do(mock_job).next_run.day == 12 assert every().wednesday.do(mock_job).next_run.day == 13 assert every().thursday.do(mock_job).next_run.day == 7 assert every().friday.do(mock_job).next_run.day == 8 assert every().saturday.do(mock_job).next_run.day == 9 assert every().sunday.do(mock_job).next_run.day == 10 assert ( every().minute.until(datetime.time(12, 17)).do(mock_job).next_run.minute == 16 ) def test_next_run_time_day_end(self): mock_job = make_mock_job() # At day 1, schedule job to run at daily 23:30 with mock_datetime(2010, 12, 1, 23, 0, 0): job = every().day.at("23:30").do(mock_job) # first occurrence same day assert job.next_run.day == 1 assert job.next_run.hour == 23 # Running the job 01:00 on day 2, afterwards the job should be # scheduled at 23:30 the same day. This simulates a job that started # on day 1 at 23:30 and took 1,5 hours to finish with mock_datetime(2010, 12, 2, 1, 0, 0): job.run() assert job.next_run.day == 2 assert job.next_run.hour == 23 # Run the job at 23:30 on day 2, afterwards the job should be # scheduled at 23:30 the next day with mock_datetime(2010, 12, 2, 23, 30, 0): job.run() assert job.next_run.day == 3 assert job.next_run.hour == 23 def test_next_run_time_hour_end(self): mock_job = make_mock_job() with mock_datetime(2010, 10, 10, 12, 0, 0): job = every().hour.at(":10").do(mock_job) assert job.next_run.hour == 12 assert job.next_run.minute == 10 with mock_datetime(2010, 10, 10, 13, 0, 0): job.run() assert job.next_run.hour == 13 assert job.next_run.minute == 10 with mock_datetime(2010, 10, 10, 13, 15, 0): job.run() assert job.next_run.hour == 14 assert job.next_run.minute == 10 def test_next_run_time_minute_end(self): mock_job = make_mock_job() with mock_datetime(2010, 10, 10, 10, 10, 0): job = every().minute.at(":15").do(mock_job) assert job.next_run.minute == 10 assert job.next_run.second == 15 with mock_datetime(2010, 10, 10, 10, 10, 59): job.run() assert job.next_run.minute == 11 assert job.next_run.second == 15 with mock_datetime(2010, 10, 10, 10, 12, 14): job.run() assert job.next_run.minute == 12 assert job.next_run.second == 15 with mock_datetime(2010, 10, 10, 10, 12, 16): job.run() assert job.next_run.minute == 13 assert job.next_run.second == 15 def test_run_all(self): mock_job = make_mock_job() every().minute.do(mock_job) every().hour.do(mock_job) every().day.at("11:00").do(mock_job) schedule.run_all() assert mock_job.call_count == 3 def test_run_all_with_decorator(self): mock_job = make_mock_job() @repeat(every().minute) def job1(): mock_job() @repeat(every().hour) def job2(): mock_job() @repeat(every().day.at("11:00")) def job3(): mock_job() schedule.run_all() assert mock_job.call_count == 3 def test_run_all_with_decorator_args(self): mock_job = make_mock_job() @repeat(every().minute, 1, 2, "three", foo=23, bar={}) def job(*args, **kwargs): mock_job(*args, **kwargs) schedule.run_all() mock_job.assert_called_once_with(1, 2, "three", foo=23, bar={}) def test_run_all_with_decorator_defaultargs(self): mock_job = make_mock_job() @repeat(every().minute) def job(nothing=None): mock_job(nothing) schedule.run_all() mock_job.assert_called_once_with(None) def test_job_func_args_are_passed_on(self): mock_job = make_mock_job() every().second.do(mock_job, 1, 2, "three", foo=23, bar={}) schedule.run_all() mock_job.assert_called_once_with(1, 2, "three", foo=23, bar={}) def test_to_string(self): def job_fun(): pass s = str(every().minute.do(job_fun, "foo", bar=23)) assert s == ( "Job(interval=1, unit=minutes, do=job_fun, " "args=('foo',), kwargs={'bar': 23})" ) assert "job_fun" in s assert "foo" in s assert "{'bar': 23}" in s def test_to_repr(self): def job_fun(): pass s = repr(every().minute.do(job_fun, "foo", bar=23)) assert s.startswith( "Every 1 minute do job_fun('foo', bar=23) (last run: [never], next run: " ) assert "job_fun" in s assert "foo" in s assert "bar=23" in s # test repr when at_time is not None s2 = repr(every().day.at("00:00").do(job_fun, "foo", bar=23)) assert s2.startswith( ( "Every 1 day at 00:00:00 do job_fun('foo', " "bar=23) (last run: [never], next run: " ) ) def test_to_string_lambda_job_func(self): assert len(str(every().minute.do(lambda: 1))) > 1 assert len(str(every().day.at("10:30").do(lambda: 1))) > 1 def test_repr_functools_partial_job_func(self): def job_fun(arg): pass job_fun = functools.partial(job_fun, "foo") job_repr = repr(every().minute.do(job_fun, bar=True, somekey=23)) assert "functools.partial" in job_repr assert "bar=True" in job_repr assert "somekey=23" in job_repr def test_to_string_functools_partial_job_func(self): def job_fun(arg): pass job_fun = functools.partial(job_fun, "foo") job_str = str(every().minute.do(job_fun, bar=True, somekey=23)) assert "functools.partial" in job_str assert "bar=True" in job_str assert "somekey=23" in job_str def test_run_pending(self): """Check that run_pending() runs pending jobs. We do this by overriding datetime.datetime with mock objects that represent increasing system times. Please note that it is *intended behavior that run_pending() does not run missed jobs*. For example, if you've registered a job that should run every minute and you only call run_pending() in one hour increments then your job won't be run 60 times in between but only once. """ mock_job = make_mock_job() with mock_datetime(2010, 1, 6, 12, 15): every().minute.do(mock_job) every().hour.do(mock_job) every().day.do(mock_job) every().sunday.do(mock_job) schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 6, 12, 16): schedule.run_pending() assert mock_job.call_count == 1 with mock_datetime(2010, 1, 6, 13, 16): mock_job.reset_mock() schedule.run_pending() assert mock_job.call_count == 2 with mock_datetime(2010, 1, 7, 13, 16): mock_job.reset_mock() schedule.run_pending() assert mock_job.call_count == 3 with mock_datetime(2010, 1, 10, 13, 16): mock_job.reset_mock() schedule.run_pending() assert mock_job.call_count == 4 def test_run_every_weekday_at_specific_time_today(self): mock_job = make_mock_job() with mock_datetime(2010, 1, 6, 13, 16): every().wednesday.at("14:12").do(mock_job) schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 6, 14, 16): schedule.run_pending() assert mock_job.call_count == 1 def test_run_every_weekday_at_specific_time_past_today(self): mock_job = make_mock_job() with mock_datetime(2010, 1, 6, 13, 16): every().wednesday.at("13:15").do(mock_job) schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 13, 13, 14): schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 13, 13, 16): schedule.run_pending() assert mock_job.call_count == 1 def test_run_every_n_days_at_specific_time(self): mock_job = make_mock_job() with mock_datetime(2010, 1, 6, 11, 29): every(2).days.at("11:30").do(mock_job) schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 6, 11, 31): schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 7, 11, 31): schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 8, 11, 29): schedule.run_pending() assert mock_job.call_count == 0 with mock_datetime(2010, 1, 8, 11, 31): schedule.run_pending() assert mock_job.call_count == 1 with mock_datetime(2010, 1, 10, 11, 31): schedule.run_pending() assert mock_job.call_count == 2 def test_next_run_property(self): original_datetime = datetime.datetime with mock_datetime(2010, 1, 6, 13, 16): hourly_job = make_mock_job("hourly") daily_job = make_mock_job("daily") every().day.do(daily_job) every().hour.do(hourly_job) assert len(schedule.jobs) == 2 # Make sure the hourly job is first assert schedule.next_run() == original_datetime(2010, 1, 6, 14, 16) def test_idle_seconds(self): assert schedule.next_run() is None assert schedule.idle_seconds() is None mock_job = make_mock_job() with mock_datetime(2020, 12, 9, 21, 46): job = every().hour.do(mock_job) assert schedule.idle_seconds() == 60 * 60 schedule.cancel_job(job) assert schedule.next_run() is None assert schedule.idle_seconds() is None def test_cancel_job(self): def stop_job(): return schedule.CancelJob mock_job = make_mock_job() every().second.do(stop_job) mj = every().second.do(mock_job) assert len(schedule.jobs) == 2 schedule.run_all() assert len(schedule.jobs) == 1 assert schedule.jobs[0] == mj schedule.cancel_job("Not a job") assert len(schedule.jobs) == 1 schedule.default_scheduler.cancel_job("Not a job") assert len(schedule.jobs) == 1 schedule.cancel_job(mj) assert len(schedule.jobs) == 0 def test_cancel_jobs(self): def stop_job(): return schedule.CancelJob every().second.do(stop_job) every().second.do(stop_job) every().second.do(stop_job) assert len(schedule.jobs) == 3 schedule.run_all() assert len(schedule.jobs) == 0 def test_tag_type_enforcement(self): job1 = every().second.do(make_mock_job(name="job1")) self.assertRaises(TypeError, job1.tag, {}) self.assertRaises(TypeError, job1.tag, 1, "a", []) job1.tag(0, "a", True) assert len(job1.tags) == 3 def test_get_by_tag(self): every().second.do(make_mock_job()).tag("job1", "tag1") every().second.do(make_mock_job()).tag("job2", "tag2", "tag4") every().second.do(make_mock_job()).tag("job3", "tag3", "tag4") # Test None input yields all 3 jobs = schedule.get_jobs() assert len(jobs) == 3 assert {"job1", "job2", "job3"}.issubset( {*jobs[0].tags, *jobs[1].tags, *jobs[2].tags} ) # Test each 1:1 tag:job jobs = schedule.get_jobs("tag1") assert len(jobs) == 1 assert "job1" in jobs[0].tags # Test multiple jobs found. jobs = schedule.get_jobs("tag4") assert len(jobs) == 2 assert "job1" not in {*jobs[0].tags, *jobs[1].tags} # Test no tag. jobs = schedule.get_jobs("tag5") assert len(jobs) == 0 schedule.clear() assert len(schedule.jobs) == 0 def test_clear_by_tag(self): every().second.do(make_mock_job(name="job1")).tag("tag1") every().second.do(make_mock_job(name="job2")).tag("tag1", "tag2") every().second.do(make_mock_job(name="job3")).tag( "tag3", "tag3", "tag3", "tag2" ) assert len(schedule.jobs) == 3 schedule.run_all() assert len(schedule.jobs) == 3 schedule.clear("tag3") assert len(schedule.jobs) == 2 schedule.clear("tag1") assert len(schedule.jobs) == 0 every().second.do(make_mock_job(name="job1")) every().second.do(make_mock_job(name="job2")) every().second.do(make_mock_job(name="job3")) schedule.clear() assert len(schedule.jobs) == 0 def test_misconfigured_job_wont_break_scheduler(self): """ Ensure an interrupted job definition chain won't break the scheduler instance permanently. """ scheduler = schedule.Scheduler() scheduler.every() scheduler.every(10).seconds scheduler.run_pending()