ir.schedule¶
Install and operate the OS job that runs ir maintain — ir schedule (issue #75).
ir maintain is idempotent and cron-shaped, but something has to call it.
This module manages that caller: it writes a definition (a launchd plist or a
crontab line), hands it to the OS, and exits.
``ir`` still does not run a scheduler. The boundary from issue #58 — “ir holds
the declarative policy as data and exposes an idempotent ir maintain; it does
not run a scheduler” — is preserved exactly. There is no daemon here, no event
loop, no in-process timer, and no orchestration import: launchd/cron remain the
executor, and this module is the installer for the crontab snippet
ir.maintenance already prescribes in its docstring. Turning that snippet
into a command is packaging, not a change of architecture.
The command is idempotent and tells you how to operate what it finds:
ir schedule # ensure a schedule exists; report + menu if one does
ir schedule --status # report only, never mutates
ir schedule --every 30m # set/change the interval
ir schedule --restart # reload the definition, re-pinning the interpreter
ir schedule --remove # stop it and delete the definition
ir schedule --dry-run # print what would be written
An existing definition is data, not a template to re-derive. Only a fresh
install snapshots the calling shell’s environment. Every operation on a schedule
that already exists (--every, --restart) carries the stored environment
forward untouched, because the alternative silently re-points a working job at a
different corpus store the first time you operate it from a shell that happens to
lack $PP — which is the exact failure this module exists to prevent. Only
--restart re-pins the interpreter, and only because repairing a dead
interpreter is what it is for.
Two seams, both one keyword argument:
backend— which OS scheduler holds the definition. Feature-detected (launchctlthencrontab), never hardcoded per-platform;systemd --usertimers are the documented next backend. A backend owns its own definition format, its own preview, and whether “loaded” means anything for it, so adding one touchesBACKENDSand nothing else.ScheduleSpec.args— the command the job runs, defaulting to-m ir maintain --allpinned to the installing interpreter. An orchestration layer’s own entry point (#58 / ADR #43) drops in here without touching backends.
Everything else — the plist keys, the crontab line syntax, interval parsing, output formatting — is written directly, on purpose.
Module Attributes
Reverse-DNS job identifier. |
|
How often the job fires, in minutes. |
|
Variables a job must inherit to see what the shell that installed it sees. |
|
Of those, the ones whose absence breaks a corpus outright rather than merely relocating it. |
|
Backends in preference order. |
Functions
|
The five-field cron time spec for an every-minutes schedule. |
|
ir's crontab entry for spec — deliberately one self-contained line. |
|
The trailing comment that identifies ir's crontab line. |
|
text with ir's line replaced by (or appended as) spec's line. |
|
text with ir's line removed (unchanged if there is none). |
|
Make sure a schedule exists — the idempotent front door. |
|
Human rendering of an interval ( |
|
Install (or overwrite) the schedule, and return the resulting status. |
|
Minutes from |
|
spec rendered as launchd plist XML. |
|
The launchd job description for spec, as a plist-ready dict. |
|
Stop the schedule and delete its definition. |
|
A human report of state, including how to operate what was found. |
|
The backend named backend, or the first available one. |
|
Reload the existing definition, re-pinning it to the current interpreter. |
|
Report the current schedule without changing anything. |
Classes
|
What to run and how often — backend-independent. |
|
What the scheduler holds for ir, and what just happened to it. |
Exceptions
A schedule could not be read, written, or expressed on this platform. |
- ir.schedule.BACKENDS = (<class 'ir.schedule._LaunchdBackend'>, <class 'ir.schedule._CronBackend'>)¶
Backends in preference order. launchd first on macOS because a LaunchAgent survives reboots and logs where the OS expects; cron is the POSIX fallback and also works on macOS if asked for by name.
This tuple is the whole seam. A backend owns its definition format, its
preview, and itsreports_loadedanswer, so addingsystemd --usermeans adding a class here and nothing else — no branch in the renderer, the previewer, or the CLI.
- ir.schedule.DFLT_EVERY_MINUTES = 60¶
How often the job fires, in minutes. Hourly rather than the docstring example’s 15 minutes: a
source-changerebuild is a near-no-op only after it has walked the sources, and a large corpus (tens of thousands of records) makes that walk worth an hour’s spacing.--everymoves it either way.
- ir.schedule.DFLT_LABEL = 'com.i2mint.ir.maintain'¶
Reverse-DNS job identifier. Also the launchd
Labeland the token that marks ir’s line in the user crontab, so both backends find their own work and never touch anybody else’s.
- ir.schedule.INHERITED_ENV_VARS = ('IR_CONFIG_DIR', 'IR_DATA_DIR', 'IR_CACHE_DIR', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'PP', 'PTH_FILEPATH')¶
Variables a job must inherit to see what the shell that installed it sees. launchd starts jobs with a near-empty environment and cron with a minimal one, so anything ir reads from the environment has to be carried into the definition or the job maintains a different (or empty) set of corpora.
IR_*/XDG_*locate the stores (ir.config);PPandPTH_FILEPATHare read byir.sourcesitself to find the package manifest and the projects root, and without them thepackagesandreportscorpora fail outright — which is precisely how a scheduled job ends up “running fine” while two of three corpora go stale.
- ir.schedule.LOAD_BEARING_ENV_VARS = ('PP', 'PTH_FILEPATH')¶
Of those, the ones whose absence breaks a corpus outright rather than merely relocating it.
status()reports a job that lacks one the current shell has, because that job is failing silently right now.
- exception ir.schedule.ScheduleError[source]¶
Bases:
ExceptionA schedule could not be read, written, or expressed on this platform.
- class ir.schedule.ScheduleSpec(every_minutes=60, python=<factory>, args=('-u', '-m', 'ir', 'maintain', '--all'), label='com.i2mint.ir.maintain', log_path=<factory>, env=<factory>)[source]¶
Bases:
objectWhat to run and how often — backend-independent.
pythonis pinned to an absolute interpreter path (by default the one installing the schedule) because neither launchd nor cron inherits the shell’sPATH-resolvedir. Pinning is also what makes staleness detectable:status()can check the recorded interpreter still exists.
- class ir.schedule.ScheduleStatus(installed, backend, label='com.i2mint.ir.maintain', action='reported', every_minutes=None, definition=None, command=None, python=None, env=(), log_path=None, loaded=None, last_run=None, last_log_line=None, problems=(), detail='')[source]¶
Bases:
objectWhat the scheduler holds for ir, and what just happened to it.
One return type for every operation:
actionsays what the call did, so a caller never has to pair a status with a separate “did it change?” flag.
- ir.schedule.cron_expression(minutes)[source]¶
The five-field cron time spec for an every-minutes schedule.
cron restarts its
*/ncount each hour (and each day), so only intervals that divide evenly fire evenly. Rejecting the rest with the valid values named beats installing a job that quietly skips.- Return type:
>>> cron_expression(15), cron_expression(60), cron_expression(360) ('*/15 * * * *', '0 * * * *', '0 */6 * * *')
- ir.schedule.cron_line(spec)[source]¶
ir’s crontab entry for spec — deliberately one self-contained line.
An earlier design used a begin/end marker block with the environment on its own
NAME=valuecrontab lines. Both halves of that were wrong:a block has an interior, and anything that lands in it (a job the user typed just above the end marker, or everything after a begin marker whose end marker got lost) is deleted the next time ir rewrites its block;
crontab-scope
NAME=valuelines apply to **every command after them in the file**, so ir’sHOMEandPATHwould silently attach themselves to whatever job the user appends next — and detach again the next time ir moved its block. A cron job whose environment depends on when you last ranir scheduleis close to undiagnosable.
One line with an inline
envprefix has no interior to corrupt and no scope beyond itself.%is escaped because cron reads an unescaped one as a newline that terminates the command.- Return type:
- ir.schedule.cron_marker(label)[source]¶
The trailing comment that identifies ir’s crontab line.
- Return type:
- ir.schedule.crontab_with_line(text, spec)[source]¶
text with ir’s line replaced by (or appended as) spec’s line.
- Return type:
- ir.schedule.crontab_without_line(text, label='com.i2mint.ir.maintain')[source]¶
text with ir’s line removed (unchanged if there is none).
Only lines ir itself wrote are dropped, identified by the trailing marker. Nothing else in the file is read, moved, or rewritten.
- Return type:
- ir.schedule.ensure(*, every=None, backend=None, label='com.i2mint.ir.maintain')[source]¶
Make sure a schedule exists — the idempotent front door.
Installs when there is none. When one already exists it changes nothing unless every differs, so running it twice is safe and running it out of habit never silently reinstalls over a schedule you tuned.
- Return type:
- ir.schedule.format_every(minutes)[source]¶
Human rendering of an interval (
90->'1h30m').- Return type:
- ir.schedule.install(*, every=None, backend=None, label='com.i2mint.ir.maintain', python=None, inherit=True, dry_run=False)[source]¶
Install (or overwrite) the schedule, and return the resulting status.
inherit carries an existing definition’s environment, interpreter and log path forward; pass
Falseto re-snapshot them from this process.- Return type:
- ir.schedule.parse_every(value)[source]¶
Minutes from
"30m"/"2h"/"1d"/"45"(Nonepasses through).>>> parse_every("30m"), parse_every("2h"), parse_every("1d"), parse_every(45) (30, 120, 1440, 45)
- ir.schedule.plist_dict(spec)[source]¶
The launchd job description for spec, as a plist-ready dict.
StartInterval(seconds) andEnvironmentVariablesare what make the interval and the job environment readable back out of an installed job, sostatus()needs no separate bookkeeping file.ProcessTypeis deliberatelyStandardrather thanBackground: launchd throttles aBackgroundjob’s CPU and I/O to keep it from disrupting the user, and a corpus rebuild is exactly the file-walking, embedding-heavy work that throttling would stretch past its own interval.
- ir.schedule.remove(*, backend=None, label='com.i2mint.ir.maintain')[source]¶
Stop the schedule and delete its definition.
- Return type:
- ir.schedule.render(state)[source]¶
A human report of state, including how to operate what was found.
- Return type:
- ir.schedule.resolve_backend(backend=None, *, require_available=True)[source]¶
The backend named backend, or the first available one.
Naming one this machine cannot run is an error rather than a half-install:
--backend launchdon Linux would otherwise write a plist into a directory nothing reads. Passrequire_available=Falseto obtain a backend purely to render its definition format.>>> resolve_backend("cron", require_available=False).name 'cron'
- ir.schedule.restart(*, backend=None, label='com.i2mint.ir.maintain')[source]¶
Reload the existing definition, re-pinning it to the current interpreter.
This is the repair for a schedule left pointing at a rebuilt or deleted interpreter. The stored environment is carried forward untouched — restarting from a shell that lacks
$PPmust not quietly re-point a working job at a different (or empty) set of corpora.- Return type: