python-dateutil-rs 0.1.1__tar.gz → 0.1.2__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.
Files changed (47) hide show
  1. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/Cargo.lock +11 -4
  2. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/PKG-INFO +2 -2
  3. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/README.md +1 -1
  4. python_dateutil_rs-0.1.2/crates/dateutil-core/Cargo.toml +30 -0
  5. python_dateutil_rs-0.1.2/crates/dateutil-core/README.md +209 -0
  6. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/benches/benchmarks.rs +8 -8
  7. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/rrule/parse.rs +5 -0
  8. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/tz.rs +71 -18
  9. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/Cargo.toml +2 -2
  10. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py/common.rs +31 -3
  11. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py/conv.rs +20 -0
  12. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py/easter.rs +1 -1
  13. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py/parser.rs +2 -2
  14. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py/relativedelta.rs +3 -3
  15. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py/rrule.rs +179 -29
  16. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py/tz.rs +51 -11
  17. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/pyproject.toml +1 -1
  18. python_dateutil_rs-0.1.1/crates/dateutil-core/Cargo.toml +0 -25
  19. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/Cargo.toml +0 -0
  20. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/LICENSE +0 -0
  21. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/CLAUDE.md +0 -0
  22. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/common.rs +0 -0
  23. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/easter.rs +0 -0
  24. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/error.rs +0 -0
  25. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/lib.rs +0 -0
  26. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/parser/isoparser.rs +0 -0
  27. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/parser/parserinfo.rs +0 -0
  28. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/parser/tokenizer.rs +0 -0
  29. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/parser.rs +0 -0
  30. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/relativedelta.rs +0 -0
  31. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/rrule/iter.rs +0 -0
  32. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/rrule/set.rs +0 -0
  33. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/rrule.rs +0 -0
  34. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/tz/file.rs +0 -0
  35. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/tz/local.rs +0 -0
  36. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/tz/offset.rs +0 -0
  37. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-core/src/tz/utc.rs +0 -0
  38. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/lib.rs +0 -0
  39. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/crates/dateutil-py/src/py.rs +0 -0
  40. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/__init__.py +0 -0
  41. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/_native.pyi +0 -0
  42. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/easter.py +0 -0
  43. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/parser.py +0 -0
  44. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/py.typed +0 -0
  45. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/relativedelta.py +0 -0
  46. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/rrule.py +0 -0
  47. {python_dateutil_rs-0.1.1 → python_dateutil_rs-0.1.2}/python/dateutil_rs/tz.py +0 -0
@@ -219,24 +219,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
219
219
  checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
220
220
 
221
221
  [[package]]
222
- name = "dateutil-core"
223
- version = "0.1.1"
222
+ name = "dateutil"
223
+ version = "0.1.2"
224
224
  dependencies = [
225
225
  "bitflags",
226
226
  "chrono",
227
227
  "criterion",
228
228
  "iana-time-zone",
229
229
  "phf",
230
+ "rustc-hash",
230
231
  "smallvec",
231
232
  "thiserror",
232
233
  ]
233
234
 
234
235
  [[package]]
235
236
  name = "dateutil-py"
236
- version = "0.1.1"
237
+ version = "0.1.2"
237
238
  dependencies = [
238
239
  "chrono",
239
- "dateutil-core",
240
+ "dateutil",
240
241
  "pyo3",
241
242
  ]
242
243
 
@@ -576,6 +577,12 @@ version = "0.8.10"
576
577
  source = "registry+https://github.com/rust-lang/crates.io-index"
577
578
  checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
578
579
 
580
+ [[package]]
581
+ name = "rustc-hash"
582
+ version = "2.1.2"
583
+ source = "registry+https://github.com/rust-lang/crates.io-index"
584
+ checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
585
+
579
586
  [[package]]
580
587
  name = "rustversion"
581
588
  version = "1.0.22"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-dateutil-rs
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Classifier: Programming Language :: Python :: 3.10
5
5
  Classifier: Programming Language :: Python :: 3.11
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -142,7 +142,7 @@ Benchmarks compare the original `python-dateutil` (PyPI) and the Rust extension
142
142
  | Parser (parse) | **19.5x-36.0x** |
143
143
  | Parser (isoparse) | **13.0x-38.4x** |
144
144
  | RRule | **5.9x-63.7x** |
145
- | Timezone | **1.0x-896.7x** ¹ |
145
+ | Timezone | **1.0x-896.7x** |
146
146
  | RelativeDelta | **2.0x-28.1x** |
147
147
  | Easter | **5.0x-7.3x** |
148
148
 
@@ -120,7 +120,7 @@ Benchmarks compare the original `python-dateutil` (PyPI) and the Rust extension
120
120
  | Parser (parse) | **19.5x-36.0x** |
121
121
  | Parser (isoparse) | **13.0x-38.4x** |
122
122
  | RRule | **5.9x-63.7x** |
123
- | Timezone | **1.0x-896.7x** ¹ |
123
+ | Timezone | **1.0x-896.7x** |
124
124
  | RelativeDelta | **2.0x-28.1x** |
125
125
  | Easter | **5.0x-7.3x** |
126
126
 
@@ -0,0 +1,30 @@
1
+ [package]
2
+ name = "dateutil"
3
+ version = "0.1.2"
4
+ edition = "2021"
5
+ license = "MIT"
6
+ description = "Fast date utility library — parser, relativedelta, rrule, timezone (Rust reimplementation of python-dateutil)"
7
+ repository = "https://github.com/wakita181009/dateutil-rs"
8
+ keywords = ["date", "datetime", "parser", "rrule", "timezone"]
9
+ categories = ["date-and-time", "parser-implementations"]
10
+ readme = "README.md"
11
+
12
+ [lib]
13
+ name = "dateutil"
14
+ crate-type = ["rlib"]
15
+
16
+ [dependencies]
17
+ bitflags = "2"
18
+ chrono = "0.4"
19
+ phf = { version = "0.13", features = ["macros"] }
20
+ rustc-hash = "2"
21
+ smallvec = "1.15"
22
+ thiserror = "2"
23
+ iana-time-zone = "0.1"
24
+
25
+ [dev-dependencies]
26
+ criterion = { version = "0.8", features = ["html_reports"] }
27
+
28
+ [[bench]]
29
+ name = "benchmarks"
30
+ harness = false
@@ -0,0 +1,209 @@
1
+ # python-dateutil-rs
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/python-dateutil-rs.svg?style=flat-square)](https://pypi.org/project/python-dateutil-rs/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/python-dateutil-rs.svg?style=flat-square)](https://pypi.org/project/python-dateutil-rs/)
5
+ [![License](https://img.shields.io/pypi/l/python-dateutil-rs.svg?style=flat-square)](https://pypi.org/project/python-dateutil-rs/)
6
+ [![CI](https://github.com/wakita181009/dateutil-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/wakita181009/dateutil-rs/actions/workflows/ci.yml)
7
+ [![Coverage](https://codecov.io/gh/wakita181009/dateutil-rs/branch/main/graph/badge.svg)](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)
@@ -1,14 +1,14 @@
1
1
  use criterion::{criterion_group, criterion_main, Criterion};
2
2
  use std::hint::black_box;
3
3
  use chrono::NaiveDate;
4
- use dateutil_core::common::Weekday;
5
- use dateutil_core::easter::{easter, EasterMethod};
6
- use dateutil_core::parser;
7
- use dateutil_core::parser::tokenizer;
8
- use dateutil_core::relativedelta::RelativeDelta;
9
- use dateutil_core::rrule::{Frequency, Recurrence, RRuleBuilder};
10
- use dateutil_core::rrule::parse::rrulestr;
11
- use dateutil_core::rrule::set::RRuleSet;
4
+ use dateutil::common::Weekday;
5
+ use dateutil::easter::{easter, EasterMethod};
6
+ use dateutil::parser;
7
+ use dateutil::parser::tokenizer;
8
+ use dateutil::relativedelta::RelativeDelta;
9
+ use dateutil::rrule::{Frequency, Recurrence, RRuleBuilder};
10
+ use dateutil::rrule::parse::rrulestr;
11
+ use dateutil::rrule::set::RRuleSet;
12
12
 
13
13
  fn bench_tokenizer(c: &mut Criterion) {
14
14
  c.bench_function("tokenize_simple_date", |b| {
@@ -115,6 +115,11 @@ pub fn rrulestr(
115
115
  }
116
116
  }
117
117
  } else if prop.eq_ignore_ascii_case("DTSTART") {
118
+ if value.contains(',') {
119
+ return Err(RRuleError::ValueError(
120
+ "DTSTART must be a single date-time value".into(),
121
+ ));
122
+ }
118
123
  if let Some(dt) = parse_rfc_datetime(value) {
119
124
  dtstart = Some(dt);
120
125
  }
@@ -13,13 +13,70 @@ pub use offset::TzOffset;
13
13
  pub use file::{TzFile, TzFileData};
14
14
  pub use local::TzLocal;
15
15
 
16
- use std::collections::HashMap;
17
16
  use std::sync::{LazyLock, RwLock};
18
17
 
18
+ use rustc_hash::FxHashMap;
19
+
19
20
  use chrono::{NaiveDateTime, TimeDelta};
20
21
 
21
22
  use crate::error::TzError;
22
23
 
24
+ // ---------------------------------------------------------------------------
25
+ // TzOps — shared trait for all timezone types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /// Core timezone operations shared by all timezone types.
29
+ ///
30
+ /// Implemented by `TzUtc`, `TzOffset`, `TzFile`, `TzLocal`, and `TimeZone`.
31
+ /// Helper functions like `datetime_exists` and `resolve_imaginary` are generic
32
+ /// over this trait, so callers can use any timezone type without constructing
33
+ /// the `TimeZone` enum (avoiding clones in the PyO3 binding layer).
34
+ pub trait TzOps {
35
+ /// UTC offset in seconds for a wall-clock datetime.
36
+ /// `fold` disambiguates repeated wall times (PEP 495).
37
+ fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32;
38
+ /// DST offset component in seconds.
39
+ fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32;
40
+ /// Timezone abbreviation string.
41
+ fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str;
42
+ /// Whether the given wall time is ambiguous (falls in a DST overlap).
43
+ fn is_ambiguous(&self, dt: NaiveDateTime) -> bool;
44
+ /// Convert a UTC datetime to wall time.
45
+ fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime;
46
+ }
47
+
48
+ impl TzOps for TzUtc {
49
+ #[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
50
+ #[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
51
+ #[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
52
+ #[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
53
+ #[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
54
+ }
55
+
56
+ impl TzOps for TzOffset {
57
+ #[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
58
+ #[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
59
+ #[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
60
+ #[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
61
+ #[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
62
+ }
63
+
64
+ impl TzOps for TzFile {
65
+ #[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
66
+ #[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
67
+ #[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
68
+ #[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
69
+ #[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
70
+ }
71
+
72
+ impl TzOps for TzLocal {
73
+ #[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
74
+ #[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
75
+ #[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
76
+ #[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
77
+ #[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
78
+ }
79
+
23
80
  // ---------------------------------------------------------------------------
24
81
  // TimeZone — enum dispatch for all timezone types
25
82
  // ---------------------------------------------------------------------------
@@ -37,11 +94,9 @@ pub enum TimeZone {
37
94
  Local(TzLocal),
38
95
  }
39
96
 
40
- impl TimeZone {
41
- /// UTC offset in seconds for a wall-clock datetime.
42
- /// `fold` disambiguates repeated wall times (PEP 495).
97
+ impl TzOps for TimeZone {
43
98
  #[inline]
44
- pub fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 {
99
+ fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 {
45
100
  match self {
46
101
  TimeZone::Utc(tz) => tz.utcoffset(dt, fold),
47
102
  TimeZone::Offset(tz) => tz.utcoffset(dt, fold),
@@ -50,9 +105,8 @@ impl TimeZone {
50
105
  }
51
106
  }
52
107
 
53
- /// DST offset component in seconds.
54
108
  #[inline]
55
- pub fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 {
109
+ fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 {
56
110
  match self {
57
111
  TimeZone::Utc(tz) => tz.dst(dt, fold),
58
112
  TimeZone::Offset(tz) => tz.dst(dt, fold),
@@ -61,9 +115,8 @@ impl TimeZone {
61
115
  }
62
116
  }
63
117
 
64
- /// Timezone abbreviation string.
65
118
  #[inline]
66
- pub fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str {
119
+ fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str {
67
120
  match self {
68
121
  TimeZone::Utc(tz) => tz.tzname(dt, fold),
69
122
  TimeZone::Offset(tz) => tz.tzname(dt, fold),
@@ -72,9 +125,8 @@ impl TimeZone {
72
125
  }
73
126
  }
74
127
 
75
- /// Whether the given wall time is ambiguous (falls in a DST overlap).
76
128
  #[inline]
77
- pub fn is_ambiguous(&self, dt: NaiveDateTime) -> bool {
129
+ fn is_ambiguous(&self, dt: NaiveDateTime) -> bool {
78
130
  match self {
79
131
  TimeZone::Utc(tz) => tz.is_ambiguous(dt),
80
132
  TimeZone::Offset(tz) => tz.is_ambiguous(dt),
@@ -83,9 +135,8 @@ impl TimeZone {
83
135
  }
84
136
  }
85
137
 
86
- /// Convert a UTC datetime to wall time.
87
138
  #[inline]
88
- pub fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime {
139
+ fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime {
89
140
  match self {
90
141
  TimeZone::Utc(tz) => tz.fromutc(dt),
91
142
  TimeZone::Offset(tz) => tz.fromutc(dt),
@@ -93,7 +144,9 @@ impl TimeZone {
93
144
  TimeZone::Local(tz) => tz.fromutc(dt),
94
145
  }
95
146
  }
147
+ }
96
148
 
149
+ impl TimeZone {
97
150
  /// UTC offset as a `chrono::TimeDelta`.
98
151
  #[inline]
99
152
  pub fn utcoffset_delta(&self, dt: NaiveDateTime, fold: bool) -> TimeDelta {
@@ -147,8 +200,8 @@ pub(super) const TZPATHS: &[&str] = &[
147
200
  const UTC_NAMES: &[&str] = &["UTC", "utc", "GMT", "gmt", "Z", "z"];
148
201
 
149
202
  /// Thread-safe timezone cache.
150
- static TZ_CACHE: LazyLock<RwLock<HashMap<Box<str>, TimeZone>>> =
151
- LazyLock::new(|| RwLock::new(HashMap::new()));
203
+ static TZ_CACHE: LazyLock<RwLock<FxHashMap<Box<str>, TimeZone>>> =
204
+ LazyLock::new(|| RwLock::new(FxHashMap::default()));
152
205
 
153
206
  /// Look up a timezone by name.
154
207
  ///
@@ -228,7 +281,7 @@ fn resolve_tz(name: &str) -> Result<TimeZone, TzError> {
228
281
 
229
282
  /// Check if a wall-clock datetime exists in the given timezone.
230
283
  /// Returns `false` for times in DST gaps (spring forward).
231
- pub fn datetime_exists(dt: NaiveDateTime, tz: &TimeZone) -> bool {
284
+ pub fn datetime_exists(dt: NaiveDateTime, tz: &impl TzOps) -> bool {
232
285
  let offset_secs = tz.utcoffset(dt, false) as i64;
233
286
  let utc = dt - TimeDelta::seconds(offset_secs);
234
287
  let wall = tz.fromutc(utc);
@@ -237,13 +290,13 @@ pub fn datetime_exists(dt: NaiveDateTime, tz: &TimeZone) -> bool {
237
290
 
238
291
  /// Check if a wall-clock datetime is ambiguous in the given timezone.
239
292
  /// Returns `true` for times in DST overlaps (fall back).
240
- pub fn datetime_ambiguous(dt: NaiveDateTime, tz: &TimeZone) -> bool {
293
+ pub fn datetime_ambiguous(dt: NaiveDateTime, tz: &impl TzOps) -> bool {
241
294
  tz.is_ambiguous(dt)
242
295
  }
243
296
 
244
297
  /// Resolve an imaginary datetime (in a DST gap) by shifting forward.
245
298
  /// If the datetime already exists, returns it unchanged.
246
- pub fn resolve_imaginary(dt: NaiveDateTime, tz: &TimeZone) -> NaiveDateTime {
299
+ pub fn resolve_imaginary(dt: NaiveDateTime, tz: &impl TzOps) -> NaiveDateTime {
247
300
  if datetime_exists(dt, tz) {
248
301
  return dt;
249
302
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "dateutil-py"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  description = "PyO3 bindings for the dateutil-core crate"
@@ -10,7 +10,7 @@ name = "dateutil_py"
10
10
  crate-type = ["rlib"]
11
11
 
12
12
  [dependencies]
13
- dateutil-core = { path = "../dateutil-core" }
13
+ dateutil = { path = "../dateutil-core" }
14
14
  chrono = "0.4"
15
15
  pyo3 = { version = "0.28", features = ["extension-module", "chrono"], optional = true }
16
16
 
@@ -1,8 +1,8 @@
1
- use dateutil_core::common;
1
+ use dateutil::common;
2
2
  use pyo3::prelude::*;
3
3
 
4
- /// Python wrapper for dateutil_core::common::Weekday.
5
- #[pyclass(name = "weekday", frozen, hash, eq, from_py_object)]
4
+ /// Python wrapper for dateutil::common::Weekday.
5
+ #[pyclass(name = "weekday", frozen, from_py_object)]
6
6
  #[derive(Clone, Debug, PartialEq, Eq, Hash)]
7
7
  pub struct PyWeekday {
8
8
  inner: common::Weekday,
@@ -47,6 +47,34 @@ impl PyWeekday {
47
47
  self.inner.n()
48
48
  }
49
49
 
50
+ fn __hash__(&self) -> isize {
51
+ use std::hash::{Hash, Hasher};
52
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
53
+ self.inner.hash(&mut hasher);
54
+ hasher.finish() as isize
55
+ }
56
+
57
+ fn __eq__(&self, _py: Python<'_>, other: &Bound<'_, PyAny>) -> bool {
58
+ // Fast path: other is a PyWeekday
59
+ if let Ok(wd) = other.extract::<PyWeekday>() {
60
+ return self.inner == wd.inner;
61
+ }
62
+ // Duck-type: must have both `weekday` AND `n` attributes
63
+ let Ok(wd_attr) = other.getattr("weekday") else {
64
+ return false;
65
+ };
66
+ let Ok(n_attr) = other.getattr("n") else {
67
+ return false;
68
+ };
69
+ let Ok(wd_val) = wd_attr.extract::<u8>() else {
70
+ return false;
71
+ };
72
+ let Ok(n_val) = n_attr.extract::<Option<i32>>() else {
73
+ return false;
74
+ };
75
+ self.inner.weekday() == wd_val && self.inner.n() == n_val
76
+ }
77
+
50
78
  fn __repr__(&self) -> String {
51
79
  self.inner.to_string()
52
80
  }
@@ -148,6 +148,26 @@ pub fn ndt_to_py_datetime_with_fold<'py>(
148
148
  )
149
149
  }
150
150
 
151
+ // ---------------------------------------------------------------------------
152
+ // Scalar-or-sequence extraction helpers
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /// Accept either a single `i32` or a list of `i32`.
156
+ pub fn extract_i32_list(obj: &Bound<'_, PyAny>) -> PyResult<Vec<i32>> {
157
+ if let Ok(v) = obj.extract::<i32>() {
158
+ return Ok(vec![v]);
159
+ }
160
+ obj.extract::<Vec<i32>>()
161
+ }
162
+
163
+ /// Accept either a single `u8` or a list of `u8`.
164
+ pub fn extract_u8_list(obj: &Bound<'_, PyAny>) -> PyResult<Vec<u8>> {
165
+ if let Ok(v) = obj.extract::<u8>() {
166
+ return Ok(vec![v]);
167
+ }
168
+ obj.extract::<Vec<u8>>()
169
+ }
170
+
151
171
  // ---------------------------------------------------------------------------
152
172
  // Timezone / timedelta helpers
153
173
  // ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- use dateutil_core::easter::{self, EasterMethod};
1
+ use dateutil::easter::{self, EasterMethod};
2
2
  use pyo3::prelude::*;
3
3
 
4
4
  /// Python constant values matching python-dateutil convention.
@@ -1,8 +1,8 @@
1
1
  use std::collections::HashMap;
2
2
 
3
3
  use super::conv::{make_py_tz, make_py_utc, ndt_to_py_datetime};
4
- use dateutil_core::parser;
5
- use dateutil_core::parser::ParserInfo;
4
+ use dateutil::parser;
5
+ use dateutil::parser::ParserInfo;
6
6
  use pyo3::prelude::*;
7
7
  use pyo3::types::{PyDict, PyTzInfo, PyType};
8
8
 
@@ -1,12 +1,12 @@
1
1
  use super::common::PyWeekday;
2
2
  use super::conv;
3
3
  use chrono::{Datelike, NaiveDateTime};
4
- use dateutil_core::common;
5
- use dateutil_core::relativedelta::{RelativeDelta, RelativeDeltaBuilder};
4
+ use dateutil::common;
5
+ use dateutil::relativedelta::{RelativeDelta, RelativeDeltaBuilder};
6
6
  use pyo3::prelude::*;
7
7
  use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDeltaAccess, PyTzInfoAccess};
8
8
 
9
- /// Python wrapper for dateutil_core::relativedelta::RelativeDelta.
9
+ /// Python wrapper for dateutil::relativedelta::RelativeDelta.
10
10
  #[pyclass(name = "relativedelta", from_py_object)]
11
11
  #[derive(Clone, Debug)]
12
12
  pub struct PyRelativeDelta {
@@ -1,37 +1,22 @@
1
1
  use std::sync::{Arc, Mutex, OnceLock};
2
2
 
3
3
  use super::common::PyWeekday;
4
- use dateutil_core::common::Weekday;
5
- use dateutil_core::rrule::iter::RRuleIter as CoreRRuleIter;
6
- use dateutil_core::rrule::{
4
+ use super::conv::{extract_i32_list, extract_u8_list, py_any_to_naive_datetime};
5
+ use dateutil::common::Weekday;
6
+ use dateutil::rrule::iter::RRuleIter as CoreRRuleIter;
7
+ use dateutil::rrule::{
7
8
  search_after, search_before, search_between,
8
9
  Frequency, Recurrence, RRule, RRuleBuilder,
9
10
  };
10
- use dateutil_core::rrule::parse::{rrulestr as core_rrulestr, RRuleStrResult};
11
- use dateutil_core::rrule::set::{RRuleSet, RRuleSetIter as CoreRRuleSetIter};
11
+ use dateutil::rrule::parse::{rrulestr as core_rrulestr, RRuleStrResult};
12
+ use dateutil::rrule::set::{RRuleSet, RRuleSetIter as CoreRRuleSetIter};
12
13
  use pyo3::prelude::*;
13
14
  use pyo3::types::{PyAnyMethods, PySlice};
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
- // Helpers: accept scalar-or-sequence for by* parameters
17
+ // Helper: accept weekday scalar-or-sequence for byweekday parameter
17
18
  // ---------------------------------------------------------------------------
18
19
 
19
- /// Accept either a single `i32` or a list of `i32`.
20
- fn extract_i32_list(obj: &Bound<'_, PyAny>) -> PyResult<Vec<i32>> {
21
- if let Ok(v) = obj.extract::<i32>() {
22
- return Ok(vec![v]);
23
- }
24
- obj.extract::<Vec<i32>>()
25
- }
26
-
27
- /// Accept either a single `u8` or a list of `u8`.
28
- fn extract_u8_list(obj: &Bound<'_, PyAny>) -> PyResult<Vec<u8>> {
29
- if let Ok(v) = obj.extract::<u8>() {
30
- return Ok(vec![v]);
31
- }
32
- obj.extract::<Vec<u8>>()
33
- }
34
-
35
20
  /// Accept a single weekday/int or a list/tuple of weekday/int and return `Vec<Weekday>`.
36
21
  fn extract_byweekday_any(obj: &Bound<'_, PyAny>) -> PyResult<Vec<Weekday>> {
37
22
  // Try as a single weekday object
@@ -128,11 +113,11 @@ impl PyRRule {
128
113
  fn new(
129
114
  py: Python<'_>,
130
115
  freq: u8,
131
- dtstart: Option<chrono::NaiveDateTime>,
116
+ dtstart: Option<&Bound<'_, PyAny>>,
132
117
  interval: u32,
133
118
  wkst: Option<Bound<'_, PyAny>>,
134
119
  count: Option<u32>,
135
- until: Option<chrono::NaiveDateTime>,
120
+ until: Option<&Bound<'_, PyAny>>,
136
121
  bysetpos: Option<Bound<'_, PyAny>>,
137
122
  bymonth: Option<Bound<'_, PyAny>>,
138
123
  bymonthday: Option<Bound<'_, PyAny>>,
@@ -149,8 +134,8 @@ impl PyRRule {
149
134
  .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
150
135
  let mut builder = RRuleBuilder::new(f).interval(interval);
151
136
 
152
- if let Some(dt) = dtstart {
153
- builder = builder.dtstart(dt);
137
+ if let Some(obj) = dtstart {
138
+ builder = builder.dtstart(py_any_to_naive_datetime(obj)?);
154
139
  }
155
140
  if let Some(ref w) = wkst {
156
141
  let val = if let Ok(wd) = w.extract::<PyWeekday>() {
@@ -176,8 +161,8 @@ impl PyRRule {
176
161
  if let Some(c) = count {
177
162
  builder = builder.count(c);
178
163
  }
179
- if let Some(u) = until {
180
- builder = builder.until(u);
164
+ if let Some(obj) = until {
165
+ builder = builder.until(py_any_to_naive_datetime(obj)?);
181
166
  }
182
167
  if let Some(ref v) = bysetpos {
183
168
  builder = builder.bysetpos(extract_i32_list(v)?);
@@ -413,6 +398,170 @@ impl PyRRule {
413
398
  }
414
399
  self.inner.contains(dt)
415
400
  }
401
+
402
+ /// Return an iterator yielding `count` occurrences after `dt`.
403
+ #[pyo3(signature = (dt, count=1, inc=false))]
404
+ fn xafter(
405
+ &self,
406
+ dt: chrono::NaiveDateTime,
407
+ count: usize,
408
+ inc: bool,
409
+ ) -> Vec<chrono::NaiveDateTime> {
410
+ if let Some(cached) = self.get_cache() {
411
+ let start = if inc {
412
+ cached.partition_point(|&x| x < dt)
413
+ } else {
414
+ cached.partition_point(|&x| x <= dt)
415
+ };
416
+ return cached[start..].iter().copied().take(count).collect();
417
+ }
418
+ self.inner
419
+ .iter()
420
+ .filter(move |&i| if inc { i >= dt } else { i > dt })
421
+ .take(count)
422
+ .collect()
423
+ }
424
+
425
+ /// Return a new rrule with specified parameters replaced.
426
+ #[pyo3(signature = (
427
+ freq=None,
428
+ dtstart=None,
429
+ interval=None,
430
+ wkst=None,
431
+ count=None,
432
+ until=None,
433
+ bysetpos=None,
434
+ bymonth=None,
435
+ bymonthday=None,
436
+ byyearday=None,
437
+ byeaster=None,
438
+ byweekno=None,
439
+ byweekday=None,
440
+ byhour=None,
441
+ byminute=None,
442
+ bysecond=None,
443
+ cache=None,
444
+ ))]
445
+ #[allow(clippy::too_many_arguments)]
446
+ fn replace(
447
+ &self,
448
+ freq: Option<u8>,
449
+ dtstart: Option<&Bound<'_, PyAny>>,
450
+ interval: Option<u32>,
451
+ wkst: Option<Bound<'_, PyAny>>,
452
+ count: Option<u32>,
453
+ until: Option<&Bound<'_, PyAny>>,
454
+ bysetpos: Option<Bound<'_, PyAny>>,
455
+ bymonth: Option<Bound<'_, PyAny>>,
456
+ bymonthday: Option<Bound<'_, PyAny>>,
457
+ byyearday: Option<Bound<'_, PyAny>>,
458
+ byeaster: Option<Bound<'_, PyAny>>,
459
+ byweekno: Option<Bound<'_, PyAny>>,
460
+ byweekday: Option<Bound<'_, PyAny>>,
461
+ byhour: Option<Bound<'_, PyAny>>,
462
+ byminute: Option<Bound<'_, PyAny>>,
463
+ bysecond: Option<Bound<'_, PyAny>>,
464
+ cache: Option<bool>,
465
+ ) -> PyResult<Self> {
466
+ let f = if let Some(fv) = freq {
467
+ Frequency::try_from(fv)
468
+ .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?
469
+ } else {
470
+ self.inner.freq()
471
+ };
472
+ let mut builder = RRuleBuilder::new(f);
473
+
474
+ builder = builder.dtstart(if let Some(obj) = dtstart {
475
+ py_any_to_naive_datetime(obj)?
476
+ } else {
477
+ self.inner.dtstart()
478
+ });
479
+
480
+ builder = builder.interval(interval.unwrap_or_else(|| self.inner.interval()));
481
+
482
+ let wkst_val = if let Some(ref w) = wkst {
483
+ if let Ok(wd) = w.extract::<PyWeekday>() {
484
+ wd.weekday()
485
+ } else {
486
+ w.extract::<u8>()?
487
+ }
488
+ } else {
489
+ self.inner.wkst()
490
+ };
491
+ builder = builder.wkst(wkst_val);
492
+
493
+ if let Some(c) = count.or(self.inner.count()) {
494
+ builder = builder.count(c);
495
+ }
496
+ if let Some(obj) = until {
497
+ builder = builder.until(py_any_to_naive_datetime(obj)?);
498
+ } else if let Some(u) = self.inner.until() {
499
+ builder = builder.until(u);
500
+ }
501
+
502
+ // by* fields: use provided override, else copy from existing rule
503
+ if let Some(ref v) = bysetpos {
504
+ builder = builder.bysetpos(extract_i32_list(v)?);
505
+ } else if let Some(v) = self.inner.bysetpos() {
506
+ builder = builder.bysetpos(v.to_vec());
507
+ }
508
+ if let Some(ref v) = bymonth {
509
+ builder = builder.bymonth(extract_u8_list(v)?);
510
+ } else if let Some(v) = self.inner.bymonth() {
511
+ builder = builder.bymonth(v.to_vec());
512
+ }
513
+ if let Some(ref v) = bymonthday {
514
+ builder = builder.bymonthday(extract_i32_list(v)?);
515
+ } else if !self.inner.bymonthday().is_empty() {
516
+ builder = builder.bymonthday(self.inner.bymonthday().to_vec());
517
+ }
518
+ if let Some(ref v) = byyearday {
519
+ builder = builder.byyearday(extract_i32_list(v)?);
520
+ } else if let Some(v) = self.inner.byyearday() {
521
+ builder = builder.byyearday(v.to_vec());
522
+ }
523
+ if let Some(ref v) = byeaster {
524
+ builder = builder.byeaster(extract_i32_list(v)?);
525
+ } else if let Some(v) = self.inner.byeaster() {
526
+ builder = builder.byeaster(v.to_vec());
527
+ }
528
+ if let Some(ref v) = byweekno {
529
+ builder = builder.byweekno(extract_i32_list(v)?);
530
+ } else if let Some(v) = self.inner.byweekno() {
531
+ builder = builder.byweekno(v.to_vec());
532
+ }
533
+ if let Some(ref v) = byweekday {
534
+ builder = builder.byweekday(extract_byweekday_any(v)?);
535
+ } else if let Some(v) = self.inner.byweekday() {
536
+ builder = builder.byweekday(v.to_vec());
537
+ }
538
+ if let Some(ref v) = byhour {
539
+ builder = builder.byhour(extract_u8_list(v)?);
540
+ } else if let Some(v) = self.inner.byhour() {
541
+ builder = builder.byhour(v.to_vec());
542
+ }
543
+ if let Some(ref v) = byminute {
544
+ builder = builder.byminute(extract_u8_list(v)?);
545
+ } else if let Some(v) = self.inner.byminute() {
546
+ builder = builder.byminute(v.to_vec());
547
+ }
548
+ if let Some(ref v) = bysecond {
549
+ builder = builder.bysecond(extract_u8_list(v)?);
550
+ } else if let Some(v) = self.inner.bysecond() {
551
+ builder = builder.bysecond(v.to_vec());
552
+ }
553
+
554
+ let inner = builder
555
+ .build()
556
+ .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
557
+ let cache_val = cache.unwrap_or(self.cache_enabled);
558
+ let cache_enabled = cache_val && inner.is_finite();
559
+ Ok(Self {
560
+ inner,
561
+ cache_enabled,
562
+ cache: OnceLock::new(),
563
+ })
564
+ }
416
565
  }
417
566
 
418
567
  impl PyRRule {
@@ -874,12 +1023,13 @@ impl PyRRuleSetIter {
874
1023
  fn rrulestr_py(
875
1024
  py: Python<'_>,
876
1025
  s: &str,
877
- dtstart: Option<chrono::NaiveDateTime>,
1026
+ dtstart: Option<&Bound<'_, PyAny>>,
878
1027
  forceset: bool,
879
1028
  compatible: bool,
880
1029
  unfold: bool,
881
1030
  cache: bool,
882
1031
  ) -> PyResult<Py<PyAny>> {
1032
+ let dtstart = dtstart.map(py_any_to_naive_datetime).transpose()?;
883
1033
  let result = core_rrulestr(s, dtstart, forceset, compatible, unfold)
884
1034
  .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
885
1035
  match result {
@@ -1,5 +1,5 @@
1
1
  use chrono::NaiveDateTime;
2
- use dateutil_core::tz::{self, TimeZone, TzFile, TzLocal, TzOffset, TzUtc};
2
+ use dateutil::tz::{self, TimeZone, TzFile, TzLocal, TzOffset, TzOps};
3
3
  use pyo3::prelude::*;
4
4
  use pyo3::types::{PyDateTime, PyDelta, PyTzInfo};
5
5
 
@@ -315,43 +315,83 @@ fn gettz_py(py: Python<'_>, name: Option<&str>) -> PyResult<Py<PyAny>> {
315
315
 
316
316
  #[derive(FromPyObject)]
317
317
  enum PyTimezone<'py> {
318
+ #[allow(dead_code)]
318
319
  Utc(PyRef<'py, PyTzUtc>),
319
320
  Offset(PyRef<'py, PyTzOffset>),
320
321
  File(PyRef<'py, PyTzFile>),
321
322
  Local(PyRef<'py, PyTzLocal>),
322
323
  }
323
324
 
324
- impl PyTimezone<'_> {
325
- fn to_timezone(&self) -> TimeZone {
325
+ impl TzOps for PyTimezone<'_> {
326
+ #[inline]
327
+ fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 {
326
328
  match self {
327
- PyTimezone::Utc(_utc) => TimeZone::Utc(TzUtc),
328
- PyTimezone::Offset(tz) => TimeZone::Offset(tz.inner.clone()),
329
- PyTimezone::File(tz) => TimeZone::File(tz.inner.clone()),
330
- PyTimezone::Local(tz) => TimeZone::Local(tz.inner.clone()),
329
+ PyTimezone::Utc(_) => 0,
330
+ PyTimezone::Offset(tz) => tz.inner.offset_seconds(),
331
+ PyTimezone::File(tz) => tz.inner.utcoffset(dt, fold),
332
+ PyTimezone::Local(tz) => tz.inner.utcoffset(dt, fold),
333
+ }
334
+ }
335
+
336
+ #[inline]
337
+ fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 {
338
+ match self {
339
+ PyTimezone::Utc(_) | PyTimezone::Offset(_) => 0,
340
+ PyTimezone::File(tz) => tz.inner.dst(dt, fold),
341
+ PyTimezone::Local(tz) => tz.inner.dst(dt, fold),
342
+ }
343
+ }
344
+
345
+ #[inline]
346
+ fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str {
347
+ match self {
348
+ PyTimezone::Utc(_) => "UTC",
349
+ PyTimezone::Offset(tz) => tz.inner.display_name(),
350
+ PyTimezone::File(tz) => tz.inner.tzname(dt, fold),
351
+ PyTimezone::Local(tz) => tz.inner.tzname(dt, fold),
352
+ }
353
+ }
354
+
355
+ #[inline]
356
+ fn is_ambiguous(&self, dt: NaiveDateTime) -> bool {
357
+ match self {
358
+ PyTimezone::Utc(_) | PyTimezone::Offset(_) => false,
359
+ PyTimezone::File(tz) => tz.inner.is_ambiguous(dt),
360
+ PyTimezone::Local(tz) => tz.inner.is_ambiguous(dt),
361
+ }
362
+ }
363
+
364
+ #[inline]
365
+ fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime {
366
+ match self {
367
+ PyTimezone::Utc(_) => dt,
368
+ PyTimezone::Offset(tz) => tz.inner.fromutc(dt),
369
+ PyTimezone::File(tz) => tz.inner.fromutc(dt),
370
+ PyTimezone::Local(tz) => tz.inner.fromutc(dt),
331
371
  }
332
372
  }
333
373
  }
334
374
 
335
375
  // ---------------------------------------------------------------------------
336
- // Helper functions
376
+ // Helper functions — zero-clone via TzOps trait
337
377
  // ---------------------------------------------------------------------------
338
378
 
339
379
  #[pyfunction]
340
380
  #[pyo3(name = "datetime_exists")]
341
381
  fn datetime_exists_py(dt: NaiveDateTime, tz: PyTimezone<'_>) -> bool {
342
- tz::datetime_exists(dt, &tz.to_timezone())
382
+ tz::datetime_exists(dt, &tz)
343
383
  }
344
384
 
345
385
  #[pyfunction]
346
386
  #[pyo3(name = "datetime_ambiguous")]
347
387
  fn datetime_ambiguous_py(dt: NaiveDateTime, tz: PyTimezone<'_>) -> bool {
348
- tz::datetime_ambiguous(dt, &tz.to_timezone())
388
+ tz::datetime_ambiguous(dt, &tz)
349
389
  }
350
390
 
351
391
  #[pyfunction]
352
392
  #[pyo3(name = "resolve_imaginary")]
353
393
  fn resolve_imaginary_py(dt: NaiveDateTime, tz: PyTimezone<'_>) -> NaiveDateTime {
354
- tz::resolve_imaginary(dt, &tz.to_timezone())
394
+ tz::resolve_imaginary(dt, &tz)
355
395
  }
356
396
 
357
397
  // ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-dateutil-rs"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "A Rust-backed port of python-dateutil"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,25 +0,0 @@
1
- [package]
2
- name = "dateutil-core"
3
- version = "0.1.1"
4
- edition = "2021"
5
- license = "MIT"
6
- description = "Performance-optimized date utility library for Rust"
7
-
8
- [lib]
9
- name = "dateutil_core"
10
- crate-type = ["rlib"]
11
-
12
- [dependencies]
13
- bitflags = "2"
14
- chrono = "0.4"
15
- phf = { version = "0.13", features = ["macros"] }
16
- smallvec = "1.15"
17
- thiserror = "2"
18
- iana-time-zone = "0.1"
19
-
20
- [dev-dependencies]
21
- criterion = { version = "0.8", features = ["html_reports"] }
22
-
23
- [[bench]]
24
- name = "benchmarks"
25
- harness = false