#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-schedule. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds and every measurement below is integer and date arithmetic over a handful of objects, so the start is the entire cost and taking it once is the difference between a half-minute suite and an hour. THE CLOCK IS PATCHED, BECAUSE THE PACKAGE OFFERS NO WAY TO SUPPLY ONE. BRINTOS-2349's description says the suite "supplies the instants it tests with" and that the clock "is an input, not an ambient fact". That is not true of this package and the difference is the whole design of this probe. `Job._schedule_next_run' and `Job.should_run' call `datetime.datetime.now()' directly; there is no injection point, no `now' parameter and no seam short of the module global. So the only way to test the scheduler deterministically is the one upstream's own test_schedule.py takes: SUBCLASS `datetime.datetime' and assign the subclass over `datetime.datetime'. `schedule' holds a reference to the datetime MODULE rather than to the class, so the assignment is visible to it. `_Clock' below is that patch plus the one thing upstream's version lacks: the instant is MUTABLE, so `advance()' moves the scheduler's "now" forward and `run_pending' can be driven past a due time without sleeping. Nothing in this probe sleeps and nothing reads the real clock, so the measurements do not depend on when the suite runs, on the target's timezone, or on any time source off the machine -- which is the property the ticket asked for, reached by a different route than the one it describes. THREE RULES THE VALUES OBEY. The runner parses its input LINE BY LINE, so no value may contain a newline. Every value here is a scalar or a comma-joined list of scalars. No value may be ENCODING-DEPENDENT. The guest runs under the C locale. Every fixture is ASCII, and every instant is reported through `strftime' with an explicit format rather than through `str' or `repr'. And no value may be a REPR THAT CARRIES AN ADDRESS. A `Job' repr does not carry one, but a `functools.partial' does, so a job is reported through its fields -- interval, unit, start_day, the isoformat of `at_time' -- and never as the object. WHY THIS FILE IS NOT NAMED schedule.py. It sits in ${libdir}/brintos-tests/data and the interpreter puts a script's own directory FIRST on sys.path, so a probe named after the module would shadow the module it is measuring. `module_file' below is the case that would catch it if that ever stopped being true. """ import datetime import sys _BASELINE = set(sys.modules) emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def raises(fn, *a, **kw): """The name of the exception type `fn' raised, or `'.""" try: fn(*a, **kw) except BaseException as exc: # noqa: BLE001 return type(exc).__name__ return '' # --------------------------------------------------------------- the clock --- _REAL_DATETIME = datetime.datetime # A Wednesday, deliberately: the weekday cases below need a reference day that # is neither the first nor the last of the week, so `days_ahead <= 0' -- the # branch that pushes a target day into NEXT week -- is exercised in both # directions by the same reference instant. _NOW = [_REAL_DATETIME(2024, 5, 15, 12, 0, 0)] class _MockDate(_REAL_DATETIME): @classmethod def now(cls, tz=None): n = _NOW[0] return cls(n.year, n.month, n.day, n.hour, n.minute, n.second, n.microsecond) @classmethod def today(cls): n = _NOW[0] return cls(n.year, n.month, n.day) def advance(**kw): _NOW[0] = _NOW[0] + datetime.timedelta(**kw) def set_now(*a): _NOW[0] = _REAL_DATETIME(*a) datetime.datetime = _MockDate import schedule # noqa: E402 _AFTER_IMPORT = set(sys.modules) def ts(dt): """An instant, to the second, in one unambiguous ASCII spelling.""" return dt.strftime('%Y-%m-%d %H:%M:%S') if dt is not None else '' def fresh(): """A private Scheduler, so no case can be perturbed by another's jobs.""" return schedule.Scheduler() def noop(): return None # ------------------------------------------------------------- provenance --- emit('module_file', getattr(schedule, '__file__', '')) emit('is_package', 'yes' if hasattr(schedule, '__path__') else 'no') # There is no `schedule.__version__'. The version lives in the distribution # metadata alone -- setup.py carries SCHEDULE_VERSION and the package does not # re-export it -- so a caller reading the attribute gets an AttributeError. emit('has_version_attr', 'yes' if hasattr(schedule, '__version__') else 'no') try: import importlib.metadata as _md emit('dist_version', _md.version('schedule')) except Exception as exc: # noqa: BLE001 emit('dist_version', '<%s>' % type(exc).__name__) emit('reference_now', ts(_NOW[0])) emit('reference_weekday', _NOW[0].strftime('%A').lower()) # ------------------------------------------------------------- the surface --- for _n in ('every', 'run_pending', 'run_all', 'get_jobs', 'clear', 'cancel_job', 'next_run', 'idle_seconds', 'repeat'): emit('has_func_' + _n, 'yes' if callable(getattr(schedule, _n, None)) else 'no') for _n in ('Job', 'Scheduler', 'CancelJob', 'ScheduleError', 'ScheduleValueError', 'IntervalError'): emit('has_class_' + _n, 'yes' if isinstance(getattr(schedule, _n, None), type) else 'no') emit('has_default_scheduler', 'yes' if isinstance(getattr(schedule, 'default_scheduler', None), schedule.Scheduler) else 'no') emit('has_all_attr', 'yes' if hasattr(schedule, '__all__') else 'no') emit('logger_name', schedule.logger.name) # `schedule.jobs' is bound to the default scheduler's LIST OBJECT at import, # not to a copy and not to a property -- upstream's own source carries a `todo' # asking whether it should be. So it aliases, and a caller who holds it sees # the scheduler's mutations. schedule.clear() _probe_job = schedule.every().day.do(noop) emit('module_jobs_aliases', 'yes' if len(schedule.jobs) == 1 else 'no') emit('module_jobs_is_same_object', 'yes' if schedule.jobs is schedule.default_scheduler.jobs else 'no') schedule.clear() emit('module_jobs_after_clear', str(len(schedule.jobs))) # ------------------------------------------------------ the exception tree --- emit('interval_under_valueerror', 'yes' if issubclass(schedule.IntervalError, schedule.ScheduleValueError) else 'no') emit('valueerror_under_scheduleerror', 'yes' if issubclass(schedule.ScheduleValueError, schedule.ScheduleError) else 'no') emit('scheduleerror_base', schedule.ScheduleError.__bases__[0].__name__) # NOT a builtin ValueError, despite the name. `except ValueError' does not # catch one of these, which is the trap the name sets. emit('valueerror_is_builtin_valueerror', 'yes' if issubclass(schedule.ScheduleValueError, ValueError) else 'no') # CancelJob is a bare marker class, not an exception. emit('canceljob_is_exception', 'yes' if issubclass(schedule.CancelJob, BaseException) else 'no') # ------------------------------------------------------------- every unit --- # next_run is `now + interval*unit', computed at `do()' time. One case per # documented unit, each against an instant written out in full. for _key, _mk in ( ('sec1', lambda s: s.every().second), ('sec10', lambda s: s.every(10).seconds), ('min1', lambda s: s.every().minute), ('min10', lambda s: s.every(10).minutes), ('hour1', lambda s: s.every().hour), ('hour3', lambda s: s.every(3).hours), ('day1', lambda s: s.every().day), ('day2', lambda s: s.every(2).days), ('week1', lambda s: s.every().week), ('week2', lambda s: s.every(2).weeks)): _s = fresh() _j = _mk(_s).do(noop) emit('next_' + _key, ts(_j.next_run)) emit('unit_' + _key, str(_j.unit)) emit('period_' + _key, str(int(_j.period.total_seconds()))) # The singular properties are the plural ones plus a guard, and the guard is # the only difference: `every(2).second' raises rather than meaning `seconds'. _s = fresh() emit('err_plural_second', raises(lambda: _s.every(2).second)) emit('err_plural_minute', raises(lambda: _s.every(2).minute)) emit('err_plural_hour', raises(lambda: _s.every(2).hour)) emit('err_plural_day', raises(lambda: _s.every(2).day)) emit('err_plural_week', raises(lambda: _s.every(2).week)) # --------------------------------------------------------- named weekdays --- # The reference instant is a WEDNESDAY. `days_ahead = weekday - now.weekday()' # and `if days_ahead <= 0: days_ahead += 7', so Thursday-to-Tuesday land in the # next seven days and WEDNESDAY ITSELF GOES A FULL WEEK OUT -- the `<= 0' is # what makes "today" mean "a week today" rather than "in a moment". for _d in ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'): _s = fresh() _j = getattr(_s.every(), _d).do(noop) emit('weekday_' + _d, ts(_j.next_run)) emit('weekday_' + _d + '_name', _j.next_run.strftime('%A').lower()) emit('weekday_' + _d + '_startday', str(_j.start_day)) emit('weekday_' + _d + '_unit', str(_j.unit)) # A named weekday on a MULTI-week interval is refused -- the property checks # `interval != 1' before it sets start_day. _s = fresh() emit('err_weekday_multiweek', raises(lambda: _s.every(2).monday)) # ------------------------------------------------------------------- at() --- # now is 12:00:00. For a DAILY job the computation is `now + 1 day', then the # clock fields are replaced, then -- because `interval == 1' and the replaced # time is LATER in the day than now -- one day is subtracted again. So a time # still ahead of us today runs TODAY, and a time already past runs TOMORROW. # The subtraction is the branch a reader does not expect, so both arms are # asserted. _s = fresh() emit('at_day_future', ts(_s.every().day.at('18:00').do(noop).next_run)) _s = fresh() emit('at_day_past', ts(_s.every().day.at('06:00').do(noop).next_run)) _s = fresh() emit('at_day_seconds', ts(_s.every().day.at('18:30:45').do(noop).next_run)) # The seconds default to 0 when the string does not carry them, and # `microsecond' is replaced with 0 unconditionally. _s = fresh() _j = _s.every().day.at('18:00').do(noop) emit('at_day_micro', str(_j.next_run.microsecond)) emit('at_time_iso', _j.at_time.isoformat()) # A MULTI-DAY interval does not take the roll-back arm at all: the `interval == # 1' guard fails, so `every(2).days.at("06:00")' stays two days out even though # 06:00 has already passed today. _s = fresh() emit('at_day2_past', ts(_s.every(2).days.at('06:00').do(noop).next_run)) # HOURLY jobs take `MM:SS' or `:SS'; the hour is forced to 0 and only minute # and second are replaced. now is 12:00:00, so `:30' -- 30 seconds past the # minute -- is still ahead within this hour and the roll-back does not fire, # while `10:00' (ten minutes past) is behind and one hour is subtracted. _s = fresh() emit('at_hour_ss', ts(_s.every().hour.at(':30').do(noop).next_run)) _s = fresh() emit('at_hour_mmss', ts(_s.every().hour.at('10:00').do(noop).next_run)) # MINUTE jobs take `:SS' only. _s = fresh() emit('at_minute_ss', ts(_s.every().minute.at(':30').do(noop).next_run)) # A named weekday PLUS a time. _s = fresh() emit('at_weekday', ts(_s.every().friday.at('09:15').do(noop).next_run)) # The format guards, each by the exception it raises. These are the documented # refusals, and they are ScheduleValueError -- not the builtin ValueError. _s = fresh() emit('err_at_day_bad_format', raises(lambda: _s.every().day.at('10:30:45:99'))) _s = fresh() emit('err_at_day_bad_hour', raises(lambda: _s.every().day.at('30:00'))) _s = fresh() emit('err_at_hour_hhmmss', raises(lambda: _s.every().hour.at('10:30:00'))) _s = fresh() emit('err_at_minute_mmss', raises(lambda: _s.every().minute.at('10:30'))) _s = fresh() emit('err_at_on_seconds', raises(lambda: _s.every().second.at(':30'))) _s = fresh() emit('err_at_on_weeks', raises(lambda: _s.every().week.at('10:30'))) _s = fresh() emit('err_at_not_string', raises(lambda: _s.every().day.at(1030))) # `24:00' PASSES the regex -- it is `^[0-2]\d:[0-5]\d(:[0-5]\d)?$', so the # first digit may be 2 and the second anything -- and is then refused by the # `0 <= hour <= 23' range check. Two different guards, and the case is here # because the regex alone would let it through. _s = fresh() emit('err_at_hour_24', raises(lambda: _s.every().day.at('24:00'))) # THE TIMEZONE ARGUMENT WORKS HERE, AND NOT BECAUSE THIS RECIPE ARRANGED IT. # `at(tz=...)' does a function-local `import pytz'. pytz is NOT in this # recipe's RDEPENDS -- the recipe names python3-datetime, python3-logging and # python3-math and nothing else -- so the argument is satisfied only because # some other port put python3-pytz in this image. On a rootfs that took this # .deb alone the same call would raise ModuleNotFoundError. Both halves are # measured: that pytz is importable, and that the recipe does not ask for it # (the suite reads the latter off the built package's Depends line). try: import pytz as _pytz emit('pytz_importable', 'yes') emit('pytz_zone_ok', 'yes' if _pytz.timezone('Europe/Berlin') else 'no') except Exception as exc: # noqa: BLE001 emit('pytz_importable', 'no') emit('pytz_zone_ok', '<%s>' % type(exc).__name__) _s = fresh() emit('err_at_tz', raises(lambda: _s.every().day.at('10:30', 'Europe/Berlin'))) # The zone is recorded on the job as a pytz object; its `zone' attribute is # pure pytz and involves no local timezone at all, so it is the one part of # this group that is the same on every machine. _s = fresh() _tzj = _s.every().day.at('10:30', 'Europe/Berlin').do(noop) emit('at_tz_zone', getattr(_tzj.at_time_zone, 'zone', '')) emit('at_tz_at_time', _tzj.at_time.isoformat()) # The INSTANT it lands on is the local rendering of 10:30 Berlin, so it depends # on the guest's own timezone. The probe therefore forces TZ to UTC and says # whether the force took effect, rather than reporting a number whose meaning # is a property of the image. In May, Berlin is CEST (+02:00), so 10:30 there # is 08:30 UTC. import os as _os import time as _time _os.environ['TZ'] = 'UTC0' try: _time.tzset() emit('tzset_available', 'yes') except Exception as exc: # noqa: BLE001 emit('tzset_available', '<%s>' % type(exc).__name__) emit('local_utcoffset_seconds', str(int(_REAL_DATETIME.now().astimezone().utcoffset().total_seconds()))) _s = fresh() emit('at_tz_utc_next', ts(_s.every().day.at('10:30', 'Europe/Berlin').do(noop).next_run)) # ...and the same job with no tz, under the same forced TZ, for the contrast: # 10:30 is behind 12:00 so it rolls to tomorrow, while the Berlin one lands on # 08:30 and rolls too. The PAIR is what shows the conversion happened. _s = fresh() emit('at_naive_utc_next', ts(_s.every().day.at('10:30').do(noop).next_run)) # ------------------------------------------------------------ the invalid --- # `do()' without a unit: `_schedule_next_run' refuses `None' as a unit. _s = fresh() emit('err_no_unit', raises(lambda: _s.every().do(noop))) # A job built with no scheduler cannot register itself. emit('err_no_scheduler', raises(lambda: schedule.Job(1, None).seconds.do(noop))) # `to()' below the interval. _s = fresh() emit('err_to_below_interval', raises(lambda: _s.every(5).to(2).seconds.do(noop))) # Tags must be hashable. _s = fresh() emit('err_unhashable_tag', raises(lambda: _s.every().second.tag(['a']))) # ------------------------------------------------------------ run_pending --- # THE SCHEDULER FIRES BY THE CLOCK AND NOT BY A SLEEP. Three jobs at one, # five and ten minutes; the clock is moved forward and `run_pending' is asked # what it did. `run_pending' sorts runnable jobs by `next_run', so the order # is the schedule's, which is what `fired_*' records. set_now(2024, 5, 15, 12, 0, 0) # every group starts from the reference instant _fired = [] _s = fresh() def _mk(tag): def _f(): _fired.append(tag) _f.__name__ = 'job_' + tag return _f _j1 = _s.every().minute.do(_mk('a')) _j5 = _s.every(5).minutes.do(_mk('b')) _j10 = _s.every(10).minutes.do(_mk('c')) emit('run_initial_next', ts(_s.next_run)) emit('run_idle_seconds', str(int(_s.idle_seconds))) # Nothing is due yet. _s.run_pending() emit('run_fired_at_t0', ','.join(_fired) or '') emit('run_should_run_t0', 'yes' if _j1.should_run else 'no') # ...at +1 minute exactly, only the one-minute job is due. `should_run' is # `now >= next_run', so the boundary instant itself fires. advance(minutes=1) emit('run_should_run_t1', 'yes' if _j1.should_run else 'no') _s.run_pending() emit('run_fired_at_t1', ','.join(_fired) or '') emit('run_next_after_t1', ts(_j1.next_run)) emit('run_last_after_t1', ts(_j1.last_run)) # ...at +5 minutes from the start, the one- and five-minute jobs are both due # and fire in schedule order. The one-minute job fires ONCE, not four times: # a missed run is not made up, which is upstream's documented behaviour. del _fired[:] advance(minutes=4) _s.run_pending() emit('run_fired_at_t5', ','.join(_fired) or '') # ...and at +10 all three. del _fired[:] advance(minutes=5) _s.run_pending() emit('run_fired_at_t10', ','.join(_fired) or '') emit('run_jobs_still', str(len(_s.jobs))) # `run_all' fires everything regardless of whether it is due. del _fired[:] _s.run_all() emit('run_all_fired', ','.join(sorted(_fired)) or '') # --------------------------------------------------------- cancel / clear --- set_now(2024, 5, 15, 12, 0, 0) # every group starts from the reference instant _s = fresh() _a = _s.every().second.tag('alpha', 'shared').do(noop) _b = _s.every().minute.tag('beta', 'shared').do(noop) _c = _s.every().hour.tag('gamma').do(noop) emit('tag_count_all', str(len(_s.get_jobs()))) emit('tag_count_shared', str(len(_s.get_jobs('shared')))) emit('tag_count_alpha', str(len(_s.get_jobs('alpha')))) emit('tag_count_missing', str(len(_s.get_jobs('nosuchtag')))) emit('tag_set_a', ','.join(sorted(_a.tags))) _s.cancel_job(_a) emit('cancel_count', str(len(_s.jobs))) # Cancelling a job that is not scheduled is a NO-OP, not an error: the # `ValueError' from `list.remove' is caught and logged at debug. emit('cancel_twice', raises(_s.cancel_job, _a)) emit('cancel_twice_count', str(len(_s.jobs))) _s.clear('shared') emit('clear_tag_count', str(len(_s.jobs))) _s.clear() emit('clear_all_count', str(len(_s.jobs))) emit('next_run_when_empty', ts(_s.next_run)) emit('idle_when_empty', '' if _s.idle_seconds is None else 'set') # A job may unschedule ITSELF by returning CancelJob. _s = fresh() def _selfcancel(): return schedule.CancelJob _s.every().second.do(_selfcancel) advance(seconds=2) _s.run_pending() emit('selfcancel_jobs_left', str(len(_s.jobs))) # ------------------------------------------------------------- until() --- # The deadline is checked when the next run is computed AND immediately before # a run, so a job past its deadline is cancelled rather than run. set_now(2024, 5, 15, 12, 0, 0) # every group starts from the reference instant _s = fresh() # The deadline is built with `datetime.datetime' -- which IS the patch -- and # not with the real class, because `until()' dispatches on # `isinstance(until_time, datetime.datetime)'. A real `datetime.datetime' is # not an instance of the subclass, so passing one takes the `else' arm and # raises TypeError. That is an artefact of patching the clock and not a # property of the package, and it is recorded here so the next reader does not # rediscover it as a bug. _dl = _s.every().minute.until(datetime.datetime(2024, 5, 15, 12, 3, 0)).do(noop) emit('until_deadline', ts(_dl.cancel_after)) advance(minutes=1) _s.run_pending() emit('until_alive_at_t1', str(len(_s.jobs))) advance(minutes=5) _s.run_pending() emit('until_gone_at_t6', str(len(_s.jobs))) # A deadline in the past is refused outright. _s = fresh() emit('err_until_past', raises(lambda: _s.every().minute.until(datetime.datetime(2020, 1, 1, 0, 0, 0)))) emit('err_until_type', raises(lambda: _s.every().minute.until(12345))) # --------------------------------------------------------------- to() --- # The randomized interval is inclusive at both ends. Rather than assert one # draw -- which would be a statement about the seed -- 200 draws are taken and # the observed range is reported. `random.randint' is the only reason this # package depends on python3-math at all. set_now(2024, 5, 15, 12, 0, 0) # every group starts from the reference instant _lo, _hi = 99, -1 for _i in range(200): _s = fresh() _j = _s.every(3).to(6).seconds.do(noop) _n = int(_j.period.total_seconds()) _lo = min(_lo, _n) _hi = max(_hi, _n) emit('to_min_seen', str(_lo)) emit('to_max_seen', str(_hi)) emit('to_within_bounds', 'yes' if _lo >= 3 and _hi <= 6 else 'no') # ------------------------------------------------------------- repeat() --- set_now(2024, 5, 15, 12, 0, 0) # every group starts from the reference instant schedule.clear() _rep = [] @schedule.repeat(schedule.every().minute, 'payload') def _repeated(what): _rep.append(what) emit('repeat_registered', str(len(schedule.jobs))) advance(minutes=2) schedule.run_pending() emit('repeat_fired', ','.join(_rep) or '') schedule.clear() # ------------------------------------------------------------- the strings --- # `__str__' and `__repr__' are the documented human-readable forms and neither # carries an address, so both are safe to pin. `__repr__' singularises the # unit when the interval is 1, which is the detail a format change would move. _s = fresh() set_now(2024, 5, 15, 12, 0, 0) _j = _s.every().day.at('18:00').do(noop) emit('str_form', str(_j)) emit('repr_form', repr(_j)) _s = fresh() _j2 = _s.every(3).hours.do(noop) emit('repr_plural', repr(_j2)) # ------------------------------------------------------------- the closure --- # NOTHING OUTSIDE THE STANDARD LIBRARY IS REACHED BY THE IMPORT. This image's # python3 is ONE STATIC BINARY with an empty lib-dynload and no dynamic loader # (BRINTOS-1476), so a release that grew a third-party dependency would fail at # `import' here rather than resolve it. # # Scored against `sys.stdlib_module_names' rather than against a literal list: # what the import delta CONTAINS depends on what the interpreter had already # loaded, so a literal here would red on target for a reason that has nothing # to do with schedule. _pulled = sorted(_AFTER_IMPORT - _BASELINE - {'schedule'}) _stdlib = getattr(sys, 'stdlib_module_names', None) if _stdlib is None: emit('pulled_nonstdlib', '') else: # `sys.stdlib_module_names' lists TOP-LEVEL names only, so each pulled # name is reduced to its root before it is looked up -- otherwise # `re._parser' and `collections.abc', which are stdlib, would be reported # as third-party and this case would red on a correct image. emit('pulled_nonstdlib', ','.join(n for n in _pulled if n.split('.')[0] not in _stdlib) or '') # The four stdlib modules the RDEPENDS is actually about, named positively. emit('needs_datetime', 'yes' if 'datetime' in sys.modules else 'no') emit('needs_logging', 'yes' if 'logging' in sys.modules else 'no') emit('needs_random', 'yes' if 'random' in sys.modules else 'no') emit('needs_re', 'yes' if 're' in sys.modules else 'no') # ...and WHY `python3-math' is in RDEPENDS, which is not what the name says. # schedule's own source does not import `math'. It imports `random', and # poky's python3-manifest.json puts `random.py' in the `math' SPLIT -- so the # dependency is spelled `python3-math' and is really about `random'. `math' # itself does turn up in the closure anyway, because `random' imports it; the # two cases below keep both halves of that legible, so a reader does not # conclude from the RDEPENDS line that schedule does arithmetic it does not do. emit('math_in_closure', 'yes' if 'math' in _pulled else 'no') # `random' does `from math import log as _log, ...', so it binds the NAMES and # never the module object -- which is why this is asserted through `_log' and # not by looking for `math' in random's namespace, where it is not. emit('random_uses_math_names', 'yes' if '_log' in getattr(sys.modules.get('random'), '__dict__', {}) else 'no') # pytz is NOT pulled by the import: the `import pytz' in `at()' is function # local, so it costs nothing until a caller passes `tz'. emit('pulled_pytz', 'yes' if 'pytz' in _AFTER_IMPORT else 'no') emit('probe_done', 'yes') emit('emitted_count', len(emitted) + 1)