A Python datetime rule engine for working with recurring events and time patterns
Project description
Zerotime
A Python datetime rule engine for defining and working with recurring time patterns. Zerotime lets you express complex scheduling rules declaratively using a simple DSL, then query for matching datetimes, generate sequences, or combine rules using set operations.
Problem
Working with recurring events in datetime is harder than it looks. "Every Monday at 9 AM", "the last business day of each quarter", "business hours except lunch" — each one is a small puzzle that ends up as ad-hoc calendar math sprinkled across the codebase. The standard library gives you datetime and timedelta; everything beyond that — leap years, varying month lengths, weekday alignment, DST gaps, last-day-of-month — is on you.
Existing libraries either model a different problem (cron expressions, parsed but not composable; iCalendar RRULE, powerful but verbose and tied to the iCal format) or are heavy and stateful (full scheduling frameworks). What is missing is a small, dependency-free abstraction that treats a recurring instant in time as a first-class value: comparable, composable, serializable.
Solution
Zerotime models recurring instants as rules. A rule answers one question: "does this datetime match?". From that single predicate everything else derives — finding the next or previous match, generating sequences, combining rules with set operators.
Two rule kinds, one interface:
AtomicRule— temporal constraints expressed in a string DSL, one constraint per datetime field. All constraints AND together.CombinedRule— two rules joined by a set operator (+union,&intersection,-difference). Combinations nest freely.
Both kinds share the Rule base, so any operation (get_next, get_prev, generate, to_json) works the same regardless of how the rule was built. The library has zero runtime dependencies, ships PEP 561 type information, and uses second-level resolution end-to-end.
What it gives you
- Declarative DSL for each datetime field — values, ranges, steps, lists, exclusions, last-day-of-month negatives.
- Composable rules via Python operators —
union + intersection & difference -, with arbitrary nesting. - Lazy generation —
generate()yields one datetime at a time, suitable for multi-year ranges. - Batched generation —
generate_batch()for memory-efficient bulk processing. - Temporal navigation —
get_next()/get_prev()find the nearest match in either direction. - Immutable builders — every
with_*method returns a new rule; originals are never mutated. - Timezone awareness — optional timezone binding, DST gap handling, naive/aware mismatch detection.
- JSON round-trip —
to_json()/from_json()for persistence, with size and depth caps. - Thread-safe — parsed-field cache uses double-checked locking; configuration uses
ContextVar. - Zero runtime dependencies — standard library only.
Installation
pip install zerotime
or
uv add zerotime
Requires Python 3.12+.
Quick start
from datetime import datetime, UTC
from zerotime import AtomicRule
# Business hours: weekdays, 09:00-17:00 on the hour
business_hours = AtomicRule(
weekdays="1..5",
hours="9..17",
minutes="0",
seconds="0",
timezone=UTC,
)
# Lunch break: 12:00 and 13:00
lunch_break = AtomicRule(
weekdays="1..5",
hours="12,13",
minutes="0",
seconds="0",
timezone=UTC,
)
# Working hours = business hours minus lunch
working_hours = business_hours - lunch_break
# Find the next working hour after a reference instant
ref = datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC)
print(working_hours.get_next(ref))
# 2025-01-15 11:00:00+00:00
# Generate every working hour for a single day
day_start = datetime(2025, 1, 15, 0, 0, 0, tzinfo=UTC)
day_end = datetime(2025, 1, 15, 23, 59, 59, tzinfo=UTC)
for dt in working_hours.generate(day_start, day_end):
print(dt.strftime("%H:%M"))
# 09:00, 10:00, 11:00, 14:00, 15:00, 16:00, 17:00
# Persist and restore
from zerotime import Rule
restored = Rule.from_json(working_hours.to_json())
DSL syntax
Each AtomicRule field accepts a string expression:
| Syntax | Meaning | Example |
|---|---|---|
"N" |
Single value | "15" — day 15 |
"N..M" |
Inclusive range | "1..5" — Mon to Fri |
"N..M/S" |
Range with step | "0..59/15" — 0, 15, 30, 45 |
"/S" |
Global step | "/15" for minutes — 0, 15, 30, 45 |
"A,B,C" |
List | "1,15,-1" — 1st, 15th, last day |
"!N" |
Exclusion | "1..12,!7,!8" — all months except July and August |
"-N" |
Negative day | "-1" — last day of month (days field only) |
Field ranges: months 1-12, days 1-31 (or -1..-31), weekdays 1-7 (Mon=1), hours 0-23, minutes 0-59, seconds 0-59.
Comparison with alternatives
| Capability | Zerotime | croniter |
python-dateutil (rrule) |
recurrent |
|---|---|---|---|---|
| Pure-Python, stdlib only | yes | yes | no (six) |
depends on dateutil |
Set-operator composition (+ & -) |
yes | no | no | no |
Negative day-of-month (-1 = last) |
yes | no | partial (bymonthday=-1) |
no |
DSL exclusions (!7) |
yes | no | no | no |
| Last-day / quarterly patterns out of the box | yes | manual | manual | partial |
| JSON round-trip | yes | no | no | no |
| Timezone-aware with DST gap handling | yes | partial | yes | partial |
| Sub-second resolution | no | no | yes | no |
| iCalendar RFC 5545 conformance | no | no | yes | partial |
| Cron-expression input | no | yes | no | no |
If you need cron expressions, use croniter. If you need RFC 5545 RRULE compatibility, use python-dateutil. Zerotime targets the gap in the middle: a small, composable, stdlib-only rule abstraction with set algebra.
Known limits and open issues
A small library deliberately solves a small problem. The list below sets expectations up front.
- limit: second-level resolution; microseconds are ignored during matching.
- limit: year range bounded to
[1, 9999]; outside that range, methods raiseValueError. - limit: negative-day syntax is valid only in the
daysfield. - limit: JSON timezone round-trip preserves UTC offsets, not IANA zone names — DST-aware behavior may differ after deserialization.
- design: combining a tz-aware rule with a tz-naive rule is rejected at construction time (
InvalidRuleError); no implicit promotion. - design:
get_next/get_prevsearch a bounded number of years (default 5) — setmax_years_searchhigher when needed. - design:
generate()over very large ranges with nomax_generate_itemscap can produce unbounded output; usegenerate_batch()or set a cap. - open: the library version in
src/zerotime/__init__.pymay lead the publishedCHANGELOG.mdbetween releases.
Anti-patterns — how NOT to use this project
A short cheat sheet of usages the library does not support; expanded examples live in docs/ANTI_PATTERNS.md.
- Do not pass a naive datetime to a rule that has a timezone — it raises
ValueError. - Do not combine a tz-aware rule with a tz-naive rule — it raises
InvalidRuleError. - Do not call
generate()over multi-year ranges on a high-frequency rule without a cap or batches. - Do not write a DSL expression that excludes every value in the field — it raises
InvalidExpressionError. - Do not use negative values (
"-1") outside thedaysfield — onlydaysaccepts them. - Do not mutate
_*_exprattributes directly — usewith_*builder methods. - Do not assume IANA zone fidelity across
to_json/from_json— only UTC offsets round-trip. - Do not expect sub-second matching — Zerotime rounds to the second.
Running tests
uv run pytest
With coverage:
uv run pytest --cov=zerotime --cov-report=term-missing
Running examples
The examples/ directory ships ten standalone scripts covering every public feature:
| File | Description |
|---|---|
01_basic_usage.py |
AtomicRule, get_next, get_prev |
02_dsl_syntax.py |
Every DSL form with one example each |
03_rule_combination.py |
+, &, - operators |
04_generation_methods.py |
generate(), generate_reverse(), generate_batch() |
05_navigation.py |
Temporal navigation and error handling |
06_timezones.py |
Timezone-aware rules and DST handling |
07_configuration.py |
RuleConfig and global settings |
08_json_serialization.py |
to_json / from_json round-trip |
09_builder_methods.py |
Immutable with_* builders |
10_real_world_examples.py |
Billing, scheduling, SLA, maintenance windows |
Run any example directly:
python examples/01_basic_usage.py
Development
Contributor setup, quality pipeline, pre-commit hooks and release process live in docs/DEVELOPMENT.md.
Documentation map
User documentation:
README.md— this file.docs/ANTI_PATTERNS.md— how NOT to use the library, with worked examples.
Developer documentation:
docs/API_REFERENCE.md— every public symbol, verbatim signatures, parameters, raises.docs/ARCHITECTURE.md— component map, data flow, design decisions.docs/DEVELOPMENT.md— local setup, test/quality pipeline, commit conventions, release process.examples/README.md— runnable examples index.
Contributing
Zerotime is a personal portfolio project. Issues and discussions are welcome; pull requests may not be accepted in order to keep the design surface intentional. If you have a use case the library does not support, open an issue describing the scenario rather than sending a patch — that way the conversation about scope happens before the code.
License
MIT License — Copyright (c) 2025 Francesco Favi.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zerotime-0.1.4.tar.gz.
File metadata
- Download URL: zerotime-0.1.4.tar.gz
- Upload date:
- Size: 266.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f2d74042135be6f6e19362a2f0ef03360d9171519520574bf06dd42693449e99
|
|
| MD5 |
4fcba33949b08f21c3092e9ac0f8ca1b
|
|
| BLAKE2b-256 |
f0b5e19414b2167b7230e1ee82d2eb62cbeb58e24cc94e8e80f21ef72c959059
|
Provenance
The following attestation bundles were made for zerotime-0.1.4.tar.gz:
Publisher:
publish.yml on francescofavi/zerotime
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zerotime-0.1.4.tar.gz -
Subject digest:
f2d74042135be6f6e19362a2f0ef03360d9171519520574bf06dd42693449e99 - Sigstore transparency entry: 1395334621
- Sigstore integration time:
-
Permalink:
francescofavi/zerotime@31c84c48d1a1b44d4753425f0763556a62508378 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/francescofavi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@31c84c48d1a1b44d4753425f0763556a62508378 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file zerotime-0.1.4-py3-none-any.whl.
File metadata
- Download URL: zerotime-0.1.4-py3-none-any.whl
- Upload date:
- Size: 17.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d221ef4c6385b3800adbd7039c1c526b336733f8692acdc73511c5ffd1c6c5fd
|
|
| MD5 |
3efe89061250f4f737b6df83dd7a2647
|
|
| BLAKE2b-256 |
c81745748185b132c23aa3817c75b230207d2c428311bf6dc02706aafa638cad
|
Provenance
The following attestation bundles were made for zerotime-0.1.4-py3-none-any.whl:
Publisher:
publish.yml on francescofavi/zerotime
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zerotime-0.1.4-py3-none-any.whl -
Subject digest:
d221ef4c6385b3800adbd7039c1c526b336733f8692acdc73511c5ffd1c6c5fd - Sigstore transparency entry: 1395334668
- Sigstore integration time:
-
Permalink:
francescofavi/zerotime@31c84c48d1a1b44d4753425f0763556a62508378 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/francescofavi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@31c84c48d1a1b44d4753425f0763556a62508378 -
Trigger Event:
workflow_dispatch
-
Statement type: