dtrexp-py
Python implementation of DTRExp (read: "DTR Expression") — a compact string expression for date-time ranges and recurrence, evaluated by coverage rather than enumeration.
T0900:1800 E1:5 Mon–Fri, 09:00–18:00
E7#-1 M4 last Sunday of April, every year
20200106/10D every 10 days from 2020-01-06 (cron can't say this)
M!7 every month except July
Scope: parsing, validation and coverage evaluation — the spec's core interface. Rendering, description and RRULE export are out of scope; the reference implementation has them.
Install
pip install dtrexp
Python 3.11+, stdlib only (zoneinfo for IANA zones).
Usage
from datetime import datetime, timezone
import dtrexp
dtr = dtrexp.parse("T0900:1800 E1:5") # business hours, Mon–Fri
dtr.covers(datetime(2026, 7, 7, 10, 0, tzinfo=timezone.utc))
# —> True (UTC — the default)
dtr.covers(datetime(2026, 7, 7, 7, 30, tzinfo=timezone.utc), tz="Europe/Berlin")
# —> True (09:30 Berlin local time)
berlin = dtr.with_zone("Europe/Berlin") # a copy; dtr is left as it is
berlin.covers(datetime(2026, 7, 7, 7, 30, tzinfo=timezone.utc))
# —> True (the bound zone)
berlin.covers(datetime(2026, 7, 7, 7, 30, tzinfo=timezone.utc), tz="UTC")
# —> False (a tz passed to the call wins)
Note that you parse once (at write/config time) and evaluate many; Expression objects are read-only (setting an attribute raises AttributeError; with_zone returns a copy), so one can be shared freely, and covers is a single calendar-field extraction followed by integer comparisons (no occurrence iteration). The zone is an evaluation parameter, never part of the expression. covers also accepts an ISO 8601 string for the instant; one without an offset is read as UTC, and one that is no real date-time raises ValueError.
covers(instant, tz=None): tz is an IANA zone name;Nonemeans the boundzone, else"UTC". A tz passed to the call,"UTC"included, wins over the bound zone. An unknown zone raisesZoneInfoNotFoundError. An instant whose local date in tz falls outside years 1–9999 (Tokyo at9999-12-31T15:00Z, New York at0001-01-01T00:00Z) lies past the edge of the timeline and raisesValueError.with_zone(tz): a copy whose evaluation defaults to tz, readable aszone(Nonewhen unbound). The copy'stextandwarningsare the original's. The zone is checked at once: an unknown zone raisesZoneInfoNotFoundError, an empty nameValueError, and a non-str(Noneincluded)TypeError; binding never falls back to UTC or the host's zone.
Time Zones
A zone name is one of the tz database's zone and link names (Europe/Berlin, US/Eastern, UTC, Etc/GMT+5), matched without regard to case: europe/berlin works, and with_zone("europe/berlin").zone is "Europe/Berlin". Names are checked against the list zoneinfo.available_timezones() gives, never looked up as file paths, so the answer is the same on a file system that ignores case and on one that does not. Anything else is an unknown zone: a fixed offset (+02:00, UTC+2), Z, the zoneinfo files that name no zone (posixrules, Factory, localtime, SystemV/*, the posix/ and right/ copies) and the empty name.
The zone rules come from zoneinfo: the system's tz database (the TZPATH folders), else the tzdata package. Two machines agree wherever their tz releases agree. To pin a release, install a pinned tzdata and set PYTHONTZPATH to an empty string, so that only the package is read.
Which to use: bind the zone with with_zone() when it belongs to what the expression describes (a store's hours, a venue's schedule), and pass tz per call when it belongs to whoever is asking (a rule shown to each user in their own zone). A bound copy shares its text with the original, so a cache of parsed expressions keys on text and zone together.
Errors and Warnings
Both carry a position. An error's position is the 0-based offset, in Unicode code points, of the first character of the token at fault, as spec §8 defines it; the same in every DTRExp implementation:
dtrexp.parse("Y*/3")
# raises DTRExpSyntaxError: "anchorless stride — an explicit start is required (at 2)"
# error.position —> 2 (the '/' that the anchorless start does not allow)
res = dtrexp.validate("D30 M2") # never raises
res.valid # True — it parses
res.warnings # (DTRExpWarning(message="unsatisfiable …", position=0),)
parse(text)raisesDTRExpSyntaxError(aValueError) on invalid input, and nothing else; position points at the token at fault and is appended to the rendered message. An integer is at most 2,147,483,647 (leading zeros are free:M0000000003isM3); a larger one is a syntax error at that integer.validate(text)never raises; typo-shaped input comes back as data. Returns a frozenValidationResultwith validbool, errors (parsing stops at the first syntax error, so at most one) and warnings.- Warnings are the spec's §9.1 unsatisfiability lint: expressions that parse but can never match. They are
DTRExpWarningobjects (messagestr, positionint | None);str(warning)is the message.Expression.warningsandvalidate(text).warningscarry the same content.
Conformance & Quality
- The test suite is driven by the shared
vectors.jsonfrom the spec repo (the draft 3 file): every coverage, rejection (at its pinned position), warning and quiet vector, including the calendar traps (Feb 29 across 2000/2024/2100,W53existence, DST gap/overlap inEurope/Berlin). See VECTORS.md for how the suite works. - 100% line + branch coverage, enforced (
uv run poe cover); mutation-tested with mutmut (uv run poe mutation). - Zero dependencies.
Related Projects
- dtrexp (spec): the DTRExp specification (grammar, semantics, conformance vectors) this package implements.
- dtrexp-js: the reference implementation; adds
intersect,next,describe,toRRuleand canonicalization. - dtrexp-go · dtrexp-swift · dtrexp-rs · dtrexp-java: the other ports; same core interface.
- dtrexp-wasm: the Rust core compiled to WebAssembly for JS hosts.
License
© 2026, Onur Yıldırım. MIT License.
Metadata
Release files for dtrexp 1.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| dtrexp-1.2.0.tar.gz | 102.7 kB | Without provenance | Details for dtrexp-1.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| dtrexp-1.2.0-py3-none-any.whl | Python 3 | none | any | Details for dtrexp-1.2.0-py3-none-any.whl |
Total release size: 127.3 kB
Release files / dtrexp-1.2.0.tar.gz
| Download URL | dtrexp-1.2.0.tar.gz |
|---|---|
| Size | 102.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
74ee37f115363f180f0ac41e58b64bdeefe743098073ad04bf3c22e79821e239
|
|
BLAKE2b-256 checksum How to use checksums |
710e5a326640e2618c6adbd6035ad83ea0eda4095294db8a047abb99ad3b6e5d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / dtrexp-1.2.0-py3-none-any.whl
| Download URL | dtrexp-1.2.0-py3-none-any.whl |
|---|---|
| Size | 24.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1bd166ffb501275f8ebe238e97f099c89a5ae5588300edebea6202917c4a1ac4
|
|
BLAKE2b-256 checksum How to use checksums |
1f3b318131e8dbbf67bdfc6f87585a1fe2e5e80ac32b19bd3601bb35d3f5ff7c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for dtrexp 1.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.