How I Wrote a Python Module for Cron Expressions
Writing a Python module for parsing and validating cron expressions began with a small frustration: a deployment script accepted five-field schedules, but nobody could tell whether a rejected expression was malformed, semantically impossible, or merely unsupported. I wanted a dependable library that returned precise, machine-readable errors instead of a generic exception. The result became a small open-source tool that fits naturally beside my other Python utilities.
Cron syntax is deceptively compact. Minutes, hours, days of the month, months and weekdays can contain numbers, names, ranges, lists, steps and wildcards, yet a valid-looking string can still describe a job that never runs. February 30 is the familiar example, but timezone changes create subtler problems. I separated parsing from calendar validation and from the policy a scheduler uses when local time moves.
Because this personal site already hosts projects such as DenyHosts, Kodos and Scratchy, I treated the module as shared engineering rather than a private script. The source is written for Python programmers, the tests document unusual inputs, and the documentation explains every supported construct. Publishing the code also means taking responsibility for naming, compatibility, licensing and a sensible release process.
Schedules are particularly sensitive to Australian conditions. Sydney and Melbourne move between Australia Eastern Standard Time and Australia Eastern Daylight Time, while Brisbane stays on UTC+10 without daylight saving. A daily 02:30 task can disappear or occur twice when clocks change. These local realities shaped the validator’s behaviour and made timezone-aware testing essential, even though the core parser remains country-neutral.
Defining the supported cron dialect
I began by defining the accepted dialect instead of coding against whatever the first test happened to contain. The module supports standard five-field cron: minute, hour, day of the month, month and day of the week. Six-field schedules with seconds, Quartz-style extensions and macro strings are rejected explicitly. That boundary is important because a clear “unsupported format” response is safer than silently interpreting a different scheduling convention.
Within each field, numbers, month names and weekday names are accepted alongside *, ranges, comma-separated lists and step values such as */15. Sunday may use either 0 or 7, and range endpoints are checked against the field’s own limits. I kept the grammar data-driven so adding a permitted alias would not require rewriting the expression parser.
Turning text into a structured schedule
The implementation has three layers. A scanner converts the input into tokens, a recursive-descent parser turns those tokens into field objects, and a calendar validator checks the resulting schedule. Keeping these stages apart made failures easier to locate. It also prevented regular expressions from becoming an opaque box that accepted valid text but still mishandled the order of ranges, lists and steps.
Each parsed field is represented by a small immutable Python class using dataclasses. A schedule object exposes the five normalised fields and can report its next matching date when given a datetime. The parser never uses eval, and unsupported syntax produces a diagnostic rather than being discarded. This gives callers stable data to inspect, test and pass to their own scheduler.
Validating dates, weekdays and time zones
Calendar validation came next. A schedule requesting 30 February must fail before it reaches a scheduler, and 31 April needs the same treatment. For classic cron, a restricted day-of-month and a restricted day-of-week are commonly combined with OR semantics, so the validator must avoid inventing a stricter rule. I therefore validate each field independently, then apply traditional matching policy only when evaluating a date.
Australian daylight saving made a second layer necessary. Sydney and Melbourne change clocks on the first Sunday in October and the first Sunday in April; Brisbane does not change offset. During the missing-hour interval, a local run time may never occur, while during the repeated hour it may map to two instants. Using Python’s zoneinfo database, the module identifies those cases and asks the caller whether to skip, delay or execute the job.
Cron itself has no universal daylight-saving policy, so validation must not decide that policy for every scheduler. The API reports the ambiguity, while an application can apply its own operational rule. This separation matters to Australian payroll, logistics and retail teams, where a skipped batch can affect staff rosters, stock feeds and customer orders.
Designing errors developers can act on
Errors had to be useful during both development and production. Every diagnostic includes the offending value, the field, a character position where possible and a stable error code. A caller can therefore map a failure into a CLI message or structured API response without scraping prose. Common errors include an invalid range, an out-of-bounds hour, an impossible date and an unsupported field count.
The exception hierarchy distinguishes bad syntax from valid syntax that fails semantic checks. Messages use plain Australian English and show times in 24-hour format, which makes them familiar to local operations teams. Each message suggests the accepted range or construct, but it does not modify the input automatically, because guessing the intended schedule can turn a small mistake into a silent production change.
Testing the awkward parts of time
Testing began with a table of ordinary schedules and then grew towards hostile inputs. I covered leap years, month boundaries, weekday names, Sunday aliases, overlapping ranges and restricted day combinations. Property-based tests generate random fields and compare the module’s date calculation with Python’s datetime for known-safe dates. This caught off-by-one errors around midnight and inclusive range endpoints.
Timezone tests use zoneinfo fixtures for Sydney, Melbourne and Brisbane, including both daylight-saving transitions. The integration tests feed parsed schedules into a miniature scheduler and verify skipped or repeated local times. Continuous integration runs the full suite against supported Python versions, while another check ensures documentation examples remain valid, because an API that passes unit tests but publishes broken cron strings is difficult to trust.
Publishing and maintaining the module
The module is packaged with pyproject.toml, type hints and a supported Python version range. Source code, tests and release notes are linked from this site, while PyPI provides the convenient installation path for Australian developers and Linux administrators. Downloads and basic project statistics are shown beside the other utilities, and the MIT licence keeps adoption straightforward for businesses and community projects.
- Publish a concise grammar reference showing each supported field, alias, range and step syntax.
- Return stable error codes alongside human-readable messages for CLI and API consumers.
- Document daylight-saving behaviour with Sydney, Melbourne and Brisbane examples.
- Run the full test suite in continuous integration before every tagged release.
Releasing it this way turns a one-off parser into a dependable component of the open-source collection. The module now serves as both a practical utility and a compact example of careful grammar design, defensive validation and real-world testing.
