Skip to main content

hron

Human-readable cron — scheduling expressions that read like English and convert to and from cron.

from hron import Schedule

schedule = Schedule.parse("every weekday at 9:00 in America/New_York")

Install

pip install hron

Usage

from datetime import datetime
from zoneinfo import ZoneInfo
from hron import Schedule

# Parse an expression
schedule = Schedule.parse("every weekday at 9:00 except dec 25, jan 1 in America/New_York")

# Get next occurrence
now = datetime.now(ZoneInfo("America/New_York"))
next_time = schedule.next_from(now)
print(next_time)

# Get next 5 occurrences
upcoming = schedule.next_n_from(now, 5)
for dt in upcoming:
    print(dt)

# Check if a datetime matches
schedule.matches(datetime(2026, 2, 9, 9, 0, tzinfo=ZoneInfo("America/New_York")))  # True

# Convert to/from cron
simple = Schedule.parse("every day at 9:00")
print(simple.to_cron())  # "0 9 * * *"

from_cron = Schedule.from_cron("*/15 9-17 * * 1-5")
print(from_cron)  # "every 15 min from 09:00 to 17:45 on weekday"

# Validate without exceptions
Schedule.validate("every day at 9:00")  # True
Schedule.validate("invalid")  # False

Expression Syntax

See the full expression reference.

API

Schedule.parse(input_text: str) -> Schedule

Parse an hron expression string. Raises HronError with kind "lex" or "parse" on an invalid expression (see Errors), and TypeError for anything that is not a str, None included.

Schedule.from_cron(cron_expr: str) -> Schedule

Convert a 5-field cron expression or @ shortcut to a Schedule that fires at the same times. This ignores the timezone and DST transitions, where cron schedulers differ. Raises HronError with kind == "cron" when the input is not valid cron or has no exact hron equivalent: a cron that restricts both the day of month and the day of week (0 9 15 * 1), or more than 24 times a day, unless they are evenly spaced on days an interval can carry (*/7 * * * * fires 216 times at uneven gaps). Raises TypeError for anything that is not a str, None included.

Schedule.validate(input_text: str) -> bool

Check if an input string is a valid hron expression: False for anything parse rejects, unknown timezones included. Raises TypeError for anything that is not a str, None included, rather than returning False.

Schedule(data: ScheduleData) -> Schedule

Build a schedule from its parts: the expression (IntervalRepeat, DayRepeat, WeekRepeat, MonthRepeat, SingleDateExpr or YearRepeat) and the clauses except_, until, starting (a date, as YYYY-MM-DD), during and timezone. The parts are checked with the rules parse applies, so a built schedule behaves as a parsed one: str of it parses back to an equal schedule, and evaluating it never fails. See Building in code.

schedule.data -> ScheduleData

The parts the schedule was built from. Change one with dataclasses.replace and build again.

schedule.next_from(now: datetime) -> datetime | None

Compute the next occurrence after now.

schedule.next_n_from(now: datetime, n: int) -> list[datetime]

Compute the next n occurrences after now: fewer if the schedule ends, none if n <= 0. n only caps the count, so a huge n returns every occurrence through the end of the supported range. Raises TypeError if n is not an integer, that is, anything operator.index rejects, such as 2.0.

schedule.previous_from(now: datetime) -> datetime | None

Compute the most recent occurrence strictly before now, or None if there is none, such as before a starting date.

schedule.matches(dt: datetime) -> bool

Check if a datetime matches this schedule: true when the minute containing dt, on the schedule's wall clock, is an occurrence.

schedule.occurrences(from_: datetime) -> Iterator[datetime]

A lazy iterator of the occurrences strictly after from_, unbounded unless an until ends the schedule.

schedule.between(from_: datetime, to: datetime) -> Iterator[datetime]

A lazy iterator of the occurrences where from_ < occurrence <= to.

schedule.to_cron() -> str

Convert to a 5-field cron expression that fires at the same times. Raises HronError with kind == "cron" for except, until, starting, an ISO date, a repeat every n > 1 days, weeks, months or years, a directional nearest weekday, a during that excludes a yearly or named date's month, and times that are not every combination of their minutes and hours (at 09:00, 17:30). The schedule's timezone is not part of the cron: run it in the schedule's timezone. The spec has the full rules and every error message.

str(schedule) -> str

Render as the canonical string form (roundtrip-safe).

Getters

Read-only properties, one per part. Assigning to one raises AttributeError, and what they return is frozen, so nothing can change the schedule.

Property Type
schedule.expression ScheduleExpr The repeat: IntervalRepeat, DayRepeat, WeekRepeat, MonthRepeat, SingleDateExpr or YearRepeat.
schedule.timezone str | None The IANA timezone name with its canonical capitalization, if specified.
schedule.except_ tuple[ExceptionSpec, ...] The except dates, NamedException or IsoException; empty without an except clause.
schedule.until UntilSpec | None The until date, IsoUntil or NamedUntil, if specified.
schedule.starting str | None The starting date as YYYY-MM-DD, if specified.
schedule.during tuple[MonthName, ...] The during months; empty without a during clause.
from hron import Schedule

schedule = Schedule.parse("every weekday at 9:00 except dec 25 starting 2026-01-05 during jan, dec")
schedule.except_   # (NamedException(month=<MonthName.DEC: 'dec'>, day=25),)
schedule.until     # None
schedule.starting  # "2026-01-05"
schedule.during    # (<MonthName.JAN: 'jan'>, <MonthName.DEC: 'dec'>)

schedule == other

Schedules are equal, and hash alike, when their parts are equal, however they were built: every day at 9:00 equals every day at 09:00. Lists compare in order, duplicates included, as str writes them. A schedule never equals anything but a schedule, None included, and comparing never raises.

Building in code

import dataclasses
from hron import DayFilterWeekday, DayRepeat, HronError, Schedule, ScheduleData, TimeOfDay

schedule = Schedule(
    ScheduleData(DayRepeat(1, DayFilterWeekday(), (TimeOfDay(9, 0),)), timezone="america/new_york")
)
print(schedule)  # every weekday at 09:00 in America/New_York
schedule == Schedule.parse("every weekday at 9:00 in America/New_York")  # True

try:
    Schedule(dataclasses.replace(schedule.data, timezone="EST"))
except HronError as error:
    error.kind  # "eval"
    print(error.display_rich())
    # error: timezone must be UTC or an Area/Location name such as America/New_York, got EST

A part that breaks a rule raises HronError with kind == "eval", the exact message of the spec, and no span, input or suggestion. The first part in the spec's order decides the message; its table lists every rule: an interval outside 1-2147483647, a DayRepeat with an interval above 1 on days other than DayFilterEvery(), a time outside 00:00-23:59, a window that runs backwards, no times or no days, a day outside 1-31 or beyond its month, a day range that runs backwards, a date that is not a calendar YYYY-MM-DD from 0001-01-01 to 9999-12-31, a timezone other than UTC or an IANA Area/Location name, and a NamedUntil without starting. Any value where an expression, day filter, day spec, month or year target, date, exception or until goes that is none of its classes, None included, raises unknown {kind} {value!r}: unknown day filter None.

A value of the wrong type is a TypeError, not a HronError: anything but a member of the name's Enum where a Weekday, MonthName, OrdinalPosition, IntervalUnit or NearestDirection goes ("monday", 1 and None included, though None is a NearestWeekdayTarget's plain nearest direction), a str, float or bool where an int goes, anything but a list or tuple where a sequence goes (None included), anything but a str for a date, starting or timezone, and anything but a TimeOfDay for a time.

Building copies the parts, so changing a list afterwards does not change the schedule; lists are kept as tuples. ScheduleData and every part are frozen, so nothing a getter returns can change the schedule either. An empty except_ or during is no clause, and the timezone is kept in its IANA capitalization, as parse keeps it.

Timestamps

Each now, from_, to and dt names an instant, and the zone it is written in changes nothing. A naive datetime is read as the host's local time. Every returned datetime is aware and in the schedule's ZoneInfo, or ZoneInfo("UTC") when it has none. No method modifies its arguments.

from datetime import datetime
from zoneinfo import ZoneInfo
from hron import Schedule

schedule = Schedule.parse("every day at 09:00 in America/New_York")
tokyo = datetime(2026, 2, 6, 21, 0, tzinfo=ZoneInfo("Asia/Tokyo"))  # 07:00 in New York
print(schedule.next_from(tokyo))  # 2026-02-06 09:00:00-05:00
schedule.next_from(tokyo).tzinfo  # zoneinfo.ZoneInfo(key='America/New_York')

A timestamp outside the supported range, 0001-01-02T00:00:00Z <= t < 9999-12-30T00:00:00Z, is not an error, even at datetime.min and datetime.max: next_from and previous_from return None, matches returns False, and next_n_from, occurrences and between return nothing. A timestamp that is not a datetime, such as a str, a date or None, raises TypeError, and so does occurrences or between at the call, before anything is iterated.

Errors

Schedule(ScheduleData(...)) raises HronError with kind "eval" (see Building in code). Schedule.parse raises HronError with kind "lex" or "parse", message (also str(error)) the exact message of the spec, input the expression as given, and span the part of it the error points at. A parse error may carry a suggestion, text to put in place of the span. display_rich() renders the error with carets under the span:

from hron import HronError, Schedule

try:
    Schedule.parse("every weekday at 09:00 until dec 31")
except HronError as error:
    error.kind        # "parse"
    error.message     # "until dec 31 has no year: add a starting date, or use an ISO date"
    error.input       # "every weekday at 09:00 until dec 31"
    error.span        # Span(start=23, end=35)
    error.suggestion  # "until dec 31 starting YYYY-MM-DD"
    print(error.display_rich())
    # error: until dec 31 has no year: add a starting date, or use an ISO date
    #   every weekday at 09:00 until dec 31
    #                          ^^^^^^^^^^^^ try: "until dec 31 starting YYYY-MM-DD"

A span is [start, end) in code points, which is how Python indexes a str, so error.input[error.span.start:error.span.end] is the text it points at. For the UTF-8 byte offset of position n, use len(error.input[:n].encode("utf-8", "surrogatepass")).

HronError

Member
kind "lex", "parse", "eval" or "cron" (HronErrorKind).
message The message alone; str(error) is the same.
span A Span(start, end) for lex and parse errors, else None.
input The expression as given for lex and parse errors, else None.
suggestion Text to put in place of the span, when a parse error has one, else None.
display_rich() The message, then for lex and parse errors the input with carets under the span and any suggestion.
HronError.lex(message, span, input), HronError.parse(message, span, input, suggestion=None), HronError.eval(message), HronError.cron(message) Build an error of each kind. Raises TypeError if message or input is not a str, span is not a Span, or suggestion is neither a str nor None.
HronError(kind, message, span=None, input=None, suggestion=None) Build an error from its members. Raises TypeError if message is not a str, span is neither a Span nor None, or input or suggestion is neither a str nor None.

Usage errors

A bad argument raises Python's TypeError, never HronError: an input to parse, validate or from_cron that is not a str, a timestamp that is not a datetime, an n that is not an integer, a part of the wrong type in ScheduleData (see Building in code), and a message, span, input or suggestion of the wrong type when building a HronError.

License

MIT

Metadata

Release files for hron 2.2.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 hron 2.2.0
File Size Uploaded
hron-2.2.0.tar.gz 34.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hron 2.2.0
File Interpreter ABI Platform
hron-2.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 74.2 kB

Release files / hron-2.2.0.tar.gz

Download URL hron-2.2.0.tar.gz
Size 34.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c1f40329a30d7ef7746ff46e90b5450debcf0e6c8730fd986e05d210e7e0cdd9
BLAKE2b-256 checksum
How to use checksums
840c62bc9cc65bb4ca62ac8061fe339b1e03628d94fd90fd5a8796cb1565f5d3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / hron-2.2.0-py3-none-any.whl

Download URL hron-2.2.0-py3-none-any.whl
Size 40.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3a4000f501b20946e772cf22702d0cc28afb27d8cff6f859807d371272ada19e
BLAKE2b-256 checksum
How to use checksums
df4a148ff770b9792546a851c2b723b37b87f6cf8ae177cc20568bdf9806f2ca
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

2.2.1

2 release files

This release

2.2.0 This release

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.0.0

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.2

2 release files

0.4.1

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