diff --git a/.travis.yml b/.travis.yml index 5850c6b2..eacd7d7a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ dist: xenial language: python + python: - "2.7" - "3.5" @@ -7,7 +8,13 @@ python: - "3.7" - "3.8-dev" - "nightly" + install: pip install tox-travis coveralls + +before_install: + - export TZ=America/Los_Angeles + - date + script: - tox after_success: diff --git a/schedule/__init__.py b/schedule/__init__.py index 7b87e6a5..ede6c26f 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -48,7 +48,12 @@ import re import time -logger = logging.getLogger('schedule') +try: + from datetime import timezone + utc = timezone.utc +except ImportError: + from schedule.timezone import UTC + utc = UTC() class ScheduleError(Exception): @@ -81,6 +86,7 @@ class Scheduler(object): """ def __init__(self): self.jobs = [] + self.logger = logging.getLogger('schedule.Scheduler') def run_pending(self): """ @@ -96,19 +102,30 @@ def run_pending(self): for job in sorted(runnable_jobs): self._run_job(job) - def run_all(self, delay_seconds=0): + def run_all(self, delay_seconds=0, tag=None): """ - Run all jobs regardless if they are scheduled to run or not. + Run all jobs regardless if they are scheduled to run or not, + optionally matching one or more tags. 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 + + :param tag: An identifier used to identify a subset of + jobs to run """ - logger.info('Running *all* %i jobs with %is delay inbetween', - len(self.jobs), delay_seconds) - for job in self.jobs[:]: + + if tag is None: + runnable_jobs = self.jobs[:] + else: + runnable_jobs = (job for job in self.jobs if tag in job.tags) + + self.logger.info('Running *all* %i jobs with %is delay inbetween', + len(self.jobs), delay_seconds) + + for job in sorted(runnable_jobs): self._run_job(job) time.sleep(delay_seconds) @@ -121,8 +138,10 @@ def clear(self, tag=None): jobs to delete """ if tag is None: + self.logger.info('Deleting *all* jobs') del self.jobs[:] else: + self.logger.info('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): @@ -168,7 +187,28 @@ def idle_seconds(self): :return: Number of seconds until :meth:`next_run `. """ - return (self.next_run - datetime.datetime.now()).total_seconds() + return (self.next_run - datetime.datetime.now(utc)).total_seconds() + + @property + def last_run(self): + """ + Datetime when the last job ran (check for NoneType before using). + + :return: A :class:`~datetime.datetime` object + """ + if not self.jobs: + return None + return max(self.jobs).last_run + + @property + def idle_seconds_since(self): + """ + :return: Number of seconds since (check for NoneType before using). + :meth:`next_run `. + """ + if self.last_run is None: + return None + return (datetime.datetime.now(utc) - self.last_run).total_seconds() class Job(object): @@ -192,6 +232,8 @@ def __init__(self, interval, scheduler=None): self.interval = interval # pause interval * unit between runs self.latest = None # upper limit to the interval self.job_func = None # the job job_func to run + self.job_name = None # the name of job_func to run + self.job_info = None # the job timestats (see below) self.unit = None # time units, e.g. 'minutes', 'hours', ... self.at_time = None # optional time at which this job runs self.last_run = None # datetime of the last run @@ -200,6 +242,7 @@ def __init__(self, interval, scheduler=None): self.start_day = None # Specific day of the week to start on self.tags = set() # unique set of tags for the job self.scheduler = scheduler # scheduler to register with + self.logger = logging.getLogger('schedule.Job') def __lt__(self, other): """ @@ -223,7 +266,7 @@ def __str__(self): def __repr__(self): def format_time(t): - return t.strftime('%Y-%m-%d %H:%M:%S') if t else '[never]' + return t.strftime('%Y-%m-%d %H:%M:%S %Z') if t else '[never]' def is_repr(j): return not isinstance(j, Job) @@ -240,6 +283,9 @@ def is_repr(j): for k, v in self.job_func.keywords.items()] call_repr = job_func_name + '(' + ', '.join(args + kwargs) + ')' + self.job_name = call_repr + self.job_info = timestats + if self.at_time is not None: return 'Every %s %s at %s do %s %s' % ( self.interval, @@ -473,7 +519,14 @@ def should_run(self): """ :return: ``True`` if the job should be run now. """ - return datetime.datetime.now() >= self.next_run + return datetime.datetime.now(utc) >= self.next_run + + @property + def info(self): + """ + :return: ``string`` with `job_func` name and timestats + """ + return self.job_name + self.job_info def run(self): """ @@ -481,9 +534,9 @@ def run(self): :return: The return value returned by the `job_func` """ - logger.info('Running job %s', self) + self.logger.info('Running job %s', self) ret = self.job_func() - self.last_run = datetime.datetime.now() + self.last_run = datetime.datetime.now(utc) self._schedule_next_run() return ret @@ -502,7 +555,7 @@ def _schedule_next_run(self): interval = self.interval self.period = datetime.timedelta(**{self.unit: interval}) - self.next_run = datetime.datetime.now() + self.period + self.next_run = datetime.datetime.now(utc) + self.period if self.start_day is not None: if self.unit != 'weeks': raise ScheduleValueError('`unit` should be \'weeks\'') @@ -539,7 +592,7 @@ def _schedule_next_run(self): # If we are running for the first time, make sure we run # at the specified time *today* (or *this hour*) as well if not self.last_run: - now = datetime.datetime.now() + now = datetime.datetime.now(utc) if (self.unit == 'days' and self.at_time > now.time() and self.interval == 1): self.next_run = self.next_run - datetime.timedelta(days=1) @@ -554,7 +607,7 @@ def _schedule_next_run(self): 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: + if (self.next_run - datetime.datetime.now(utc)).days >= 7: self.next_run -= self.period @@ -582,11 +635,11 @@ def run_pending(): default_scheduler.run_pending() -def run_all(delay_seconds=0): +def run_all(delay_seconds=0, tag=None): """Calls :meth:`run_all ` on the :data:`default scheduler instance `. """ - default_scheduler.run_all(delay_seconds=delay_seconds) + default_scheduler.run_all(delay_seconds, tag) def clear(tag=None): @@ -615,3 +668,17 @@ def idle_seconds(): :data:`default scheduler instance `. """ return default_scheduler.idle_seconds + + +def last_run(): + """Calls :meth:`last_run ` on the + :data:`default scheduler instance `. + """ + return default_scheduler.last_run + + +def idle_seconds_since(): + """Calls :meth:`idle_seconds_since ` on the + :data:`default scheduler instance `. + """ + return default_scheduler.idle_seconds_since diff --git a/schedule/parent_logger.py b/schedule/parent_logger.py new file mode 100644 index 00000000..fce5d415 --- /dev/null +++ b/schedule/parent_logger.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# coding: utf-8 + +import time +import logging + + +def setup_logging(debug, filename): + """ + Can be imported by ```` to create a log file for current + scheduler and job class logging. In this example we use a ``debug`` + flag set in ```` to change the Log Level and ``filename`` + to set log path. We also use UTC time and force the name in ``datefmt``. + """ + if debug: + log_level = logging.getLevelName('DEBUG') + else: + log_level = logging.getLevelName('INFO') + + logging.basicConfig(level=log_level, + format="%(asctime)s %(name)s[%(process)d] %(levelname)s - %(message)s", + datefmt='%Y-%m-%d %H:%M:%S UTC', + filename=filename) + + # BUG: This does not print the TZ name because logging module uses + # time instead of tz-aware datetime objects (so we force the + # correct name in datefmt above). + logging.Formatter.converter = time.gmtime + + # To also log parent info, try something like this + # global logger + # logger = logging.getLogger("my_package") diff --git a/schedule/timezone.py b/schedule/timezone.py new file mode 100644 index 00000000..1530e389 --- /dev/null +++ b/schedule/timezone.py @@ -0,0 +1,19 @@ +import datetime + + +class UTC(datetime.tzinfo): + """tzinfo derived concrete class named "UTC" with offset of 0""" + # can be changed to another timezone name/offset + def __init__(self): + self.__offset = datetime.timedelta(seconds=0) + self.__dst = datetime.timedelta(0) + self.__name = "UTC" + + def utcoffset(self, dt): + return self.__offset + + def dst(self, dt): + return self.__dst + + def tzname(self, dt): + return self.__name diff --git a/test_schedule.py b/test_schedule.py index 7df359ae..9e93ffa1 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -1,8 +1,12 @@ """Unit tests for schedule.py""" + +import sys import datetime +import logging import functools import mock import unittest +# from test import support # Silence "missing docstring", "method could be a function", # "class already defined", and "too many public methods" messages: @@ -10,6 +14,14 @@ import schedule from schedule import every, ScheduleError, ScheduleValueError, IntervalError +from schedule.parent_logger import setup_logging + +try: + from datetime import timezone + utc = timezone.utc +except ImportError: + from schedule.timezone import UTC + utc = UTC() def make_mock_job(name=None): @@ -37,9 +49,10 @@ def today(cls): return cls(self.year, self.month, self.day) @classmethod - def now(cls): + def now(cls, tz=None): return cls(self.year, self.month, self.day, - self.hour, self.minute, self.second) + self.hour, self.minute, self.second).replace(tzinfo=tz) + self.original_datetime = datetime.datetime datetime.datetime = MockDate @@ -262,6 +275,23 @@ def test_run_all(self): schedule.run_all() assert mock_job.call_count == 3 + def test_run_tag(self): + with mock_datetime(2010, 1, 6, 12, 15): + mock_job = make_mock_job() + assert schedule.last_run() is None + job1 = every().hour.do(mock_job(name='job1')).tag('tag1') + job2 = every().hour.do(mock_job(name='job2')).tag('tag1', 'tag2') + job3 = every().hour.do(mock_job(name='job3')).tag('tag3', 'tag3', + 'tag3', 'tag2') + assert len(schedule.jobs) == 3 + schedule.run_all(0, 'tag1') + assert 'tag1' in str(job1.tags) + assert 'tag1' not in str(job3.tags) + assert 'tag1' in str(job2.tags) + assert job1.last_run.minute == 15 + assert job2.last_run.hour == 12 + assert job3.last_run is 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={}) @@ -408,9 +438,40 @@ def test_next_run_property(self): 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) + assert schedule.next_run() == original_datetime(2010, 1, 6, 14, 16, + tzinfo=utc) assert schedule.idle_seconds() == 60 * 60 + def test_last_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 schedule.idle_seconds_since() is None + schedule.run_all() + assert schedule.last_run() == original_datetime(2010, 1, 6, 13, 16, + tzinfo=utc) + assert schedule.idle_seconds_since() == 0 + schedule.clear() + assert schedule.last_run() is None + + def test_job_info(self): + with mock_datetime(2010, 1, 6, 14, 16): + mock_job = make_mock_job(name='info_job') + info_job = every().minute.do(mock_job, 1, 7, 'three') + schedule.run_all() + assert len(schedule.jobs) == 1 + assert schedule.jobs[0] == info_job + assert repr(info_job) + assert info_job.job_name is not None + s = info_job.info + assert 'info_job' in s + assert 'three' in s + assert '2010' in s + assert '14:16' in s + def test_cancel_job(self): def stop_job(): return schedule.CancelJob @@ -478,3 +539,74 @@ def test_misconfigured_job_wont_break_scheduler(self): scheduler.every() scheduler.every(10).seconds scheduler.run_pending() + + +class TimezoneTests(unittest.TestCase): + if sys.version_info > (3, 0, 0): + from schedule.timezone import UTC + utc = UTC() + + def test_utc_is_normal(self): + fo = utc + self.assertIsInstance(fo, datetime.tzinfo) + dt = datetime.datetime.now() + self.assertEqual(fo.utcoffset(dt), datetime.timedelta(0)) + assert "UTC" in fo.tzname(dt) + + def test_utc_dst_is_dt(self): + fo = utc + dt = datetime.datetime.now() + if sys.version_info > (3, 0, 0): + dst_arg = None + else: + dst_arg = datetime.timedelta(0) + self.assertEqual(fo.dst(dt), dst_arg) + + +class BasicConfigTest(unittest.TestCase): + + """Tests for logging.basicConfig.""" + + def setUp(self): + super(BasicConfigTest, self).setUp() + self.handlers = logging.root.handlers + self.saved_handlers = logging._handlers.copy() + self.saved_handler_list = logging._handlerList[:] + self.original_logging_level = logging.root.level + self.addCleanup(self.cleanup) + logging.root.handlers = [] + + def tearDown(self): + for h in logging.root.handlers[:]: + logging.root.removeHandler(h) + h.close() + super(BasicConfigTest, self).tearDown() + + def cleanup(self): + setattr(logging.root, 'handlers', self.handlers) + logging._handlers.clear() + logging._handlers.update(self.saved_handlers) + logging._handlerList[:] = self.saved_handler_list + logging.root.level = self.original_logging_level + + def test_debug_level(self): + old_level = logging.root.level + self.addCleanup(logging.root.setLevel, old_level) + + debug = True + setup_logging(debug, '/dev/null') + self.assertEqual(logging.root.level, logging.DEBUG) + # Test that second call has no effect + logging.basicConfig(level=58) + self.assertEqual(logging.root.level, logging.DEBUG) + + def test_info_level(self): + old_level = logging.root.level + self.addCleanup(logging.root.setLevel, old_level) + + debug = False + setup_logging(debug, '/dev/null') + self.assertEqual(logging.root.level, logging.INFO) + # Test that second call has no effect + logging.basicConfig(level=58) + self.assertEqual(logging.root.level, logging.INFO) diff --git a/tox.ini b/tox.ini index 2c5b93d7..f82e1f70 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,9 @@ skip_missing_interpreters = true 3.7 = py37, docs 3.8 = py38, docs +[flake8] +max-line-length = 95 + [testenv] deps = -rrequirements-dev.txt commands =