python-dateutil-rs 0.0.15__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/Cargo.lock +2 -13
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/Cargo.toml +1 -1
- python_dateutil_rs-0.1.1/PKG-INFO +232 -0
- python_dateutil_rs-0.1.1/README.md +209 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/Cargo.toml +1 -1
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/common.rs +36 -49
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/easter.rs +0 -44
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/error.rs +2 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/parser/isoparser.rs +21 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/parser/parserinfo.rs +40 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/parser.rs +368 -82
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/relativedelta.rs +223 -25
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/rrule/iter.rs +30 -27
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/rrule/parse.rs +25 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/rrule/set.rs +38 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/rrule.rs +525 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/tz/local.rs +40 -2
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/tz/offset.rs +12 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/tz.rs +38 -14
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/Cargo.toml +1 -2
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/py/common.rs +15 -5
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/py/relativedelta.rs +34 -2
- python_dateutil_rs-0.1.1/crates/dateutil-py/src/py/rrule.rs +926 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/py.rs +2 -3
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/pyproject.toml +5 -5
- {python_dateutil_rs-0.0.15/python/dateutil_rs/v1 → python_dateutil_rs-0.1.1/python/dateutil_rs}/__init__.py +22 -15
- {python_dateutil_rs-0.0.15/python/dateutil_rs/v1 → python_dateutil_rs-0.1.1/python/dateutil_rs}/_native.pyi +34 -17
- python_dateutil_rs-0.1.1/python/dateutil_rs/easter.py +15 -0
- {python_dateutil_rs-0.0.15/python/dateutil_rs/v1 → python_dateutil_rs-0.1.1/python/dateutil_rs}/parser.py +2 -2
- python_dateutil_rs-0.1.1/python/dateutil_rs/relativedelta.py +5 -0
- {python_dateutil_rs-0.0.15/python/dateutil_rs/v1 → python_dateutil_rs-0.1.1/python/dateutil_rs}/rrule.py +2 -2
- {python_dateutil_rs-0.0.15/python/dateutil_rs/v1 → python_dateutil_rs-0.1.1/python/dateutil_rs}/tz.py +2 -2
- python_dateutil_rs-0.0.15/PKG-INFO +0 -309
- python_dateutil_rs-0.0.15/README.md +0 -286
- python_dateutil_rs-0.0.15/crates/dateutil-py/src/py/rrule.rs +0 -614
- python_dateutil_rs-0.0.15/crates/dateutil-rs/Cargo.toml +0 -29
- python_dateutil_rs-0.0.15/crates/dateutil-rs/LICENSE +0 -21
- python_dateutil_rs-0.0.15/crates/dateutil-rs/README.md +0 -286
- python_dateutil_rs-0.0.15/crates/dateutil-rs/benches/benchmarks.rs +0 -349
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/common.rs +0 -180
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/easter.rs +0 -214
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/lib.rs +0 -62
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/parser/isoparser.rs +0 -767
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/parser.rs +0 -2525
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/relativedelta.rs +0 -1667
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/rrule/iter.rs +0 -1417
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/rrule.rs +0 -3651
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/tz/file.rs +0 -819
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/tz/local.rs +0 -239
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/tz/offset.rs +0 -136
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/tz/range.rs +0 -1344
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/tz/utc.rs +0 -102
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/tz.rs +0 -1579
- python_dateutil_rs-0.0.15/crates/dateutil-rs/src/utils.rs +0 -141
- python_dateutil_rs-0.0.15/python/dateutil_rs/__init__.py +0 -31
- python_dateutil_rs-0.0.15/python/dateutil_rs/_native.pyi +0 -397
- python_dateutil_rs-0.0.15/python/dateutil_rs/common.py +0 -5
- python_dateutil_rs-0.0.15/python/dateutil_rs/easter.py +0 -5
- python_dateutil_rs-0.0.15/python/dateutil_rs/parser.py +0 -272
- python_dateutil_rs-0.0.15/python/dateutil_rs/relativedelta.py +0 -4
- python_dateutil_rs-0.0.15/python/dateutil_rs/rrule.py +0 -46
- python_dateutil_rs-0.0.15/python/dateutil_rs/tz.py +0 -476
- python_dateutil_rs-0.0.15/python/dateutil_rs/utils.py +0 -5
- python_dateutil_rs-0.0.15/python/dateutil_rs/v1/common.py +0 -5
- python_dateutil_rs-0.0.15/python/dateutil_rs/v1/easter.py +0 -10
- python_dateutil_rs-0.0.15/python/dateutil_rs/v1/py.typed +0 -0
- python_dateutil_rs-0.0.15/python/dateutil_rs/v1/relativedelta.py +0 -5
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/LICENSE +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/CLAUDE.md +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/benches/benchmarks.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/lib.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/parser/tokenizer.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/tz/file.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-core/src/tz/utc.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/lib.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/py/conv.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/py/easter.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/py/parser.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/crates/dateutil-py/src/py/tz.rs +0 -0
- {python_dateutil_rs-0.0.15 → python_dateutil_rs-0.1.1}/python/dateutil_rs/py.typed +0 -0
|
@@ -220,7 +220,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
|
|
220
220
|
|
|
221
221
|
[[package]]
|
|
222
222
|
name = "dateutil-core"
|
|
223
|
-
version = "0.1.
|
|
223
|
+
version = "0.1.1"
|
|
224
224
|
dependencies = [
|
|
225
225
|
"bitflags",
|
|
226
226
|
"chrono",
|
|
@@ -233,24 +233,13 @@ dependencies = [
|
|
|
233
233
|
|
|
234
234
|
[[package]]
|
|
235
235
|
name = "dateutil-py"
|
|
236
|
-
version = "0.1.
|
|
236
|
+
version = "0.1.1"
|
|
237
237
|
dependencies = [
|
|
238
238
|
"chrono",
|
|
239
239
|
"dateutil-core",
|
|
240
240
|
"pyo3",
|
|
241
241
|
]
|
|
242
242
|
|
|
243
|
-
[[package]]
|
|
244
|
-
name = "dateutil-rs"
|
|
245
|
-
version = "0.0.15"
|
|
246
|
-
dependencies = [
|
|
247
|
-
"chrono",
|
|
248
|
-
"criterion",
|
|
249
|
-
"dateutil-py",
|
|
250
|
-
"pyo3",
|
|
251
|
-
"thiserror",
|
|
252
|
-
]
|
|
253
|
-
|
|
254
243
|
[[package]]
|
|
255
244
|
name = "either"
|
|
256
245
|
version = "1.15.0"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-dateutil-rs
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
5
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
9
|
+
Requires-Dist: python-dateutil>=2.9 ; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=9.0 ; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest-cov>=7.1 ; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-benchmark>=5.2 ; extra == 'dev'
|
|
13
|
+
Requires-Dist: freezegun>=1.5 ; extra == 'dev'
|
|
14
|
+
Requires-Dist: hypothesis>=6.151 ; extra == 'dev'
|
|
15
|
+
Requires-Dist: maturin>=1.13 ; extra == 'dev'
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Summary: A Rust-backed port of python-dateutil
|
|
19
|
+
License-Expression: MIT
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
22
|
+
|
|
23
|
+
# python-dateutil-rs
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/python-dateutil-rs/)
|
|
26
|
+
[](https://pypi.org/project/python-dateutil-rs/)
|
|
27
|
+
[](https://pypi.org/project/python-dateutil-rs/)
|
|
28
|
+
[](https://github.com/wakita181009/dateutil-rs/actions/workflows/ci.yml)
|
|
29
|
+
[](https://codecov.io/gh/wakita181009/dateutil-rs)
|
|
30
|
+
|
|
31
|
+
A high-performance Rust-backed port of [python-dateutil](https://github.com/dateutil/dateutil) (v2.9.0).
|
|
32
|
+
|
|
33
|
+
> **Status:** All core modules (easter, relativedelta, parser, rrule, tz) are rewritten in Rust via PyO3/maturin. The optimized `dateutil-core` + `dateutil-py` architecture delivers **2x-897x** speedups over python-dateutil.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Drop-in replacement** for `python-dateutil` — same API, same behavior
|
|
38
|
+
- **Rust-accelerated:** easter, relativedelta, parser (`parse` / `isoparse`), rrule, tz, weekday
|
|
39
|
+
- **Optimized core:** zero-copy parser, PHF lookup tables, bitflag filters, buffer-reusing rrule
|
|
40
|
+
- **Comprehensive test suite** inherited from the original project
|
|
41
|
+
- **Benchmark infrastructure** for side-by-side performance comparison
|
|
42
|
+
- **Python 3.10-3.14** supported on Linux and macOS
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install python-dateutil-rs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from dateutil_rs import (
|
|
54
|
+
parse, isoparse, relativedelta, rrule, rruleset, rrulestr,
|
|
55
|
+
easter, gettz, tzutc, tzoffset,
|
|
56
|
+
MONTHLY, MO, TU, WE, TH, FR, SA, SU,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Parse date strings (zero-copy tokenizer)
|
|
60
|
+
dt = parse("2026-01-15T10:30:00+09:00")
|
|
61
|
+
|
|
62
|
+
# ISO-8601 strict parsing
|
|
63
|
+
dt = isoparse("2026-01-15T10:30:00")
|
|
64
|
+
|
|
65
|
+
# Relative deltas
|
|
66
|
+
next_month = dt + relativedelta(months=+1)
|
|
67
|
+
|
|
68
|
+
# Recurrence rules (buffer-reusing iterator)
|
|
69
|
+
monthly = rrule(MONTHLY, count=5, dtstart=parse("2026-01-01"))
|
|
70
|
+
dates = monthly.all()
|
|
71
|
+
dates = list(monthly) # also iterable
|
|
72
|
+
first = monthly[0] # indexing
|
|
73
|
+
subset = monthly[1:3] # slicing
|
|
74
|
+
n = monthly.count() # total occurrences
|
|
75
|
+
dt in monthly # membership test
|
|
76
|
+
|
|
77
|
+
# Timezones
|
|
78
|
+
tokyo = gettz("Asia/Tokyo")
|
|
79
|
+
utc = tzutc()
|
|
80
|
+
|
|
81
|
+
# Easter
|
|
82
|
+
easter_date = easter(2026)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
### Prerequisites
|
|
88
|
+
|
|
89
|
+
- Python 3.10+
|
|
90
|
+
- Rust toolchain
|
|
91
|
+
- [uv](https://github.com/astral-sh/uv) (recommended) or pip
|
|
92
|
+
|
|
93
|
+
### Setup
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/wakita181009/dateutil-rs.git
|
|
97
|
+
cd dateutil-rs
|
|
98
|
+
uv sync --extra dev
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Building
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Build the native extension
|
|
105
|
+
maturin develop --release
|
|
106
|
+
|
|
107
|
+
# Development build (faster compilation)
|
|
108
|
+
maturin develop -F python
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Running Tests
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Run the test suite
|
|
115
|
+
uv run pytest tests/ -x -q
|
|
116
|
+
|
|
117
|
+
# Run with coverage
|
|
118
|
+
uv run pytest tests/ --cov=dateutil_rs
|
|
119
|
+
|
|
120
|
+
# Run Rust tests
|
|
121
|
+
cargo test -p dateutil-core
|
|
122
|
+
cargo test --workspace
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Linting
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv run ruff check tests/ python/
|
|
129
|
+
uv run ruff format --check tests/ python/
|
|
130
|
+
uv run mypy python/
|
|
131
|
+
cargo clippy --workspace
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Benchmarks
|
|
135
|
+
|
|
136
|
+
Benchmarks compare the original `python-dateutil` (PyPI) and the Rust extension (`dateutil_rs`) using pytest-benchmark.
|
|
137
|
+
|
|
138
|
+
#### Summary (vs python-dateutil)
|
|
139
|
+
|
|
140
|
+
| Module | Speedup |
|
|
141
|
+
|--------|---------|
|
|
142
|
+
| Parser (parse) | **19.5x-36.0x** |
|
|
143
|
+
| Parser (isoparse) | **13.0x-38.4x** |
|
|
144
|
+
| RRule | **5.9x-63.7x** |
|
|
145
|
+
| Timezone | **1.0x-896.7x** ¹ |
|
|
146
|
+
| RelativeDelta | **2.0x-28.1x** |
|
|
147
|
+
| Easter | **5.0x-7.3x** |
|
|
148
|
+
|
|
149
|
+
> Measured on Apple Silicon (M-series), Python 3.13, release build. Full results: [benchmarks/RESULTS.md](benchmarks/RESULTS.md)
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# Install the original python-dateutil for comparison
|
|
153
|
+
uv pip install python-dateutil
|
|
154
|
+
|
|
155
|
+
# Run benchmarks
|
|
156
|
+
make bench
|
|
157
|
+
|
|
158
|
+
# Run and save results as JSON
|
|
159
|
+
make bench-save
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Project Structure
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
dateutil-rs/
|
|
166
|
+
├── Cargo.toml # Workspace root
|
|
167
|
+
├── pyproject.toml # Python project config (maturin)
|
|
168
|
+
├── crates/
|
|
169
|
+
│ ├── dateutil-core/ # Pure Rust optimized core (crates.io)
|
|
170
|
+
│ │ └── src/
|
|
171
|
+
│ │ ├── lib.rs # Crate root, public API
|
|
172
|
+
│ │ ├── common.rs # Weekday (MO-SU with N-th occurrence)
|
|
173
|
+
│ │ ├── easter.rs # Easter date calculations
|
|
174
|
+
│ │ ├── error.rs # Shared error types
|
|
175
|
+
│ │ ├── relativedelta.rs
|
|
176
|
+
│ │ ├── parser.rs # parse() entry point
|
|
177
|
+
│ │ ├── parser/ # tokenizer, parserinfo, isoparser
|
|
178
|
+
│ │ ├── rrule.rs # RRule entry point
|
|
179
|
+
│ │ ├── rrule/ # iter, parse (rrulestr), set
|
|
180
|
+
│ │ └── tz/ # tzutc, tzoffset, tzfile, tzlocal
|
|
181
|
+
│ └── dateutil-py/ # PyO3 binding layer → PyPI package
|
|
182
|
+
│ └── src/
|
|
183
|
+
│ ├── lib.rs # Module registration
|
|
184
|
+
│ ├── py.rs # Binding root + #[pymodule]
|
|
185
|
+
│ └── py/ # Per-module bindings (common, conv, easter, parser, relativedelta, rrule, tz)
|
|
186
|
+
├── python/dateutil_rs/ # Python package (maturin mixed layout)
|
|
187
|
+
│ ├── __init__.py # Re-exports from Rust native module
|
|
188
|
+
│ ├── _native.pyi # Type stubs for native module
|
|
189
|
+
│ ├── py.typed # PEP 561 marker
|
|
190
|
+
│ └── parser.py # parserinfo (Python subclass support)
|
|
191
|
+
├── tests/ # Python test suite
|
|
192
|
+
├── benchmarks/ # pytest-benchmark comparisons
|
|
193
|
+
├── .github/workflows/ # CI (ci.yml, publish.yml)
|
|
194
|
+
├── Makefile
|
|
195
|
+
└── LICENSE
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Crate Roles
|
|
199
|
+
|
|
200
|
+
| Crate | Purpose | PyO3 | Publish To |
|
|
201
|
+
|-------|---------|------|------------|
|
|
202
|
+
| `dateutil-core` | Pure Rust optimized core | No | crates.io |
|
|
203
|
+
| `dateutil-py` | PyO3 binding layer | Yes | PyPI (`python-dateutil-rs`) |
|
|
204
|
+
|
|
205
|
+
## Implementation Status
|
|
206
|
+
|
|
207
|
+
| Module | Status | Notes |
|
|
208
|
+
|--------|:------:|-------|
|
|
209
|
+
| common (Weekday) | ✅ | MO-SU constants with N-th occurrence |
|
|
210
|
+
| easter | ✅ | 5.0x-7.3x faster, 3 calendar methods |
|
|
211
|
+
| relativedelta | ✅ | 2.0x-28.1x faster |
|
|
212
|
+
| parser (parse) | ✅ | 19.5x-36.0x faster, zero-copy tokenizer, PHF lookups |
|
|
213
|
+
| parser (isoparse) | ✅ | 13.0x-38.4x faster |
|
|
214
|
+
| parser (parserinfo) | ✅ | Customizable via Python subclass |
|
|
215
|
+
| rrule / rruleset | ✅ | 5.9x-63.7x faster, bitflag filters, buffer reuse |
|
|
216
|
+
| rrulestr | ✅ | RFC 5545 string parsing |
|
|
217
|
+
| tz (tzutc, tzoffset, tzfile, tzlocal) | ✅ | 1.0x-896.7x faster |
|
|
218
|
+
| tz utilities (gettz, datetime_exists, etc.) | ✅ | gettz with caching |
|
|
219
|
+
|
|
220
|
+
## Roadmap
|
|
221
|
+
|
|
222
|
+
1. ~~Python-only phase — Pure Python port with full test coverage~~ ✅
|
|
223
|
+
2. ~~Rust core + PyO3 bindings — easter, relativedelta, parser, weekday~~ ✅
|
|
224
|
+
3. ~~Rust rrule — Rewrite recurrence rules in Rust~~ ✅
|
|
225
|
+
4. ~~Rust tz — Rewrite timezone support in Rust~~ ✅
|
|
226
|
+
5. ~~Optimized core — zero-copy parser, buffer-reusing rrule, consolidated architecture~~ ✅
|
|
227
|
+
6. **Release** — Publish dateutil-core to crates.io and python-dateutil-rs 1.0 to PyPI
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[MIT](LICENSE)
|
|
232
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# python-dateutil-rs
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/python-dateutil-rs/)
|
|
4
|
+
[](https://pypi.org/project/python-dateutil-rs/)
|
|
5
|
+
[](https://pypi.org/project/python-dateutil-rs/)
|
|
6
|
+
[](https://github.com/wakita181009/dateutil-rs/actions/workflows/ci.yml)
|
|
7
|
+
[](https://codecov.io/gh/wakita181009/dateutil-rs)
|
|
8
|
+
|
|
9
|
+
A high-performance Rust-backed port of [python-dateutil](https://github.com/dateutil/dateutil) (v2.9.0).
|
|
10
|
+
|
|
11
|
+
> **Status:** All core modules (easter, relativedelta, parser, rrule, tz) are rewritten in Rust via PyO3/maturin. The optimized `dateutil-core` + `dateutil-py` architecture delivers **2x-897x** speedups over python-dateutil.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Drop-in replacement** for `python-dateutil` — same API, same behavior
|
|
16
|
+
- **Rust-accelerated:** easter, relativedelta, parser (`parse` / `isoparse`), rrule, tz, weekday
|
|
17
|
+
- **Optimized core:** zero-copy parser, PHF lookup tables, bitflag filters, buffer-reusing rrule
|
|
18
|
+
- **Comprehensive test suite** inherited from the original project
|
|
19
|
+
- **Benchmark infrastructure** for side-by-side performance comparison
|
|
20
|
+
- **Python 3.10-3.14** supported on Linux and macOS
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install python-dateutil-rs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from dateutil_rs import (
|
|
32
|
+
parse, isoparse, relativedelta, rrule, rruleset, rrulestr,
|
|
33
|
+
easter, gettz, tzutc, tzoffset,
|
|
34
|
+
MONTHLY, MO, TU, WE, TH, FR, SA, SU,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Parse date strings (zero-copy tokenizer)
|
|
38
|
+
dt = parse("2026-01-15T10:30:00+09:00")
|
|
39
|
+
|
|
40
|
+
# ISO-8601 strict parsing
|
|
41
|
+
dt = isoparse("2026-01-15T10:30:00")
|
|
42
|
+
|
|
43
|
+
# Relative deltas
|
|
44
|
+
next_month = dt + relativedelta(months=+1)
|
|
45
|
+
|
|
46
|
+
# Recurrence rules (buffer-reusing iterator)
|
|
47
|
+
monthly = rrule(MONTHLY, count=5, dtstart=parse("2026-01-01"))
|
|
48
|
+
dates = monthly.all()
|
|
49
|
+
dates = list(monthly) # also iterable
|
|
50
|
+
first = monthly[0] # indexing
|
|
51
|
+
subset = monthly[1:3] # slicing
|
|
52
|
+
n = monthly.count() # total occurrences
|
|
53
|
+
dt in monthly # membership test
|
|
54
|
+
|
|
55
|
+
# Timezones
|
|
56
|
+
tokyo = gettz("Asia/Tokyo")
|
|
57
|
+
utc = tzutc()
|
|
58
|
+
|
|
59
|
+
# Easter
|
|
60
|
+
easter_date = easter(2026)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
### Prerequisites
|
|
66
|
+
|
|
67
|
+
- Python 3.10+
|
|
68
|
+
- Rust toolchain
|
|
69
|
+
- [uv](https://github.com/astral-sh/uv) (recommended) or pip
|
|
70
|
+
|
|
71
|
+
### Setup
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git clone https://github.com/wakita181009/dateutil-rs.git
|
|
75
|
+
cd dateutil-rs
|
|
76
|
+
uv sync --extra dev
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Building
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Build the native extension
|
|
83
|
+
maturin develop --release
|
|
84
|
+
|
|
85
|
+
# Development build (faster compilation)
|
|
86
|
+
maturin develop -F python
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Running Tests
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Run the test suite
|
|
93
|
+
uv run pytest tests/ -x -q
|
|
94
|
+
|
|
95
|
+
# Run with coverage
|
|
96
|
+
uv run pytest tests/ --cov=dateutil_rs
|
|
97
|
+
|
|
98
|
+
# Run Rust tests
|
|
99
|
+
cargo test -p dateutil-core
|
|
100
|
+
cargo test --workspace
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Linting
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
uv run ruff check tests/ python/
|
|
107
|
+
uv run ruff format --check tests/ python/
|
|
108
|
+
uv run mypy python/
|
|
109
|
+
cargo clippy --workspace
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Benchmarks
|
|
113
|
+
|
|
114
|
+
Benchmarks compare the original `python-dateutil` (PyPI) and the Rust extension (`dateutil_rs`) using pytest-benchmark.
|
|
115
|
+
|
|
116
|
+
#### Summary (vs python-dateutil)
|
|
117
|
+
|
|
118
|
+
| Module | Speedup |
|
|
119
|
+
|--------|---------|
|
|
120
|
+
| Parser (parse) | **19.5x-36.0x** |
|
|
121
|
+
| Parser (isoparse) | **13.0x-38.4x** |
|
|
122
|
+
| RRule | **5.9x-63.7x** |
|
|
123
|
+
| Timezone | **1.0x-896.7x** ¹ |
|
|
124
|
+
| RelativeDelta | **2.0x-28.1x** |
|
|
125
|
+
| Easter | **5.0x-7.3x** |
|
|
126
|
+
|
|
127
|
+
> Measured on Apple Silicon (M-series), Python 3.13, release build. Full results: [benchmarks/RESULTS.md](benchmarks/RESULTS.md)
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Install the original python-dateutil for comparison
|
|
131
|
+
uv pip install python-dateutil
|
|
132
|
+
|
|
133
|
+
# Run benchmarks
|
|
134
|
+
make bench
|
|
135
|
+
|
|
136
|
+
# Run and save results as JSON
|
|
137
|
+
make bench-save
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Project Structure
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
dateutil-rs/
|
|
144
|
+
├── Cargo.toml # Workspace root
|
|
145
|
+
├── pyproject.toml # Python project config (maturin)
|
|
146
|
+
├── crates/
|
|
147
|
+
│ ├── dateutil-core/ # Pure Rust optimized core (crates.io)
|
|
148
|
+
│ │ └── src/
|
|
149
|
+
│ │ ├── lib.rs # Crate root, public API
|
|
150
|
+
│ │ ├── common.rs # Weekday (MO-SU with N-th occurrence)
|
|
151
|
+
│ │ ├── easter.rs # Easter date calculations
|
|
152
|
+
│ │ ├── error.rs # Shared error types
|
|
153
|
+
│ │ ├── relativedelta.rs
|
|
154
|
+
│ │ ├── parser.rs # parse() entry point
|
|
155
|
+
│ │ ├── parser/ # tokenizer, parserinfo, isoparser
|
|
156
|
+
│ │ ├── rrule.rs # RRule entry point
|
|
157
|
+
│ │ ├── rrule/ # iter, parse (rrulestr), set
|
|
158
|
+
│ │ └── tz/ # tzutc, tzoffset, tzfile, tzlocal
|
|
159
|
+
│ └── dateutil-py/ # PyO3 binding layer → PyPI package
|
|
160
|
+
│ └── src/
|
|
161
|
+
│ ├── lib.rs # Module registration
|
|
162
|
+
│ ├── py.rs # Binding root + #[pymodule]
|
|
163
|
+
│ └── py/ # Per-module bindings (common, conv, easter, parser, relativedelta, rrule, tz)
|
|
164
|
+
├── python/dateutil_rs/ # Python package (maturin mixed layout)
|
|
165
|
+
│ ├── __init__.py # Re-exports from Rust native module
|
|
166
|
+
│ ├── _native.pyi # Type stubs for native module
|
|
167
|
+
│ ├── py.typed # PEP 561 marker
|
|
168
|
+
│ └── parser.py # parserinfo (Python subclass support)
|
|
169
|
+
├── tests/ # Python test suite
|
|
170
|
+
├── benchmarks/ # pytest-benchmark comparisons
|
|
171
|
+
├── .github/workflows/ # CI (ci.yml, publish.yml)
|
|
172
|
+
├── Makefile
|
|
173
|
+
└── LICENSE
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Crate Roles
|
|
177
|
+
|
|
178
|
+
| Crate | Purpose | PyO3 | Publish To |
|
|
179
|
+
|-------|---------|------|------------|
|
|
180
|
+
| `dateutil-core` | Pure Rust optimized core | No | crates.io |
|
|
181
|
+
| `dateutil-py` | PyO3 binding layer | Yes | PyPI (`python-dateutil-rs`) |
|
|
182
|
+
|
|
183
|
+
## Implementation Status
|
|
184
|
+
|
|
185
|
+
| Module | Status | Notes |
|
|
186
|
+
|--------|:------:|-------|
|
|
187
|
+
| common (Weekday) | ✅ | MO-SU constants with N-th occurrence |
|
|
188
|
+
| easter | ✅ | 5.0x-7.3x faster, 3 calendar methods |
|
|
189
|
+
| relativedelta | ✅ | 2.0x-28.1x faster |
|
|
190
|
+
| parser (parse) | ✅ | 19.5x-36.0x faster, zero-copy tokenizer, PHF lookups |
|
|
191
|
+
| parser (isoparse) | ✅ | 13.0x-38.4x faster |
|
|
192
|
+
| parser (parserinfo) | ✅ | Customizable via Python subclass |
|
|
193
|
+
| rrule / rruleset | ✅ | 5.9x-63.7x faster, bitflag filters, buffer reuse |
|
|
194
|
+
| rrulestr | ✅ | RFC 5545 string parsing |
|
|
195
|
+
| tz (tzutc, tzoffset, tzfile, tzlocal) | ✅ | 1.0x-896.7x faster |
|
|
196
|
+
| tz utilities (gettz, datetime_exists, etc.) | ✅ | gettz with caching |
|
|
197
|
+
|
|
198
|
+
## Roadmap
|
|
199
|
+
|
|
200
|
+
1. ~~Python-only phase — Pure Python port with full test coverage~~ ✅
|
|
201
|
+
2. ~~Rust core + PyO3 bindings — easter, relativedelta, parser, weekday~~ ✅
|
|
202
|
+
3. ~~Rust rrule — Rewrite recurrence rules in Rust~~ ✅
|
|
203
|
+
4. ~~Rust tz — Rewrite timezone support in Rust~~ ✅
|
|
204
|
+
5. ~~Optimized core — zero-copy parser, buffer-reusing rrule, consolidated architecture~~ ✅
|
|
205
|
+
6. **Release** — Publish dateutil-core to crates.io and python-dateutil-rs 1.0 to PyPI
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
[MIT](LICENSE)
|
|
@@ -5,7 +5,8 @@ use std::fmt;
|
|
|
5
5
|
///
|
|
6
6
|
/// The `weekday` field is 0-based: 0=Monday, 1=Tuesday, ..., 6=Sunday.
|
|
7
7
|
/// The `n` field indicates the N-th occurrence (e.g., 2nd Tuesday = TU(+2)).
|
|
8
|
-
/// When `n` is `None
|
|
8
|
+
/// When `n` is `None`, only the day name is displayed.
|
|
9
|
+
/// `n = Some(0)` is rejected at construction time (`Weekday::new`).
|
|
9
10
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
10
11
|
pub struct Weekday {
|
|
11
12
|
weekday: u8,
|
|
@@ -22,6 +23,9 @@ impl Weekday {
|
|
|
22
23
|
if weekday > 6 {
|
|
23
24
|
return Err(WeekdayError::InvalidWeekday(weekday));
|
|
24
25
|
}
|
|
26
|
+
if n == Some(0) {
|
|
27
|
+
return Err(WeekdayError::InvalidN);
|
|
28
|
+
}
|
|
25
29
|
Ok(Self { weekday, n })
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -36,19 +40,28 @@ impl Weekday {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/// Create a new Weekday with the same day but different `n`.
|
|
43
|
+
/// `Some(0)` is normalized to `None` (n=0 is semantically "any occurrence").
|
|
39
44
|
pub fn with_n(&self, n: Option<i32>) -> Self {
|
|
40
45
|
Self {
|
|
41
46
|
weekday: self.weekday,
|
|
42
|
-
n,
|
|
47
|
+
n: if n == Some(0) { None } else { n },
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
impl TryFrom<u8> for Weekday {
|
|
53
|
+
type Error = WeekdayError;
|
|
54
|
+
|
|
55
|
+
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
|
56
|
+
Self::new(value, None)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
impl fmt::Display for Weekday {
|
|
48
61
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
49
62
|
let name = DAY_NAMES[self.weekday as usize];
|
|
50
63
|
match self.n {
|
|
51
|
-
None
|
|
64
|
+
None => write!(f, "{name}"),
|
|
52
65
|
Some(n) => write!(f, "{name}({n:+})"),
|
|
53
66
|
}
|
|
54
67
|
}
|
|
@@ -124,9 +137,11 @@ mod tests {
|
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
#[test]
|
|
127
|
-
fn
|
|
128
|
-
|
|
129
|
-
|
|
140
|
+
fn test_weekday_n_zero_rejected() {
|
|
141
|
+
assert!(matches!(
|
|
142
|
+
Weekday::new(0, Some(0)),
|
|
143
|
+
Err(WeekdayError::InvalidN)
|
|
144
|
+
));
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
#[test]
|
|
@@ -174,30 +189,6 @@ mod tests {
|
|
|
174
189
|
assert_eq!(wd_neg.to_string(), "SU(-100)");
|
|
175
190
|
}
|
|
176
191
|
|
|
177
|
-
#[test]
|
|
178
|
-
fn test_weekday_negative_one_n() {
|
|
179
|
-
// Last occurrence (e.g., last Friday of month)
|
|
180
|
-
let wd = FR.with_n(Some(-1));
|
|
181
|
-
assert_eq!(wd.n(), Some(-1));
|
|
182
|
-
assert_eq!(wd.to_string(), "FR(-1)");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
#[test]
|
|
186
|
-
fn test_weekday_clone_copy() {
|
|
187
|
-
let wd = MO.with_n(Some(2));
|
|
188
|
-
let cloned = wd;
|
|
189
|
-
assert_eq!(wd, cloned); // Copy semantics — both usable
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
#[test]
|
|
193
|
-
fn test_weekday_boundary_values() {
|
|
194
|
-
// Weekday 0 (Monday) and 6 (Sunday) are boundaries
|
|
195
|
-
let mon = Weekday::new(0, Some(1)).unwrap();
|
|
196
|
-
let sun = Weekday::new(6, Some(-1)).unwrap();
|
|
197
|
-
assert_eq!(mon.weekday(), 0);
|
|
198
|
-
assert_eq!(sun.weekday(), 6);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
192
|
#[test]
|
|
202
193
|
fn test_weekday_all_invalid() {
|
|
203
194
|
for i in 7..=255 {
|
|
@@ -212,15 +203,6 @@ mod tests {
|
|
|
212
203
|
assert_ne!(a, b);
|
|
213
204
|
}
|
|
214
205
|
|
|
215
|
-
#[test]
|
|
216
|
-
fn test_weekday_eq_none_vs_zero() {
|
|
217
|
-
let a = MO.with_n(None);
|
|
218
|
-
let b = MO.with_n(Some(0));
|
|
219
|
-
// Display is the same but PartialEq differs (n field differs)
|
|
220
|
-
assert_eq!(a.to_string(), b.to_string());
|
|
221
|
-
assert_ne!(a, b);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
206
|
#[test]
|
|
225
207
|
fn test_weekday_hash_with_n() {
|
|
226
208
|
use std::collections::HashSet;
|
|
@@ -268,19 +250,24 @@ mod tests {
|
|
|
268
250
|
}
|
|
269
251
|
|
|
270
252
|
#[test]
|
|
271
|
-
fn
|
|
272
|
-
let a = MO.with_n(Some(1));
|
|
273
|
-
let b = MO.with_n(Some(-1));
|
|
274
|
-
assert_ne!(a, b);
|
|
275
|
-
assert_eq!(a.weekday(), b.weekday());
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
#[test]
|
|
279
|
-
fn test_weekday_hash_set_with_n_none_vs_some0() {
|
|
253
|
+
fn test_weekday_hash_set_none() {
|
|
280
254
|
use std::collections::HashSet;
|
|
281
255
|
let mut set = HashSet::new();
|
|
282
256
|
set.insert(MO.with_n(None));
|
|
283
|
-
set.insert(MO.with_n(Some(
|
|
257
|
+
set.insert(MO.with_n(Some(1)));
|
|
284
258
|
assert_eq!(set.len(), 2);
|
|
285
259
|
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn test_weekday_try_from_valid() {
|
|
263
|
+
let wd: Weekday = 3u8.try_into().unwrap();
|
|
264
|
+
assert_eq!(wd.weekday(), 3);
|
|
265
|
+
assert_eq!(wd.n(), None);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn test_weekday_try_from_invalid() {
|
|
270
|
+
let result: Result<Weekday, _> = 7u8.try_into();
|
|
271
|
+
assert!(result.is_err());
|
|
272
|
+
}
|
|
286
273
|
}
|
|
@@ -71,30 +71,6 @@ mod tests {
|
|
|
71
71
|
use super::*;
|
|
72
72
|
use chrono::Datelike;
|
|
73
73
|
|
|
74
|
-
#[test]
|
|
75
|
-
fn test_western_2024() {
|
|
76
|
-
assert_eq!(
|
|
77
|
-
easter(2024, EasterMethod::Western).unwrap(),
|
|
78
|
-
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
#[test]
|
|
83
|
-
fn test_orthodox_2024() {
|
|
84
|
-
assert_eq!(
|
|
85
|
-
easter(2024, EasterMethod::Orthodox).unwrap(),
|
|
86
|
-
NaiveDate::from_ymd_opt(2024, 5, 5).unwrap()
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
#[test]
|
|
91
|
-
fn test_julian_326() {
|
|
92
|
-
assert_eq!(
|
|
93
|
-
easter(326, EasterMethod::Julian).unwrap(),
|
|
94
|
-
NaiveDate::from_ymd_opt(326, 4, 3).unwrap()
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
74
|
#[test]
|
|
99
75
|
fn test_invalid_method_from_i32() {
|
|
100
76
|
assert!(matches!(
|
|
@@ -107,18 +83,6 @@ mod tests {
|
|
|
107
83
|
));
|
|
108
84
|
}
|
|
109
85
|
|
|
110
|
-
#[test]
|
|
111
|
-
fn test_invalid_year() {
|
|
112
|
-
assert!(matches!(
|
|
113
|
-
easter(0, EasterMethod::Western),
|
|
114
|
-
Err(EasterError::InvalidYear(0))
|
|
115
|
-
));
|
|
116
|
-
assert!(matches!(
|
|
117
|
-
easter(-1, EasterMethod::Western),
|
|
118
|
-
Err(EasterError::InvalidYear(-1))
|
|
119
|
-
));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
86
|
#[test]
|
|
123
87
|
fn test_western_range_1990_2050() {
|
|
124
88
|
let expected: Vec<(i32, u32, u32)> = vec![
|
|
@@ -347,14 +311,6 @@ mod tests {
|
|
|
347
311
|
);
|
|
348
312
|
}
|
|
349
313
|
|
|
350
|
-
#[test]
|
|
351
|
-
fn test_orthodox_boundary_exact_1600() {
|
|
352
|
-
let d1600 = easter(1600, EasterMethod::Orthodox).unwrap();
|
|
353
|
-
let d1601 = easter(1601, EasterMethod::Orthodox).unwrap();
|
|
354
|
-
assert!((3..=5).contains(&d1600.month()));
|
|
355
|
-
assert!((3..=5).contains(&d1601.month()));
|
|
356
|
-
}
|
|
357
|
-
|
|
358
314
|
#[test]
|
|
359
315
|
fn test_easter_always_sunday_western_wide_range() {
|
|
360
316
|
// Western (Gregorian) Easter is always Sunday across a wide year range
|