airflow-timetables-calendar
Calendar-driven Airflow timetables, plus a business-day rule engine using the vocabulary of classical enterprise job schedulers (kind / start day / substitution / offset / grace days).
The point of this package is that "run on the last working day of the month" is not something a cron expression can say, and hand-rolling the arithmetic for every region, exchange and holiday rule is how schedulers go quietly wrong. So instead of reimplementing calendars, this delegates to libraries that already maintain the data, and layers the scheduling vocabulary enterprise job schedulers have used for decades on top.
pip install airflow-timetables-calendar
# exchange calendars (Tokyo Stock Exchange, NYSE, LSE, ...) are opt-in
pip install "airflow-timetables-calendar[exchanges]"
Japanese documentation: README.JP.md.
Quick start
from airflow.sdk import DAG
from airflow_timetables_calendar import CalendarTimetable
with DAG(
dag_id="daily_report",
# 21:00 JST, every Japanese working day (Mon-Fri minus public holidays).
schedule=CalendarTimetable(calendar_id="JP", hour=21),
...
):
...
CalendarTimetable requires no rules; without them it behaves as "run at this
local time on every working day of the calendar".
Calendars
The bare id is resolved against both registries, so a country code and an exchange code are used the same way.
CalendarTimetable(calendar_id="JP", hour=9) # Japan
CalendarTimetable(calendar_id="US", hour=9) # United States
CalendarTimetable(calendar_id="US-CA", hour=9) # California
CalendarTimetable(calendar_id="DE-BY", hour=9) # Bavaria
CalendarTimetable(calendar_id="TSE", hour=9) # Tokyo Stock Exchange
CalendarTimetable(calendar_id="NYSE", hour=9) # New York Stock Exchange
CalendarTimetable(calendar_id="exchange:XLON", hour=8) # London Stock Exchange
CalendarTimetable(calendar_id="NONE", hour=9) # plain Mon-Fri
Backed by holidays (500+ country and
subdivision calendars) and, with the exchanges extra,
pandas_market_calendars
(200+ exchanges, including maintenance and special closures).
A bare code is resolved against holidays first, then the exchange registry.
The two registries sometimes use different codes for the same market, so use
a prefix when you mean a specific one:
CalendarTimetable(calendar_id="country:JP") # force holidays (country)
CalendarTimetable(calendar_id="exchange:XTKS") # Tokyo Stock Exchange, mcal
CalendarTimetable(calendar_id="exchange:XLON") # London Stock Exchange, mcal
Exchange ids are the pandas_market_calendars names, which are usually the
MIC code rather than the familiar abbreviation — XTKS, not TSE. Check
available_exchange_calendars() when in doubt; an unknown id raises
UnknownCalendarError at DAG-parse time rather than scheduling silently.
Business-day rules
rules takes preset names, verbose_rule() / simple_rule() kwargs dicts,
or ScheduleRule objects, in ascending priority. Passing rules replaces
"every working day": only the days a rule yields will run.
from airflow_timetables_calendar import (
CalendarTimetable,
simple_rule,
verbose_rule,
nth_business_day,
nth_business_day_from_end,
Kind,
)
# Preset names
CalendarTimetable(calendar_id="JP", hour=21, rules=["last_business_day_of_month"])
# The 10th working day, and the month's last working day
CalendarTimetable(
calendar_id="JP",
hour=21,
rules=[nth_business_day(10), nth_business_day_from_end(0)],
)
# The explicit form: argument names map 1:1 onto
# kind / start day / substitution / offset, so an existing definition can be
# transcribed directly.
CalendarTimetable(
calendar_id="JP",
hour=21,
rules=[verbose_rule(kind=Kind.ABSOLUTE, day=15, substitution="next", grace_days=3)],
)
# The compact form: one line per schedule, the way the shift/relative notation
# is normally written.
CalendarTimetable(
calendar_id="JP",
hour=21,
rules=[simple_rule(day="L", shift="prev", relative=-2)], # 3 working days before month end
)
| Preset | Meaning |
|---|---|
first_business_day_of_month |
the month's 1st working day |
last_business_day_of_month |
the month's last working day |
last_business_day_of_previous_month |
the previous month's last working day |
business_day_before_month_end |
the working day before the month's last |
next_business_day |
the next working day |
previous_business_day |
the previous working day |
every_business_day |
every working day |
Each preset has a Japanese spelling and an English name, and both work
everywhere a preset name is accepted. The canonical key is the Japanese one,
because that is the vocabulary the definitions are written in; use the English
name if that is your organisation's vocabulary. Either spelling builds exactly
the same ScheduleRule. PRESET_ALIASES holds the English names and
PRESET_LOOKUP maps every accepted spelling to its canonical key, so a tool can
offer both. An unknown name raises at DAG-parse time and lists every accepted
spelling. See README.JP.md for the Japanese table.
The model
A date is derived from an anchor plus a chain of modifiers, following the classical model:
| Concept | Meaning |
|---|---|
| base date | where a "month" starts. base_day=26 makes 2026-08-26..2026-09-25 the "August" business month |
| base time | how the classical model rolls a business date over, so that 08:00..next-day 07:59 is one business day and a run at "25:00" still belongs to the previous date. Partly modelled: hour accepts the 48-hour clock, so a run outside 0..23 is dated to the adjacent day (see The 48-hour clock). A configurable roll-over time other than midnight is still absent |
| kind | what the offset counts: ABSOLUTE calendar date, RELATIVE calendar days from the base date (identical to ABSOLUTE when base_day=1), OPERATING working days (→ "the Nth working day"), CLOSED closed days, REGISTERED the registration date |
| start day | DAY a date of the month, MONTH_END days before month end, WEEKDAY the Nth weekday. What the resulting day is measured from depends on the kind — see Where a start day is measured from |
| substitution | what to do when the day is closed: SKIP do not run, PREVIOUS the previous working day, NEXT the next working day, RUN_ANYWAY do not substitute |
| offset schedule | a final n working-day (OPERATING) or calendar-day (CALENDAR) adjustment |
| grace days | the maximum distance a shift may travel, counted in calendar days. Beyond it, that occurrence produces no run at all — matching the classical model, this is not an error. The window also bounds how far a rule may reach, so a wider grace window costs more work in matches(). grace_days=0 means "use the default", not "zero tolerance" — omit it unless you need a tighter window |
simple_rule() is the compact spelling of the same model. relative n counts n
working days from the settled anchor, with the anchor itself counting as 0, so
relative=4 on day 1 is the 5th working day and relative=-2 on L is 3 working
days before month end. The shift settles the anchor first and relative then
counts from that settled day, in relative's own direction. verbose_rule()
instead applies the offset to the anchor the rule names, without a preceding
substitution.
Note that only one stage is allowed to walk: simple_rule() keeps the
substitution from moving the date and lets the offset carry the whole distance.
Chaining the two would make every closed day in the span cost two steps.
Because both relative and substitution are movements, their result may cross the
end of the month — day="L", shift="prev", relative=1 genuinely means "the first
working day after month end". The start year-month bounds the month the rule
anchors in, so it rejects a date that walks back past it, but not one that walks
forward out of it. The same applies with a base_day, where the end-of-month run
lands in the following month by design.
# Last working day of each business month starting on the 26th
CalendarTimetable(
calendar_id="JP",
hour=21,
base_day=26,
rules=[nth_business_day_from_end(0)],
)
Where a start day is measured from
The kind and the start day are not independent: which origin a day is counted from
is decided by the kind, and the two axes are easy to conflate because they collapse
to the same answer whenever base_day=1.
| kind | a date of the month | days before month end | the Nth weekday |
|---|---|---|---|
ABSOLUTE |
the calendar month | the calendar month's last day | the calendar month's weeks |
RELATIVE |
the base date (1-based) | the period's last day | weeks counted from the base date |
OPERATING |
the period's Nth working day | the period's last working day | — |
CLOSED |
the period's Nth closed day | the period's last closed day | — |
"Period" means the base-date-defined business month. So with base_day=26, the
period opening 2026-08-26 closes on 2026-09-25, and:
# Last working day of the *period* -> 2026-09-25
CalendarTimetable(calendar_id="JP", hour=21, base_day=26,
rules=[nth_business_day_from_end(0)])
# ABSOLUTE + MONTH_END keeps the calendar reading -> 2026-08-31
CalendarTimetable(calendar_id="JP", hour=21, base_day=26,
rules=[verbose_rule(kind=Kind.ABSOLUTE,
start_day=StartDay.MONTH_END, day=0)])
The weekday form has a matching split: ABSOLUTE counts weeks from the 1st of the
calendar month, while RELATIVE counts them from the base date, so with
base_day=26 "the 1st Monday" is 2026-08-31 under RELATIVE but 2026-08-03 under
ABSOLUTE. An occurrence that does not exist within the period names the period's
last day rather than walking into the next one.
Set base_day=1 (the default) and both readings are the same thing, because the
base date is the 1st. That is why this only matters once a base date is
configured.
Rules without Airflow
airflow_timetables_calendar.rules and .calendars import nothing from
Airflow, so they work as a plain date calculator or inside another scheduler.
They are unit-tested without an Airflow install, and CI parses their ASTs to
keep it that way.
One caveat: importing the package imports Airflow, because __init__
re-exports CalendarTimetable. So the rule engine is reusable only where
Airflow is installed anyway — it is the dependency that is avoidable, not the
installation.
from datetime import date
# Through the package (needs Airflow installed):
from airflow_timetables_calendar import nth_business_day, period_for, ScheduleRule
class MyCalendar:
def is_working_day(self, day: date) -> bool:
return day.weekday() < 5
rule = ScheduleRule(**nth_business_day(5))
rule.resolve(period_for(date(2026, 9, 1)), MyCalendar()) # 2026-09-07
Any object with is_working_day(day) satisfies the WorkingDayCalendar
protocol — no import from this library is needed, so a host scheduler can pass
its own calendar object straight in.
Serialization
Custom timetables have to be resolvable when a DAG is deserialized, in every
Airflow component. Installing the package is enough: an airflow.plugins entry
point registers the timetable and teaches the DAG serializer how to encode it.
The 48-hour clock
hour accepts -47..47, not just 0..23. A value outside 0..23 runs on
an adjacent calendar day but still belongs to the business date of the declared
day, which is what lets a schedule say "the last working day of the month, at
01:00 the following morning" and have the run dated to that working day.
hour |
runs at | business date |
|---|---|---|
21 |
today 21:00 | today |
24 |
tomorrow 00:00 | today |
25 |
tomorrow 01:00 | today |
47 |
tomorrow 23:00 | today |
-1 |
yesterday 23:00 | today |
-24 |
yesterday 00:00 | today |
# Last working day of the month, run at 01:00 the next morning.
CalendarTimetable(calendar_id="JP", hour=25, rules=["last_business_day_of_month"])
# The working day itself, but one hour before midnight the previous evening.
CalendarTimetable(calendar_id="JP", hour=-1, rules=["every_business_day"])
Rules are always evaluated against the business date, so the holiday and working-day checks are unaffected by the offset. Only the wall-clock moment the run is handed to Airflow moves.
Not modelled
The rule vocabulary is a model of the classical behaviour, not a complete reimplementation of any one product. These parts are deliberately absent, and the README describes them only so that the rest makes sense:
- a configurable base time. The 48-hour clock is supported through
hour(see The 48-hour clock), but the roll-over point is fixed at midnight of the timetable'stimezone. Products that let a business date start at, say, 08:00 are not expressible; shift the run withhourinstead. - a rule validity end date. The start year-month bounds a rule from below only. There is no upper bound, and therefore no interaction between the grace window and an expiry — the classical model lets the window override the expiry, which cannot arise here.
- the registration date.
Kind.REGISTEREDresolves to the period's own start, which is a stand-in for "when this was registered" rather than a real registration timestamp. It is not wired to any Airflow run state. - substitution grace bounds. The classical model documents a 1–31 day window.
grace_daysis not range-checked, andgrace_days=0selects the default window rather than a zero-day one. - the Nth weekday for working/closed days. The specification's start-day table
defines the weekday form only for
ABSOLUTEandRELATIVE, so the two working-day / closed-day combinations are undefined. They are accepted here and resolve against the calendar month, which is a local choice rather than a documented behaviour.
If you need any of these, they are the natural next things to add — see
rules.py, where each is a self-contained stage.
Scope and compatibility
- Tested against Airflow 3.x. The timetable derives from the core
CronTriggerTimetable, not the SDKBaseTimetable, because the SDK base is missing attributes that core code reads unconditionally (seetimetable.pyfor the details). - The serializer hook uses a private Airflow attribute. It is guarded: an unsupported Airflow logs a clear error instead of taking the scheduler down.
- Not associated with or endorsed by the Apache Software Foundation. "Airflow" is used descriptively.
Licence
MIT. See LICENSE.
Sources
The rule vocabulary and its edge cases follow the conventions that classical enterprise job schedulers share — kind / start day / substitution / offset schedule / grace days, plus the compact shift-and-relative notation. That vocabulary is an industry convention rather than a public standard, so the implementation is a faithful model of the documented behaviour, not a byte-compatible parser of any particular product's configuration files. No vendor documentation or product name is reproduced here.
Release files for airflow-timetables-calendar 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| airflow_timetables_calendar-0.2.1.tar.gz | 79.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| airflow_timetables_calendar-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 118.0 kB
Release files / airflow_timetables_calendar-0.2.1.tar.gz
| Download URL | airflow_timetables_calendar-0.2.1.tar.gz |
|---|---|
| Size | 79.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e39182faa2a2b059cce2244b835273874b8f3f9c5d46417b15f8c467cf5c8968
|
|
BLAKE2b-256 checksum How to use checksums |
415169bac43709cf7b60b0a2e56a2322c6b6b854ac5bfda4cbf29eb7d0b43025
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / airflow_timetables_calendar-0.2.1-py3-none-any.whl
| Download URL | airflow_timetables_calendar-0.2.1-py3-none-any.whl |
|---|---|
| Size | 38.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3b1fc7c676e87b071fda91c74cebf966104bd98b88fa342b1f59dc80c2bf8de2
|
|
BLAKE2b-256 checksum How to use checksums |
fcf080600221b5fb4787da58d2dac36d35aedc6c4faa454182283d00d5dfdca5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency log