Metadata-Version: 2.5
Name: dtrexp
Version: 1.2.0
Summary: DTRExp — Date-Time Range & Recurrence Expression: parsing, validation and coverage evaluation (spec draft 3)
Project-URL: Homepage, https://dtrexp.org
Project-URL: Repository, https://github.com/DTRExp/dtrexp-py
Project-URL: Specification, https://dtrexp.org/spec/
Author-email: Onur Yıldırım <onur@cutepilot.com>
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Description-Content-Type: text/markdown

<p align="center"><img src="https://raw.githubusercontent.com/DTRExp/dtrexp-py/main/.github/logo.svg" width="200" alt="dtrexp-py" /></p>

# dtrexp-py

Python implementation of **[DTRExp](https://github.com/DTRExp/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][js] has them.

## Install

```sh
pip install dtrexp
```

Python 3.11+, stdlib only (`zoneinfo` for IANA zones).

## Usage

```python
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; `None` means the bound `zone`, else `"UTC"`. A *tz* passed to the call, `"UTC"` included, wins over the bound zone. An unknown zone raises `ZoneInfoNotFoundError`. An instant whose local date in *tz* falls outside years 1–9999 (Tokyo at `9999-12-31T15:00Z`, New York at `0001-01-01T00:00Z`) lies past the edge of the timeline and raises `ValueError`.
- `with_zone(tz)`: a copy whose evaluation defaults to *tz*, readable as `zone` (`None` when unbound). The copy's `text` and `warnings` are the original's. The zone is checked at once: an unknown zone raises `ZoneInfoNotFoundError`, an empty name `ValueError`, and a non-`str` (`None` included) `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`](https://pypi.org/project/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](https://github.com/DTRExp/dtrexp/blob/main/spec.md#8-grammar-ebnf) defines it; the same in every DTRExp implementation:

```python
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)` raises `DTRExpSyntaxError` (a `ValueError`) 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: `M0000000003` is `M3`); a larger one is a syntax error at that integer.
- `validate(text)` never raises; typo-shaped input comes back as data. Returns a frozen `ValidationResult` with *valid* `bool`, *errors* (parsing stops at the first syntax error, so at most one) and *warnings*.
- Warnings are the spec's [§9.1](https://github.com/DTRExp/dtrexp/blob/main/spec.md#91-the-existence-rule) unsatisfiability lint: expressions that parse but can never match. They are `DTRExpWarning` objects (*message* `str`, *position* `int | None`); `str(warning)` is the message. `Expression.warnings` and `validate(text).warnings` carry the same content.

## Conformance & Quality

- The test suite is driven by the shared [`vectors.json`][vectors] from 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**, `W53` existence, DST gap/overlap in `Europe/Berlin`). See [VECTORS.md][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)][spec]: the DTRExp specification (grammar, semantics, conformance vectors) this package implements.
- [**dtrexp-js**][js]: the reference implementation; adds `intersect`, `next`, `describe`, `toRRule` and canonicalization.
- [**dtrexp-go**][go] · [**dtrexp-swift**][swift] · [**dtrexp-rs**][rs] · [**dtrexp-java**][java]: the other ports; same core interface.
- [**dtrexp-wasm**][wasm]: the Rust core compiled to WebAssembly for JS hosts.

## License

© 2026, Onur Yıldırım. [**MIT**](LICENSE) License.

[spec]: https://github.com/DTRExp/dtrexp
[js]: https://github.com/DTRExp/dtrexp-js
[go]: https://github.com/DTRExp/dtrexp-go
[swift]: https://github.com/DTRExp/dtrexp-swift
[rs]: https://github.com/DTRExp/dtrexp-rs
[java]: https://github.com/DTRExp/dtrexp-java
[vectors]: https://github.com/DTRExp/dtrexp/blob/main/vectors.json
[vectors-md]: https://github.com/DTRExp/dtrexp/blob/main/VECTORS.md
[wasm]: https://github.com/DTRExp/dtrexp-wasm
