No description
Find a file
2026-11-03 08:30:31 -06:00
src/scheduler added policies 2026-11-03 08:28:51 -06:00
tests added policies 2026-11-03 08:28:51 -06:00
.gitignore initial commit 2026-10-10 19:39:50 -05:00
.python-version initial commit 2026-10-10 19:39:50 -05:00
pyproject.toml simplifying along the lines of the event bus 2026-10-24 16:59:39 -05:00
README.md markdown formatting 2026-11-03 08:30:31 -06:00
ruff.toml readability improvements 2026-10-10 20:33:16 -05:00
uv.lock initial commit 2026-10-10 19:39:50 -05:00

Scheduler

Dependency-free scheduling for Python 3.14+: one-off delays, aware datetimes, and fixed-rate repetition with independently configured first runs. Each pending job has one native event-loop timer. The application owns the event loop, callback execution, concurrency limits, and worker shutdown.

Lifecycle

Construct the scheduler inside async code on a running event loop. Construction binds that loop and makes the scheduler immediately usable. Registration, job inspection, cancellation, and shutdown require that same running loop. Access is not thread-safe.

Operation Behavior
request_shutdown() Immediately close the scheduler and cancel scheduled jobs. Idempotent.
async with run() Use an already-active scheduler and close it on exit, including errors or cancellation.
closed Whether shutdown has been requested.
running / stopped Whether the scheduler is open and its owning loop is running, or the inverse.
stop_event Initially clear; set on shutdown. Observe only; never set or clear it yourself.

There is no start(), stop(), runner task, polling loop, or registered-but-unarmed state. Registration installs a timer before returning unless a custom policy is already exhausted.

Shutdown is final. Registration and context entry on a closed scheduler raise RuntimeError; another run requires a fresh scheduler. Shutdown cancels native timers and scheduled job results, releases their callbacks, and prevents queued timers from handing off more jobs. It does not await or cancel dispatched execution, or close the application loop. The run context owns cleanup, not activation, and does not reopen a closed instance.

Applications that can be constructed before their event loop starts should create the scheduler during async startup and immediately register its cleanup.

Scheduling API

Method Handle Timing
call_later(delay, callback, /, *args, **kwargs) ScheduledJob Once after a nonnegative timedelta.
call_at(when, callback, /, *args, **kwargs) ScheduledJob Once at an aware datetime.
call_every(interval, callback, /, *args, **kwargs) RepeatingJob Fixed-rate, first due one positive timedelta interval later.
schedule(policy, callback, /, *args, **kwargs) ScheduledJob | RepeatingJob Determined by a Schedule policy.

Arguments are bound at registration with typed parameter forwarding; keyword values are not deep-copied. Zero delay and past datetimes become eligible on a later loop turn, never through inline callback execution.

Built-in policies are immutable and reusable: After(delay), At(when), and Every(interval, start=None). At normalizes its datetime to UTC and converts it to the owning loop's monotonic clock only at registration. Later wall-clock changes do not alter an armed deadline. Timing progression belongs to each job, not the shared policy configuration.

Native asyncio timer semantics apply: callbacks may be handed off up to one clock-resolution early, equal-deadline order is unspecified, and several handoffs may happen in one loop turn. There is no precision compensation or wall-clock monitoring.

Handles And Results

  • Both handle kinds expose id, policy, and monotonic deadline. deadline and handle are None when no occurrence is pending. There is no synthesized when; an At policy retains its requested UTC datetime.
  • jobs is a tuple of all active one-off and repeating handles in registration order. A one-off leaves it at handoff; a repeating series stays until cancellation, exhaustion, or rearm failure. No completed-job history is retained.
  • cancel(job) succeeds only for this scheduler's exact active handle. It cancels the pending timer, clears its deadline and callback, and cancels a pending one-off result. Unknown, retired, or already-cancelled handles return False. Treat handle state as scheduler-owned; there is no direct handle cancellation API.
  • await job.wait() on ScheduledJob returns the execution value or raises its exception or cancellation. Each waiter is shielded independently: cancelling one waiter does not cancel the job or other waiters. A result remains observable after scheduler shutdown.
  • RepeatingJob has no wait() or aggregate result. Each occurrence's dispatcher future is retained and observed directly, without allocating one-off handles or extra result futures.
  • Callback references are released when a job retires. Already-dispatched executions remain retained until completion and are never cancelled by job cancellation or scheduler shutdown.

Repeating Jobs

call_every(interval, callback, /, *args, **kwargs) returns a RepeatingJob:

from datetime import timedelta

from scheduler import Every

series = scheduler.call_every(timedelta(seconds=30), refresh, device_id)
immediate = scheduler.schedule(
    Every(timedelta(seconds=30), start=timedelta(0)), refresh, device_id
)
print(series.deadline)
scheduler.cancel(series)
scheduler.cancel(immediate)

Every.start accepts a nonnegative delay or an aware datetime. None defaults to one interval later; a past datetime establishes first eligibility now rather than replaying a historical cadence. call_every does not reserve a start keyword: call_every(interval, callback, start="value") passes that argument to the callback. Configure the policy's first run through schedule(Every(..., start=...), callback, ...).

Subsequent deadlines advance from the consumed monotonic deadline by an arithmetic number of intervals. If the loop stalls, one due occurrence is handed off and missed intervals are skipped directly to the first strictly future deadline. An early native timer still consumes at least one interval. Invalid non-finite or non-advancing deadlines are rejected.

The next timer is installed before dispatching the due occurrence, not after execution completes. Slow executions may overlap. Production dispatchers must provide appropriate capacity limits or admission policy; the scheduler has no internal backpressure queue or execution timeout.

Ordinary callback failures, cancelled execution futures, and dispatcher rejection do not stop future occurrences. Failure to calculate or install the next timer retires the series and logs the error but still dispatches the already-due occurrence. Cancellation or other process-control exceptions raised synchronously by a dispatcher propagate after bookkeeping cleanup; they are not treated as ordinary rejections.

Custom Policies

Schedule is a structural protocol with a read-only repeating: bool flag and:

def next_deadline(self, previous_deadline: float | None, now: float) -> float | None:
    ...

Both floats use the owning loop's monotonic clock. previous_deadline=None means first registration; returning None means exhaustion. A repeating rearm must return a finite deadline strictly after both the consumed deadline and now. Initial past deadlines are clamped to immediate eligibility. A nonrepeating policy is consulted only at registration and produces at most one handoff.

Keep policy methods fast, synchronous and nonblocking. Prefer immutable configuration with progress represented by the supplied previous deadline so definitions can be reused across jobs. All policies use the same native timer and dispatch path; no scheduler modification is needed for an extension.

An exhausted repeating policy retires normally. Initial exhaustion returns an inactive handle without installing a timer; an initially exhausted one-off has a cancelled result so waiting cannot hang. Initial policy or timer failures propagate and leave no active registration.

Calendar, Cron And Solar

CalendarSchedule, CronSchedule, and SolarSchedule are unimplemented placeholders. Registering one raises NotImplementedError before installing a timer or active job. They have no configuration API or optional dependency imports yet.

Future civil-time schedules are intended to use explicit timezones, skip nonexistent local times, and run ambiguous local times once. Each armed deadline will remain fixed; the next civil occurrence will be calculated at handoff. Cron and solar engines may use optional dependencies. These are design intentions, not implemented or tested civil-time behavior.

Dispatcher Contract

Scheduler(dispatcher) requires a synchronous, nonblocking handoff accepting a bound zero-argument callback. It must arrange execution itself, including awaiting async callback results, and return an independent asyncio Future on the bound application loop. Task is accepted; a coroutine alone is not. The scheduler never executes user callbacks directly.

Ordinary dispatcher rejection or an invalid execution future fails that occurrence. One-off errors are exposed through job.wait(); repeating errors are logged without a series result. Exceptions are observed even when there are no waiters. The package emits contextual errors through module loggers and configures no handlers; application logging handlers must not block the event loop.

The scheduler does not own a TaskGroup: sibling-failure cancellation would change the independent-execution contract. Applications choose how to supervise, limit, and drain their execution work.

Why Two Futures?

Each ScheduledJob separates a scheduler-owned result future from an application-owned execution future because they have different lifetimes and owners:

  • result is created at registration and remains the stable target of job.wait(). Callers can wait before dispatch; pending cancellation, scheduler shutdown before handoff, or dispatcher rejection can settle it without execution ever starting.
  • execution is supplied by the dispatcher at handoff. It represents actual callback completion and lets the application return an ordinary asyncio Task or compatible future without knowing about the scheduler's job handle or result plumbing.

The scheduler's completion callback forwards the execution's value, exception, or cancellation into the existing result future. After handoff, shutdown leaves application-owned execution alone, and callers can still observe its eventual outcome.

The separation preserves dispatcher independence; it is not what isolates waiter cancellation. That comes from asyncio.shield() in wait(). Pending-only shutdown likewise does not inherently require two futures.

Replacing job.result at handoff would strand callers already awaiting the original future. Under the return-a-future dispatcher contract, keeping the two roles separate makes ownership explicit.

Neither design compensates for an execution future that completes too early. For queued worker callbacks, the dispatcher must return a future that reflects the callback's actual value or exception, not merely successful queue submission; otherwise job.wait() also reports completion too early.

Usage

import asyncio
from collections.abc import Callable
from datetime import timedelta
from inspect import isawaitable
from typing import Any

from scheduler import Scheduler


async def execute(callback: Callable[[], Any]) -> Any:
    result = callback()
    return await result if isawaitable(result) else result


async def main() -> None:
    executions: set[asyncio.Task[Any]] = set()

    def dispatch(callback: Callable[[], Any]) -> asyncio.Future[Any]:
        task = asyncio.create_task(execute(callback))
        executions.add(task)
        task.add_done_callback(executions.discard)
        return task

    scheduler = Scheduler(dispatch)
    try:
        async with scheduler.run():
            job = scheduler.call_later(timedelta(seconds=1), lambda value: value + 1, 41)
            print(await job.wait())
    finally:
        await asyncio.gather(*executions, return_exceptions=True)


asyncio.run(main())

This dispatcher executes on the application loop, so synchronous callbacks must not block. Supply an appropriate worker-backed dispatcher for blocking work. The application retains and drains tasks; the scheduler only tracks completion.

Development

From the workspace root:

 uv run --no-sync pytest -q packages/scheduler/tests
 uv run --no-sync ruff check packages/scheduler/src/scheduler packages/scheduler/tests
 uv run --no-sync ty check packages/scheduler/src/scheduler packages/scheduler/tests

Internally, schedules.py owns timing policy, types.py owns handle shapes, and Scheduler owns one active-job registry, one arming/dispatch path, and a set of dispatcher execution futures. The scheduler creates no tasks and retains no completed-job history.