Metadata-Version: 2.5
Name: croner
Version: 1.1.0
Summary: Cron for Python. Parse and evaluate cron patterns, enumerate runs across time zones and DST, and schedule functions. No dependencies.
Project-URL: Homepage, https://github.com/Hexagon/croner-py
Project-URL: Repository, https://github.com/Hexagon/croner-py
Project-URL: Issues, https://github.com/Hexagon/croner-py/issues
Project-URL: Changelog, https://github.com/Hexagon/croner-py/blob/main/CHANGELOG.md
Author-email: Hexagon <hexagon@56k.guru>
License-Expression: MIT
License-File: LICENSE
Keywords: cron,crontab,job,ocps,parser,schedule,scheduler,timezone
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: tzdata
Requires-Dist: tzdata; extra == 'tzdata'
Description-Content-Type: text/markdown

# Croner - Cron for Python

Croner is available for [JavaScript/TypeScript](https://github.com/Hexagon/croner) · [Rust](https://github.com/Hexagon/croner-rust) · **Python** · [.NET](https://github.com/Hexagon/croner-dotnet) · [C++](https://github.com/Hexagon/croner-cpp)

Parse and evaluate cron patterns, enumerate runs across time zones and DST, and schedule functions.
No dependencies beyond the standard library. Python 3.9+.

*   **Evaluate patterns** without scheduling anything: next and previous runs, matching, iteration.
*   **Schedule functions** in threads, or as a decorator. Plain and `async def` functions both work.
*   **Time zone aware.** Returns aware `datetime` objects in any `zoneinfo` time zone, a fixed UTC offset,
    or system local time, with well defined behavior when the clock changes for DST.
*   **Extended syntax** from the [Open Cron Pattern Standard](https://github.com/open-source-cron/ocps)
    (OCPS) 1.0 - 1.4: optional seconds and years, `L`, `W`, `#`, `+` and `?`.
*   Over-run protection, error handling, pausing, max runs, intervals, start and stop dates, day offsets.
*   Human readable descriptions of patterns.
*   Fully typed (`py.typed`).

Quick examples:

```python
from itertools import islice

from croner import Cron

# Run a function at the fifth second of every minute
job = Cron("5 * * * * *", lambda: print("This will run every fifth second"))

# As a decorator, with over-run protection. The decorated name is the job.
@Cron("*/5 * * * * *", protect=True)
def every_five_seconds(job):
    print("Every five seconds, never overlapping. Next run:", job.next_run())

every_five_seconds.pause()

# Run once, at a specific date and time
job = Cron("2027-01-23T00:00:00", lambda: print("Yay!"), timezone="Asia/Kolkata")

# The next 100 Sundays
next_sundays = Cron("0 0 * * SUN").next_runs(100)

# A job is an iterator over its upcoming runs
for run in islice(Cron("0 9 * * MON-FRI", timezone="Europe/Stockholm"), 5):
    print(run.isoformat())

# Days left to Christmas Eve (a timedelta)
days_left = Cron("0 0 24 12 *").time_to_next().days

# Describe a pattern
print(Cron("0 12 * * MON-FRI").describe())  # At 12:00, on Monday, Tuesday, Wednesday, Thursday, and Friday.
```

## Installation

```bash
pip install croner
```

On Windows, which has no system time zone database, also install `tzdata` to use the `timezone` option:
`pip install croner[tzdata]`.

## Documentation

### Signature

`Cron` takes a pattern, an optional function, and keyword options:

```python
# - pattern: Cron pattern, datetime (run once), or ISO 8601 string (run once)
# - fn: Optional function to schedule
# - options: Keyword options (optional)
job = Cron("* * * * * *", max_runs=1)

# If the function is omitted, it can be scheduled later
job.schedule(lambda job, context: None)
```

The function is called as `fn(job, context)`, where `context` is the `context` option. A function that
takes fewer arguments gets fewer: `fn(job)` or `fn()`. Coroutine functions (`async def`) are run to
completion with `asyncio.run()` in the worker thread.

Each run happens in a thread of its own, so a slow run does not delay the schedule (use `protect` to
prevent overlapping runs). Scheduler threads are not daemon threads, so the interpreter keeps running
while a job is scheduled. Pass `daemon=True` to use daemon threads, letting the process exit while jobs
are still scheduled.

As a decorator, a job schedules the decorated function and takes its place. The decorated name then
refers to the job, which can be paused, stopped and queried, and calling it calls the function:

```python
@Cron("0 * * * *", daemon=True)
def hourly_report():
    ...

hourly_report.next_run()  # the job
hourly_report()           # runs the function now, in the calling thread
hourly_report.stop()
```

#### Status

```python
job.next_run(start=None)              # The next run (datetime), or None.
job.next_runs(10, start=None)         # The next 10 runs.
job.previous_runs(10, reference=None) # The 10 previous scheduled runs, most recent first.
job.enumerate(start=None)             # An iterator over the runs after start, with peek() and reset().
job.time_to_next(start=None)          # Time to the next run (timedelta), or None.
job.ms_to_next(start=None)            # The same in milliseconds (int).
job.current_run()                     # When the current (or last) run started.
job.previous_run()                    # When the previous run started.
job.runs_left()                       # Runs left, None for unlimited.

job.match(date)                       # Whether a datetime or ISO 8601 string matches the pattern.

job.is_running()                      # Scheduled, and not paused or stopped.
job.is_stopped()                      # Stopped with stop().
job.is_busy()                         # The function is running right now.

job.get_pattern()                     # The original pattern, or None for one-off jobs.
job.once                              # The date of a one-off job, or None. Also job.get_once().
job.describe()                        # Human readable description of the pattern.
```

`iter(job)` is the same as `job.enumerate()`, so a job can be used directly in a `for` loop or with
`itertools`. Without `stop_at` or `max_runs` the iterator is endless.

#### Control functions

```python
job.trigger()   # Run the function now, in the calling thread.
job.pause()     # Pause.
job.resume()    # Resume.
job.stop()      # Stop permanently. Also removes a named job from scheduled_jobs.
```

#### Dates and time zones

Every method that takes a date accepts a `datetime`, a `date` or an ISO 8601 string.

*   Aware datetimes, and strings with `Z` or an offset, are exact instants.
*   Naive datetimes, dates, and strings without an offset are wall clock times in the job's time zone
    (the `timezone` or `utc_offset` option), or in system local time if neither is set.

Returned datetimes are aware, in the job's time zone (`zoneinfo.ZoneInfo`), so they print in local time
and compare correctly as instants.

> **Note**
> Python subtracts two aware datetimes that share a `tzinfo` as wall clock times, ignoring DST.
> Convert to UTC first, `a.astimezone(timezone.utc) - b.astimezone(timezone.utc)`, to get the real
> time between two runs across a DST change.

#### Options

| Key                  | Default | Type                | Remarks |
|----------------------|---------|---------------------|---------|
| name                 | None    | str                 | Keep a reference to the job in `croner.scheduled_jobs`. The reference is removed on `stop()`. |
| max_runs             | None    | int                 | Maximum number of runs, None for unlimited. |
| catch                | False   | bool \| callable    | Catch errors raised by the function. `True` silently ignores them, a callable is called as `catch(error, job)`. |
| timezone             | None    | str \| tzinfo       | Time zone in Europe/Stockholm format, or a `tzinfo` (`zoneinfo`, `dateutil`, or `pytz`, which is read through `zoneinfo`). |
| utc_offset           | None    | int \| timedelta    | Schedule with a fixed UTC offset, in minutes or as a `timedelta`. This does not take DST into account, you probably want `timezone`. |
| start_at             | None    | datetime \| str     | Earliest run. Inclusive, a run exactly at `start_at` is included (except when `interval` is set). |
| stop_at              | None    | datetime \| str     | Stop before this date. Exclusive, a run exactly at `stop_at` is not included. |
| interval             | 0       | int \| timedelta    | Minimum time between runs, in seconds or as a `timedelta`. |
| paused               | False   | bool                | Start paused. |
| context              | None    | Any                 | Passed as the second argument to the function. |
| dom_and_dow          | False   | bool                | Combine day-of-month and day-of-week with AND (True) or OR (False). |
| legacy_mode          | -       | bool                | **Deprecated:** use `dom_and_dow`. Inverse of `dom_and_dow`. |
| daemon               | False   | bool                | Run the scheduler and the function in daemon threads, so the process can exit while the job is scheduled. |
| unref                | False   | bool                | Alias of `daemon`. |
| protect              | False   | bool \| callable    | Over-run protection: skip runs while a previous run is still going. A callable is called as `protect(job)` for each skipped run. |
| alternative_weekdays | False   | bool                | Quartz-style weekday numbering (1 = Sunday, 2 = Monday, ..., 7 = Saturday). |
| allow_past           | False   | bool                | Let a one-off job dated in the past run immediately. Otherwise only one dated within the last second does. |
| day_offset           | 0       | int                 | Shift every run by a number of days, e.g. `-1` for the day before each match. All methods and `start_at` / `stop_at` refer to the shifted time. |
| mode                 | "auto"  | str                 | How many fields the pattern has: `"auto"`, `"5-part"`, `"6-part"`, `"7-part"`, `"5-or-6-parts"` or `"6-or-7-parts"`. |
| sloppy_ranges        | False   | bool                | Accept non-standard steps, like `/10`, `5/5` or `30/30`. By default only `*/step` and `min-max/step` are allowed. |

Invalid patterns and options raise `croner.CronError`, a subclass of `ValueError`.

#### Pattern

Patterns are based on [Vixie cron](https://en.wikipedia.org/wiki/Cron#CRON_expression), with these extensions:

```
┌──────────────── (optional) second (0 - 59)
│ ┌────────────── minute (0 - 59)
│ │ ┌──────────── hour (0 - 23)
│ │ │ ┌────────── day of month (1 - 31)
│ │ │ │ ┌──────── month (1 - 12, JAN-DEC)
│ │ │ │ │ ┌────── day of week (0 - 6, SUN-SAT)
│ │ │ │ │ │       (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0)
│ │ │ │ │ │ ┌──── (optional) year (1 - 9999)
│ │ │ │ │ │ │
* * * * * * *
```

*   **Optional second and year fields**: 6 fields add seconds first, 7 fields also add a year last.
*   **L**: Last day of month, or last occurrence of a weekday. `L` in day-of-month is the last day of the
    month, `LW` the last weekday (Mon-Fri) of the month, and `5#L`, `5L` or `FRI#L` the last Friday of the month.
*   **W**: Nearest weekday. `15W` is the weekday closest to the 15th, never crossing into another month.
*   **#**: Nth occurrence of a weekday. `5#2` is the second Friday, `MON#1` the first Monday of the month.
*   **+**: Prefix the day-of-week field to combine it with day-of-month using AND:
    `0 12 1 * +MON` only runs when the 1st is a Monday.
*   **?**: An alias for `*`. Non-portable, avoid it in patterns meant for other systems.
*   **DST** follows Vixie cron, deviating from OCPS 1.4 §4.3.1:
    *   **Fixed-time jobs**, with a single value in each of second, minute and hour (e.g. `0 30 2 * * *`):
        a time that occurs twice (fall back) runs once, at the first occurrence. A time that doesn't
        exist (spring forward) runs at the first valid time after the gap, on the same day.
    *   **Interval jobs**, with a wildcard, step, range or list in second, minute or hour
        (e.g. `*/5 * * * *` or `0 30 1,2 * * *`): a time that occurs twice runs on both occurrences.
        A time that doesn't exist is skipped.
    *   `next_run()`, `next_runs()`, `previous_runs()` and `enumerate()` move in real time. They never
        return a time they have passed, and cover the first pass through a repeated hour before the second.

By default, day-of-month and day-of-week are combined with OR: `0 20 1 * MON` runs on the 1st of the month
and on Mondays. Use the `+` modifier or `dom_and_dow=True` for AND.

| Field        | Required | Allowed values  | Special characters | Remarks |
|--------------|----------|-----------------|--------------------|---------|
| Seconds      | Optional | 0-59            | * , - / ?          | Defaults to 0 |
| Minutes      | Yes      | 0-59            | * , - / ?          | |
| Hours        | Yes      | 0-23            | * , - / ?          | |
| Day of Month | Yes      | 1-31            | * , - / ? L W      | L = last day, W = nearest weekday |
| Month        | Yes      | 1-12 or JAN-DEC | * , - / ?          | |
| Day of Week  | Yes      | 0-7 or SUN-SAT  | * , - / ? L # +    | 0 and 7 = Sunday, 1-7 = Sunday-Saturday with `alternative_weekdays` |
| Year         | Optional | 1-9999          | * , - /            | Defaults to *. `*/2` matches even years, see below. |

Weekday and month names are case-insensitive.

**Nicknames**:

| Nickname               | Same as       |
|------------------------|---------------|
| @yearly / @annually    | `0 0 1 1 *`   |
| @monthly               | `0 0 1 * *`   |
| @weekly                | `0 0 * * 0`   (Sundays, also with `alternative_weekdays`) |
| @daily / @midnight     | `0 0 * * *`   |
| @hourly                | `0 * * * *`   |
| @reboot                | Recognized, but raises `CronError`, since a library can't detect system startup. |

#### OCPS conformance

Croner passes the [OCPS conformance tests](https://github.com/open-source-cron/ocps/blob/main/data/conformance-tests.json),
with these deliberate deviations:

*   **DST**: follows Vixie cron instead of OCPS 1.4 §4.3.1, as described above.
*   **`?`** is accepted in every field, as an alias for `*`. OCPS 1.4 only allows it in day-of-month
    and day-of-week.
*   **Nicknames** are case-insensitive, `@Daily` works. OCPS 1.1 makes them case-sensitive.
*   **`@reboot`** is rejected when the pattern is parsed. OCPS 1.1 accepts it at parse time and lets the
    runtime reject it.
*   **Years**: the supported range is 1-9999, wider than the portable 1970-2199. Wildcard steps in the
    year field count from year 0 (which is never matched itself), so `*/2` matches even years, as OCPS
    requires. Other steps can match other years than counting from 1970 would: `*/4` matches 2024,
    2028, ..., where 1970-2199 gives 2026, 2030, .... Use a range, like `2026-2199/4`, to be portable.

### Pattern objects

`croner.CronPattern` parses a pattern on its own. `job.pattern` is the parsed pattern of a job.

```python
from croner import CronPattern

pattern = CronPattern("0 9-11 * * 1-5")
pattern.values("hour")         # [9, 10, 11]
pattern.values("day_of_week")  # [1, 2, 3, 4, 5]
pattern.is_fixed_time          # False
```

### Command line

`python -m croner` describes a pattern and lists its next runs, handy for checking a pattern before
putting it in a config file:

```console
$ python -m croner "0 9 * * MON-FRI" --timezone Europe/Stockholm -n 3
At 09:00, on Monday, Tuesday, Wednesday, Thursday, and Friday.
2026-11-09T09:00:00+01:00
2026-11-10T09:00:00+01:00
2026-11-11T09:00:00+01:00
```

Use `--start` to list runs after another date, and `--previous` for previous runs.

## Development

```bash
pip install -e . pytest mypy ruff
python -m pytest
TZ=America/New_York python -m pytest  # jobs without a time zone use system local time
ruff check .
mypy
```

`tools/compare_with_js.py` is a differential test: it runs random patterns, options and dates around DST
changes through croner-py and the JavaScript implementation and compares the results. It needs Node.js
22.6 or newer, see the script for details.

## License

MIT License
