Skip to main content

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.

A query that fails for another reason raises ExchangeCalendarError rather than being answered as "not a holiday". Treating a failed query as a working day would put a run on a date the calendar could not vouch for, which is the silent wrongness this package exists to avoid.

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
repeat period the frequency. Only daily and monthly are implemented; weekly and yearly are part of the vocabulary but are rejected at construction rather than silently repeating monthly
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", and no value requests less than the default: 0 selects the default and every other value is a positive day count, so this parameter can only widen the window. Leave it unset unless you need a wider one

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's timezone. Products that let a business date start at, say, 08:00 are not expressible; shift the run with hour instead.
  • 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.REGISTERED resolves 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_days is not range-checked, and grace_days=0 selects 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 ABSOLUTE and RELATIVE, 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 SDK BaseTimetable, because the SDK base is missing attributes that core code reads unconditionally (see timetable.py for 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.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for airflow-timetables-calendar 0.5.0
File Size Uploaded
airflow_timetables_calendar-0.5.0.tar.gz 98.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for airflow-timetables-calendar 0.5.0
File Interpreter ABI Platform
airflow_timetables_calendar-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 145.1 kB

Release files / airflow_timetables_calendar-0.5.0.tar.gz

Download URL airflow_timetables_calendar-0.5.0.tar.gz
Size 98.3 kB
Tags Source
SHA-256 checksum
How to use checksums
d6164a0ba32ed07bf54857b6497cb77fa8bb9ee2eb2e6c084495191c7be0c734
BLAKE2b-256 checksum
How to use checksums
35a0c2780e9730970ca4e5d5e14f8713b163395fb944c4871dd5e06574c41766
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 20, 2026.

Transparency log

Release files / airflow_timetables_calendar-0.5.0-py3-none-any.whl

Download URL airflow_timetables_calendar-0.5.0-py3-none-any.whl
Size 46.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a56e64ebf889b940786ec04a1b432f2958c58388c3ea1db5c90620fb9e271f20
BLAKE2b-256 checksum
How to use checksums
42984248c4c61b822068a7332722381264edffe6908c01c129fbb1d050c2b728
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 20, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.0

2 release files

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page