Skip to content

Cron Expression Explainer — Python source

Translate any 5-field cron expression into plain English, build one field-by-field, and preview the next time it will fire. Supports steps, ranges, lists, and named days/months. Runs 100% in your browser.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""Pure 5-field cron parser, natural-language explainer, builder, and next-run
calculator — Python polyglot port.

Language: Python
CosmoDev polyglot showcase port of the ``cron-explainer`` tool.
Ported from src/lib/cron-explainer.ts — display source, part of CosmoDev's
polyglot tool pages.

Zero deps (stdlib only). Deterministic. Times are interpreted as UTC so
results are unambiguous and DST-independent (the caller controls the instant).

The public surface mirrors the TypeScript reference: ``explain_cron``,
``build_cron``, ``next_run``.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import List, Optional


# ─── Field model ────────────────────────────────────────────────────────────
#
# The five cron fields in positional order, each with its numeric range and
# whether it accepts named tokens (JAN..DEC / SUN..SAT). ``wrap_max`` is True
# only for day-of-week, where 7 is treated as an alias for 0 (Sunday).

@dataclass(frozen=True)
class FieldMeta:
    name: str            # 'minute' | 'hour' | 'day-of-month' | 'month' | 'day-of-week'
    label: str           # same as name; surfaced in error messages
    min: int
    max: int
    named: bool          # accepts JAN..DEC / SUN..SAT tokens
    wrap_max: bool       # max value wraps to min (dow: 7 → 0 / Sunday)


FIELDS: List[FieldMeta] = [
    FieldMeta('minute',       'minute',       0, 59, named=False, wrap_max=False),
    FieldMeta('hour',         'hour',         0, 23, named=False, wrap_max=False),
    FieldMeta('day-of-month', 'day-of-month', 1, 31, named=False, wrap_max=False),
    FieldMeta('month',        'month',        1, 12, named=True,  wrap_max=False),
    FieldMeta('day-of-week',  'day-of-week',  0, 7,  named=True,  wrap_max=True),
]

MONTH_NAMES = [
    'January', 'February', 'March', 'April', 'May', 'June',
    'July', 'August', 'September', 'October', 'November', 'December',
]
DOW_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']

# Insertion order is preserved (Python 3.7+); mirrors the TS object iteration
# order used during token substitution.
MONTH_TOKENS = {
    'JAN': 1, 'FEB': 2, 'MAR': 3, 'APR': 4, 'MAY': 5, 'JUN': 6,
    'JUL': 7, 'AUG': 8, 'SEP': 9, 'OCT': 10, 'NOV': 11, 'DEC': 12,
}
DOW_TOKENS = {'SUN': 0, 'MON': 1, 'TUE': 2, 'WED': 3, 'THU': 4, 'FRI': 5, 'SAT': 6}

_DIGITS_RE = re.compile(r'\d+')


def _pad2(n: int) -> str:
    return f'{n:02d}'


def _month_name(m: int) -> str:
    return MONTH_NAMES[m - 1]


def _dow_name(d: int) -> str:
    return DOW_NAMES[d % 7]


def _range(lo: int, hi: int) -> List[int]:
    """Inclusive integer range, e.g. _range(1, 5) -> [1, 2, 3, 4, 5]."""
    return list(range(lo, hi + 1))


class CronError(ValueError):
    """Raised for a malformed single field. Carries a human-readable message."""


def _parse_int_strict(s: str, label: str) -> int:
    """Parse a strictly-numeric token (digits only). Rejects named tokens,
    signs, and surrounding garbage so malformed fields surface clearly."""
    t = s.strip()
    if not _DIGITS_RE.fullmatch(t):
        raise CronError(f'{label}: invalid number "{s}"')
    return int(t)


def _normalize(value: str, meta: FieldMeta) -> str:
    """Replace named tokens (JAN..DEC / SUN..SAT) with their numeric values.
    Uses global substring replacement so ranges like 'JUN-AUG' and lists like
    'MON,WED,FRI' normalize in a single pass."""
    v = value.strip().upper()
    if not meta.named:
        return v
    tokens = MONTH_TOKENS if meta.name == 'month' else DOW_TOKENS
    for tok, num in tokens.items():
        v = v.replace(tok, str(num))
    return v


@dataclass
class ParsedField:
    meta: FieldMeta
    raw: str
    values: List[int]
    wildcard: bool


def _expand_field(value: str, meta: FieldMeta) -> ParsedField:
    """Expand one field value into the explicit set of numbers it matches.

    Handles ``*``, ``*/N``, ``A-B``, ``A-B/N``, ``A`` (single), ``A/N`` (A to
    field max), and comma-separated lists of any of these. Returns the deduped,
    sorted values plus a ``wildcard`` flag distinguishing a bare ``*``.
    """
    norm = _normalize(value, meta)
    if norm == '':
        raise CronError(f'{meta.label}: empty field')
    if norm == '*':
        return ParsedField(meta, value, _range(meta.min, meta.max), True)

    out = set()
    for term in norm.split(','):
        if term == '':
            raise CronError(f'{meta.label}: empty list item')
        slash_idx = term.find('/')
        base = term
        step = 1
        if slash_idx != -1:
            base = term[:slash_idx]
            step = _parse_int_strict(term[slash_idx + 1:], meta.label)
            if step <= 0:
                raise CronError(f'{meta.label}: step must be a positive number')

        if base == '*':
            lo = meta.min
            hi = meta.max
        elif '-' in base:
            dash = base.find('-')
            lo = _parse_int_strict(base[:dash], meta.label)
            hi = _parse_int_strict(base[dash + 1:], meta.label)
        else:
            lo = _parse_int_strict(base, meta.label)
            # "A/step" runs from A to the field max; a bare "A" is a single value.
            hi = meta.max if slash_idx != -1 else lo

        if lo > hi:
            raise CronError(f'{meta.label}: range start {lo} is greater than end {hi}')
        if lo < meta.min:
            raise CronError(f'{meta.label}: value {lo} is below minimum {meta.min}')
        if hi > meta.max:
            raise CronError(f'{meta.label}: value {hi} is above maximum {meta.max}')

        v = lo
        while v <= hi:
            out.add(meta.min if (meta.wrap_max and v == meta.max) else v)
            v += step

    return ParsedField(meta, value, sorted(out), False)


def _parse_expr(expr: str):
    """Parse all five fields. Returns (parts, None) or (None, error_message)."""
    tokens = [t for t in expr.strip().split() if t]
    if len(tokens) != 5:
        return None, (
            f'Expected 5 fields (minute hour day-of-month month day-of-week), '
            f'got {len(tokens)}'
        )
    parts: List[ParsedField] = []
    for i in range(5):
        try:
            parts.append(_expand_field(tokens[i], FIELDS[i]))
        except CronError as e:
            return None, str(e)
        except Exception:
            return None, f'Invalid {FIELDS[i].name} field'
    return parts, None


def _is_contiguous(values: List[int]) -> bool:
    """True when a sorted value list is a contiguous run (e.g. [3, 4, 5, 6])."""
    return all(values[i] - values[i - 1] == 1 for i in range(1, len(values)))


def _single_value(n: int, meta: FieldMeta) -> str:
    """Describe a single value in the field's own vocabulary."""
    if meta.name == 'minute':
        return f'minute {n}'
    if meta.name == 'hour':
        return f'hour {n}'
    if meta.name == 'day-of-month':
        return f'day {n} of the month'
    if meta.name == 'month':
        return _month_name(n)
    return _dow_name(n)  # day-of-week


def _describe_field(p: ParsedField) -> str:
    """Describe a parsed field as a human phrase (no leading preposition).

    ``raw`` is consulted to distinguish step syntax (star/N or A-B/N) from
    plain lists, since two different raw forms can expand to the same set.
    """
    meta, raw, values = p.meta, p.raw, p.values
    if p.wildcard:
        return {
            'minute': 'every minute',
            'hour': 'every hour',
            'day-of-month': 'every day of the month',
            'month': 'every month',
            'day-of-week': 'every day of the week',
        }[meta.name]

    # Step syntax is reported as "every N <units>".
    if '/' in raw and len(values) >= 1:
        step = _parse_int_strict(raw[raw.index('/') + 1:], meta.label)
        start = values[0]
        if meta.name == 'day-of-month':
            unit_plural = 'days of the month'
        elif meta.name == 'day-of-week':
            unit_plural = 'days of the week'
        else:
            unit_plural = f'{meta.name}s'
        if start == meta.min:
            return f'every {step} {unit_plural}'
        return f'every {step} {unit_plural} starting at {_single_value(start, meta)}'

    if len(values) == 1:
        return _single_value(values[0], meta)

    if _is_contiguous(values):
        a, b = values[0], values[-1]
        if meta.name == 'month':
            return f'{_month_name(a)} through {_month_name(b)}'
        if meta.name == 'day-of-week':
            return f'{_dow_name(a)} through {_dow_name(b)}'
        unit_plural = 'days' if meta.name == 'day-of-month' else f'{meta.name}s'
        return f'{unit_plural} {a} through {b}'

    # Explicit list of discrete values.
    if meta.name == 'month':
        return ', '.join(_month_name(v) for v in values)
    if meta.name == 'day-of-week':
        return ', '.join(_dow_name(v) for v in values)
    if meta.name == 'minute':
        return f'minutes {", ".join(str(v) for v in values)}'
    if meta.name == 'hour':
        return f'hours {", ".join(str(v) for v in values)}'
    return f'days {", ".join(str(v) for v in values)} of the month'


def _prepend(prefix: str, phrase: str) -> str:
    """Prepend a preposition, but never before a phrase that already leads with
    'every' (e.g. 'every day of the week' reads wrong as 'on every …')."""
    return phrase if phrase.startswith('every') else f'{prefix} {phrase}'


def _time_clause(minute: ParsedField, hour: ParsedField) -> str:
    """Compose the opening time-of-day clause from minute and hour fields."""
    m_all = minute.wildcard
    h_all = hour.wildcard
    m_single = not m_all and len(minute.values) == 1
    h_single = not h_all and len(hour.values) == 1

    if m_all and h_all:
        return 'Every minute'
    if m_all and h_single:
        return f'Every minute of hour {hour.values[0]}'
    if m_single and h_all:
        return f'At minute {minute.values[0]} of every hour'
    if m_single and h_single:
        return f'At {_pad2(hour.values[0])}:{_pad2(minute.values[0])}'

    # Mixed: describe each non-wildcard field, hour first.
    clauses = []
    if not h_all:
        clauses.append(_describe_field(hour))
    if not m_all:
        clauses.append(_describe_field(minute))
    s = ', '.join(clauses)
    return s[0].upper() + s[1:]


def _compose_description(parts: List[ParsedField]) -> str:
    minute, hour, dom, month, dow = parts
    clauses = [_time_clause(minute, hour)]
    if not dom.wildcard:
        clauses.append(_prepend('on', _describe_field(dom)))
    if not month.wildcard:
        clauses.append(_prepend('in', _describe_field(month)))
    if not dow.wildcard:
        clauses.append(_prepend('on', _describe_field(dow)))
    return ', '.join(clauses)


# ─── Public API ─────────────────────────────────────────────────────────────


@dataclass
class CronFieldInfo:
    field: str         # one of the five positional field names
    value: str         # raw field value as written in the expression
    meaning: str       # human-readable description of what this field matches


@dataclass
class CronExplanation:
    valid: bool
    description: str                               # '' when invalid
    fields: List[CronFieldInfo] = field(default_factory=list)
    error: Optional[str] = None                    # present only when valid is False


def explain_cron(expr: str) -> CronExplanation:
    """Parse and explain a 5-field cron expression in plain English.

    >>> explain_cron('30 14 * * *').description
    'At 14:30'
    """
    parts, error = _parse_expr(expr)
    if error is not None:
        return CronExplanation(valid=False, description='', fields=[], error=error)
    fields = [
        CronFieldInfo(field=p.meta.name, value=p.raw, meaning=_describe_field(p))
        for p in parts
    ]
    return CronExplanation(
        valid=True,
        description=_compose_description(parts),
        fields=fields,
    )


@dataclass
class BuildCronOptions:
    minute: Optional[str] = None
    hour: Optional[str] = None
    dom: Optional[str] = None
    month: Optional[str] = None
    dow: Optional[str] = None


def build_cron(opts: Optional[BuildCronOptions] = None) -> str:
    """Assemble a 5-field cron expression from per-field specs.

    Each field defaults to ``*`` when empty/omitted; invalid fields raise
    ``CronError`` so callers cannot build a malformed expression.

    >>> build_cron(BuildCronOptions(minute='30', hour='14'))
    '30 14 * * *'
    """
    opts = opts or BuildCronOptions()
    specs = [
        (FIELDS[0], opts.minute),
        (FIELDS[1], opts.hour),
        (FIELDS[2], opts.dom),
        (FIELDS[3], opts.month),
        (FIELDS[4], opts.dow),
    ]
    out: List[str] = []
    for meta, value in specs:
        v = (value or '').strip()
        if v == '':
            out.append('*')
            continue
        _expand_field(v, meta)  # validates; raises on bad input
        out.append(v)
    return ' '.join(out)


def next_run(expr: str, after: datetime) -> Optional[datetime]:
    """Next time the expression fires, strictly after ``after``, in UTC.

    Implements standard Vixie-cron day matching: when BOTH day-of-month and
    day-of-week are restricted, a match on either suffices (OR); otherwise
    both must match (AND). Returns ``None`` if no firing occurs within ~3
    years. ``after`` is interpreted in UTC; a naive datetime is assumed UTC.
    """
    parts, error = _parse_expr(expr)
    if error is not None:
        return None

    minute, hour, dom, month, dow = parts
    m_set = set(minute.values)
    h_set = set(hour.values)
    dom_set = set(dom.values)
    mon_set = set(month.values)
    dow_set = set(dow.values)
    dom_wild = dom.wildcard
    dow_wild = dow.wildcard

    # Work in UTC; treat naive datetimes as UTC (caller controls the instant).
    if after.tzinfo is None:
        cur = after.replace(tzinfo=timezone.utc)
    else:
        cur = after.astimezone(timezone.utc)

    # Start at the top of the minute following `after`, seconds zeroed.
    cur = cur.replace(second=0, microsecond=0) + timedelta(minutes=1)
    limit = cur.year + 3  # hard stop ~3 years out

    while cur.year < limit:
        if cur.month not in mon_set:
            # setUTCMonth(getUTCMonth()+1, 1) + zero time → next month, day 1.
            if cur.month == 12:
                cur = cur.replace(year=cur.year + 1, month=1, day=1,
                                  hour=0, minute=0, second=0, microsecond=0)
            else:
                cur = cur.replace(month=cur.month + 1, day=1,
                                  hour=0, minute=0, second=0, microsecond=0)
            continue
        dom_ok = cur.day in dom_set
        # Python weekday() is 0=Mon..6=Sun; cron day-of-week is 0=Sun..6=Sat,
        # so rotate by one to align Sunday with 0.
        cron_dow = (cur.weekday() + 1) % 7
        dow_ok = cron_dow in dow_set
        day_ok = (dom_ok and dow_ok) if (dom_wild or dow_wild) else (dom_ok or dow_ok)
        if not day_ok:
            cur = cur.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
            continue
        if cur.hour not in h_set:
            cur = cur.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
            continue
        if cur.minute not in m_set:
            cur = cur.replace(second=0, microsecond=0) + timedelta(minutes=1)
            continue
        return cur
    return None

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →