# BRINTOS-2350 -- python3-apscheduler 3.10.4 on the wasm32-hwjs glibc image. # # ONE interpreter start for every measurement below. A python3 start on this arm # is tens of seconds before the first line of the program runs, so the suite # takes four of them in total and this probe is the one that does the work. # # THE CLOCK IS AN INPUT, NEVER AN AMBIENT FACT. Every instant scored here is one # this file supplies. Nothing calls datetime.now(), time.time() or # get_localzone() for a VALUE it scores -- so the transcript does not move with # when the suite runs, with the target's local zone, or with any time source off # the machine. The triggers make that easy: CronTrigger, IntervalTrigger and # DateTrigger each answer get_next_fire_time(previous, now) as a pure function of # the two instants handed in. # # NOTHING HERE STARTS A THREAD, AND THAT IS DELIBERATE. pthread_create does not # produce a running thread on this arm (linux-in-the-browser#85), so the # scheduler is driven through BaseScheduler's own documented extension point -- # a subclass supplying wakeup() -- over a DebugExecutor, which runs a job # synchronously in the calling thread. The threaded path is measured by the # suite in a SEPARATE python3 start, so that a start() which hangs rather than # raising costs one case instead of this whole transcript. # # Output is `key=value', one per line, read back by the .test through `pv'. # Every value is a flat string: the runner parses line by line, so an embedded # newline would land as uncounted stray lines. import sys _emitted = 0 def emit(k, v): global _emitted s = str(v).replace("\n", " ").replace("\r", " ") sys.stdout.write("%s=%s\n" % (k, s)) sys.stdout.flush() _emitted += 1 def attempt(k, fn): """Emit fn()'s value, or the EXCEPTION TYPE NAME if it raised. The type is what a caller's `except' clause names; the message is English prose a release may reword. Nothing here scores a message.""" try: emit(k, fn()) except Exception as e: emit(k, type(e).__name__) # What the import reaches, measured across the import rather than after it -- # see `pulled_nonstdlib' at the foot of the file. _before = set(sys.modules) import apscheduler from apscheduler.schedulers.base import BaseScheduler, STATE_STOPPED, STATE_RUNNING from apscheduler.executors.debug import DebugExecutor from apscheduler.jobstores.memory import MemoryJobStore from apscheduler.jobstores.base import ConflictingIdError, JobLookupError from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.date import DateTrigger _after = set(sys.modules) from datetime import datetime, timedelta import pytz UTC = pytz.utc NY = pytz.timezone("America/New_York") HEL = pytz.timezone("Europe/Helsinki") def u(*a): """A UTC instant, supplied rather than read.""" return UTC.localize(datetime(*a)) def ny(*a): return NY.localize(datetime(*a)) def nxt(trig, previous, now): """get_next_fire_time as a flat string, or `None'.""" r = trig.get_next_fire_time(previous, now) return "None" if r is None else r.strftime("%Y-%m-%d %H:%M:%S %Z%z") # ---------------------------------------------------------------- identity --- emit("module_file", getattr(apscheduler, "__file__", "")) emit("is_package", "yes" if hasattr(apscheduler, "__path__") else "no") emit("version_attr", getattr(apscheduler, "__version__", "")) attempt("version_info", lambda: ".".join(str(x) for x in apscheduler.version_info[:3])) try: from importlib.metadata import version as _mv emit("dist_version", _mv("APScheduler")) except Exception as e: emit("dist_version", type(e).__name__) # `apscheduler/__init__.py' reads its version from importlib.metadata and falls # back to the literal '3.5.0' on PackageNotFoundError. So a version_attr of # 3.5.0 does not mean an old package -- it means the .dist-info did not ship, # which is the same absence that would empty the alias tables below. emit("version_is_fallback", "yes" if getattr(apscheduler, "__version__", "") == "3.5.0" else "no") # ------------------------------------------------------------------- API ---- for _n in ("schedulers", "triggers", "jobstores", "executors", "job", "util", "events"): try: __import__("apscheduler." + _n) emit("has_" + _n, "yes") except Exception as e: emit("has_" + _n, type(e).__name__) # ------------------------------------------------------------- alias tables -- # BaseScheduler resolves the `trigger=', `executor=' and jobstore aliases through # importlib.metadata AT CLASS-DEFINITION TIME, i.e. during the import above. The # table is therefore evidence that this package's own .dist-info shipped and is # readable; an empty one is what a missing entry_points.txt looks like from the # inside, and it would turn every string alias into a KeyError at add_job(). emit("trigger_aliases", ",".join(sorted(BaseScheduler._trigger_plugins))) emit("executor_aliases", ",".join(sorted(BaseScheduler._executor_plugins))) emit("jobstore_aliases", ",".join(sorted(BaseScheduler._jobstore_plugins))) # ------------------------------------------------------- the port's scoping -- # THIS PORT IS SCOPED AT THE IN-MEMORY SCHEDULER. The alias tables above NAME # every optional backend upstream ships an entry point for; none of the # distributions behind them is on this image, and none is needed. The two facts # are separate and both are asserted: the alias is LISTED (it comes from # apscheduler's own metadata) and LOADING it fails on the missing third-party # import. A future image that grew one of these would flip the second without # touching the first, which is the signal worth having. for _n in ("sqlalchemy", "pymongo", "redis", "rethinkdb", "twisted", "tornado", "gevent", "kazoo"): try: __import__(_n) emit("opt_" + _n, "present") except ImportError: emit("opt_" + _n, "absent") except Exception as e: emit("opt_" + _n, type(e).__name__) for _alias in ("sqlalchemy", "mongodb", "redis"): attempt("jobstore_load_" + _alias, lambda a=_alias: BaseScheduler._jobstore_plugins[a].load().__name__) for _alias in ("twisted", "tornado", "gevent"): attempt("executor_load_" + _alias, lambda a=_alias: BaseScheduler._executor_plugins[a].load().__name__) # What the port DOES ship, loaded through the same table rather than imported # directly -- so the entry point and the module are asserted together. for _alias in ("memory",): attempt("jobstore_load_" + _alias, lambda a=_alias: BaseScheduler._jobstore_plugins[a].load().__name__) for _alias in ("debug", "threadpool", "processpool", "asyncio"): attempt("executor_load_" + _alias, lambda a=_alias: BaseScheduler._executor_plugins[a].load().__name__) for _alias in ("date", "interval", "cron", "and", "or"): attempt("trigger_load_" + _alias, lambda a=_alias: BaseScheduler._trigger_plugins[a].load().__name__) # ============================================================ cron trigger === # Every case supplies both instants, and `previous' IS READ -- which is the # opposite of what the name "cron" suggests and is measured rather than assumed. # CronTrigger starts its search at `min(now, previous + 1us)', so handing it a # previous fire time in the past answers from THERE and returns an instant # BEFORE `now'. That is the scheduler's misfire path, not a bug, and # `cron_uses_previous' is the case that pins it. _c = lambda **kw: CronTrigger(timezone=UTC, **kw) attempt("cron_minute_top", lambda: nxt(_c(minute=0), None, u(2024, 3, 15, 13, 45, 30))) attempt("cron_on_the_boundary", lambda: nxt(_c(minute=0), None, u(2024, 3, 15, 14, 0, 0))) attempt("cron_hour_minute", lambda: nxt(_c(hour=3, minute=30), None, u(2024, 3, 15, 13, 45, 30))) attempt("cron_uses_previous", lambda: nxt(_c(hour=3, minute=30), u(2024, 1, 1), u(2024, 3, 15, 13, 45, 30))) # RANGES. `9-17' is inclusive at both ends, so 17:00 fires and 18:00 rolls over. attempt("cron_range_before", lambda: nxt(_c(hour="9-17", minute=0), None, u(2024, 3, 15, 8, 0, 0))) attempt("cron_range_inside", lambda: nxt(_c(hour="9-17", minute=0), None, u(2024, 3, 15, 12, 30, 0))) attempt("cron_range_last", lambda: nxt(_c(hour="9-17", minute=0), None, u(2024, 3, 15, 16, 30, 0))) attempt("cron_range_after", lambda: nxt(_c(hour="9-17", minute=0), None, u(2024, 3, 15, 18, 0, 0))) # STEPS. `*/15' is anchored at 0, not at `now'. attempt("cron_step_quarter", lambda: nxt(_c(minute="*/15"), None, u(2024, 3, 15, 13, 44, 0))) attempt("cron_step_wraps", lambda: nxt(_c(minute="*/15"), None, u(2024, 3, 15, 13, 46, 0))) attempt("cron_step_in_range", lambda: nxt(_c(hour="8-18/5", minute=0), None, u(2024, 3, 15, 9, 0, 0))) # LISTS, and a list of ranges. attempt("cron_list", lambda: nxt(_c(minute="0,30"), None, u(2024, 3, 15, 13, 45, 0))) attempt("cron_list_wraps", lambda: nxt(_c(minute="0,30"), None, u(2024, 3, 15, 13, 31, 0))) attempt("cron_list_of_ranges", lambda: nxt(_c(hour="0-2,20-23", minute=0), None, u(2024, 3, 15, 12, 0, 0))) # DAY OF WEEK, by name and by range. 2024-03-15 is a Friday. attempt("cron_dow_name", lambda: nxt(_c(day_of_week="mon", hour=0, minute=0), None, u(2024, 3, 15, 12, 0, 0))) attempt("cron_dow_range", lambda: nxt(_c(day_of_week="mon-fri", hour=6, minute=0), None, u(2024, 3, 15, 12, 0, 0))) attempt("cron_dow_number", lambda: nxt(_c(day_of_week="0", hour=0, minute=0), None, u(2024, 3, 15, 12, 0, 0))) # THE LAST-DAY-OF-MONTH FORM, which is why `day' is not merely a number. `last' # is the month's own length -- 29 in February 2024 and 28 in February 2023 -- # and `last fri' is the last Friday, not the 5th. attempt("cron_last_dom_leap", lambda: nxt(_c(day="last", hour=0, minute=0), None, u(2024, 2, 1, 0, 0, 0))) attempt("cron_last_dom_nonleap", lambda: nxt(_c(day="last", hour=0, minute=0), None, u(2023, 2, 1, 0, 0, 0))) attempt("cron_last_dom_30", lambda: nxt(_c(day="last", hour=0, minute=0), None, u(2024, 4, 2, 0, 0, 0))) attempt("cron_last_friday", lambda: nxt(_c(day="last fri", hour=0, minute=0), None, u(2024, 3, 1, 0, 0, 0))) attempt("cron_3rd_friday", lambda: nxt(_c(day="3rd fri", hour=0, minute=0), None, u(2024, 3, 1, 0, 0, 0))) # MONTH AND WEEK fields, and the ISO week numbering `week' uses. attempt("cron_month_step", lambda: nxt(_c(month="*/3", day=1, hour=0, minute=0), None, u(2024, 2, 1, 0, 0, 0))) attempt("cron_week", lambda: nxt(_c(week=10, day_of_week="mon", hour=0, minute=0), None, u(2024, 1, 1, 0, 0, 0))) attempt("cron_year", lambda: nxt(_c(year=2025, month=1, day=1, hour=0, minute=0), None, u(2024, 3, 15, 0, 0, 0))) # BOUNDS. An end_date the next fire would pass is `None', not an exception. attempt("cron_end_date", lambda: nxt( CronTrigger(timezone=UTC, hour=0, minute=0, end_date=u(2024, 3, 16, 12, 0, 0)), None, u(2024, 3, 16, 13, 0, 0))) attempt("cron_start_date", lambda: nxt( CronTrigger(timezone=UTC, minute=0, start_date=u(2024, 6, 1, 0, 0, 0)), None, u(2024, 3, 15, 13, 45, 0))) # The string form and the keyword form must agree. attempt("cron_from_crontab", lambda: nxt( CronTrigger.from_crontab("0 9 * * mon-fri", timezone=UTC), None, u(2024, 3, 15, 12, 0, 0))) attempt("cron_repr", lambda: str(_c(hour=3, minute=30))) # A field value outside its range is refused at CONSTRUCTION, not at fire time. attempt("cron_bad_field", lambda: nxt(_c(minute=61), None, u(2024, 1, 1))) attempt("cron_bad_expr", lambda: nxt(_c(day_of_week="funday"), None, u(2024, 1, 1))) # ======================================================== interval trigger === _i_start = u(2024, 3, 15, 12, 0, 0) attempt("iv_first_is_start", lambda: nxt( IntervalTrigger(seconds=30, start_date=_i_start, timezone=UTC), None, u(2024, 3, 15, 11, 59, 0))) attempt("iv_from_previous", lambda: nxt( IntervalTrigger(seconds=30, start_date=_i_start, timezone=UTC), u(2024, 3, 15, 12, 0, 0), u(2024, 3, 15, 12, 0, 1))) # `now' RUNNING AHEAD of previous + interval does NOT make the trigger skip: # with a previous fire time it adds exactly one interval to it and answers an # instant four minutes in the past. Catching up is `coalesce', a JOB option, and # not something the trigger does -- which is why a stalled scheduler replays # rather than jumps. attempt("iv_no_catchup", lambda: nxt( IntervalTrigger(seconds=30, start_date=_i_start, timezone=UTC), u(2024, 3, 15, 12, 0, 0), u(2024, 3, 15, 12, 4, 5))) attempt("iv_minutes", lambda: nxt( IntervalTrigger(minutes=5, start_date=_i_start, timezone=UTC), None, u(2024, 3, 15, 12, 3, 0))) attempt("iv_mixed_units", lambda: nxt( IntervalTrigger(hours=1, minutes=30, start_date=_i_start, timezone=UTC), None, u(2024, 3, 15, 12, 0, 1))) attempt("iv_end_date", lambda: nxt( IntervalTrigger(minutes=5, start_date=_i_start, end_date=u(2024, 3, 15, 12, 7, 0), timezone=UTC), u(2024, 3, 15, 12, 5, 0), u(2024, 3, 15, 12, 6, 0))) attempt("iv_weeks_days", lambda: nxt( IntervalTrigger(weeks=1, days=1, start_date=_i_start, timezone=UTC), u(2024, 3, 15, 12, 0, 0), u(2024, 3, 15, 12, 0, 1))) attempt("iv_repr", lambda: str(IntervalTrigger(seconds=30, start_date=_i_start, timezone=UTC))) # ============================================================ date trigger === # A one-shot in the PAST still answers with its run_date: it is the scheduler's # misfire handling, not the trigger, that decides whether a late job runs. attempt("dt_past", lambda: nxt( DateTrigger(run_date=u(2024, 1, 1, 0, 0, 0), timezone=UTC), None, u(2024, 3, 15, 12, 0, 0))) attempt("dt_future", lambda: nxt( DateTrigger(run_date=u(2024, 6, 1, 0, 0, 0), timezone=UTC), None, u(2024, 3, 15, 12, 0, 0))) # Once it has fired, it is done -- `previous' is the one place DateTrigger reads it. attempt("dt_fires_once", lambda: nxt( DateTrigger(run_date=u(2024, 1, 1, 0, 0, 0), timezone=UTC), u(2024, 1, 1, 0, 0, 0), u(2024, 3, 15, 12, 0, 0))) attempt("dt_from_string", lambda: nxt( DateTrigger(run_date="2024-06-01 08:30:00", timezone=UTC), None, u(2024, 3, 15, 12, 0, 0))) attempt("dt_repr", lambda: str(DateTrigger(run_date=u(2024, 6, 1, 0, 0, 0), timezone=UTC))) # ==================================================================== DST ==== # THE DOCUMENTED ANSWER DIFFERS FROM NAIVE ARITHMETIC, in both directions, and # these are the cases a caller gets wrong by adding 24 hours. # # America/New_York springs forward 2024-03-10: 02:00 EST becomes 03:00 EDT, so # the wall-clock hour 02:xx does not exist on that date. A daily 02:30 job is NOT # skipped -- the trigger computes the naive 2024-03-10 02:30 and localizes it at # the offset in force BEFORE the transition, EST-0500, which is the instant a # clock on the wall shows as 03:30 EDT. Measured, not reasoned: the plausible # answers were "no fire that day" and "03:30 EDT", and it is neither as written. attempt("dst_spring_gap", lambda: nxt( CronTrigger(timezone=NY, hour=2, minute=30), None, ny(2024, 3, 9, 12, 0, 0))) attempt("dst_spring_before", lambda: nxt( CronTrigger(timezone=NY, hour=1, minute=30), None, ny(2024, 3, 9, 12, 0, 0))) # The day AFTER the transition is back to a plain 02:30, now at EDT's offset -- # so the answer's OFFSET is what moved, not its wall-clock time. attempt("dst_spring_after", lambda: nxt( CronTrigger(timezone=NY, hour=2, minute=30), None, ny(2024, 3, 10, 12, 0, 0))) # Falling back 2024-11-03: 02:00 EDT becomes 01:00 EST, so the wall-clock hour # 01:xx happens twice. The trigger answers the SECOND one, EST-0500 -- an hour # later in absolute time than the EDT occurrence a reader tends to assume. attempt("dst_fall_ambiguous", lambda: nxt( CronTrigger(timezone=NY, hour=1, minute=30), None, ny(2024, 11, 2, 12, 0, 0))) attempt("dst_fall_after", lambda: nxt( CronTrigger(timezone=NY, hour=1, minute=30), None, ny(2024, 11, 3, 12, 0, 0))) # An INTERVAL trigger is absolute and a CRON trigger is wall-clock, and the # spring-forward is where they part: 24 hours after 12:00 EST on 9 March is # 13:00 EDT on the 10th, while a daily cron at 12:00 stays at 12:00 EDT. attempt("dst_interval_absolute", lambda: nxt( IntervalTrigger(days=1, start_date=ny(2024, 3, 9, 12, 0, 0), timezone=NY), ny(2024, 3, 9, 12, 0, 0), ny(2024, 3, 9, 12, 0, 1))) attempt("dst_cron_wallclock", lambda: nxt( CronTrigger(timezone=NY, hour=12, minute=0), ny(2024, 3, 9, 12, 0, 0), ny(2024, 3, 9, 12, 0, 1))) # A second named zone, so nothing above is an accident of one zone's rules. attempt("dst_helsinki", lambda: nxt( CronTrigger(timezone=HEL, hour=3, minute=30), None, HEL.localize(datetime(2024, 3, 30, 12, 0, 0)))) # ============================================== the scheduler, without threads = # BaseScheduler's documented extension point is a subclass that supplies # wakeup(); over a DebugExecutor -- which runs a job synchronously in the calling # thread -- and a MemoryJobStore, the whole scheduling path runs with no thread # created anywhere. _process_jobs() is the same call a real scheduler's main loop # makes, and it returns the delay until it should be called again. # apscheduler logs a WARNING through the standard `logging' module when it skips # a missed job, and with no handler configured that goes to stderr through # logging.lastResort. `j-missed' below deliberately provokes exactly one of them, # so the logger is silenced here rather than left to make the probe's stderr # non-empty -- which the suite asserts, and which is worth more as an assertion # than this one warning is as output. import logging logging.getLogger("apscheduler").setLevel(logging.CRITICAL) class SyncScheduler(BaseScheduler): def wakeup(self): pass def shutdown(self, wait=True): super(SyncScheduler, self).shutdown(wait) _runs = [] def _job(tag): _runs.append(tag) _sched = SyncScheduler(timezone=UTC, jobstores={"default": MemoryJobStore()}, executors={"default": DebugExecutor()}) attempt("sched_state_stopped", lambda: str(_sched.state == STATE_STOPPED)) attempt("sched_start", lambda: (_sched.start(), str(_sched.state == STATE_RUNNING))[1]) attempt("sched_running", lambda: str(_sched.running)) # add_job through the STRING aliases, which is the path the alias table above # feeds -- so a missing .dist-info would surface here as well as there. attempt("sched_add_date", lambda: _sched.add_job( _job, "date", run_date=u(2024, 1, 1, 0, 0, 0), args=["date"], id="j-date", misfire_grace_time=None).id) # `next_run_time' IS SUPPLIED for the two recurring jobs, and that is what keeps # this section clock-independent. Left to compute their own, an interval and a # cron trigger answer from `now' FORWARD -- the next 30-second boundary, the next # top of the hour -- so whether either had come due by the time _process_jobs ran # would depend on how many seconds the interpreter spent on the twelve calls in # between. Pinned to 2099 they are unambiguously not due, on any machine, at any # speed, and the one job that fires below is the one-shot that is due by # construction. attempt("sched_add_interval", lambda: _sched.add_job( _job, "interval", seconds=30, start_date=u(2024, 1, 1, 0, 0, 0), args=["iv"], id="j-interval", misfire_grace_time=None, next_run_time=u(2099, 1, 1, 0, 0, 0)).id) attempt("sched_add_cron", lambda: _sched.add_job( _job, "cron", minute=0, start_date=u(2024, 1, 1, 0, 0, 0), args=["cron"], id="j-cron", misfire_grace_time=None, next_run_time=u(2099, 1, 1, 0, 0, 0)).id) # THE MISFIRE GRACE TIME IS ONE SECOND BY DEFAULT, and that is the whole reason # the three above pass `misfire_grace_time=None'. A job whose run time has # passed by more than the grace time is reported MISSED and its function is # never called -- so a scheduler that was stopped over the interesting instant # silently runs nothing. `j-missed' is the control that shows it. attempt("sched_add_missed", lambda: _sched.add_job( _job, "date", run_date=u(2024, 1, 1, 0, 0, 0), args=["missed"], id="j-missed").id) attempt("sched_jobs_count", lambda: len(_sched.get_jobs())) attempt("sched_job_ids", lambda: ",".join(sorted(j.id for j in _sched.get_jobs()))) attempt("sched_get_job", lambda: _sched.get_job("j-date").id) attempt("sched_get_missing", lambda: str(_sched.get_job("nope"))) # A duplicate id is refused rather than silently replacing the job. attempt("sched_conflicting_id", lambda: _sched.add_job( _job, "date", run_date=u(2024, 1, 1), args=["dup"], id="j-date").id) attempt("sched_replace_existing", lambda: _sched.add_job( _job, "date", run_date=u(2024, 1, 1, 0, 0, 0), args=["date"], id="j-date", misfire_grace_time=None, replace_existing=True).id) # THE JOBS ACTUALLY FIRE, in the calling thread, and each exactly once. All four # are due; the three with no grace limit run and `j-missed' does not, which is # the pair that separates "the scheduler did not work" from "the scheduler did # exactly what it documents". attempt("sched_process_1", lambda: (_sched._process_jobs(), ",".join(sorted(_runs)))[1]) attempt("sched_ran_count", lambda: str(len(_runs))) attempt("sched_missed_not_run", lambda: "yes" if "missed" not in _runs else "no") # A one-shot has removed itself; the recurring two have moved on. So a second # pass adds nothing -- no job fires twice for one due instant. attempt("sched_process_2", lambda: (_sched._process_jobs(), ",".join(sorted(_runs)))[1]) attempt("sched_date_job_gone", lambda: str(_sched.get_job("j-date"))) attempt("sched_jobs_after_run", lambda: ",".join(sorted(j.id for j in _sched.get_jobs()))) attempt("sched_remove", lambda: (_sched.remove_job("j-cron"), ",".join( sorted(j.id for j in _sched.get_jobs())))[1]) attempt("sched_remove_missing", lambda: _sched.remove_job("nope")) # Pausing clears next_run_time rather than deleting the job; resuming recomputes it. attempt("sched_pause_clears_next", lambda: (_sched.pause_job("j-interval"), str(_sched.get_job("j-interval").next_run_time))[1]) attempt("sched_paused_job_kept", lambda: _sched.get_job("j-interval").id) attempt("sched_resume_restores_next", lambda: ( _sched.resume_job("j-interval"), "set" if _sched.get_job("j-interval").next_run_time is not None else "none")[1]) attempt("sched_remove_all", lambda: (_sched.remove_all_jobs(), str(len(_sched.get_jobs())))[1]) # shutdown(wait=False) never joins anything -- there is nothing to join, and a # wait=True here would be the one call in this file that could block. attempt("sched_shutdown", lambda: (_sched.shutdown(wait=False), str(_sched.state == STATE_STOPPED))[1]) attempt("sched_shutdown_twice", lambda: _sched.shutdown(wait=False)) # ============================================================== local zone === # The one thing read off the MACHINE, and it is configuration rather than a # clock: tzdata puts /etc/timezone and the /etc/localtime symlink on this image # and tzlocal resolves the zone from them. Scored by TYPE and by KEY, never by # any instant. from tzlocal import get_localzone attempt("localzone_type", lambda: type(get_localzone()).__name__) attempt("localzone_key", lambda: str(get_localzone())) attempt("default_tz_scheduler", lambda: str(SyncScheduler( jobstores={"default": MemoryJobStore()}, executors={"default": DebugExecutor()}).timezone)) # ================================================================ closure ==== # WHAT THE IMPORT PULLED IN FROM OUTSIDE THE STANDARD LIBRARY. 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 compiled dependency would fail at # `import' rather than resolve it. Scored against sys.stdlib_module_names rather # than a literal list, for the reason 99-python3-aniso8601 records: what the # delta CONTAINS depends on what the interpreter had already loaded. # Underscore-prefixed names are dropped: `_sysconfigdata__' is named # after the build arm and `__mp_main__' after the multiprocessing bootstrap, so # both are facts about the interpreter rather than about this package, and # scoring them would make the case fail on a rename nobody made here. _new = sorted(m for m in (_after - _before) if "." not in m and m != "apscheduler") _nonstd = sorted(m for m in _new if m not in sys.stdlib_module_names and not m.startswith("_")) emit("pulled_nonstdlib", ",".join(_nonstd) if _nonstd else "") emit("needs_six", "yes" if "six" in sys.modules else "no") emit("needs_pytz", "yes" if "pytz" in sys.modules else "no") emit("needs_tzlocal", "yes" if "tzlocal" in sys.modules else "no") emit("needs_asyncio", "yes" if "asyncio" in sys.modules else "no") # Upstream's own test suite must not be reachable as a subpackage: the sdist # keeps tests/ at the top level and setup.py excludes it, so nothing named # *apscheduler.tests* exists. emit("tests_not_imported", ",".join(m for m in sys.modules if "apscheduler" in m and "test" in m) or "") emit("emitted_count", _emitted + 2) emit("probe_done", "yes")