Parse job schedules based on relative start and stop times.
scheduleparse is a Python library for defining and working with schedules. It supports absolute times, sunrise/sunset-based times, and recurring patterns with timezone support. Perfect for automation tasks, job scheduling, and time-based system control.
- Absolute time schedules: Define schedules using standard time format (e.g., "09:00", "17:00")
- Sunrise/sunset-based schedules: Create schedules relative to sunrise and sunset with offsets (e.g., "sunrise+30m", "sunset-1h")
- Timezone support: Schedules are evaluated in their own timezone, whatever timezone the checked time is in, and follow DST changes
- Overnight schedules: Support for schedules that span across midnight
- Recurring schedules: Skip days feature for every-other-day or custom recurring patterns
- Active status checking: Easily determine if a schedule is currently active
scheduleparse isn't published on PyPI. Install a release from GitHub instead:
pip install git+https://github.com/trackIT-Systems/scheduleparse.git@2026.10.1Each release also has a wheel and a source archive attached, which you can install directly:
pip install https://github.com/trackIT-Systems/scheduleparse/releases/download/2026.10.1/scheduleparse-2026.10.1-py3-none-any.whl- Python 3.11 or higher
astral>=3.2(for sunrise/sunset calculations)pytimeparse>=1.1.8(for flexible time parsing)
from scheduleparse import ScheduleEntry
# Create a simple daily schedule from 9 AM to 5 PM
schedule = ScheduleEntry("work-hours", "09:00", "17:00")
# Check if the schedule is currently active
if schedule.active():
print("Schedule is active!")
# Get the next start time
next_start = schedule.next_start()
print(f"Next start: {next_start}")start and stop accept:
- Absolute times after midnight, parsed by pytimeparse:
"09:00","12:00:30","9h30m","90m"."24:00"means midnight at the end of the day. - Sun events with an offset:
<event>+<duration>or<event>-<duration>, where<event>is one ofdawn,sunrise,noon,sunsetordusk, and the duration uses the same formats, e.g."sunrise+30m","sunset-1h","dusk+01:30". The offset is required; use"sunrise+0m"for sunrise itself. Sun events need alocation.
A schedule is active from its start (inclusive) until its stop (exclusive). The stop is the first stop time at or after the start, so a stop earlier than the start makes an overnight schedule.
Times are wall-clock times in the schedule's timezone. On days with a DST change a run can be an hour shorter or longer, e.g. "00:00"–"24:00" lasts 23 hours on the day clocks go forward.
Create a schedule with absolute start and stop times:
import datetime
from scheduleparse import ScheduleEntry
# Daily schedule from noon to 1 PM
lunch = ScheduleEntry("lunch-break", "12:00", "13:00", tz=datetime.UTC)
# Check if active at a specific time
now = datetime.datetime(2025, 2, 17, 12, 30, 0, tzinfo=datetime.UTC)
print(lunch.active(now)) # TrueSchedules can span across midnight:
import datetime
from scheduleparse import ScheduleEntry
# Night shift: 8 PM to 5 AM
night_shift = ScheduleEntry("night-shift", "20:00", "05:00", tz=datetime.UTC)
# Active during the night
night = datetime.datetime(2025, 2, 17, 3, 0, 0, tzinfo=datetime.UTC)
print(night_shift.active(night)) # True
# Inactive during the day
day = datetime.datetime(2025, 2, 17, 9, 0, 0, tzinfo=datetime.UTC)
print(night_shift.active(day)) # FalseCreate schedules relative to sunrise and sunset times:
import astral
import zoneinfo
from scheduleparse import ScheduleEntry
# Define location for sunrise/sunset calculations
location = astral.LocationInfo("Berlin", "Germany", "Europe/Berlin", 52.52, 13.405)
tz = zoneinfo.ZoneInfo("Europe/Berlin")
# Active from sunrise to sunset
daytime = ScheduleEntry(
"daytime",
"sunrise+00:00",
"sunset-00:00",
location=location,
tz=tz
)
# Active from 30 minutes after sunrise to 1 hour before sunset
adjusted = ScheduleEntry(
"adjusted-daytime",
"sunrise+30m",
"sunset-1h",
location=location,
tz=tz
)A schedule is evaluated in its own timezone. The time you check can be in any timezone; it is converted first:
import datetime
import zoneinfo
from scheduleparse import ScheduleEntry
# 09:00 to 17:00 New York time
tz = zoneinfo.ZoneInfo("America/New_York")
schedule = ScheduleEntry("ny-schedule", "09:00", "17:00", tz=tz)
# 15:00 UTC is 10:00 in New York
now = datetime.datetime(2025, 2, 17, 15, 0, 0, tzinfo=datetime.UTC)
print(schedule.active(now)) # True
print(schedule.prev_start(now)) # 2025-02-17 09:00:00-05:00Without tz, the system timezone is used, including its DST rules. It is read from the TZ environment variable, else from /etc/localtime. If neither works, the current fixed UTC offset is used and a warning is logged; that offset is wrong after the next DST change. Pass tz explicitly to avoid depending on the system configuration.
Create schedules that run every N days:
import datetime
from scheduleparse import ScheduleEntry
# Every other day (skip 1 day between runs)
every_other_day = ScheduleEntry(
"bidaily",
"00:00",
"24:00",
skip_days=1,
tz=datetime.UTC
)
# Every 4 days (skip 3 days between runs)
every_four_days = ScheduleEntry(
"every-4-days",
"00:00",
"24:00",
skip_days=3,
tz=datetime.UTC
)
# Use skip_offset to stagger schedules
# This will be active on different days than every_other_day
staggered = ScheduleEntry(
"bidaily-staggered",
"00:00",
"24:00",
skip_days=1,
skip_offset=1,
tz=datetime.UTC
)Get previous and next schedule times:
import datetime
from scheduleparse import ScheduleEntry
schedule = ScheduleEntry("daily", "09:00", "17:00", tz=datetime.UTC)
now = datetime.datetime(2025, 2, 17, 15, 0, 0, tzinfo=datetime.UTC)
# Get previous start and stop times
prev_start = schedule.prev_start(now)
prev_stop = schedule.prev_stop(now)
print(f"Previous run: {prev_start} to {prev_stop}")
# Get next start and stop times
next_start = schedule.next_start(now)
next_stop = schedule.next_stop(now)
print(f"Next run: {next_start} to {next_stop}")The main class for defining and working with schedules.
ScheduleEntry(
name: str,
start: str,
stop: str,
location: astral.LocationInfo | None = None,
tz: datetime.tzinfo | None = None,
skip_days: int = 0,
skip_offset: int = 0
)Parameters:
name(str): A descriptive name for the schedule entrystart(str): Start time, see Time Formatsstop(str): Stop time, see Time Formatslocation(astral.LocationInfo, optional): Location for sunrise/sunset calculations. Required if using sun eventstz(datetime.tzinfo, optional): Timezone the schedule is evaluated in. Defaults to the system timezone (seelocal_tz())skip_days(int, optional): Number of days to skip between activations (0 = daily, 1 = every other day, etc.)skip_offset(int, optional): Offset for skip_days calculation to allow staggered schedules
All methods take an optional, timezone-aware now and return datetimes in the schedule's timezone.
Check if the schedule is currently active.
Parameters:
now(datetime.datetime, optional): Reference time. Defaults to current time.
Returns: bool - True if schedule is active, False otherwise.
Get the timestamp of the schedule's most recent start time.
Parameters:
now(datetime.datetime, optional): Reference time. Defaults to current time.
Returns: datetime.datetime - The timestamp of the previous schedule start.
Get the stop time of the most recent schedule run.
Parameters:
now(datetime.datetime, optional): Reference time. Defaults to current time.
Returns: datetime.datetime - The timestamp of the previous schedule's stop time.
Get the timestamp of the schedule's next start time.
Parameters:
now(datetime.datetime, optional): Reference time. Defaults to current time.
Returns: datetime.datetime - The timestamp of the next schedule start.
Get the stop time of the schedule's next run.
Parameters:
now(datetime.datetime, optional): Reference time. Defaults to current time.
Returns: datetime.datetime - The timestamp of the next schedule's stop time.
Get the system timezone including its DST rules, from TZ or /etc/localtime. Falls back to the current fixed UTC offset and logs a warning.
These are documented by expected-failure tests in tests/test_scheduleparse.py:
skip_daysrestarts every year. Active days are computed from the day of the year, so the pattern restarts on January 1. For example, withskip_days=1there are three days between 2025-12-30 and 2026-01-02.- Naive datetimes don't work with
active(). It raisesTypeError. Pass a timezone-awarenow. - Unparseable times mean midnight. A time string that can't be parsed, such as
"1:30 pm","sunrise"without an offset, or a typo, silently resolves to 00:00 instead of raising an error. - Large
skip_daysfail. Values of about 1000 or more raiseRecursionError. Negative values aren't rejected:-1raisesZeroDivisionError, and smaller values make the schedule run daily. - No sunrise or sunset. At locations where the sun doesn't rise or set on a given day (polar day or night), sun-based schedules raise
ValueError.
Install the project with its development dependencies using pdm:
pdm install -G devRun the tests, including the docstring examples, with branch coverage:
pdm run pytestRun a single test:
pdm run pytest tests/test_scheduleparse.py::test_fixedTests are run by CI on every push, on Python 3.11 to 3.14.
Versions use calendar versioning as YYYY.MM.MICRO, where MICRO counts the releases within a month. Tags have no v prefix.
-
Set
versioninpyproject.toml. -
In
CHANGELOG.md, rename## [Unreleased]to## [<version>] - <date>, add a new empty## [Unreleased]section above it, and update the comparison links at the bottom. -
Commit, then push the commit and the tag:
git tag <version> git push origin main <version>
The release workflow runs the tests, checks that the tag matches the version in pyproject.toml, builds the package and creates a GitHub release. The release notes come from the version's section in the changelog, and the wheel and source archive are attached.
See CHANGELOG.md.
This project is licensed under the MIT License.