. "$BST_LIB" # BST-TIMEOUT: 1200 # THREE python3 starts, and they are the whole cost. Every # # assertion below either reads the filesystem/dpkg with # # shell builtins or greps the output of one of those three. # # # # They are a bare `import', a second one whose STDERR is # # the assertion (`import-quiet'), and the probe -- which # # takes ONE start for a hundred and seventy-five # # measurements. The work inside the probe is integer and # # date arithmetic over a few hundred small objects, # # microseconds beside an interpreter start on this arm, # # which is tens of seconds before the first line of the # # program runs. # # # # 1200 is 400s per start, the same budget # # 99-python3-aniso8601 and 99-python3-parse set for their # # three, and roughly six times the ~60s a python3 start # # has measured at here. # # # # A suite-timeout red is a batch-wide cost, so if the # # three starts prove slower than that on target the RIGHT # # fix is this number, not fewer cases. # # # # NOT MEASURED ON TARGET YET: this port's first on-target # # run is the batch verify, and the measured elapsed # # replaces this note in the test plan rather than in this # # comment, which would cost an image build to keep the # # shipped suite byte-identical. # # python3-schedule 1.2.1 (meta-openembedded/meta-python # recipes-devtools/python/python3-schedule_1.2.1.bb, `inherit pypi # setuptools3', PyPI sdist, NO patches and NO bbappend -- the recipe is taken # exactly as meta-python ships it) on the wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE DOES. # ============================================================================ # # An in-process job scheduler with a builder-pattern API: # `every(10).minutes.do(job)'. It is NOT cron and it is not a daemon -- there # is no thread, no timer, no signal and no fork anywhere in it. A Job records # an interval, a unit and an optional time of day; the Scheduler keeps a list # of them and computes each one's `next_run'. Nothing happens until the caller # calls `run_pending()', which runs whatever is due and reschedules it. # # So the package is a DUE-TIME CALCULATOR plus a list. That is what makes it # testable here at all, and it is why this suite finishes in milliseconds of # actual work. # # ============================================================================ # THE CLOCK IS NOT AN INPUT, AND THE TICKET SAYS IT IS. # ============================================================================ # # 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 the one # thing in the ticket that reading the source contradicts, and it changes how # the suite has to be built. # # `Job._schedule_next_run' and `Job.should_run' call `datetime.datetime.now()' # directly. There is no `now' parameter, no injectable clock, no seam of any # kind short of the module global. The package cannot be told what time it is. # # The probe therefore does what upstream's own test_schedule.py does: # SUBCLASSES `datetime.datetime' and assigns the subclass over # `datetime.datetime'. `schedule' holds a reference to the datetime MODULE, so # the assignment is visible to it. The probe's subclass adds the one thing # upstream's lacks -- a mutable instant -- so `advance()' moves the scheduler's # "now" forward and `run_pending' can be driven past a due time. # # The ticket's REQUIREMENT is met: nothing here sleeps, nothing reads the real # clock, and the results do not depend on when the suite runs or on the # target's timezone. The ticket's DESCRIPTION of how is wrong, and this is the # note that says so, because a later reader who trusts it will go looking for # an injection point that does not exist. # # ONE CONSEQUENCE IS WORTH NAMING, because it looks like a bug and is not. # `Job.until()' dispatches on `isinstance(until_time, datetime.datetime)', and # under the patch that name IS the subclass -- so a genuine # `datetime.datetime' passed to `until()' takes the `else' arm and raises # TypeError. That is an artefact of patching the clock, not a property of the # package, and the probe builds its deadlines with the patched class for that # reason. # # ============================================================================ # THE REFERENCE INSTANT IS A WEDNESDAY, ON PURPOSE. # ============================================================================ # # Every case is computed from 2024-05-15 12:00:00, a Wednesday, and every # timing group resets to it. `_schedule_next_run' picks a named weekday with # `days_ahead = weekday - now.weekday()' and then `if days_ahead <= 0: # days_ahead += 7'. A Wednesday reference puts Thursday-to-Sunday in the next # four days and Monday-Tuesday in the next week, so BOTH arms of that branch # are exercised by the same instant -- and `every().wednesday' lands a FULL # WEEK OUT, because `<= 0' means "today" is never "in a moment". All seven # weekdays are asserted, each against a date written out in full and each # against the weekday NAME of the date it landed on, so a wrong answer says # which day it picked rather than only that a number moved. # # ============================================================================ # `at()' ROLLS BACKWARDS, WHICH IS NOT WHAT A READER EXPECTS. # ============================================================================ # # For a daily job the computation is `now + 1 day', then the clock fields are # REPLACED, and then -- if the interval is 1 and the replaced time is later in # the day than now -- ONE DAY IS SUBTRACTED AGAIN. The net effect is right and # the route is surprising: a time still ahead of us today runs TODAY, and a # time already past runs TOMORROW. # # At the 12:00 reference: `at("18:00")' is TODAY at 18:00 and `at("06:00")' is # TOMORROW at 06:00. Both arms are asserted, because a change that dropped the # subtraction would leave the second one right and the first one a day late. # # The subtraction is guarded by `self.interval == 1', so it does NOT apply to # a multi-day job: `every(2).days.at("06:00")' stays two days out even though # 06:00 is already past. That third case is what makes the guard visible. # # The hourly and minutely forms have their own arms of the same shape -- # `every().hour.at(":30")' is 12:00:30 (30 seconds ahead, no roll-back) while # `every().hour.at("10:00")' is 12:10:00 (rolled back an hour) -- and both are # asserted for the same reason. # # ============================================================================ # `24:00' PASSES THE REGEX AND IS REFUSED BY THE RANGE CHECK. # ============================================================================ # # The daily format guard is `^[0-2]\d:[0-5]\d(:[0-5]\d)?$', so the first digit # may be 2 and the SECOND may be anything -- `24:00', `25:00' and `29:59' all # match. They are then refused by an explicit `0 <= hour <= 23'. Two guards, # and the case is here because the regex alone would let the hour through; a # release that dropped the range check would not fail any format case. # # ============================================================================ # THE EXCEPTIONS ARE NOT BUILTIN ValueError, DESPITE THE NAME. # ============================================================================ # # `ScheduleValueError' derives from `ScheduleError', which derives from # `Exception' -- NOT from `ValueError'. So `except ValueError' does not catch # one, which is exactly the trap the name sets for a caller who never looked. # `IntervalError' is under `ScheduleValueError'. All three edges are asserted # directly, and the negative one -- that it is not a builtin ValueError -- is # the case worth having. # # `CancelJob' is a bare marker class and NOT an exception; a job unschedules # itself by RETURNING it, not by raising it. # # ============================================================================ # `python3-math' IS IN RDEPENDS FOR `random', NOT FOR `math'. # ============================================================================ # # The recipe declares python3-datetime, python3-logging and python3-math, and # the third one is the interesting one. schedule's source does not import # `math' at all. It imports `random' -- used by exactly one feature, the # randomized interval `every(3).to(6).seconds' -- 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', and # python3-crypt comes in behind it because the manifest gives `math' an # rdepends on `crypt' for `hashlib'. # # The suite pins both halves so the reason stays legible: `needs-random' says # what the import actually pulls, and `random-uses-math-names' says why `math' # is in the closure anyway (`random' does `from math import log as _log'). # Without those two cases a reader concludes from the RDEPENDS line that this # package does arithmetic it does not do. # # ============================================================================ # THE TIMEZONE ARGUMENT WORKS HERE, AND THIS RECIPE DID NOT ARRANGE IT. # ============================================================================ # # `at(tz=...)' does a FUNCTION-LOCAL `import pytz'. pytz is not in this # recipe's RDEPENDS and never appears in the import closure -- `pulled-pytz' # asserts that, and it is why the argument costs nothing until somebody uses # it. It resolves on this image only because ANOTHER port put python3-pytz on # `EMCRAFT_TOOLS'. # # So the tz path is satisfied by the image and not by the package, and on a # rootfs that took this .deb alone it would raise ModuleNotFoundError. # `pkg-depends-no-pytz' asserts the gap on the packaging side and # `at-tz-accepted' asserts that the image closes it. That pair is the finding; # either case alone reads as a fact about something else. # # The INSTANT a tz-bearing job lands on is the local rendering of a foreign # wall-clock time, so it depends on the guest's own timezone. The probe forces # TZ=UTC0 and reports 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, and the naive job at the same string # lands on 10:30. The PAIR is what shows a conversion happened at all. # # ============================================================================ # WHAT WAS MEASURED, AND WHERE. # ============================================================================ # # Every expected value in this file was produced by running this suite's own # probe against the exact sdist this recipe builds -- sha256 # 843bc0538b99c93f02b8b50e3e39886c06f2d003b24f48e1aa4cadfa3f341279, checked # against the recipe's SRC_URI -- on CPython 3.12.3. None of it was read off # the source and predicted. # # That is a HOST measurement. This port's first run on wasm32-hwjs is the # batch verify, and everything here is pure-Python integer and date # arithmetic with no syscall, no device and no libc surface in it, so the # expectation is that the arm makes no difference. Where it does, the red is # the finding. # # The suite reaches no device, no peer, no terminal and no network, and it # never sleeps. So this port closes on its suite, with no follow-on under # BRINTOS-780. P=python3-schedule # THE CANARY, which asserts nothing about python3-schedule. # # A suite's FIRST BST result line is lost between the guest's printf and the # host's read -- per-suite, deterministic, and NOT a defect in the host tool # (BRINTOS-1149 established that and filed it as linux-in-the-browser#177). The # harness computes its pass count as total-minus-failures, so THE DROPPED LINE # IS COUNTED AS A PASS. A nothing-case therefore stands in that position and the # eaten line costs nothing. It sits ahead of the `require' guards deliberately, # because a `require' that skips would emit its skip as the first line and be # eaten too. # # Remove this case when linux-in-the-browser#177 is fixed. bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKGDIR=$SITE/schedule DI=$SITE/schedule-1.2.1.dist-info PKGLIST=/var/lib/dpkg/info/python3-schedule.list PROBE=$BST_DATA/python3-schedule-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-schedule.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ------------------------------------------------------------- the payload --- # Where the package has to land. `import schedule' further down is the # functional statement; these are the ones that say WHICH copy answered, so a # package that imported out of some other path on sys.path is distinguishable # from one that imported out of what this image installed. check $P/site-packages-dir test -d "$SITE" # A DIRECTORY, NOT A MODULE. setup.py declares `packages=["schedule"]', so a # package tree lands in site-packages. This is the case that fails a release # which flattened to one module, loudly and here, rather than nowhere. check $P/package-dir test -d "$PKGDIR" check $P/no-module-py test ! -f "$SITE/schedule.py" check $P/init-file test -s "$PKGDIR/__init__.py" # THE WHOLE LIBRARY IS ONE FILE. `__init__.py' is not a re-export shim here -- # Scheduler, Job and every module-level shortcut are defined in it and there is # no second source file. Asserted so a release that split the module announces # itself. check_eq $P/one-source-file "1" \ "$(ls "$PKGDIR"/*.py 2>/dev/null | wc -l | tr -d ' ')" # `py.typed' -- setup.py ships it via package_data, so this package advertises # inline type information. A release that dropped it would silently turn off # type checking for every caller. check $P/py-typed test -f "$PKGDIR/py.typed" # The precompiled bytecode, which is what `import-quiet' below turns on. check $P/pycache test -s "$PKGDIR/__pycache__/__init__.cpython-312.pyc" # THE UPSTREAM TEST SUITE IS NOT INSTALLED. `test_schedule.py' sits at the TOP # LEVEL of the sdist, outside the package, and `packages=["schedule"]' does not # reach it -- so unlike python3-aniso8601, whose `find_packages()' dragged its # tests into the runtime package, nothing of it ships. Asserted positively # because it also means the upstream tests' own dependency on `mock', which is # NOT in this image, is not a dependency of this port. check $P/no-test-module test ! -f "$PKGDIR/test_schedule.py" check $P/no-tests-dir test ! -d "$PKGDIR/tests" check $P/no-toplevel-test test ! -f "$SITE/test_schedule.py" # NO SHIPPED SCRIPT. This package is a library and installs no executable; # there is no console_scripts entry point in setup.py. Asserted so a release # that grew one announces itself here. check $P/no-cli test ! -e /usr/bin/schedule # NO COMPILED EXTENSION ANYWHERE THE PACKAGE PUT ANYTHING. On this image a .so # could not be loaded at all -- python3 is ONE STATIC BINARY with an empty # lib-dynload and no dynamic loader (BRINTOS-1476) -- so this is the case that # fails a version bump which grew one, at install rather than at `import'. # # The list is copied to tmpfs first and never redirected from the rootfs: a # top-level stdin redirect on the virtiofs root makes dup2(2) return 513 and ash # EXITS, ending the suite silently (brintos/linux#73). cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" check_eq $P/pkg-no-so "" \ "$(grep -E '\.so($|\.)' "$W/pkgf" 2>/dev/null)" # EVERY REGULAR FILE THE PACKAGE SHIPS IS PYTHON, ITS METADATA, OR py.typed. # The case above names the failure that is expected; this one catches the shape # nobody predicted -- a binary, a .pyd, a stray data file -- without having to # enumerate what it might be. # # A dpkg .list names DIRECTORIES too, and without a trailing slash, so a # name-only filter would report every directory in the package as an unexpected # shape; each entry is therefore tested for being a regular file first. _bad="" while IFS= read -r _f; do [ -f "$_f" ] || continue case "$_f" in *.py|*.pyc) continue ;; */py.typed) continue ;; */__pycache__/*) continue ;; */*.dist-info/*) continue ;; esac _bad="$_bad $_f" done < "$W/pkgf" check_eq $P/pkg-only-python "" "$_bad" # ----------------------------------------------------------- the dist-info --- # The installed metadata. There is no `schedule.__version__' (see # `no-version-attr' below), so the dist-info is the ONLY place the version # appears at runtime -- which makes these cases the whole of the version # statement rather than half of it. check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -f "$DI/METADATA" check_out $P/dist-info-version "Version: 1.2.1" cat "$DI/METADATA" check_out $P/dist-info-name "Name: schedule" cat "$DI/METADATA" # The one-line description, which is upstream's own and is also the recipe's # SUMMARY. check_out $P/dist-info-summary "Job scheduling for humans." cat "$DI/METADATA" # top_level.txt names the ONE importable this package installs. It is what an # `import schedule' has to match, and a wheel that renamed the tree would # change it here first. check_out $P/dist-info-top-level "schedule" cat "$DI/top_level.txt" # PURE PYTHON, AS THE WHEEL ITSELF DECLARES IT. `Root-Is-Purelib: true' is the # packaging-side statement of the header's claim -- no platform tag, no # extension module, nothing that could need the absent dynamic loader. check_out $P/dist-info-purelib "Root-Is-Purelib: true" cat "$DI/WHEEL" # ------------------------------------------------------------- the package --- # dpkg KNOWS the package. Every case above would look identical if some other # package had installed the same files, so this is the one that says # python3-schedule is a real package this image installed rather than an # IMAGE_INSTALL line that resolved to nothing. # # The stanza is read from a tmpfs copy for the same reason as the file list, and # the anchor is exact -- an unanchored match would also take the -dev or -dbg # stanza of the same family. sed -n '/^Package: python3-schedule$/,/^$/p' /var/lib/dpkg/status > "$W/st" 2>/dev/null check $P/pkg-known test -s "$W/st" check_out $P/pkg-installed "Status: install ok installed" cat "$W/st" # THE RUNTIME DEPENDENCIES, as dpkg recorded them. The recipe's three are each # asserted by name, because a Depends line that lost one would still install on # THIS image -- all three are here for other reasons too -- and break on the # first rootfs that took the .deb alone. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/pkg-depends-line test -s "$W/dep" check_out $P/pkg-depends-core "python3-core" cat "$W/dep" check_out $P/pkg-depends-datetime "python3-datetime" cat "$W/dep" check_out $P/pkg-depends-logging "python3-logging" cat "$W/dep" # ...and python3-math, which is in RDEPENDS for `random' and not for `math' -- # see the header. This is the packaging-side half of `needs-random' below. check_out $P/pkg-depends-math "python3-math" cat "$W/dep" # THE PACKAGE DOES NOT DEPEND ON pytz, AND `at(tz=...)' NEEDS IT. See the # header: the tz path resolves on this image only because another port put # python3-pytz on the image. This case asserts the gap so it stays visible; # `at-tz-accepted' below asserts that the image happens to close it, and the # two together are the finding. check_eq $P/pkg-depends-no-pytz "" \ "$(grep -o 'python3-pytz' "$W/dep" 2>/dev/null)" # NO -ptest PACKAGE EXISTS FOR THIS RECIPE: it does not `inherit ptest', and # the upstream tests are not installed at all (see `no-test-module' above). The # case is here so a version bump that grows one is noticed. check_eq $P/no-ptest-package "" \ "$(sed -n '/^Package: python3-schedule-ptest$/p' /var/lib/dpkg/status 2>/dev/null)" # --------------------------------------------------------- the interpreter --- # THE IMPORT, ON ITS OWN, BEFORE THE PROBE. If schedule cannot be imported then # every probe case below reds at once and each names a different symptom; this # case is what says the cause is the import. It is also the cheapest possible # statement of the deliverable -- and, given the header, the one that would # have failed on an image without python3-math, because `random' is imported at # module scope and not lazily. check $P/import python3 -c 'import schedule' # ...AND THE IMPORT SAYS NOTHING WHILE DOING IT. This is a statement about the # shipped bytecode: the package ships precompiled .pyc files and OE writes them # with CHECKED-HASH invalidation, so each source is hashed and never # recompiled. A red here means a shipped .pyc was REJECTED -- a stale hash, a # python3 minor bump that changed the magic, or a missing __pycache__ -- and the # tree is being compiled from source on every import. # # schedule creates a logger at import (`logging.getLogger("schedule")') and # nothing else; getLogger does not configure a handler and does not print, so # a clean import is silent. A "No handlers could be found" line here would mean # something CALLED the logger during import, which nothing should. check_eq $P/import-quiet "" \ "$(python3 -c 'import schedule' 2>&1)" # --------------------------------------------------------------- the probe --- if [ ! -f "$PROBE" ]; then bst_notok $P/probe-present "$PROBE did not ship" rm -rf "$W" exit 0 fi bst_ok $P/probe-present # ONE interpreter start for everything that follows. bst_probe bounds it and # keeps BOTH streams: a probe that dies early otherwise leaves every case below # reporting got:'' and naming a symptom each, with the actual traceback nowhere. # The bound is generous because a python3 start on this arm is tens of seconds # before any work begins. bst_probe $P/probe-run "$W/probe" 900 python3 "$PROBE" OUT="$W/probe" # Read a single key. The probe emits each key at most once, so head -n 1 is # defensive rather than load-bearing; the anchors keep `next_day1' distinct # from `next_day2', and `at_tz_at_time' from `at_time_iso' -- the pairs where # one name is a prefix of, or a substring of, another. pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # THE EVIDENCE PLANE. If the probe emitted nothing, every case below is reading # an absent file: each would red with got:'' and the set would read exactly like # a broken library, when what actually happened is that no measurement was # taken. The word `unscorable' is PARSED -- brintos_local_verify re-measures a # suite that prints it on a fresh guest rather than trusting a shared boot's # numbers (BRINTOS-1644) -- so it goes in this structural check, not a comment. # # The cases below still print their reds: withdraw, do not hide. This case is # what says what those reds measure. if [ -s "$OUT" ]; then bst_ok $P/probe-output else bst_notok $P/probe-output "the probe emitted nothing -- python3 did not start, or died before its first line; every CASE below is unscorable" fi # ...and it ran to the END. Every key is emitted in file order, so a probe that # died two thirds of the way through would leave the earlier cases green and the # later ones reading got:'' -- which looks like a partial library rather than a # partial run. This is the case that tells those apart. check_eq $P/probe-complete "yes" "$(pv probe_done)" check_eq $P/probe-count "175" "$(pv emitted_count)" # The tree answered out of the files this image installed, not from a stray copy # on sys.path -- which for THIS probe is a live risk, because the probe sits in # a directory the interpreter puts FIRST on sys.path. A file named schedule.py # beside it would shadow the package entirely and every behavioural case below # would still pass, against the wrong code. check_eq $P/module-answered "$PKGDIR/__init__.py" "$(pv module_file)" check_eq $P/is-package "yes" "$(pv is_package)" # THE VERSION. There is NO `schedule.__version__' -- setup.py carries # SCHEDULE_VERSION and the package never re-exports it -- so a caller reading # the attribute gets an AttributeError, and the distribution metadata is the # only runtime source. Both halves asserted. check_eq $P/no-version-attr "no" "$(pv has_version_attr)" check_eq $P/dist-version "1.2.1" "$(pv dist_version)" # The reference instant the whole file is computed from, asserted so a red # below cannot be caused by the probe having silently used a different one. check_eq $P/reference-now "2024-05-15 12:00:00" "$(pv reference_now)" check_eq $P/reference-weekday "wednesday" "$(pv reference_weekday)" # ------------------------------------------------------------------ the API --- # THE NINE MODULE-LEVEL SHORTCUTS, each a thin call onto `default_scheduler'. # This is the surface `import schedule; schedule.every()...' actually uses. check_eq $P/api-every "yes" "$(pv has_func_every)" check_eq $P/api-run-pending "yes" "$(pv has_func_run_pending)" check_eq $P/api-run-all "yes" "$(pv has_func_run_all)" check_eq $P/api-get-jobs "yes" "$(pv has_func_get_jobs)" check_eq $P/api-clear "yes" "$(pv has_func_clear)" check_eq $P/api-cancel-job "yes" "$(pv has_func_cancel_job)" check_eq $P/api-next-run "yes" "$(pv has_func_next_run)" check_eq $P/api-idle-seconds "yes" "$(pv has_func_idle_seconds)" check_eq $P/api-repeat "yes" "$(pv has_func_repeat)" # THE SIX PUBLIC CLASSES. check_eq $P/api-class-job "yes" "$(pv has_class_Job)" check_eq $P/api-class-scheduler "yes" "$(pv has_class_Scheduler)" check_eq $P/api-class-canceljob "yes" "$(pv has_class_CancelJob)" check_eq $P/api-class-error "yes" "$(pv has_class_ScheduleError)" check_eq $P/api-class-valueerror "yes" "$(pv has_class_ScheduleValueError)" check_eq $P/api-class-intervalerror "yes" "$(pv has_class_IntervalError)" check_eq $P/api-default-scheduler "yes" "$(pv has_default_scheduler)" # THERE IS NO `__all__'. `from schedule import *' therefore takes the module's # other globals with it, which is worth pinning: a release that ADDED one would # narrow a caller's star-import without warning. check_eq $P/api-no-all "no" "$(pv has_all_attr)" # The logger is named for the package, which is what a caller configures. check_eq $P/logger-name "schedule" "$(pv logger_name)" # `schedule.jobs' IS THE DEFAULT SCHEDULER'S OWN LIST, not a copy and not a # property -- upstream's source carries a `todo' asking whether it should be. # So a caller who holds it sees the scheduler's mutations, and a caller who # mutates it corrupts the scheduler. Pinned because the `todo' says this may # change, and a change would break both readings silently. check_eq $P/jobs-aliases "yes" "$(pv module_jobs_aliases)" check_eq $P/jobs-same-object "yes" "$(pv module_jobs_is_same_object)" check_eq $P/jobs-after-clear "0" "$(pv module_jobs_after_clear)" # ------------------------------------------------------- the exception tree --- # THE SHAPE, ASSERTED DIRECTLY -- see the header. The negative case is the one # worth having: `ScheduleValueError' is NOT a builtin ValueError, so `except # ValueError' does not catch one. check_eq $P/exc-interval-under-value "yes" "$(pv interval_under_valueerror)" check_eq $P/exc-value-under-schedule "yes" "$(pv valueerror_under_scheduleerror)" check_eq $P/exc-schedule-base "Exception" "$(pv scheduleerror_base)" check_eq $P/exc-not-builtin-valueerror "no" "$(pv valueerror_is_builtin_valueerror)" # `CancelJob' is a MARKER, not an exception: a job unschedules itself by # RETURNING it. check_eq $P/canceljob-not-exception "no" "$(pv canceljob_is_exception)" # --------------------------------------------------------- the unit ladder --- # next_run is `now + interval * unit', computed at `do()' time. One group per # documented unit, each against an instant written out in full and each against # the `period' in seconds -- so a unit that silently changed meaning is named # by the period rather than only implied by the date. check_eq $P/unit-second-next "2024-05-15 12:00:01" "$(pv next_sec1)" check_eq $P/unit-second-name "seconds" "$(pv unit_sec1)" check_eq $P/unit-second-period "1" "$(pv period_sec1)" check_eq $P/unit-seconds10-next "2024-05-15 12:00:10" "$(pv next_sec10)" check_eq $P/unit-seconds10-period "10" "$(pv period_sec10)" check_eq $P/unit-minute-next "2024-05-15 12:01:00" "$(pv next_min1)" check_eq $P/unit-minute-name "minutes" "$(pv unit_min1)" check_eq $P/unit-minute-period "60" "$(pv period_min1)" check_eq $P/unit-minutes10-next "2024-05-15 12:10:00" "$(pv next_min10)" check_eq $P/unit-minutes10-period "600" "$(pv period_min10)" check_eq $P/unit-hour-next "2024-05-15 13:00:00" "$(pv next_hour1)" check_eq $P/unit-hour-name "hours" "$(pv unit_hour1)" check_eq $P/unit-hour-period "3600" "$(pv period_hour1)" check_eq $P/unit-hours3-next "2024-05-15 15:00:00" "$(pv next_hour3)" check_eq $P/unit-hours3-period "10800" "$(pv period_hour3)" check_eq $P/unit-day-next "2024-05-16 12:00:00" "$(pv next_day1)" check_eq $P/unit-day-name "days" "$(pv unit_day1)" check_eq $P/unit-day-period "86400" "$(pv period_day1)" check_eq $P/unit-days2-next "2024-05-17 12:00:00" "$(pv next_day2)" check_eq $P/unit-days2-period "172800" "$(pv period_day2)" check_eq $P/unit-week-next "2024-05-22 12:00:00" "$(pv next_week1)" check_eq $P/unit-week-name "weeks" "$(pv unit_week1)" check_eq $P/unit-week-period "604800" "$(pv period_week1)" check_eq $P/unit-weeks2-next "2024-05-29 12:00:00" "$(pv next_week2)" check_eq $P/unit-weeks2-period "1209600" "$(pv period_week2)" # THE SINGULAR PROPERTIES ARE THE PLURAL ONES PLUS A GUARD, and the guard is # the only difference: `every(2).second' raises rather than quietly meaning # `seconds'. All five, because the guard is copy-pasted five times and a # release that dropped one would be caught nowhere else. check_eq $P/err-plural-second "IntervalError" "$(pv err_plural_second)" check_eq $P/err-plural-minute "IntervalError" "$(pv err_plural_minute)" check_eq $P/err-plural-hour "IntervalError" "$(pv err_plural_hour)" check_eq $P/err-plural-day "IntervalError" "$(pv err_plural_day)" check_eq $P/err-plural-week "IntervalError" "$(pv err_plural_week)" # --------------------------------------------------------- named weekdays --- # From a WEDNESDAY reference -- see the header. Thursday through Sunday land in # the next four days; Monday and Tuesday go into next week; and WEDNESDAY # ITSELF goes a full week out, because `days_ahead <= 0' adds seven. Each is # asserted against the date AND against the weekday name of the date it landed # on, so a wrong answer says which day it picked. check_eq $P/weekday-monday "2024-05-20 12:00:00" "$(pv weekday_monday)" check_eq $P/weekday-monday-name "monday" "$(pv weekday_monday_name)" check_eq $P/weekday-tuesday "2024-05-21 12:00:00" "$(pv weekday_tuesday)" check_eq $P/weekday-tuesday-name "tuesday" "$(pv weekday_tuesday_name)" # The reference day itself: a WEEK out, not today and not in a moment. check_eq $P/weekday-wednesday "2024-05-22 12:00:00" "$(pv weekday_wednesday)" check_eq $P/weekday-wednesday-name "wednesday" "$(pv weekday_wednesday_name)" check_eq $P/weekday-thursday "2024-05-16 12:00:00" "$(pv weekday_thursday)" check_eq $P/weekday-thursday-name "thursday" "$(pv weekday_thursday_name)" check_eq $P/weekday-friday "2024-05-17 12:00:00" "$(pv weekday_friday)" check_eq $P/weekday-friday-name "friday" "$(pv weekday_friday_name)" check_eq $P/weekday-saturday "2024-05-18 12:00:00" "$(pv weekday_saturday)" check_eq $P/weekday-saturday-name "saturday" "$(pv weekday_saturday_name)" check_eq $P/weekday-sunday "2024-05-19 12:00:00" "$(pv weekday_sunday)" check_eq $P/weekday-sunday-name "sunday" "$(pv weekday_sunday_name)" # A NAMED WEEKDAY IS A WEEKLY JOB. The property sets `start_day' and returns # `self.weeks', so the unit is `weeks' however the caller spelled it -- which # is what makes `every().monday' and `every().week' the same ladder. check_eq $P/weekday-startday "monday" "$(pv weekday_monday_startday)" check_eq $P/weekday-unit "weeks" "$(pv weekday_monday_unit)" # ...and a named weekday on a MULTI-week interval is refused outright. check_eq $P/err-weekday-multiweek "IntervalError" "$(pv err_weekday_multiweek)" # ------------------------------------------------------------------- at() --- # THE ROLL-BACK, BOTH ARMS -- see the header. A time still ahead of us today # runs TODAY; a time already past runs TOMORROW. check_eq $P/at-day-future "2024-05-15 18:00:00" "$(pv at_day_future)" check_eq $P/at-day-past "2024-05-16 06:00:00" "$(pv at_day_past)" # The `HH:MM:SS' form, which the ticket asks for by name. check_eq $P/at-day-seconds "2024-05-15 18:30:45" "$(pv at_day_seconds)" # The seconds default to 0 when the string does not carry them, and # `microsecond' is replaced with 0 unconditionally -- so a job's instant is # always whole seconds and never inherits a fraction from `now'. check_eq $P/at-time-iso "18:00:00" "$(pv at_time_iso)" check_eq $P/at-day-micro "0" "$(pv at_day_micro)" # THE GUARD ON THE ROLL-BACK IS `interval == 1', so a multi-day job does NOT # take it: `every(2).days.at("06:00")' stays two days out even though 06:00 has # already passed today. This is the case that makes the guard visible. check_eq $P/at-days2-past "2024-05-17 06:00:00" "$(pv at_day2_past)" # THE HOURLY FORMS. The hour is forced to 0 and only minute and second are # replaced. `:30' is 30 seconds past the minute -- still ahead within this hour, # so no roll-back -- while `10:00' is ten minutes past and rolls back an hour. check_eq $P/at-hour-ss "2024-05-15 12:30:00" "$(pv at_hour_ss)" check_eq $P/at-hour-mmss "2024-05-15 12:10:00" "$(pv at_hour_mmss)" # THE MINUTELY FORM takes `:SS' only. check_eq $P/at-minute-ss "2024-05-15 12:00:30" "$(pv at_minute_ss)" # A NAMED WEEKDAY PLUS A TIME: the Friday two days out, at the stated time. check_eq $P/at-weekday "2024-05-17 09:15:00" "$(pv at_weekday)" # THE FORMAT GUARDS, each by the exception it raises. Every one of these is a # ScheduleValueError -- see the header, they are NOT builtin ValueErrors -- and # each unit refuses the OTHER units' spellings, which is the whole point of # having three regexes rather than one. check_eq $P/err-at-day-bad-format "ScheduleValueError" "$(pv err_at_day_bad_format)" check_eq $P/err-at-day-bad-hour "ScheduleValueError" "$(pv err_at_day_bad_hour)" check_eq $P/err-at-hour-hhmmss "ScheduleValueError" "$(pv err_at_hour_hhmmss)" check_eq $P/err-at-minute-mmss "ScheduleValueError" "$(pv err_at_minute_mmss)" # `at()' on a unit that has no time-of-day meaning at all. check_eq $P/err-at-on-seconds "ScheduleValueError" "$(pv err_at_on_seconds)" check_eq $P/err-at-on-weeks "ScheduleValueError" "$(pv err_at_on_weeks)" # A NON-STRING IS A TypeError, not a ScheduleValueError -- a different branch # and a different class, so the two are asserted separately. check_eq $P/err-at-not-string "TypeError" "$(pv err_at_not_string)" # `24:00' PASSES THE REGEX and is stopped by the range check -- see the header. check_eq $P/err-at-hour-24 "ScheduleValueError" "$(pv err_at_hour_24)" # ---------------------------------------------------------------- the tz --- # See the header: pytz is NOT this recipe's dependency, and the tz path works # here only because another port put python3-pytz on the image. check_eq $P/pytz-importable "yes" "$(pv pytz_importable)" check_eq $P/pytz-zone-ok "yes" "$(pv pytz_zone_ok)" # ...so `at(tz=...)' is accepted rather than raising. On a rootfs that took # this .deb alone this case would read `ModuleNotFoundError', and that is the # point of asserting it beside `pkg-depends-no-pytz' above. check_eq $P/at-tz-accepted "" "$(pv err_at_tz)" check_eq $P/at-tz-zone "Europe/Berlin" "$(pv at_tz_zone)" check_eq $P/at-tz-at-time "10:30:00" "$(pv at_tz_at_time)" # THE CONVERSION, MADE TIMEZONE-INDEPENDENT. The probe forces TZ=UTC0 before # measuring, and these two cases say whether the force took: a guest whose # `tzset' did nothing would report a non-zero offset and the pair below would # red for THAT reason rather than for a schedule defect. check_eq $P/tz-tzset-available "yes" "$(pv tzset_available)" check_eq $P/tz-local-offset "0" "$(pv local_utcoffset_seconds)" # In May, Berlin is CEST (+02:00), so 10:30 there is 08:30 UTC; the naive job # at the same string lands on 10:30. Both roll to tomorrow, and the PAIR is # what shows a conversion happened at all -- either instant alone is just a # number. check_eq $P/at-tz-utc-next "2024-05-16 08:30:00" "$(pv at_tz_utc_next)" check_eq $P/at-naive-utc-next "2024-05-16 10:30:00" "$(pv at_naive_utc_next)" # ------------------------------------------------------- the refusals --- # A job with no unit: `_schedule_next_run' refuses `None'. check_eq $P/err-no-unit "ScheduleValueError" "$(pv err_no_unit)" # A Job built with no scheduler cannot register itself -- and this is a bare # `ScheduleError', not the value subclass. check_eq $P/err-no-scheduler "ScheduleError" "$(pv err_no_scheduler)" # `to()' below the interval is also a bare `ScheduleError'. Both of these are # asserted as the BASE class deliberately: a release that tightened them to # `ScheduleValueError' would be a compatible-looking change that broke an # `except ScheduleError' that had been catching only these. check_eq $P/err-to-below-interval "ScheduleError" "$(pv err_to_below_interval)" # Tags must be hashable, and that guard is a builtin TypeError. check_eq $P/err-unhashable-tag "TypeError" "$(pv err_unhashable_tag)" # ------------------------------------------------------------ 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. Nothing in this group waits, and none of it reads the real clock. check_eq $P/run-initial-next "2024-05-15 12:01:00" "$(pv run_initial_next)" # `idle_seconds' is the gap to the soonest job, which is the one-minute one. check_eq $P/run-idle-seconds "60" "$(pv run_idle_seconds)" # Nothing is due at the reference instant. check_eq $P/run-nothing-at-t0 "" "$(pv run_fired_at_t0)" check_eq $P/run-should-run-at-t0 "no" "$(pv run_should_run_t0)" # `should_run' is `now >= next_run', so THE BOUNDARY INSTANT ITSELF FIRES. # Asserted because `>' rather than `>=' would delay every job by one tick and # nothing else in this suite would notice. check_eq $P/run-should-run-at-t1 "yes" "$(pv run_should_run_t1)" check_eq $P/run-fired-at-t1 "a" "$(pv run_fired_at_t1)" # ...and the job rescheduled itself from the run, with `last_run' recorded. check_eq $P/run-next-after-t1 "2024-05-15 12:02:00" "$(pv run_next_after_t1)" check_eq $P/run-last-after-t1 "2024-05-15 12:01:00" "$(pv run_last_after_t1)" # AT +5 MINUTES THE ONE-MINUTE JOB FIRES ONCE, NOT FOUR TIMES. A missed run is # not made up -- upstream documents that on `run_pending' -- so a scheduler # driven coarsely loses runs rather than bursting. This is the case that says # so, and it is the behaviour a caller is most likely to assume the other way. check_eq $P/run-fired-at-t5 "a,b" "$(pv run_fired_at_t5)" # ...and the order is the SCHEDULE's: `run_pending' sorts runnable jobs by # `next_run' before running them. check_eq $P/run-fired-at-t10 "a,b,c" "$(pv run_fired_at_t10)" check_eq $P/run-jobs-still "3" "$(pv run_jobs_still)" # `run_all' fires everything whether or not it is due. check_eq $P/run-all-fired "a,b,c" "$(pv run_all_fired)" # --------------------------------------------------------- tags and clear --- check_eq $P/tag-count-all "3" "$(pv tag_count_all)" check_eq $P/tag-count-shared "2" "$(pv tag_count_shared)" check_eq $P/tag-count-alpha "1" "$(pv tag_count_alpha)" # An unknown tag is an empty list, not an error. check_eq $P/tag-count-missing "0" "$(pv tag_count_missing)" # Tags are a SET: duplicates are discarded and order is not preserved, so this # is asserted sorted. check_eq $P/tag-set "alpha,shared" "$(pv tag_set_a)" check_eq $P/cancel-count "2" "$(pv cancel_count)" # 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. A caller # cancelling defensively therefore cannot be told whether anything happened. check_eq $P/cancel-twice-quiet "" "$(pv cancel_twice)" check_eq $P/cancel-twice-count "2" "$(pv cancel_twice_count)" # Tag-filtered clearing removes both `shared' jobs and leaves the third. check_eq $P/clear-tag-count "1" "$(pv clear_tag_count)" check_eq $P/clear-all-count "0" "$(pv clear_all_count)" # An empty scheduler reports no next run and no idle time -- `None' for both, # not zero and not an exception. check_eq $P/next-run-when-empty "" "$(pv next_run_when_empty)" check_eq $P/idle-when-empty "" "$(pv idle_when_empty)" # A JOB MAY UNSCHEDULE ITSELF by RETURNING `CancelJob' -- see # `canceljob-not-exception' above; it is returned, never raised. check_eq $P/selfcancel-jobs-left "0" "$(pv selfcancel_jobs_left)" # ---------------------------------------------------------------- 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. check_eq $P/until-deadline "2024-05-15 12:03:00" "$(pv until_deadline)" check_eq $P/until-alive-at-t1 "1" "$(pv until_alive_at_t1)" check_eq $P/until-gone-at-t6 "0" "$(pv until_gone_at_t6)" # A deadline already in the past is refused outright rather than producing a # job that dies on its first run. check_eq $P/err-until-past "ScheduleValueError" "$(pv err_until_past)" check_eq $P/err-until-type "TypeError" "$(pv err_until_type)" # ------------------------------------------------------------------- to() --- # THE RANDOMIZED INTERVAL, INCLUSIVE AT BOTH ENDS. Asserting one draw would be # a statement about the seed, so the probe takes 200 draws and reports the # observed range. `every(3).to(6)' must reach both 3 and 6 and never leave # [3,6]; 200 draws miss an endpoint with probability (3/4)^200, which is far # below anything that could flake here. # # This is also the ONLY feature in the package that uses `random', and # therefore the only reason python3-math is in RDEPENDS at all -- see the # header. check_eq $P/to-min-seen "3" "$(pv to_min_seen)" check_eq $P/to-max-seen "6" "$(pv to_max_seen)" check_eq $P/to-within-bounds "yes" "$(pv to_within_bounds)" # ---------------------------------------------------------------- repeat() --- # The decorator form, which registers on the DEFAULT scheduler and passes its # extra arguments through to the decorated function. check_eq $P/repeat-registered "1" "$(pv repeat_registered)" check_eq $P/repeat-fired "payload" "$(pv repeat_fired)" # ---------------------------------------------------------------- the reprs --- # `__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 (`Every 1 day', `Every 3 hours'), which is the detail # a format change would move first -- and it is what a caller logging a job # will have in their log. check_eq $P/str-form "Job(interval=1, unit=days, do=noop, args=(), kwargs={})" \ "$(pv str_form)" check_eq $P/repr-singular \ "Every 1 day at 18:00:00 do noop() (last run: [never], next run: 2024-05-15 18:00:00)" \ "$(pv repr_form)" check_eq $P/repr-plural \ "Every 3 hours do noop() (last run: [never], next run: 2024-05-15 15:00:00)" \ "$(pv repr_plural)" # ------------------------------------------------------------- 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. check_eq $P/pulled-nonstdlib "" "$(pv pulled_nonstdlib)" # The four stdlib modules the RDEPENDS is actually about, named positively. check_eq $P/needs-datetime "yes" "$(pv needs_datetime)" check_eq $P/needs-logging "yes" "$(pv needs_logging)" check_eq $P/needs-random "yes" "$(pv needs_random)" check_eq $P/needs-re "yes" "$(pv needs_re)" # WHY `python3-math' IS IN RDEPENDS, kept legible -- see the header. `math' is # in the closure because `random' pulls it, NOT because schedule imports it; # and `random' does `from math import log as _log, ...', binding the names and # never the module, which is why the second case looks for `_log'. check_eq $P/math-in-closure "yes" "$(pv math_in_closure)" check_eq $P/random-uses-math-names "yes" "$(pv random_uses_math_names)" # pytz is NOT pulled by the import: the `import pytz' in `at()' is function # local, so it costs nothing until a caller passes `tz'. This is what makes the # missing RDEPENDS harmless for every caller who does not use timezones. check_eq $P/pulled-pytz "no" "$(pv pulled_pytz)" rm -rf "$W"