diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 00000000..c85bc312
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,15 @@
+[build]
+rustflags = []
+
+# see https://pyo3.rs/v0.21.2/building-and-distribution.html?highlight=macos#manual-builds
+[target.x86_64-apple-darwin]
+rustflags = [
+ "-C", "link-arg=-undefined",
+ "-C", "link-arg=dynamic_lookup",
+]
+
+[target.aarch64-apple-darwin]
+rustflags = [
+ "-C", "link-arg=-undefined",
+ "-C", "link-arg=dynamic_lookup",
+]
diff --git a/.flake8 b/.flake8
index 349c9b24..4a95e796 100644
--- a/.flake8
+++ b/.flake8
@@ -1,5 +1,8 @@
[flake8]
exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,.tox
-extend-ignore =
+extend-ignore =
# let black handle line length
E501
+per-file-ignores =
+ __init__.py: F401,F403
+
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 00f90393..a7b5be4e 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -15,10 +15,12 @@ jobs:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
# Note: pypy/pytest fails sometimes (https://github.com/pypy/pypy/issues/3959)
- python-version: ["3.9", "3.10", "3.11", "3.12", "pypy3.8", "pypy3.9", "pypy3.10"]
+ python-version: ["3.9", "3.10", "3.11", "3.12", "pypy3.9", "pypy3.10"]
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: nightly
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
@@ -54,7 +56,7 @@ jobs:
- run: |
pip install .
pip install -r requirements/test.txt
- pytest tests/ --ignore tests/test_extension_demo.py
+ pytest tests/
env:
WHENEVER_NO_BUILD_RUST_EXT: "1"
diff --git a/.gitignore b/.gitignore
index 4a9c4df1..8c481e89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,3 +72,6 @@ docs/_build/
.python-version
.hypothesis
+.benchmarks
+
+benchmarks/comparison/*.json
diff --git a/.readthedocs.yml b/.readthedocs.yml
index d928484c..10853a3b 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -9,7 +9,8 @@ build:
os: ubuntu-22.04
tools:
python: "3.11"
- rust: "1.75"
+ # rust shouldn't be needed as we disable building the extension
+ # in the readthedocs configuration
python:
install:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ce36889a..7e289bba 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,16 @@
🚀 Changelog
============
+0.6.0 (2024-??-??)
+------------------
+
+- Implement as a Rust extension module
+
+**Breaking changes**
+
+- Removed weakref support. The overhead of weakrefs was too high for
+ such primitive objects, and the use case was not clear.
+
0.5.1 (2024-04-02)
------------------
diff --git a/Cargo.lock b/Cargo.lock
index 7a8ee7ef..dc2898ac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,21 +2,6 @@
# It is not intended for manual editing.
version = 3
-[[package]]
-name = "android-tzdata"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
-
-[[package]]
-name = "android_system_properties"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "autocfg"
version = "1.2.0"
@@ -29,72 +14,17 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-[[package]]
-name = "bumpalo"
-version = "3.15.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa"
-
-[[package]]
-name = "cc"
-version = "1.0.90"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5"
-
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
-[[package]]
-name = "chrono"
-version = "0.4.37"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e"
-dependencies = [
- "android-tzdata",
- "iana-time-zone",
- "js-sys",
- "num-traits",
- "wasm-bindgen",
- "windows-targets 0.52.4",
-]
-
-[[package]]
-name = "core-foundation-sys"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
-
[[package]]
name = "heck"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
-
-[[package]]
-name = "iana-time-zone"
-version = "0.1.60"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
-dependencies = [
- "android_system_properties",
- "core-foundation-sys",
- "iana-time-zone-haiku",
- "js-sys",
- "wasm-bindgen",
- "windows-core",
-]
-
-[[package]]
-name = "iana-time-zone-haiku"
-version = "0.1.2"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
-dependencies = [
- "cc",
-]
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indoc"
@@ -102,15 +32,6 @@ version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
-[[package]]
-name = "js-sys"
-version = "0.3.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
-dependencies = [
- "wasm-bindgen",
-]
-
[[package]]
name = "libc"
version = "0.2.153"
@@ -127,12 +48,6 @@ dependencies = [
"scopeguard",
]
-[[package]]
-name = "log"
-version = "0.4.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
-
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -142,15 +57,6 @@ dependencies = [
"autocfg",
]
-[[package]]
-name = "num-traits"
-version = "0.2.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
-dependencies = [
- "autocfg",
-]
-
[[package]]
name = "once_cell"
version = "1.19.0"
@@ -177,7 +83,7 @@ dependencies = [
"libc",
"redox_syscall",
"smallvec",
- "windows-targets 0.48.5",
+ "windows-targets",
]
[[package]]
@@ -197,9 +103,7 @@ dependencies = [
[[package]]
name = "pyo3"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a02a88a17e74cadbc8ce77855e1d6c8ad0ab82901a4a9b5046bd01c1c0bd95cd"
+version = "0.21.2"
dependencies = [
"cfg-if",
"indoc",
@@ -215,9 +119,7 @@ dependencies = [
[[package]]
name = "pyo3-build-config"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5eb0b6ecba38961f6f4bd6cd5906dfab3cd426ff37b2eed5771006aa31656f1"
+version = "0.21.2"
dependencies = [
"once_cell",
"target-lexicon",
@@ -225,9 +127,7 @@ dependencies = [
[[package]]
name = "pyo3-ffi"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba8a6e48a29b5d22e4fdaf132d8ba8d3203ee9f06362d48f244346902a594ec3"
+version = "0.21.2"
dependencies = [
"libc",
"pyo3-build-config",
@@ -235,9 +135,7 @@ dependencies = [
[[package]]
name = "pyo3-macros"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e80493c5965f94a747d0782a607b2328a4eea5391327b152b00e2f3b001cede"
+version = "0.21.2"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
@@ -247,9 +145,7 @@ dependencies = [
[[package]]
name = "pyo3-macros-backend"
-version = "0.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fcd7d86f42004025200e12a6a8119bd878329e6fddef8178eaafa4e4b5906c5b"
+version = "0.21.2"
dependencies = [
"heck",
"proc-macro2",
@@ -317,107 +213,28 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce"
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.92"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
-dependencies = [
- "cfg-if",
- "wasm-bindgen-macro",
-]
-
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.92"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
-dependencies = [
- "bumpalo",
- "log",
- "once_cell",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.92"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.92"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-backend",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.92"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
-
[[package]]
name = "whenever"
version = "0.6.0-beta.1"
dependencies = [
- "chrono",
"pyo3",
"pyo3-build-config",
"pyo3-ffi",
]
-[[package]]
-name = "windows-core"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
-dependencies = [
- "windows-targets 0.52.4",
-]
-
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
- "windows_aarch64_gnullvm 0.48.5",
- "windows_aarch64_msvc 0.48.5",
- "windows_i686_gnu 0.48.5",
- "windows_i686_msvc 0.48.5",
- "windows_x86_64_gnu 0.48.5",
- "windows_x86_64_gnullvm 0.48.5",
- "windows_x86_64_msvc 0.48.5",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
-dependencies = [
- "windows_aarch64_gnullvm 0.52.4",
- "windows_aarch64_msvc 0.52.4",
- "windows_i686_gnu 0.52.4",
- "windows_i686_msvc 0.52.4",
- "windows_x86_64_gnu 0.52.4",
- "windows_x86_64_gnullvm 0.52.4",
- "windows_x86_64_msvc 0.52.4",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -426,80 +243,38 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
-
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
-
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
diff --git a/Cargo.toml b/Cargo.toml
index 7f33796f..24dada69 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.6.0-beta.1"
authors = []
description = "Rust extension module for whenever"
edition = "2021"
-rust-version = "1.75"
+rust-version = "1.77"
license = "MIT"
readme = "README.rst"
keywords = []
@@ -20,15 +20,22 @@ include = [
[lib]
name = "_whenever"
-crate-type = ["cdylib"]
+crate-type = ["cdylib", "rlib"]
[features]
-default = []
+extension-module = ["pyo3/extension-module"]
+
+[[bench]]
+name = "benchmarks"
+path = "benchmarks/rust/main.rs"
[dependencies]
-chrono = "0.4.35"
-pyo3-ffi = { version = "^0.21.0", default_features = false, features = ["extension-module"]}
-pyo3 = { version = "^0.21.0", features = ["extension-module"] }
+# TODO: await response to https://github.com/PyO3/pyo3/issues/4093
+# pyo3-ffi = { version = "^0.21.0", default_features = false, features = ["extension-module"]}
+# pyo3 = { version = "^0.21.0", features = ["extension-module"] }
+pyo3-ffi = { path = "../pyo3/pyo3-ffi", features = ["extension-module"] }
+pyo3 = { path = "../pyo3", features = ["extension-module"] }
[build-dependencies]
-pyo3-build-config = { version = "^0.21.0" }
+# pyo3-build-config = { version = "^0.21.0" }
+pyo3-build-config = { path = "../pyo3/pyo3-build-config" }
diff --git a/Makefile b/Makefile
index c583d0e6..057b2d10 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,11 @@
+.PHONY: init
+init:
+ pip install -U pip setuptools-rust build twine build pyperf
+ pip install -r requirements/all.txt
+ pip install -e .
+
.PHONY: typecheck
-typecheck:
+typecheck:
mypy pysrc/ tests/
pytest typesafety/
@@ -7,23 +13,37 @@ typecheck:
format:
black pysrc/ tests/
isort pysrc/ tests/
+ cargo fmt
.PHONY: docs
docs:
- @touch docs/api.rst
+ rm -f pysrc/whenever/*.so # Presence of the rust extension breaks sphinx (TODO: a better workaround)
+ @touch docs/api.rst # force rebuild of API docs: code changes aren't detected
make -C docs/ html
-.PHONY: check-dist
-check-dist:
- pip install -U build twine
+.PHONY: check-readme
+check-readme:
python -m build --sdist
twine check dist/*
+.PHONY: test-py
+test-py:
+ RUST_BACKTRACE=1 pytest -s tests/
+
+
+.PHONY: test-rs
+test-rs:
+ RUST_BACKTRACE=1 cargo test
+
+.PHONY: test
+test: test-py test-rs
+
.PHONY: ci-lint
-ci-lint: check-dist
+ci-lint: check-readme
flake8 pysrc/ tests/
black --check pysrc/ tests/
isort --check pysrc/ tests/
+ cargo fmt -- --check
python -m slotscheck pysrc/
.PHONY: clean
@@ -32,6 +52,36 @@ clean:
rm -rf build/ dist/ pysrc/**/*.so pysrc/**/__pycache__ *.egg-info **/*.egg-info \
docs/_build/ htmlcov/ .mypy_cache/ .pytest_cache/ target/
-.PHONY: develop
-develop:
- python setup.py build_ext --inplace
+
+.PHONY: build
+build:
+ python setup.py build_rust --inplace
+
+.PHONY: build-release
+build-release:
+ python setup.py build_rust --inplace --release
+
+.PHONY: bench
+bench: build-release
+ pytest -s benchmarks/ \
+ --benchmark-group-by=group \
+ --benchmark-columns=median,stddev \
+ --benchmark-autosave \
+ --benchmark-group-by=fullname \
+
+.PHONY: bench-compare
+bench-compare: build-release
+ rm -f benchmarks/comparison/result_*.json
+ python benchmarks/comparison/run_stdlib_dateutil.py --fast -o \
+ benchmarks/comparison/result_stdlib_dateutil.json
+ python benchmarks/comparison/run_pendulum.py --fast -o \
+ benchmarks/comparison/result_pendulum.json
+ python benchmarks/comparison/run_arrow.py --fast -o \
+ benchmarks/comparison/result_arrow.json
+ python benchmarks/comparison/run_whenever.py --fast -o \
+ benchmarks/comparison/result_whenever.json
+ python -m pyperf compare_to benchmarks/comparison/result_stdlib_dateutil.json \
+ benchmarks/comparison/result_pendulum.json \
+ benchmarks/comparison/result_arrow.json \
+ benchmarks/comparison/result_whenever.json \
+ --table
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..aee4bf32
--- /dev/null
+++ b/README.md
@@ -0,0 +1,242 @@
+# ⏰ Whenever
+
+[![](https://img.shields.io/pypi/v/whenever.svg?style=flat-square&color=blue)](https://pypi.python.org/pypi/whenever)
+[![](https://img.shields.io/pypi/pyversions/whenever.svg?style=flat-square)](https://pypi.python.org/pypi/whenever)
+[![](https://img.shields.io/pypi/l/whenever.svg?style=flat-square&color=blue)](https://pypi.python.org/pypi/whenever)
+[![](https://img.shields.io/badge/mypy-strict-forestgreen?style=flat-square)](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict)
+[![](https://img.shields.io/badge/coverage-100%25-forestgreen?style=flat-square)](https://github.com/ariebovenberg/whenever)
+[![]( https://img.shields.io/github/actions/workflow/status/ariebovenberg/whenever/tests.yml?branch=main&style=flat-square)](https://github.com/ariebovenberg/whenever)
+[![](https://img.shields.io/readthedocs/whenever.svg?style=flat-square)](http://whenever.readthedocs.io/)
+
+**Fast, typesafe, and correct datetimes for Python—written in Rust**
+
+Whenever is:
+
+- ⚡️ **Fast**: written in Rust for performance, it blows other datetime libraries out of the water.
+ It's even faster than the standard library in most cases.
+
+
+
+
+
+
+ Parsing an RFC3339 timestamp, changing the timezone, and adding 30 days (1M times)
+
+
+- 🔒 **Typesafe**: no more runtime errors from mixing naive and aware datetimes!
+ Whenever defines types such that your IDE and typechecker can these (and more) bugs before they happen.
+- ✅ **Correct**: built from the ground up,
+ it avoids the [imfamous pitfalls of the standard library](https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls/).
+ Whenever's design takes after other modern datetime libraries and industry standards.
+
+
+
+[📖 Docs](https://whenever.readthedocs.io) |
+[🐍 PyPI](https://pypi.org/project/whenever/) |
+[🐙 GitHub](https://github.com/ariebovenberg/whenever) |
+[🚀 Changelog](https://whenever.readthedocs.io/en/latest/changelog.html) |
+[❓ FAQ](https://whenever.readthedocs.io/en/latest/faq.html) |
+[🗺️ Roadmap](#roadmap) |
+[💬 Issues & discussions](https://github.com/ariebovenberg/whenever/issues)
+
+
+## Quickstart
+
+```python
+>>> from whenever import (
+... # Explicit types for different use cases
+... UTCDateTime, # -> Enforce UTC-normalization
+... OffsetDateTime, # -> Simple localized times
+... ZonedDateTime, # -> Full-featured timezones
+... NaiveDateTime, # -> Without any timezone
+... )
+
+>>> py311_release = UTCDateTime(2022, 10, 24, hour=17)
+UTCDateTime(2022-10-24 17:00:00Z)
+>>> pycon23_start = OffsetDateTime(2023, 4, 21, hour=9, offset=-6)
+OffsetDateTime(2023-04-21 09:00:00-06:00)
+
+# Simple, explicit conversions
+>>> py311_release.as_zoned("Europe/Paris")
+ZonedDateTime(2022-10-24 19:00:00+02:00[Europe/Paris])
+>>> pycon23_start.as_local() # example: system timezone in NYC
+LocalSystemDateTime(2023-04-21 11:00:00-04:00)
+
+# Comparison and equality across aware types
+>>> py311_release > pycon23_start
+False
+>>> py311_release == py311_release.as_zoned("America/Los_Angeles")
+True
+
+# Naive type that can't accidentally mix with aware types
+>>> hackathon_invite = NaiveDateTime(2023, 10, 28, hour=12)
+>>> # Naïve/aware mixups are caught by typechecker
+>>> hackathon_invite - py311_release
+>>> # Only explicit assumptions will make it aware
+>>> hackathon_start = hackathon_invite.assume_zoned("Europe/Amsterdam")
+ZonedDateTime(2023-10-28 12:00:00+02:00[Europe/Amsterdam])
+
+# DST-aware operators
+>>> hackathon_end = hackathon_start.add(hours=24)
+ZonedDateTime(2022-10-29 11:00:00+01:00[Europe/Amsterdam])
+
+# Lossless round-trip to/from text (useful for JSON/serialization)
+>>> py311_release.canonical_format()
+'2022-10-24T17:00:00Z'
+>>> ZonedDateTime.from_canonical_format('2022-10-24T19:00:00+02:00[Europe/Paris]')
+ZonedDateTime(2022-10-24 19:00:00+02:00[Europe/Paris])
+
+# Conversion to/from common formats
+>>> py311_release.rfc2822() # also: from_rfc2822()
+"Mon, 24 Oct 2022 17:00:00 GMT"
+>>> pycon23_start.rfc3339() # also: from_rfc3339()
+"2023-04-21T09:00:00-06:00"
+
+# Basic parsing
+>>> OffsetDateTime.strptime("2022-10-24+02:00", "%Y-%m-%d%z")
+OffsetDateTime(2022-10-24 00:00:00+02:00)
+
+# If you must: you can access the underlying datetime object
+>>> pycon23_start.py_datetime().ctime()
+'Fri Apr 21 09:00:00 2023'
+```
+
+Read more in the [feature overview](https://whenever.readthedocs.io/en/latest/overview.html)
+or [API reference](https://whenever.readthedocs.io/en/latest/api.html).
+
+## Why not...?
+
+### The standard library
+
+The standard library is full of quirks and pitfalls.
+To summarize the detailed [blog post](https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls/):
+
+1. Incompatible concepts of naive and aware are squeezed into one class
+2. Operators ignore Daylight Saving Time (DST)
+3. The meaning of "naive" is inconsistent (UTC, local, or unspecified?)
+4. Non-existent datetimes pass silently
+5. It guesses in the face of ambiguity
+6. False negatives on equality of ambiguous times between timezones
+7. False positives on equality of ambiguous times within the same timezone
+8. ``datetime`` inherits from ``date``, but behaves inconsistently
+9. ``datetime.timezone`` isn’t enough for full-featured timezones.
+10. The local timezone is DST-unaware
+
+### Pendulum
+
+Pendulum is full-featured datetime library, but it's
+hamstrung by the decision to inherit from the standard library ``datetime``.
+This means it inherits most of the pitfalls mentioned above,
+with the notable exception of DST-aware addition/subtraction.
+
+### Arrow
+
+Arrow is probably the most historically popular datetime library.
+Pendulum did a good write-up of [the issues with Arrow](https://pendulum.eustace.io/faq/).
+It addresses fewer of datetime's pitfalls than Pendulum.
+
+### DateType
+
+DateType mostly fixes the issue of mixing naive and aware datetimes,
+and datetime/date inheritance during type-checking,
+but doesn't address the other pitfalls.
+The type-checker-only approach also means that it doesn't enforce correctness at runtime,
+and it requires developers to be knowledgeable about
+how the 'type checking reality' differs from the 'runtime reality'.
+
+### python-dateutil
+
+Dateutil attempts to solve some of the issues with the standard library.
+However, it only *adds* functionality to work around the issues,
+instead of *removing* the pitfalls themselves.
+This still puts the burden on the developer to know about the issues,
+and to use the correct functions to avoid them.
+Without removing the pitfalls, it's still very likely to make mistakes.
+
+### Maya
+
+It's unmaintained, but does have an interesting approach.
+By enforcing UTC, it bypasses a lot of issues with the standard library.
+To do so, it sacrifices the ability to represent offset, zoned, and local datetimes.
+So in order to perform any timezone-aware operations, you need to convert
+to the standard library ``datetime`` first, which reintroduces the issues.
+
+### Heliclockter
+
+This library is a lot more explicit about the different types of datetimes,
+addressing issue of naive/aware mixing with UTC, local, and zoned datetime subclasses.
+It doesn't address the other datetime pitfalls though.
+
+## Roadmap
+
+- 🧪 **0.x**: get to feature-parity, process feedback, and tweak the API:
+
+ - ✅ Datetime classes
+ - ✅ Deltas
+ - ✅ Date and time of day (separate from datetime)
+ - 🚧 Interval
+ - 🚧 Improved parsing and formatting
+ - 🚧 Implement Rust extension for performance
+- 🔒 **1.0**: API stability and backwards compatibility
+- 🐍 **future**: Inspire a standard library improvement
+
+## Versioning and compatibility policy
+
+**Whenever** follows semantic versioning.
+Until the 1.0 version, the API may change with minor releases.
+Breaking changes will be avoided as much as possible,
+and meticulously explained in the changelog.
+Since the API is fully typed, your typechecker and/or IDE
+will help you adjust to any API changes.
+
+> ⚠️ **Note**: until 1.x, pickled objects may not be unpicklable across
+> versions. After 1.0, backwards compatibility of pickles will be maintained
+> as much as possible.
+
+## Acknowledgements
+
+This project is inspired by the following projects. Check them out!
+
+- [Noda Time](https://nodatime.org/)
+- [Temporal](https://tc39.es/proposal-temporal/docs/)
+- [Chrono](https://docs.rs/chrono/latest/chrono/)
+
+The benchmark comparison graph is based on the one from the [Ruff](https://github.com/astral-sh/ruff) project.
+
+## Contributing
+
+Contributions are welcome! Please open an issue or a pull request.
+
+> ⚠️ **Note**: Non-trivial changes should be discussed in an issue first.
+> This is to avoid wasted effort if the change isn't a good fit for the project.
+
+> ⚠️ **Note**: Some tests are skipped on Windows.
+> These tests use unix-specific features to set the timezone for the current process.
+> As a result, Windows isn't able to run certain tests that rely on the system timezone.
+> It appears that this functionality (only needed for the tests) is
+> [not available on Windows](https://stackoverflow.com/questions/62004265/python-3-time-tzset-alternative-for-windows>).
+
+## Setting up a development environment
+
+An example of setting up things up:
+
+```bash
+# install the dependencies
+make init
+
+# build the rust extension
+make build
+
+make test # run the tests (Python and Rust)
+make format # apply autoformatting
+make ci-lint # various static checks
+make typecheck # run mypy and typing tests
+```
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 0b0ac1ef..00000000
--- a/README.rst
+++ /dev/null
@@ -1,273 +0,0 @@
-⏰ Whenever
-===========
-
-.. image:: https://img.shields.io/pypi/v/whenever.svg?style=flat-square&color=blue
- :target: https://pypi.python.org/pypi/whenever
-
-.. image:: https://img.shields.io/pypi/pyversions/whenever.svg?style=flat-square
- :target: https://pypi.python.org/pypi/whenever
-
-.. image:: https://img.shields.io/pypi/l/whenever.svg?style=flat-square&color=blue
- :target: https://pypi.python.org/pypi/whenever
-
-.. image:: https://img.shields.io/badge/mypy-strict-forestgreen?style=flat-square
- :target: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict
-
-.. image:: https://img.shields.io/badge/coverage-100%25-forestgreen?style=flat-square
- :target: https://github.com/ariebovenberg/whenever
-
-.. image:: https://img.shields.io/github/actions/workflow/status/ariebovenberg/whenever/tests.yml?branch=main&style=flat-square
- :target: https://github.com/ariebovenberg/whenever
-
-.. image:: https://img.shields.io/readthedocs/whenever.svg?style=flat-square
- :target: http://whenever.readthedocs.io/
-
-**Sensible and typesafe datetimes**
-
-Do you cross your fingers every time you work with datetimes,
-hoping that you didn't mix naive and aware?
-or that you converted to UTC everywhere?
-or that you avoided the many `pitfalls of the standard library `_?
-There's no way to be sure...
-
-✨ Until now! ✨
-
-**Whenever** is a datetime library designed from the ground up to enforce correctness.
-Mistakes become red squiggles in your IDE, instead of bugs in production.
-
-`📖 Docs `_ |
-`🐍 PyPI `_ |
-`🐙 GitHub `_ |
-`🚀 Changelog `_ |
-`❓ FAQ `_ |
-🗺️ `Roadmap`_ |
-`💬 Issues & discussions `_
-
-Benefits
---------
-
-- Distinct classes with well-defined behavior
-- Fixes pitfalls that `arrow and pendulum don't `_
-- Enforce correctness without runtime checks
-- Based on `familiar concepts `_ and standards
-- Simple and obvious; no frills or surprises
-- `Thoroughly documented `_ and tested
-- One file; no third-party dependencies
-
-Quickstart
-----------
-
-.. code-block:: python
-
- >>> from whenever import (
- ... # Explicit types for different use cases
- ... UTCDateTime, # -> Enforce UTC-normalization
- ... OffsetDateTime, # -> Simple localized times
- ... ZonedDateTime, # -> Full-featured timezones
- ... NaiveDateTime, # -> Without any timezone
- ... )
-
- >>> py311_release = UTCDateTime(2022, 10, 24, hour=17)
- UTCDateTime(2022-10-24 17:00:00Z)
- >>> pycon23_start = OffsetDateTime(2023, 4, 21, hour=9, offset=-6)
- OffsetDateTime(2023-04-21 09:00:00-06:00)
-
- # Simple, explicit conversions
- >>> py311_release.as_zoned("Europe/Paris")
- ZonedDateTime(2022-10-24 19:00:00+02:00[Europe/Paris])
- >>> pycon23_start.as_local() # example: system timezone in NYC
- LocalSystemDateTime(2023-04-21 11:00:00-04:00)
-
- # Comparison and equality across aware types
- >>> py311_release > pycon23_start
- False
- >>> py311_release == py311_release.as_zoned("America/Los_Angeles")
- True
-
- # Naive type that can't accidentally mix with aware types
- >>> hackathon_invite = NaiveDateTime(2023, 10, 28, hour=12)
- >>> # Naïve/aware mixups are caught by typechecker
- >>> hackathon_invite - py311_release
- >>> # Only explicit assumptions will make it aware
- >>> hackathon_start = hackathon_invite.assume_zoned("Europe/Amsterdam")
- ZonedDateTime(2023-10-28 12:00:00+02:00[Europe/Amsterdam])
-
- # DST-aware operators
- >>> hackathon_end = hackathon_start.add(hours=24)
- ZonedDateTime(2022-10-29 11:00:00+01:00[Europe/Amsterdam])
-
- # Lossless round-trip to/from text (useful for JSON/serialization)
- >>> py311_release.canonical_format()
- '2022-10-24T17:00:00Z'
- >>> ZonedDateTime.from_canonical_format('2022-10-24T19:00:00+02:00[Europe/Paris]')
- ZonedDateTime(2022-10-24 19:00:00+02:00[Europe/Paris])
-
- # Conversion to/from common formats
- >>> py311_release.rfc2822() # also: from_rfc2822()
- "Mon, 24 Oct 2022 17:00:00 GMT"
- >>> pycon23_start.rfc3339() # also: from_rfc3339()
- "2023-04-21T09:00:00-06:00"
-
- # Basic parsing
- >>> OffsetDateTime.strptime("2022-10-24+02:00", "%Y-%m-%d%z")
- OffsetDateTime(2022-10-24 00:00:00+02:00)
-
- # If you must: you can access the underlying datetime object
- >>> pycon23_start.py_datetime().ctime()
- 'Fri Apr 21 09:00:00 2023'
-
-Read more in the `feature overview `_
-or `API reference `_.
-
-Why not...?
------------
-
-The standard library
-~~~~~~~~~~~~~~~~~~~~
-
-The standard library is full of quirks and pitfalls.
-To summarize the detailed `blog post `_:
-
-1. Incompatible concepts of naive and aware are squeezed into one class
-2. Operators ignore Daylight Saving Time (DST)
-3. The meaning of "naive" is inconsistent (UTC, local, or unspecified?)
-4. Non-existent datetimes pass silently
-5. It guesses in the face of ambiguity
-6. False negatives on equality of ambiguous times between timezones
-7. False positives on equality of ambiguous times within the same timezone
-8. ``datetime`` inherits from ``date``, but behaves inconsistently
-9. ``datetime.timezone`` isn’t enough for full-featured timezones.
-10. The local timezone is DST-unaware
-
-Pendulum
-~~~~~~~~
-
-Pendulum is full-featured datetime library, but it's
-hamstrung by the decision to inherit from the standard library ``datetime``.
-This means it inherits most of the pitfalls mentioned above,
-with the notable exception of DST-aware addition/subtraction.
-
-Arrow
-~~~~~
-
-Arrow is probably the most historically popular datetime library.
-Pendulum did a good write-up of `the issues with Arrow `_.
-It addresses fewer of datetime's pitfalls than Pendulum.
-
-DateType
-~~~~~~~~
-
-DateType mostly fixes the issue of mixing naive and aware datetimes,
-and datetime/date inheritance during type-checking,
-but doesn't address the other pitfalls.
-The type-checker-only approach also means that it doesn't enforce correctness at runtime,
-and it requires developers to be knowledgeable about
-how the 'type checking reality' differs from the 'runtime reality'.
-
-python-dateutil
-~~~~~~~~~~~~~~~
-
-Dateutil attempts to solve some of the issues with the standard library.
-However, it only *adds* functionality to work around the issues,
-instead of *removing* the pitfalls themselves.
-This still puts the burden on the developer to know about the issues,
-and to use the correct functions to avoid them.
-Without removing the pitfalls, it's still very likely to make mistakes.
-
-Maya
-~~~~
-
-It's unmaintained, but does have an interesting approach.
-By enforcing UTC, it bypasses a lot of issues with the standard library.
-To do so, it sacrifices the ability to represent offset, zoned, and local datetimes.
-So in order to perform any timezone-aware operations, you need to convert
-to the standard library ``datetime`` first, which reintroduces the issues.
-
-Heliclockter
-~~~~~~~~~~~~
-
-This library is a lot more explicit about the different types of datetimes,
-addressing issue of naive/aware mixing with UTC, local, and zoned datetime subclasses.
-It doesn't address the other datetime pitfalls though.
-
-.. _roadmap:
-
-Roadmap
--------
-
-- 🧪 **0.x**: get to feature-parity, process feedback, and tweak the API:
-
- - ✅ Datetime classes
- - ✅ Deltas
- - ✅ Date and time of day (separate from datetime)
- - 🚧 Interval
- - 🚧 Improved parsing and formatting
-- 🔒 **1.0**:
- - API stability and backwards compatibility
- - Implement Rust extension for performance
-- 🐍 **future**: Inspire a standard library improvement
-
-Not planned:
-
-- Different calendar systems
-
-Versioning and compatibility policy
------------------------------------
-
-**Whenever** follows semantic versioning.
-Until the 1.0 version, the API may change with minor releases.
-Breaking changes will be avoided as much as possible,
-and meticulously explained in the changelog.
-Since the API is fully typed, your typechecker and/or IDE
-will help you adjust to any API changes.
-
- ⚠️ **Note**: until 1.x, pickled objects may not be unpicklable across
- versions. After 1.0, backwards compatibility of pickles will be maintained
- as much as possible.
-
-Acknowledgements
-----------------
-
-This project is inspired by the following projects. Check them out!
-
-- `Noda Time `_
-- `Temporal `_
-- `Chrono `_
-
-Contributing
-------------
-
-Contributions are welcome! Please open an issue or a pull request.
-
- ⚠️ **Note**: big changes should be discussed in an issue first.
- This is to avoid wasted effort if the change isn't a good fit for the project.
-
-..
-
- ⚠️ **Note**: Some tests are skipped on Windows.
- These tests use unix-specific features to set the timezone for the current process.
- As a result, Windows isn't able to run certain tests that rely on the system timezone.
- It appears that `this functionality is not available on Windows `_.
-
-Setting up a development environment
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-An example of setting up things up:
-
-.. code-block:: bash
-
- pip install -r requirements/all.txt
-
- # To run the tests with the current Python version
- pytest
-
- # if you want to build the docs
- pip install -r docs/requirements.txt
-
- # Various checks
- mypy pysrc/ tests/
- flake8 pysrc/ tests/
-
- # autoformatting
- black pysrc/ tests/
- isort pysrc/ tests/
diff --git a/benchmarks/comparison/graph-dark.svg b/benchmarks/comparison/graph-dark.svg
new file mode 100644
index 00000000..fcb371ae
--- /dev/null
+++ b/benchmarks/comparison/graph-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/benchmarks/comparison/graph-light.svg b/benchmarks/comparison/graph-light.svg
new file mode 100644
index 00000000..2613b7c7
--- /dev/null
+++ b/benchmarks/comparison/graph-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/benchmarks/comparison/run_arrow.py b/benchmarks/comparison/run_arrow.py
new file mode 100644
index 00000000..a8b29b9c
--- /dev/null
+++ b/benchmarks/comparison/run_arrow.py
@@ -0,0 +1,39 @@
+# See Makefile for how to run this
+import pyperf
+
+runner = pyperf.Runner()
+
+
+runner.timeit(
+ "parse + convert + add",
+ "arrow.get('2020-04-05 22:04:00-04:00')"
+ ".to('Europe/Amsterdam')"
+ ".shift(days=30)",
+ "import arrow",
+)
+
+
+
+runner.timeit(
+ "new date",
+ "get(2020, 2, 29)",
+ "from arrow import get",
+)
+
+runner.timeit(
+ "date add",
+ "d.shift(years=-4, months=59, weeks=-7, days=3)",
+ setup="from arrow import get; d = get(1987, 3, 31)",
+)
+
+runner.timeit(
+ "parse date",
+ "get('2020-02-29')",
+ setup="from arrow import get",
+)
+
+runner.timeit(
+ "change tz",
+ "dt.to('America/New_York')",
+ setup="import arrow; dt = arrow.get(2020, 3, 20, 12, 30, 45, 0, tz='Europe/Amsterdam'); ",
+)
diff --git a/benchmarks/comparison/run_pendulum.py b/benchmarks/comparison/run_pendulum.py
new file mode 100644
index 00000000..b7ef10ae
--- /dev/null
+++ b/benchmarks/comparison/run_pendulum.py
@@ -0,0 +1,49 @@
+# See Makefile for how to run this
+import pyperf
+
+runner = pyperf.Runner()
+
+runner.timeit(
+ "parse + convert + add",
+ "parse('2020-04-05 22:04:00-04:00')"
+ ".in_tz('Europe/Amsterdam')"
+ ".add(days=30)",
+ "from pendulum import parse",
+)
+
+runner.timeit(
+ "new date",
+ "Date(2020, 2, 29)",
+ "from pendulum import Date",
+)
+
+
+runner.timeit(
+ "date add",
+ "d.add(years=-4, months=59, weeks=-7, days=3)",
+ setup="from pendulum import Date; d = Date(1987, 3, 31)",
+)
+
+runner.timeit(
+ "date diff",
+ "d1 - d2",
+ setup="from pendulum import Date; d1 = Date(2020, 2, 29); d2 = Date(2025, 2, 28)",
+)
+
+runner.timeit(
+ "parse date",
+ "f('2020-02-29')",
+ setup="from pendulum import Date; f = Date.fromisoformat",
+)
+
+runner.timeit(
+ "parse date delta",
+ "f('P5Y2M4D')",
+ setup="from pendulum import parse as f",
+)
+
+runner.timeit(
+ "change tz",
+ "dt.in_tz('America/New_York')",
+ setup="from pendulum import datetime; dt = datetime(2020, 3, 20, 12, 30, 45, tz='Europe/Amsterdam')",
+)
diff --git a/benchmarks/comparison/run_stdlib_dateutil.py b/benchmarks/comparison/run_stdlib_dateutil.py
new file mode 100644
index 00000000..00b8e656
--- /dev/null
+++ b/benchmarks/comparison/run_stdlib_dateutil.py
@@ -0,0 +1,45 @@
+# See Makefile for how to run this
+import pyperf
+
+runner = pyperf.Runner()
+
+runner.timeit(
+ "parse + convert + add",
+ "datetime.fromisoformat('2020-04-05 22:04:00-04:00')"
+ ".astimezone(ZoneInfo('Europe/Amsterdam'))"
+ " + timedelta(days=30)",
+ "from datetime import datetime, timedelta; from zoneinfo import ZoneInfo",
+)
+
+runner.timeit(
+ "new date",
+ "date(2020, 2, 29)",
+ "from datetime import date",
+)
+
+runner.timeit(
+ "date add",
+ "d + relativedelta(years=-4, months=59, weeks=-7, days=3)",
+ setup="import datetime; from dateutil.relativedelta import relativedelta;"
+ "d = datetime.date(1987, 3, 31)",
+)
+
+runner.timeit(
+ "date diff",
+ "relativedelta(d1, d2)",
+ setup="from datetime import date; from dateutil.relativedelta import relativedelta;"
+ "d1 = date(2020, 2, 29); d2 = date(2025, 2, 28)",
+)
+
+runner.timeit(
+ "parse date",
+ "f('2020-02-29')",
+ setup="from datetime import date; f = date.fromisoformat",
+)
+
+runner.timeit(
+ "change tz",
+ "dt.astimezone(ZoneInfo('America/New_York'))",
+ setup="from datetime import datetime; from zoneinfo import ZoneInfo; "
+ "dt = datetime(2020, 3, 20, 12, 30, 45, tzinfo=ZoneInfo('Europe/Amsterdam'))",
+)
diff --git a/benchmarks/comparison/run_whenever.py b/benchmarks/comparison/run_whenever.py
new file mode 100644
index 00000000..91d0c74a
--- /dev/null
+++ b/benchmarks/comparison/run_whenever.py
@@ -0,0 +1,48 @@
+# See Makefile for how to run this
+import pyperf
+
+runner = pyperf.Runner()
+
+# runner.timeit(
+# "parse + convert + add",
+# "OffsetDateTime.from_rfc3339('2020-04-05 22:04:00-04:00')"
+# ".as_zoned('Europe/Amsterdam')"
+# ".add(days=30)",
+# "from whenever import OffsetDateTime",
+# )
+
+runner.timeit(
+ "new date",
+ "Date(2020, 2, 29)",
+ "from whenever import Date",
+)
+
+runner.timeit(
+ "date add",
+ "d.add(years=-4, months=59, weeks=-7, days=3)",
+ setup="from whenever import Date; d = Date(1987, 3, 31)",
+)
+
+runner.timeit(
+ "date diff",
+ "d1 - d2",
+ setup="from whenever import Date; d1 = Date(2020, 2, 29); d2 = Date(2025, 2, 28)",
+)
+
+runner.timeit(
+ "parse date",
+ "f('2020-02-29')",
+ setup="from whenever import Date; f = Date.from_canonical_format",
+)
+
+runner.timeit(
+ "parse date delta",
+ "f('P5Y2M4D')",
+ setup="from whenever import DateDelta; f = DateDelta.from_canonical_format",
+)
+
+runner.timeit(
+ "change tz",
+ "dt.as_zoned('America/New_York')",
+ setup="from whenever import ZonedDateTime; dt = ZonedDateTime(2020, 3, 20, 12, 30, 45, tz='Europe/Amsterdam')",
+)
diff --git a/benchmarks/python/test_date.py b/benchmarks/python/test_date.py
new file mode 100644
index 00000000..33725d79
--- /dev/null
+++ b/benchmarks/python/test_date.py
@@ -0,0 +1,51 @@
+import pickle
+import sys
+
+from whenever import Date
+
+
+def test_hash(benchmark):
+ d1 = Date(2020, 8, 24)
+ benchmark(hash, d1)
+
+
+def test_new(benchmark):
+ benchmark(Date, 2020, 8, 24)
+
+
+def test_canonical_format(benchmark):
+ d1 = Date(2020, 8, 24)
+ benchmark(d1.canonical_format)
+
+
+def test_from_canonical_format(benchmark):
+ benchmark(Date.from_common_iso8601, "2020-08-24")
+
+
+def test_add(benchmark):
+ d1 = Date(2020, 8, 24)
+ benchmark(d1.add, years=-4, months=59, weeks=-7, days=3)
+
+
+def test_diff(benchmark):
+ d1 = Date(2020, 2, 29)
+ d2 = Date(2025, 2, 28)
+ benchmark(lambda: d1 - d2)
+
+
+def test_attributes(benchmark):
+ d1 = Date(2020, 8, 24)
+ benchmark(lambda: d1.year)
+
+
+def test_pickle(benchmark):
+ d1 = Date(2020, 8, 24)
+ benchmark(pickle.dumps, d1)
+
+
+def test_parse(benchmark):
+ benchmark(Date.from_canonical_format, "2020-08-24")
+
+
+def test_sizeof():
+ assert sys.getsizeof(Date(2020, 8, 24)) == 24
diff --git a/benchmarks/python/test_naive_datetime.py b/benchmarks/python/test_naive_datetime.py
new file mode 100644
index 00000000..2aad3655
--- /dev/null
+++ b/benchmarks/python/test_naive_datetime.py
@@ -0,0 +1,9 @@
+from whenever import NaiveDateTime
+
+
+def test_new(benchmark):
+ benchmark(NaiveDateTime, 2020, 3, 20, 12, 30, 45, 450)
+
+
+def test_parse_canonical(benchmark):
+ benchmark(NaiveDateTime.from_canonical_format, "2023-09-03 23:01:00")
diff --git a/benchmarks/python/test_zoned_datetime.py b/benchmarks/python/test_zoned_datetime.py
new file mode 100644
index 00000000..0b21a556
--- /dev/null
+++ b/benchmarks/python/test_zoned_datetime.py
@@ -0,0 +1,12 @@
+from whenever import ZonedDateTime
+
+
+def test_new(benchmark):
+ benchmark(
+ ZonedDateTime, 2020, 3, 20, 12, 30, 45, 450, tz="Europe/Amsterdam"
+ )
+
+
+def test_change_tz(benchmark):
+ dt = ZonedDateTime(2020, 3, 20, 12, 30, 45, 450, tz="Europe/Amsterdam")
+ benchmark(dt.as_zoned, "America/New_York")
diff --git a/benchmarks/rust/main.rs b/benchmarks/rust/main.rs
new file mode 100644
index 00000000..4b13c238
--- /dev/null
+++ b/benchmarks/rust/main.rs
@@ -0,0 +1,25 @@
+#![feature(test)]
+
+extern crate test;
+
+use _whenever::date::ord_to_ymd;
+use _whenever::naive_datetime;
+use test::{black_box, Bencher};
+
+#[bench]
+fn date_ord_to_ymd(bench: &mut Bencher) {
+ let ord = black_box(730179);
+ bench.iter(|| {
+ let (year, month, day) = ord_to_ymd(ord);
+ black_box((year, month, day));
+ })
+}
+
+#[bench]
+fn parse_naive_datetime(bench: &mut Bencher) {
+ let s = black_box("2023-03-02 02:09:09");
+ bench.iter(|| {
+ let (date, time) = black_box(naive_datetime::parse(s.as_bytes()).unwrap());
+ black_box((date, time));
+ })
+}
diff --git a/build.rs b/build.rs
index a37b6162..7467ca65 100644
--- a/build.rs
+++ b/build.rs
@@ -1,5 +1,4 @@
fn main() {
-
for cfg in pyo3_build_config::get().build_script_outputs() {
println!("{cfg}");
}
diff --git a/docs/conf.py b/docs/conf.py
index 7e839d80..fb645334 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -25,12 +25,17 @@
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
"sphinx_copybutton",
+ "myst_parser",
]
templates_path = ["_templates"]
-source_suffix = ".rst"
+source_suffix = {
+ ".md": "markdown",
+ ".rst": "restructuredtext",
+}
master_doc = "index"
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+myst_heading_anchors = 2
# -- Options for HTML output ----------------------------------------------
diff --git a/docs/index.rst b/docs/index.rst
index 0473e6dd..9b2a31a0 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,4 +1,6 @@
-.. include:: ../README.rst
+.. include:: ../README.md
+ :parser: myst_parser.sphinx_
+
Contents
========
diff --git a/pyproject.toml b/pyproject.toml
index 9fb148b7..4c666483 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ authors = [
readme = "README.rst"
version = "0.6.0rc0"
description = "Sensible and typesafe datetimes"
-requires-python = ">=3.8"
+requires-python = ">=3.9"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
@@ -15,7 +15,6 @@ classifiers = [
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
@@ -26,14 +25,13 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "backports-zoneinfo>=0.2.1; python_version < '3.9'",
"tzdata>=2020.1; sys_platform == 'win32'",
]
keywords = [
- "datetime", "typesafe", "rust", "time", "timezone", "utc", "naive", "aware",
+ "datetime", "typesafe", "rust", "time", "timezone", "utc", "naive", "aware",
"zoneinfo", "tzdata"
]
-
+
[project.urls]
Documentation = "https://whenever.readthedocs.io"
diff --git a/pysrc/whenever/__init__.py b/pysrc/whenever/__init__.py
index f7c76bbe..5bcbd5ff 100644
--- a/pysrc/whenever/__init__.py
+++ b/pysrc/whenever/__init__.py
@@ -1,17 +1,30 @@
-from ._pywhenever import * # noqa: F401,F403
-from ._pywhenever import ( # noqa: F401; noqa: F401,F403
- __all__,
- __version__,
- _AwareDateTime,
- _DateTime,
- _unpkl_date,
- _unpkl_ddelta,
- _unpkl_dtdelta,
- _unpkl_local,
- _unpkl_naive,
- _unpkl_offset,
- _unpkl_tdelta,
- _unpkl_time,
- _unpkl_utc,
- _unpkl_zoned,
-)
+try:
+ from ._whenever import *
+ from ._whenever import (
+ _unpkl_date,
+ _unpkl_ddelta,
+ _unpkl_naive,
+ _unpkl_time,
+ _unpkl_zoned,
+ )
+
+except ModuleNotFoundError as e:
+ if e.name != "whenever._whenever":
+ raise e
+ from ._pywhenever import *
+ from ._pywhenever import (
+ __all__,
+ __version__,
+ _AwareDateTime,
+ _DateTime,
+ _unpkl_date,
+ _unpkl_ddelta,
+ _unpkl_dtdelta,
+ _unpkl_local,
+ _unpkl_naive,
+ _unpkl_offset,
+ _unpkl_tdelta,
+ _unpkl_time,
+ _unpkl_utc,
+ _unpkl_zoned,
+ )
diff --git a/pysrc/whenever/__init__.pyi b/pysrc/whenever/__init__.pyi
index be0fe9f0..e838dc63 100644
--- a/pysrc/whenever/__init__.pyi
+++ b/pysrc/whenever/__init__.pyi
@@ -1,5 +1,4 @@
import abc
-import sys
from abc import ABC, abstractmethod
from datetime import (
date as _date,
@@ -7,12 +6,8 @@ from datetime import (
time as _time,
timedelta as _timedelta,
)
-from typing import ClassVar, Literal, TypeVar, overload
-
-if sys.version_info >= (3, 9):
- from zoneinfo import ZoneInfo
-else:
- from backports.zoneinfo import ZoneInfo
+from typing import ClassVar, Literal, TypeVar, final, overload
+from zoneinfo import ZoneInfo
__all__ = [
"Date",
@@ -36,7 +31,6 @@ __all__ = [
"SkippedTime",
"AmbiguousTime",
"InvalidOffsetForZone",
- "InvalidFormat",
"MONDAY",
"TUESDAY",
"WEDNESDAY",
@@ -59,6 +53,7 @@ class _UNSET: ...
_TDateTime = TypeVar("_TDateTime")
Disambiguate = Literal["raise", "earlier", "later", "compatible"]
+@final
class Date:
def __init__(self, year: int, month: int, day: int) -> None: ...
@property
@@ -93,7 +88,15 @@ class Date:
def common_iso8601(self) -> str: ...
@classmethod
def from_common_iso8601(cls, s: str) -> Date: ...
+ def replace(
+ self,
+ *,
+ year: int | _UNSET = ...,
+ month: int | _UNSET = ...,
+ day: int | _UNSET = ...,
+ ) -> Date: ...
+@final
class Time:
MIDNIGHT: ClassVar[Time]
NOON: ClassVar[Time]
@@ -103,7 +106,7 @@ class Time:
hour: int = 0,
minute: int = 0,
second: int = 0,
- microsecond: int = 0,
+ nanosecond: int = 0,
) -> None: ...
@property
def hour(self) -> int: ...
@@ -112,7 +115,7 @@ class Time:
@property
def second(self) -> int: ...
@property
- def microsecond(self) -> int: ...
+ def nanosecond(self) -> int: ...
def py_time(self) -> _time: ...
@classmethod
def from_py_time(cls, t: _time) -> Time: ...
@@ -128,6 +131,7 @@ class Time:
@classmethod
def from_common_iso8601(cls, s: str) -> Time: ...
+@final
class TimeDelta:
def __init__(
self,
@@ -135,13 +139,15 @@ class TimeDelta:
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
- microseconds: int = 0,
+ milliseconds: float = 0,
+ microseconds: float = 0,
+ nanoseconds: int = 0,
) -> None: ...
ZERO: ClassVar[TimeDelta]
def in_hours(self) -> float: ...
def in_minutes(self) -> float: ...
def in_seconds(self) -> float: ...
- def in_microseconds(self) -> int: ...
+ def in_nanoseconds(self) -> int: ...
def __hash__(self) -> int: ...
def __lt__(self, other: TimeDelta) -> bool: ...
def __le__(self, other: TimeDelta) -> bool: ...
@@ -169,6 +175,7 @@ class TimeDelta:
def from_py_timedelta(cls, td: _timedelta) -> TimeDelta: ...
def as_tuple(self) -> tuple[int, int, int, int]: ...
+@final
class DateDelta:
ZERO: ClassVar[DateDelta]
def __init__(
@@ -214,6 +221,7 @@ class DateDelta:
def from_common_iso8601(cls, s: str) -> DateDelta: ...
def as_tuple(self) -> tuple[int, int, int, int]: ...
+@final
class DateTimeDelta:
def __init__(
self,
@@ -225,7 +233,7 @@ class DateTimeDelta:
hours: float = 0,
minutes: float = 0,
seconds: float = 0,
- microseconds: int = 0,
+ nanoseconds: int = 0,
) -> None: ...
ZERO: ClassVar[DateTimeDelta]
@property
@@ -309,6 +317,7 @@ class _AwareDateTime(_DateTime, metaclass=abc.ABCMeta):
@abstractmethod
def exact_eq(self: _TDateTime, other: _TDateTime) -> bool: ...
+@final
class UTCDateTime(_AwareDateTime):
def __init__(
self,
@@ -395,6 +404,7 @@ class UTCDateTime(_AwareDateTime):
@classmethod
def from_common_iso8601(cls, s: str) -> UTCDateTime: ...
+@final
class OffsetDateTime(_AwareDateTime):
def __init__(
self,
@@ -456,6 +466,7 @@ class OffsetDateTime(_AwareDateTime):
@classmethod
def from_common_iso8601(cls, s: str) -> OffsetDateTime: ...
+@final
class ZonedDateTime(_AwareDateTime):
def __init__(
self,
@@ -518,6 +529,7 @@ class ZonedDateTime(_AwareDateTime):
def as_offset(self, offset: int | TimeDelta) -> OffsetDateTime: ...
def as_zoned(self, tz: str) -> ZonedDateTime: ...
+@final
class LocalSystemDateTime(_AwareDateTime):
def __init__(
self,
@@ -574,6 +586,7 @@ class LocalSystemDateTime(_AwareDateTime):
def as_zoned(self, tz: str) -> ZonedDateTime: ...
def as_local(self) -> LocalSystemDateTime: ...
+@final
class NaiveDateTime(_DateTime):
def __init__(
self,
@@ -583,7 +596,7 @@ class NaiveDateTime(_DateTime):
hour: int = 0,
minute: int = 0,
second: int = 0,
- microsecond: int = 0,
+ nanosecond: int = 0,
) -> None: ...
def canonical_format(self, sep: Literal[" ", "T"] = "T") -> str: ...
@classmethod
@@ -600,7 +613,7 @@ class NaiveDateTime(_DateTime):
hour: int | _UNSET = ...,
minute: int | _UNSET = ...,
second: int | _UNSET = ...,
- microsecond: int | _UNSET = ...,
+ nanosecond: int | _UNSET = ...,
) -> NaiveDateTime: ...
MIN: ClassVar[NaiveDateTime]
MAX: ClassVar[NaiveDateTime]
@@ -627,20 +640,22 @@ class NaiveDateTime(_DateTime):
@classmethod
def from_common_iso8601(cls, s: str) -> NaiveDateTime: ...
+@final
class AmbiguousTime(Exception):
@staticmethod
def for_timezone(d: _datetime, tz: ZoneInfo) -> AmbiguousTime: ...
@staticmethod
def for_system_timezone(d: _datetime) -> AmbiguousTime: ...
+@final
class SkippedTime(Exception):
@staticmethod
def for_timezone(d: _datetime, tz: ZoneInfo) -> SkippedTime: ...
@staticmethod
def for_system_timezone(d: _datetime) -> SkippedTime: ...
+@final
class InvalidOffsetForZone(ValueError): ...
-class InvalidFormat(ValueError): ...
def years(i: int) -> DateDelta: ...
def months(i: int) -> DateDelta: ...
diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py
index 6aa9c8fa..22c945d0 100644
--- a/pysrc/whenever/_pywhenever.py
+++ b/pysrc/whenever/_pywhenever.py
@@ -63,12 +63,7 @@
except ImportError:
SPHINX_BUILD = False
-try:
- from zoneinfo import ZoneInfo
-except ImportError: # pragma: no cover
- from backports.zoneinfo import ( # type: ignore[import-not-found,no-redef]
- ZoneInfo,
- )
+from zoneinfo import ZoneInfo
__all__ = [
# Date and time
@@ -95,7 +90,6 @@
"SkippedTime",
"AmbiguousTime",
"InvalidOffsetForZone",
-
# Constants
"MONDAY",
"TUESDAY",
@@ -217,6 +211,20 @@ def __ge__(self, other: Date) -> bool:
return NotImplemented
return self._py_date >= other._py_date
+ if not TYPE_CHECKING:
+
+ def replace(self, **kwargs) -> Date:
+ """Create a new instance with the given fields replaced
+
+ Example
+ -------
+ >>> d = Date(2021, 1, 2)
+ >>> d.replace(day=3)
+ Date(2021-01-03)
+
+ """
+ return Date.from_py_date(self._py_date.replace(**kwargs))
+
def py_date(self) -> _date:
"""Convert to a standard library :class:`~datetime.date`"""
return self._py_date
@@ -1395,7 +1403,7 @@ def from_canonical_format(cls, s: str, /) -> DateDelta:
Example
-------
- >>> DateDelta.from_canonical_format("1Y2M-3W4D")
+ >>> DateDelta.from_canonical_format("P1Y2M-3W4D")
DateDelta(P1Y2M-3W4D)
"""
try:
diff --git a/pytest.ini b/pytest.ini
index 766bbcf3..da84eee8 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,7 +1,12 @@
[pytest]
addopts=
- --benchmark-disable
--mypy-ini-file=tests/mypy.ini
+ # TODO: remove as Rust extension is implemented
+ --ignore=tests/test_time_delta.py
+ --ignore=tests/test_datetime_delta.py
+ --ignore=tests/test_local_datetime.py
+ --ignore=tests/test_offset_datetime.py
+ --ignore=tests/test_utc_datetime.py
filterwarnings =
error
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 2cab609f..d11dea58 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -1,3 +1,4 @@
sphinx<8
furo~=2024.1.29
sphinx-copybutton~=0.5
+myst-parser>=3,<4
diff --git a/requirements/lint.txt b/requirements/lint.txt
index 185e8154..db76a247 100644
--- a/requirements/lint.txt
+++ b/requirements/lint.txt
@@ -2,3 +2,6 @@ black>=24,<25
flake8>=6,<8
isort>=5,<6
slotscheck>=0.17,<0.20
+build
+twine
+pyperf>2,<3
diff --git a/requirements/test.txt b/requirements/test.txt
index a44e17d7..fb4529e2 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -1,6 +1,6 @@
pytest>=7,<9
pytest-cov>=4,<6
-pytest-benchmark>=4,<6
+pytest-benchmark[histogram]>=4,<6
pytest-mypy-plugins>=3,<4
hypothesis>=6,<7
freezegun>=1,<2
diff --git a/requirements/typecheck.txt b/requirements/typecheck.txt
index 19bfdfc3..bcc8362e 100644
--- a/requirements/typecheck.txt
+++ b/requirements/typecheck.txt
@@ -1,2 +1,2 @@
mypy>=1,<2
-pytest-mypy-plugins>=1.4.0,<2
+pytest-mypy-plugins>=3,<4
diff --git a/setup.py b/setup.py
index 171fcd08..382bce05 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from setuptools import setup
from setuptools_rust import Binding, RustExtension, build_rust
-_SKIP_BUILD_SUGGESTION = f"""
+_SKIP_BUILD_SUGGESTION = """
*******************************************************************************
Building the Rust extension of the library `whenever` failed. See errors above.
@@ -24,7 +24,10 @@ def run(self):
setup(
- rust_extensions=[RustExtension("whenever._whenever", binding=Binding.PyO3)]
- * (not os.getenv("WHENEVER_NO_BUILD_RUST_EXT")),
+ rust_extensions=(
+ []
+ if os.getenv("WHENEVER_NO_BUILD_RUST_EXT")
+ else [RustExtension("whenever._whenever", binding=Binding.PyO3)]
+ ),
cmdclass={"build_rust": CustomBuildExtCommand},
)
diff --git a/src/common.rs b/src/common.rs
new file mode 100644
index 00000000..399c04aa
--- /dev/null
+++ b/src/common.rs
@@ -0,0 +1,88 @@
+use pyo3_ffi::*;
+
+macro_rules! propagate_exc {
+ ($e:expr) => {{
+ let x = $e;
+ if x.is_null() {
+ return ptr::null_mut();
+ }
+ x
+ }};
+}
+
+macro_rules! raise(
+ ($exc:ident, $msg:expr) => {{
+ use crate::common::c_str;
+ PyErr_SetString($exc, c_str!($msg));
+ return ptr::null_mut();
+ }};
+ ($exc:ident, $msg:expr, $($args:tt),*) => {{
+ use crate::common::c_str;
+ PyErr_Format($exc, c_str!($msg), $($args),*);
+ return ptr::null_mut();
+ }};
+);
+
+macro_rules! get_digit(
+ ($s:ident, $index:expr) => {
+ match $s[$index] {
+ c if c.is_ascii_digit() => c - b'0',
+ _ => return None,
+ }
+ }
+);
+
+macro_rules! pystr_to_utf8(
+ ($s:expr, $msg:expr) => {{
+ use crate::common::c_str;
+ if PyUnicode_Check($s) == 0 {
+ PyErr_SetString(PyExc_TypeError, c_str!($msg));
+ return ptr::null_mut();
+ }
+ let mut size = 0;
+ let p = PyUnicode_AsUTF8AndSize($s, &mut size);
+ if p.is_null() {
+ return ptr::null_mut();
+ };
+ std::slice::from_raw_parts(p.cast::(), size as usize)
+ }}
+);
+
+macro_rules! try_get_long(
+ ($o:expr) => {{
+ let x = PyLong_AsLong($o);
+ if x == -1 && !PyErr_Occurred().is_null() {
+ return ptr::null_mut();
+ }
+ x
+ }}
+);
+
+macro_rules! c_str(
+ ($s:expr) => {
+ concat!($s, "\0").as_ptr().cast::()
+ };
+);
+
+// TODO: remove
+// Used for debugging--OK if not used
+#[allow(unused_macros)]
+macro_rules! print_repr {
+ ($e:expr) => {{
+ let s = pystr_to_utf8!(propagate_exc!(PyObject_Repr($e)), "Expected a string");
+ println!("{:?}", std::str::from_utf8_unchecked(s));
+ }};
+}
+
+#[inline]
+pub(crate) unsafe fn py_str(s: &str) -> *mut PyObject {
+ PyUnicode_FromStringAndSize(s.as_ptr().cast(), s.len() as Py_ssize_t)
+}
+
+pub(crate) unsafe extern "C" fn identity(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ Py_INCREF(slf);
+ slf
+}
+
+#[allow(unused_imports)]
+pub(crate) use {c_str, get_digit, print_repr, propagate_exc, pystr_to_utf8, raise, try_get_long};
diff --git a/src/date.rs b/src/date.rs
new file mode 100644
index 00000000..a18cb386
--- /dev/null
+++ b/src/date.rs
@@ -0,0 +1,991 @@
+use core::ffi::{c_char, c_int, c_long, c_uint, c_void};
+use core::{mem, ptr, ptr::null_mut as NULL};
+use pyo3_ffi::*;
+use std::cmp::min;
+use std::fmt::{self, Display, Formatter};
+
+use crate::common::{c_str, get_digit, propagate_exc, py_str, pystr_to_utf8, raise, try_get_long};
+use crate::date_delta;
+use crate::date_delta::{DateDelta, PyDateDelta};
+use crate::ModuleState;
+
+#[repr(C)]
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)]
+pub struct Date {
+ pub(crate) year: u16,
+ pub(crate) month: u8,
+ pub(crate) day: u8,
+}
+
+#[repr(C)]
+pub(crate) struct PyDate {
+ _ob_base: PyObject,
+ date: Date,
+ // TODO: use the extra padding to cache the ordinal?
+}
+
+impl Date {
+ pub(crate) unsafe fn hash(self) -> u32 {
+ mem::transmute::<_, u32>(self)
+ }
+
+ pub(crate) fn increment(mut self) -> Self {
+ if self.day < days_in_month(self.year, self.month) {
+ self.day += 1
+ } else {
+ self.day = 1;
+ self.month = self.month % 12 + 1;
+ }
+ self
+ }
+
+ pub(crate) fn decrement(mut self) -> Self {
+ if self.day > 1 {
+ self.day -= 1;
+ } else {
+ self.day = days_in_month(self.year, self.month - 1);
+ self.month = self.month.saturating_sub(1);
+ }
+ self
+ }
+}
+
+impl Display for Date {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
+ }
+}
+
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub(crate) enum DateError {
+ InvalidYear,
+ InvalidMonth,
+ InvalidDay,
+}
+
+impl DateError {
+ pub(crate) unsafe fn set_pyerr(&self) {
+ match self {
+ DateError::InvalidYear => {
+ PyErr_SetString(PyExc_ValueError, c_str!("year is out of range (1..9999)"));
+ }
+ DateError::InvalidMonth => {
+ PyErr_SetString(PyExc_ValueError, c_str!("month must be in 1..12"));
+ }
+ DateError::InvalidDay => {
+ PyErr_SetString(PyExc_ValueError, c_str!("day is out of range"));
+ }
+ }
+ }
+}
+
+pub(crate) const MAX_YEAR: c_long = 9999;
+const MIN_YEAR: c_long = 1;
+const DAYS_IN_MONTH: [u8; 13] = [
+ 0, // 1-indexed
+ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
+];
+const MIN_ORD: c_long = 1;
+const MAX_ORD: c_long = 3_652_059;
+const DAYS_BEFORE_MONTH: [u16; 13] = [
+ 0, // 1-indexed
+ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334,
+];
+const DAYS_IN_400Y: u32 = 146_097;
+const DAYS_IN_100Y: u32 = 36_524;
+const DAYS_IN_4Y: u32 = 1_461;
+
+fn is_leap(year: u16) -> bool {
+ (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
+}
+
+fn days_in_month(year: u16, month: u8) -> u8 {
+ debug_assert!(month >= 1 && month <= 12);
+ if month == 2 && is_leap(year) {
+ 29
+ } else {
+ DAYS_IN_MONTH[month as usize]
+ }
+}
+
+pub(crate) fn in_range(year: c_long, month: c_long, day: c_long) -> Result {
+ if year < MIN_YEAR || year > MAX_YEAR {
+ return Err(DateError::InvalidYear);
+ }
+ if month < 1 || month > 12 {
+ return Err(DateError::InvalidMonth);
+ }
+ let y = year as u16;
+ let m = month as u8;
+ if day < 1 || day > days_in_month(y, m) as c_long {
+ return Err(DateError::InvalidDay);
+ }
+ Ok(Date {
+ year: y,
+ month: m,
+ day: day as u8,
+ })
+}
+
+unsafe extern "C" fn __new__(
+ type_: *mut PyTypeObject,
+ args: *mut PyObject,
+ kwargs: *mut PyObject,
+) -> *mut PyObject {
+ let nargs = PyTuple_GET_SIZE(args);
+ let nkwargs = if kwargs.is_null() {
+ 0
+ } else {
+ PyDict_Size(kwargs)
+ };
+
+ // Fast path for the most common case
+ if nargs == 3 && nkwargs == 0 {
+ new_checked(
+ type_,
+ try_get_long!(PyTuple_GET_ITEM(args, 0)),
+ try_get_long!(PyTuple_GET_ITEM(args, 1)),
+ try_get_long!(PyTuple_GET_ITEM(args, 2)),
+ )
+ } else if nargs + nkwargs > 3 {
+ raise!(
+ PyExc_TypeError,
+ // TODO: reinstate formatting
+ "Date() takes exactly 3 arguments",
+ );
+ // slow path: parse args and kwargs
+ } else {
+ let mut year: Option = None;
+ let mut month: Option = None;
+ let mut day: Option = None;
+
+ if nargs > 0 {
+ year = Some(try_get_long!(PyTuple_GET_ITEM(args, 0)));
+ if nargs > 1 {
+ month = Some(try_get_long!(PyTuple_GET_ITEM(args, 1)));
+ debug_assert!(nargs == 2); // follows from the first branches
+ }
+ }
+ if nkwargs > 0 {
+ let mut key_obj: *mut PyObject = NULL();
+ let mut value_obj: *mut PyObject = NULL();
+ let mut pos: Py_ssize_t = 0;
+ while PyDict_Next(kwargs, &mut pos, &mut key_obj, &mut value_obj) != 0 {
+ match pystr_to_utf8!(key_obj, "Kwargs keys must be str") {
+ b"year" => {
+ if year.replace(try_get_long!(value_obj)).is_some() {
+ raise!(
+ PyExc_TypeError,
+ "Date() got multiple values for argument 'year'"
+ );
+ }
+ }
+ b"month" => {
+ if month.replace(try_get_long!(value_obj)).is_some() {
+ raise!(
+ PyExc_TypeError,
+ "Date() got multiple values for argument 'month'"
+ );
+ }
+ }
+ b"day" => {
+ if day.replace(try_get_long!(value_obj)).is_some() {
+ raise!(
+ PyExc_TypeError,
+ "Date() got multiple values for argument 'day'"
+ );
+ }
+ }
+ _ => {
+ raise!(
+ PyExc_TypeError,
+ "Date() got an unexpected keyword argument: %R",
+ key_obj
+ );
+ }
+ }
+ }
+ }
+ new_checked(
+ type_,
+ match year {
+ Some(year) => year,
+ None => raise!(PyExc_TypeError, "Date() missing required argument 'year'"),
+ },
+ match month {
+ Some(month) => month,
+ None => raise!(PyExc_TypeError, "Date() missing required argument 'month'"),
+ },
+ match day {
+ Some(day) => day,
+ None => raise!(PyExc_TypeError, "Date() missing required argument 'day'"),
+ },
+ )
+ }
+}
+
+unsafe extern "C" fn __repr__(slf: *mut PyObject) -> *mut PyObject {
+ let date = (*slf.cast::()).date;
+ py_str(format!("Date({:04}-{:02}-{:02})", date.year, date.month, date.day).as_str())
+}
+
+unsafe extern "C" fn __hash__(slf: *mut PyObject) -> Py_hash_t {
+ // TODO: check this is valid on 32-bit systems
+ (*slf.cast::()).date.hash() as Py_hash_t
+}
+
+unsafe extern "C" fn __richcmp__(
+ slf: *mut PyObject,
+ other: *mut PyObject,
+ op: c_int,
+) -> *mut PyObject {
+ let result = if Py_TYPE(other) == Py_TYPE(slf) {
+ let a = (*slf.cast::()).date;
+ let b = (*other.cast::()).date;
+ let cmp = match op {
+ pyo3_ffi::Py_LT => a < b,
+ pyo3_ffi::Py_LE => a <= b,
+ pyo3_ffi::Py_EQ => a == b,
+ pyo3_ffi::Py_NE => a != b,
+ pyo3_ffi::Py_GT => a > b,
+ pyo3_ffi::Py_GE => a >= b,
+ _ => unreachable!(),
+ };
+ if cmp {
+ Py_True()
+ } else {
+ Py_False()
+ }
+ } else {
+ Py_NotImplemented()
+ };
+ Py_INCREF(result);
+ result
+}
+
+unsafe extern "C" fn dealloc(slf: *mut PyObject) {
+ let tp_free = PyType_GetSlot(Py_TYPE(slf), Py_tp_free);
+ debug_assert_ne!(tp_free, NULL());
+ let f: freefunc = std::mem::transmute(tp_free);
+ f(slf.cast());
+}
+
+static mut SLOTS: &[PyType_Slot] = &[
+ PyType_Slot {
+ slot: Py_tp_new,
+ pfunc: __new__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_doc,
+ pfunc: "A calendar date type\0".as_ptr() as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_str,
+ pfunc: canonical_format as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_repr,
+ pfunc: __repr__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_richcompare,
+ pfunc: __richcmp__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_methods,
+ pfunc: unsafe { METHODS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_nb_subtract,
+ pfunc: __sub__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_add,
+ pfunc: __add__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_getset,
+ pfunc: unsafe { GETSETTERS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_hash,
+ pfunc: __hash__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_members,
+ pfunc: unsafe { MEMBERS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_dealloc,
+ pfunc: dealloc as *mut c_void,
+ },
+ PyType_Slot {
+ slot: 0,
+ pfunc: NULL(),
+ },
+];
+
+static mut MEMBERS: &[PyMemberDef] = &[PyMemberDef {
+ name: NULL(),
+ type_code: 0,
+ offset: 0,
+ flags: 0,
+ doc: NULL(),
+}];
+
+unsafe extern "C" fn as_py_date(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let date = (*slf.cast::()).date;
+ let api = *(*ModuleState::from(Py_TYPE(slf))).datetime_api;
+ (api.Date_FromDate)(
+ date.year as c_int,
+ date.month as c_int,
+ date.day as c_int,
+ api.DateType,
+ )
+}
+
+unsafe extern "C" fn from_py_date(cls: *mut PyObject, date: *mut PyObject) -> *mut PyObject {
+ // TODO: allow subclasses?
+ if PyDate_Check(date) == 0 {
+ raise!(PyExc_TypeError, "argument must be datetime.date");
+ }
+ new_unchecked(
+ cls.cast(),
+ Date {
+ year: PyDateTime_GET_YEAR(date) as u16,
+ month: PyDateTime_GET_MONTH(date) as u8,
+ day: PyDateTime_GET_DAY(date) as u8,
+ },
+ )
+ .cast()
+}
+
+unsafe extern "C" fn canonical_format(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let date = (*slf.cast::()).date;
+ py_str(format!("{:04}-{:02}-{:02}", date.year, date.month, date.day).as_str())
+}
+
+pub(crate) fn parse(s: &[u8]) -> Option<(u16, u8, u8)> {
+ // TODO: allow length check to be skipped
+ if s.len() != 10 {
+ return None;
+ }
+ let year = get_digit!(s, 0) as u16 * 1000
+ + get_digit!(s, 1) as u16 * 100
+ + get_digit!(s, 2) as u16 * 10
+ + get_digit!(s, 3) as u16;
+ let month = get_digit!(s, 5) * 10 + get_digit!(s, 6);
+ let day = get_digit!(s, 8) * 10 + get_digit!(s, 9);
+ Some((year, month, day))
+}
+
+unsafe extern "C" fn from_canonical_format(cls: *mut PyObject, s: *mut PyObject) -> *mut PyObject {
+ if let Some((y, m, d)) = parse(pystr_to_utf8!(s, "argument must be str")) {
+ new_checked(cls.cast(), y as c_long, m as c_long, d as c_long).cast()
+ } else {
+ raise!(PyExc_ValueError, "Could not parse date: %R", s);
+ }
+}
+
+unsafe extern "C" fn identity(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ Py_INCREF(slf);
+ slf
+}
+
+fn days_before_year(year: u16) -> u32 {
+ debug_assert!(year >= 1);
+ let y = (year - 1) as u32;
+ return y * 365 + y / 4 - y / 100 + y / 400;
+}
+
+fn days_before_month(year: u16, month: u8) -> u16 {
+ debug_assert!(month >= 1 && month <= 12);
+ let mut days = DAYS_BEFORE_MONTH[month as usize];
+ if month > 2 && is_leap(year) {
+ days += 1;
+ }
+ days
+}
+
+pub(crate) fn ymd_to_ord(year: u16, month: u8, day: u8) -> u32 {
+ days_before_year(year) + days_before_month(year, month) as u32 + day as u32
+}
+
+unsafe extern "C" fn day_of_week(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let date = (*slf.cast::()).date;
+ PyLong_FromLong(((ymd_to_ord(date.year, date.month, date.day) + 6) % 7 + 1).into())
+}
+
+unsafe extern "C" fn __reduce__(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ // All args are unused. We don't need to check this since __reduce__
+ // is only called internally by pickle (without arguments).
+ _: *const *mut PyObject,
+ _: Py_ssize_t,
+ _: *mut PyObject,
+) -> *mut PyObject {
+ let module = ModuleState::from(type_);
+ let date = (*slf.cast::()).date;
+ PyTuple_Pack(
+ 2,
+ (*module).unpickle_date,
+ propagate_exc!(PyTuple_Pack(
+ 3,
+ PyLong_FromLong(date.year as c_long),
+ PyLong_FromLong(date.month as c_long),
+ PyLong_FromLong(date.day as c_long),
+ )),
+ )
+}
+
+pub fn ord_to_ymd(ord: u32) -> (u16, u8, u8) {
+ // based on the algorithm from datetime.date.fromordinal
+ let mut n = ord - 1;
+ let n400 = n / DAYS_IN_400Y;
+ n %= DAYS_IN_400Y;
+ let n100 = n / DAYS_IN_100Y;
+ n %= DAYS_IN_100Y;
+ let n4 = n / DAYS_IN_4Y;
+ n %= DAYS_IN_4Y;
+ let n1 = n / 365;
+ n %= 365;
+
+ let year = (400 * n400 + 100 * n100 + 4 * n4 + n1 + 1) as u16;
+ if (n1 == 4) || (n100 == 4) {
+ (year - 1, 12, 31)
+ } else {
+ let leap = (n1 == 3) && (n4 != 24 || n100 == 3);
+ debug_assert!(is_leap(year) == leap);
+ // first estimate that's at most 1 too high
+ let mut month = (n + 50 >> 5) as u8;
+ let mut monthdays = days_before_month(year, month);
+ if n < monthdays as u32 {
+ month -= 1;
+ monthdays -= days_in_month(year, month) as u16;
+ }
+ n -= monthdays as u32;
+ debug_assert!((n as u8) < days_in_month(year, month));
+ (year, month as u8, n as u8 + 1)
+ }
+}
+
+pub(crate) fn add(d: Date, years: c_long, months: c_long, days: c_long) -> Option {
+ let mut year = d.year as c_long + years;
+ let month = ((d.month as c_long + months - 1).rem_euclid(12)) as u8 + 1;
+ year += (d.month as c_long + months - 1).div_euclid(12);
+ if year < MIN_YEAR || year > MAX_YEAR {
+ return None;
+ }
+ let ord = ymd_to_ord(
+ year as u16,
+ month,
+ min(d.day, days_in_month(year as u16, month)),
+ ) as i64
+ + days;
+ if ord < MIN_ORD || ord > MAX_ORD {
+ return None;
+ }
+ let (year, month, day) = ord_to_ymd(ord as u32);
+ Some(Date { year, month, day })
+}
+
+unsafe extern "C" fn __sub__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> *mut PyObject {
+ let a = (*obj_a.cast::()).date;
+ if Py_TYPE(obj_b) == Py_TYPE(obj_a) {
+ let b = (*obj_b.cast::()).date;
+
+ let mut months = a.month as i32 - b.month as i32 + 12 * (a.year as i32 - b.year as i32);
+ let mut days = a.day as i8;
+ // TODO: use unchecked, faster version of this function
+ let mut moved_a = add(
+ b,
+ a.year as c_long - b.year as c_long,
+ a.month as c_long - b.month as c_long,
+ 0,
+ )
+ // subtracting two valid dates never overflows
+ .unwrap();
+
+ // Check if we've overshot
+ if b > a && moved_a < a {
+ months += 1;
+ moved_a = add(b, 0, months as c_long, 0).unwrap();
+ days -= days_in_month(a.year, a.month) as i8;
+ } else if b < a && moved_a > a {
+ months -= 1;
+ moved_a = add(b, 0, months as c_long, 0).unwrap();
+ days += days_in_month(moved_a.year, moved_a.month) as i8
+ };
+ date_delta::new_unchecked(
+ (*ModuleState::from(Py_TYPE(obj_a))).date_delta_type,
+ DateDelta {
+ years: (months / 12) as i16,
+ months: months % 12,
+ weeks: 0,
+ days: (days - moved_a.day as i8) as i32,
+ },
+ )
+ .cast()
+ } else if Py_TYPE(obj_b) == (*ModuleState::from(Py_TYPE(obj_a))).date_delta_type {
+ let delta = (*obj_b.cast::()).delta;
+ match add(
+ a,
+ -delta.years as c_long,
+ -delta.months as c_long,
+ -(delta.weeks * 7 + delta.days) as c_long,
+ ) {
+ Some(shifted) => new_unchecked(Py_TYPE(obj_a), shifted).cast(),
+ None => {
+ raise!(PyExc_ValueError, "Resulting date out of range");
+ }
+ }
+ } else {
+ let result = Py_NotImplemented();
+ Py_INCREF(result);
+ result
+ }
+}
+
+unsafe extern "C" fn __add__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> *mut PyObject {
+ if Py_TYPE(obj_b) != (*ModuleState::from(Py_TYPE(obj_a))).date_delta_type {
+ let result = Py_NotImplemented();
+ Py_INCREF(result);
+ result
+ } else {
+ let delta = (*obj_b.cast::()).delta;
+ if let Some(date) = add(
+ (*obj_a.cast::()).date,
+ delta.years as c_long,
+ delta.months as c_long,
+ (delta.weeks * 7 + delta.days) as c_long,
+ ) {
+ new_unchecked(Py_TYPE(obj_a), date).cast()
+ } else {
+ raise!(PyExc_ValueError, "Resulting date out of range");
+ }
+ }
+}
+
+unsafe extern "C" fn add_method(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ args: *const *mut PyObject,
+ nargs: Py_ssize_t,
+ kwnames: *mut PyObject,
+) -> *mut PyObject {
+ shift(slf, type_, args, nargs, kwnames, false)
+}
+
+unsafe extern "C" fn subtract(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ args: *const *mut PyObject,
+ nargs: Py_ssize_t,
+ kwnames: *mut PyObject,
+) -> *mut PyObject {
+ shift(slf, type_, args, nargs, kwnames, true)
+}
+
+unsafe extern "C" fn shift(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ args: *const *mut PyObject,
+ nargs: Py_ssize_t,
+ kwnames: *mut PyObject,
+ negate: bool,
+) -> *mut PyObject {
+ let mut days: c_long = 0;
+ let mut months: c_long = 0;
+ let mut years: c_long = 0;
+
+ if PyVectorcall_NARGS(nargs as usize) != 0 {
+ raise!(PyExc_TypeError, "add() takes no positional arguments");
+ }
+ if !kwnames.is_null() {
+ for i in 0..=Py_SIZE(kwnames).saturating_sub(1) {
+ let name = PyTuple_GET_ITEM(kwnames, i as Py_ssize_t);
+ let value = try_get_long!(*args.offset(i));
+ if name == PyUnicode_InternFromString(c_str!("days")) {
+ days += value;
+ } else if name == PyUnicode_InternFromString(c_str!("months")) {
+ months = value;
+ } else if name == PyUnicode_InternFromString(c_str!("years")) {
+ years = value;
+ } else if name == PyUnicode_InternFromString(c_str!("weeks")) {
+ days += value * 7;
+ } else {
+ raise!(
+ PyExc_TypeError,
+ // TODO: add() may be subtract()!
+ "add() got an unexpected keyword argument %R",
+ name
+ );
+ }
+ }
+ }
+
+ if let Some(date) = add(
+ (*slf.cast::()).date,
+ if negate { -years } else { years },
+ if negate { -months } else { months },
+ if negate { -days } else { days },
+ ) {
+ new_unchecked(type_, date).cast()
+ } else {
+ raise!(PyExc_ValueError, "Resulting date out of range");
+ }
+}
+
+unsafe extern "C" fn replace(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ args: *const *mut PyObject,
+ nargs: Py_ssize_t,
+ kwnames: *mut PyObject,
+) -> *mut PyObject {
+ if PyVectorcall_NARGS(nargs as usize) != 0 {
+ raise!(PyExc_TypeError, "replace() takes no positional arguments");
+ }
+ if !kwnames.is_null() {
+ let date = (*slf.cast::()).date;
+ let mut year = date.year as c_long;
+ let mut month = date.month as c_long;
+ let mut day = date.day as c_long;
+ for i in 0..=Py_SIZE(kwnames).saturating_sub(1) {
+ let name = PyTuple_GET_ITEM(kwnames, i as Py_ssize_t);
+ if name == PyUnicode_InternFromString(c_str!("year")) {
+ year = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("month")) {
+ month = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("day")) {
+ day = try_get_long!(*args.offset(i));
+ } else {
+ raise!(
+ PyExc_TypeError,
+ "replace() got an unexpected keyword argument %R",
+ name
+ );
+ }
+ }
+ match in_range(year, month, day) {
+ Ok(date) => new_unchecked(type_, date).cast(),
+ Err(e) => {
+ e.set_pyerr();
+ NULL()
+ }
+ }
+ } else {
+ Py_INCREF(slf);
+ slf
+ }
+}
+
+static mut METHODS: &[PyMethodDef] = &[
+ PyMethodDef {
+ ml_name: c_str!("py_date"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: as_py_date,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Convert to a Python datetime.date"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("canonical_format"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: canonical_format,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Return the date in the canonical format"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_canonical_format"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_canonical_format,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Create a date from the canonical format"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("common_iso8601"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: canonical_format,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Return the date in the common ISO 8601 format"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_common_iso8601"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_canonical_format,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Create a date from the common ISO 8601 format"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_py_date"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_py_date,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Create a date from a Python datetime.date"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("__copy__"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: identity,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("__deepcopy__"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: identity,
+ },
+ ml_flags: METH_O,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ // TODO: rename iso_weekday
+ ml_name: c_str!("day_of_week"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: day_of_week,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Return the ISO day of the week, where monday=1"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("__reduce__"),
+ ml_meth: PyMethodDefPointer {
+ PyCMethod: __reduce__,
+ },
+ ml_flags: METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
+ ml_doc: NULL(),
+ },
+ // TODO: docstrings
+ PyMethodDef {
+ ml_name: c_str!("add"),
+ ml_meth: PyMethodDefPointer {
+ PyCMethod: add_method,
+ },
+ ml_flags: METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("subtract"),
+ ml_meth: PyMethodDefPointer {
+ PyCMethod: subtract,
+ },
+ ml_flags: METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("replace"),
+ ml_meth: PyMethodDefPointer { PyCMethod: replace },
+ ml_flags: METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
+ ml_doc: c_str!("Return a new date with the specified components replaced"),
+ },
+ PyMethodDef::zeroed(),
+];
+
+unsafe extern "C" fn get_year(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).date.year as c_long)
+}
+
+unsafe extern "C" fn get_month(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).date.month as c_long)
+}
+
+unsafe extern "C" fn get_day(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).date.day as c_long)
+}
+
+pub(crate) unsafe fn new_checked(
+ type_: *mut PyTypeObject,
+ year: c_long,
+ month: c_long,
+ day: c_long,
+) -> *mut PyObject {
+ match in_range(year, month, day) {
+ Ok(date) => new_unchecked(type_, date).cast(),
+ Err(e) => {
+ e.set_pyerr();
+ NULL()
+ }
+ }
+}
+
+pub(crate) unsafe fn new_unchecked(type_: *mut PyTypeObject, d: Date) -> *mut PyDate {
+ let f: allocfunc = (*type_).tp_alloc.expect("tp_alloc is not set");
+ let slf = propagate_exc!(f(type_, 0).cast::());
+ ptr::addr_of_mut!((*slf).date).write(d);
+ slf
+}
+
+// OPTIMIZE: a more efficient pickle?
+pub(crate) unsafe extern "C" fn unpickle(
+ module: *mut PyObject,
+ args: *mut *mut PyObject,
+ nargs: Py_ssize_t,
+) -> *mut PyObject {
+ if PyVectorcall_NARGS(nargs as usize) != 3 {
+ raise!(PyExc_TypeError, "Invalid pickle data");
+ }
+ new_unchecked(
+ (*PyModule_GetState(module).cast::()).date_type,
+ Date {
+ year: try_get_long!(*args.offset(0)) as u16,
+ month: try_get_long!(*args.offset(1)) as u8,
+ day: try_get_long!(*args.offset(2)) as u8,
+ },
+ )
+ .cast()
+}
+
+static mut GETSETTERS: &[PyGetSetDef] = &[
+ PyGetSetDef {
+ name: c_str!("year"),
+ get: Some(get_year),
+ set: None,
+ doc: c_str!("The year component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("month"),
+ get: Some(get_month),
+ set: None,
+ doc: c_str!("The month component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("day"),
+ get: Some(get_day),
+ set: None,
+ doc: c_str!("The day component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: NULL(),
+ get: None,
+ set: None,
+ doc: NULL(),
+ closure: NULL(),
+ },
+];
+
+pub(crate) static mut SPEC: PyType_Spec = PyType_Spec {
+ name: c_str!("whenever.Date"),
+ basicsize: mem::size_of::() as c_int,
+ itemsize: 0,
+ flags: Py_TPFLAGS_DEFAULT as c_uint,
+ slots: unsafe { SLOTS as *const [_] as *mut _ },
+};
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_check_date_valid() {
+ assert_eq!(
+ in_range(2021, 1, 1),
+ Ok(Date {
+ year: 2021,
+ month: 1,
+ day: 1
+ })
+ );
+ assert_eq!(
+ in_range(2021, 12, 31),
+ Ok(Date {
+ year: 2021,
+ month: 12,
+ day: 31
+ })
+ );
+ assert_eq!(
+ in_range(2021, 2, 28),
+ Ok(Date {
+ year: 2021,
+ month: 2,
+ day: 28
+ })
+ );
+ assert_eq!(
+ in_range(2020, 2, 29),
+ Ok(Date {
+ year: 2020,
+ month: 2,
+ day: 29
+ })
+ );
+ assert_eq!(
+ in_range(2021, 4, 30),
+ Ok(Date {
+ year: 2021,
+ month: 4,
+ day: 30
+ })
+ );
+ assert_eq!(
+ in_range(2000, 2, 29),
+ Ok(Date {
+ year: 2000,
+ month: 2,
+ day: 29
+ })
+ );
+ assert_eq!(
+ in_range(1900, 2, 28),
+ Ok(Date {
+ year: 1900,
+ month: 2,
+ day: 28
+ })
+ );
+ }
+
+ #[test]
+ fn test_check_date_invalid_year() {
+ assert_eq!(in_range(0, 1, 1), Err(DateError::InvalidYear));
+ assert_eq!(in_range(10_000, 1, 1), Err(DateError::InvalidYear));
+ }
+
+ #[test]
+ fn test_check_date_invalid_month() {
+ assert_eq!(in_range(2021, 0, 1), Err(DateError::InvalidMonth));
+ assert_eq!(in_range(2021, 13, 1), Err(DateError::InvalidMonth));
+ }
+
+ #[test]
+ fn test_check_date_invalid_day() {
+ assert_eq!(in_range(2021, 1, 0), Err(DateError::InvalidDay));
+ assert_eq!(in_range(2021, 1, 32), Err(DateError::InvalidDay));
+ assert_eq!(in_range(2021, 4, 31), Err(DateError::InvalidDay));
+ assert_eq!(in_range(2021, 2, 29), Err(DateError::InvalidDay));
+ assert_eq!(in_range(2020, 2, 30), Err(DateError::InvalidDay));
+ assert_eq!(in_range(2000, 2, 30), Err(DateError::InvalidDay));
+ assert_eq!(in_range(1900, 2, 29), Err(DateError::InvalidDay));
+ }
+
+ #[test]
+ fn test_ord_to_ymd() {
+ assert_eq!(ord_to_ymd(1), (1, 1, 1));
+ assert_eq!(ord_to_ymd(365), (1, 12, 31));
+ assert_eq!(ord_to_ymd(366), (2, 1, 1));
+ assert_eq!(ord_to_ymd(1_000), (3, 9, 27));
+ assert_eq!(ord_to_ymd(1_000_000), (2738, 11, 28));
+ assert_eq!(ord_to_ymd(730179), (2000, 2, 29));
+ assert_eq!(ord_to_ymd(730180), (2000, 3, 1));
+ assert_eq!(ord_to_ymd(3_652_059), (9999, 12, 31));
+ }
+
+ #[test]
+ fn test_ord_ymd_reversible() {
+ for ord in 1..=(366 * 4) {
+ let (year, month, day) = ord_to_ymd(ord);
+ assert_eq!(ord, ymd_to_ord(year, month, day));
+ }
+ }
+}
diff --git a/src/date_delta.rs b/src/date_delta.rs
new file mode 100644
index 00000000..c6bf0e3b
--- /dev/null
+++ b/src/date_delta.rs
@@ -0,0 +1,910 @@
+use core::ffi::{c_char, c_int, c_long, c_uint, c_void};
+use core::{mem, ptr};
+use pyo3_ffi::*;
+use std::cmp::min;
+use std::collections::hash_map::DefaultHasher;
+use std::fmt;
+use std::hash::{Hash, Hasher};
+use std::ops::Neg;
+use std::ptr::null_mut as NULL;
+
+use crate::common::{c_str, get_digit, propagate_exc, py_str, pystr_to_utf8, raise, try_get_long};
+use crate::date::MAX_YEAR;
+use crate::ModuleState;
+
+#[repr(C)]
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+pub(crate) struct DateDelta {
+ pub(crate) years: i16,
+ pub(crate) months: i32,
+ pub(crate) weeks: i32,
+ pub(crate) days: i32,
+}
+
+#[repr(C)]
+pub(crate) struct PyDateDelta {
+ _ob_base: PyObject,
+ pub(crate) delta: DateDelta,
+}
+
+impl Neg for DateDelta {
+ type Output = Self;
+
+ fn neg(self) -> Self {
+ Self {
+ years: -self.years,
+ months: -self.months,
+ weeks: -self.weeks,
+ days: -self.days,
+ }
+ }
+}
+
+const MAX_MONTHS: i32 = (MAX_YEAR * 12) as i32;
+const MAX_WEEKS: i32 = (MAX_YEAR * 53) as i32;
+const MAX_DAYS: i32 = (MAX_YEAR * 366) as i32;
+
+pub(crate) const SINGLETONS: [(&str, DateDelta); 1] = [(
+ "ZERO\0",
+ DateDelta {
+ years: 0,
+ months: 0,
+ weeks: 0,
+ days: 0,
+ },
+)];
+
+impl fmt::Display for DateDelta {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let content = [
+ if self.years != 0 {
+ format!("{}Y", self.years)
+ } else {
+ String::new()
+ },
+ if self.months != 0 {
+ format!("{}M", self.months)
+ } else {
+ String::new()
+ },
+ if self.weeks != 0 {
+ format!("{}W", self.weeks)
+ } else {
+ String::new()
+ },
+ if self.days != 0 {
+ format!("{}D", self.days)
+ } else {
+ String::new()
+ },
+ ]
+ .join("");
+ write!(f, "P{}", if content.is_empty() { "0D" } else { &content })
+ }
+}
+
+unsafe extern "C" fn __new__(
+ subtype: *mut PyTypeObject,
+ args: *mut PyObject,
+ kwargs: *mut PyObject,
+) -> *mut PyObject {
+ let mut years: c_long = 0;
+ let mut months: c_long = 0;
+ let mut weeks: c_long = 0;
+ let mut days: c_long = 0;
+
+ // FUTURE: parse them manually, which is more efficient
+ if PyArg_ParseTupleAndKeywords(
+ args,
+ kwargs,
+ "|$llll:DateDelta\0".as_ptr().cast::(),
+ vec![
+ "years\0".as_ptr().cast::() as *mut c_char,
+ "months\0".as_ptr().cast::() as *mut c_char,
+ "weeks\0".as_ptr().cast::() as *mut c_char,
+ "days\0".as_ptr().cast::() as *mut c_char,
+ NULL(),
+ ]
+ .as_mut_ptr(),
+ &mut years,
+ &mut months,
+ &mut weeks,
+ &mut days,
+ ) == 0
+ {
+ return NULL();
+ }
+
+ if years < -MAX_YEAR || years > MAX_YEAR {
+ raise!(PyExc_ValueError, "years out of bounds");
+ }
+ if months < -MAX_MONTHS as c_long || months > MAX_MONTHS as c_long {
+ raise!(PyExc_ValueError, "months out of bounds");
+ }
+ if weeks < -MAX_WEEKS as c_long || weeks > MAX_WEEKS as c_long {
+ raise!(PyExc_ValueError, "weeks out of bounds");
+ }
+ if days < -MAX_DAYS as c_long || days > MAX_DAYS as c_long {
+ raise!(PyExc_ValueError, "days out of bounds");
+ }
+
+ new_unchecked(
+ subtype,
+ DateDelta {
+ years: years as i16,
+ months: months as i32,
+ weeks: weeks as i32,
+ days: days as i32,
+ },
+ )
+ .cast()
+}
+
+pub(crate) unsafe extern "C" fn years(
+ module: *mut PyObject,
+ amount: *mut PyObject,
+) -> *mut PyObject {
+ let parsed_amount = try_get_long!(amount);
+ if parsed_amount < -MAX_YEAR || parsed_amount > MAX_YEAR {
+ raise!(PyExc_ValueError, "years out of bounds");
+ }
+ new_unchecked(
+ (*PyModule_GetState(module).cast::()).date_delta_type,
+ DateDelta {
+ years: parsed_amount as i16,
+ months: 0,
+ weeks: 0,
+ days: 0,
+ },
+ )
+ .cast()
+}
+
+pub(crate) unsafe extern "C" fn months(
+ module: *mut PyObject,
+ amount: *mut PyObject,
+) -> *mut PyObject {
+ let parsed_amount = try_get_long!(amount);
+ if parsed_amount < -MAX_MONTHS as c_long || parsed_amount > MAX_MONTHS as c_long {
+ raise!(PyExc_ValueError, "months out of bounds");
+ }
+ new_unchecked(
+ (*PyModule_GetState(module).cast::()).date_delta_type,
+ DateDelta {
+ years: 0,
+ months: parsed_amount as i32,
+ weeks: 0,
+ days: 0,
+ },
+ )
+ .cast()
+}
+
+pub(crate) unsafe extern "C" fn weeks(
+ module: *mut PyObject,
+ amount: *mut PyObject,
+) -> *mut PyObject {
+ let parsed_amount = try_get_long!(amount);
+ if parsed_amount < -MAX_WEEKS as c_long || parsed_amount > MAX_WEEKS as c_long {
+ raise!(PyExc_ValueError, "weeks out of bounds");
+ }
+ new_unchecked(
+ (*PyModule_GetState(module).cast::()).date_delta_type,
+ DateDelta {
+ years: 0,
+ months: 0,
+ weeks: parsed_amount as i32,
+ days: 0,
+ },
+ )
+ .cast()
+}
+
+pub(crate) unsafe extern "C" fn days(
+ module: *mut PyObject,
+ amount: *mut PyObject,
+) -> *mut PyObject {
+ let parsed_amount = try_get_long!(amount);
+ if parsed_amount < -MAX_DAYS as c_long || parsed_amount > MAX_DAYS as c_long {
+ raise!(PyExc_ValueError, "days out of bounds");
+ }
+ new_unchecked(
+ (*PyModule_GetState(module).cast::()).date_delta_type,
+ DateDelta {
+ years: 0,
+ months: 0,
+ weeks: 0,
+ days: parsed_amount as i32,
+ },
+ )
+ .cast()
+}
+
+unsafe extern "C" fn richcmp(slf: *mut PyObject, other: *mut PyObject, op: c_int) -> *mut PyObject {
+ let result: *mut PyObject;
+ if Py_TYPE(other) != Py_TYPE(slf) {
+ result = Py_NotImplemented();
+ } else {
+ let slf = (*slf.cast::()).delta;
+ let other = (*other.cast::()).delta;
+ result = match op {
+ pyo3_ffi::Py_EQ => {
+ if slf == other {
+ Py_True()
+ } else {
+ Py_False()
+ }
+ }
+ pyo3_ffi::Py_NE => {
+ if slf != other {
+ Py_True()
+ } else {
+ Py_False()
+ }
+ }
+ _ => Py_NotImplemented(),
+ };
+ }
+ Py_INCREF(result);
+ result
+}
+
+// TODO: cache this value?
+unsafe extern "C" fn __hash__(slf: *mut PyObject) -> Py_hash_t {
+ let date = (*slf.cast::()).delta;
+ let mut hasher = DefaultHasher::new();
+ date.hash(&mut hasher);
+ // TODO: is this OK?
+ hasher.finish() as Py_hash_t
+}
+
+unsafe extern "C" fn __neg__(slf: *mut PyObject) -> *mut PyObject {
+ let date = (*slf.cast::()).delta;
+ new_unchecked(
+ Py_TYPE(slf),
+ DateDelta {
+ years: -date.years,
+ months: -date.months,
+ weeks: -date.weeks,
+ days: -date.days,
+ },
+ )
+ .cast()
+}
+
+unsafe extern "C" fn __bool__(slf: *mut PyObject) -> c_int {
+ let date = (*slf.cast::()).delta;
+ !(date.years == 0 && date.months == 0 && date.weeks == 0 && date.days == 0) as c_int
+}
+
+unsafe extern "C" fn __repr__(slf: *mut PyObject) -> *mut PyObject {
+ let delta = (*slf.cast::()).delta;
+ py_str(format!("DateDelta({})", delta).as_str())
+}
+
+unsafe extern "C" fn __str__(slf: *mut PyObject) -> *mut PyObject {
+ let delta = (*slf.cast::()).delta;
+ py_str(format!("{}", delta).as_str())
+}
+
+unsafe extern "C" fn __mul__(slf: *mut PyObject, factor_obj: *mut PyObject) -> *mut PyObject {
+ let factor = try_get_long!(factor_obj);
+ if factor == 1 {
+ Py_INCREF(slf);
+ return slf;
+ }
+ let mut delta = (*slf.cast::()).delta;
+ // Overflow checks that allow us to do `factor as i16/i32` later
+ if delta.years != 0 && (factor > i16::MAX as c_long || factor < i16::MIN as c_long)
+ || factor > i32::MAX as c_long
+ || factor < i32::MIN as c_long
+ {
+ raise!(PyExc_ValueError, "Multiplication result out of range");
+ }
+ if let (Some(years), Some(months), Some(weeks), Some(days)) = (
+ delta.years.checked_mul(factor as i16),
+ delta.months.checked_mul(factor as i32),
+ delta.weeks.checked_mul(factor as i32),
+ delta.days.checked_mul(factor as i32),
+ ) {
+ delta = DateDelta {
+ years,
+ months,
+ weeks,
+ days,
+ };
+ } else {
+ raise!(PyExc_ValueError, "Multiplication result out of range");
+ }
+ if !is_in_range(delta) {
+ raise!(PyExc_ValueError, "Multiplication result out of range");
+ }
+ new_unchecked(Py_TYPE(slf), delta).cast()
+}
+
+unsafe extern "C" fn __add__(slf: *mut PyObject, other: *mut PyObject) -> *mut PyObject {
+ if Py_TYPE(other) != Py_TYPE(slf) {
+ let result = Py_NotImplemented();
+ Py_INCREF(result);
+ return result;
+ }
+ let a = (*other.cast::()).delta;
+ let b = (*slf.cast::()).delta;
+ let new = DateDelta {
+ // don't need to check for overflow here, since valid deltas well below overflow
+ years: a.years + b.years,
+ months: a.months + b.months,
+ weeks: a.weeks + b.weeks,
+ days: a.days + b.days,
+ };
+ if !is_in_range(new) {
+ raise!(PyExc_ValueError, "Addition result out of range");
+ }
+ new_unchecked(Py_TYPE(slf), new).cast()
+}
+
+unsafe extern "C" fn __sub__(slf: *mut PyObject, other: *mut PyObject) -> *mut PyObject {
+ if Py_TYPE(other) != Py_TYPE(slf) {
+ let result = Py_NotImplemented();
+ Py_INCREF(result);
+ return result;
+ }
+ let a = (*slf.cast::()).delta;
+ let b = (*other.cast::()).delta;
+ let new = DateDelta {
+ // don't need to check for overflow here, since valid deltas well below overflow
+ years: a.years - b.years,
+ months: a.months - b.months,
+ weeks: a.weeks - b.weeks,
+ days: a.days - b.days,
+ };
+ if !is_in_range(new) {
+ raise!(PyExc_ValueError, "Subtraction result out of range");
+ }
+ new_unchecked(Py_TYPE(slf), new).cast()
+}
+
+unsafe extern "C" fn __abs__(slf: *mut PyObject) -> *mut PyObject {
+ let delta = (*slf.cast::()).delta;
+ if delta.years >= 0 && delta.months >= 0 && delta.weeks >= 0 && delta.days >= 0 {
+ Py_INCREF(slf);
+ return slf;
+ }
+ new_unchecked(
+ Py_TYPE(slf),
+ DateDelta {
+ years: delta.years.abs(),
+ months: delta.months.abs(),
+ weeks: delta.weeks.abs(),
+ days: delta.days.abs(),
+ },
+ )
+ .cast()
+}
+
+static mut SLOTS: &[PyType_Slot] = &[
+ PyType_Slot {
+ slot: Py_tp_new,
+ pfunc: __new__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_doc,
+ pfunc: "A calendar date type\0".as_ptr() as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_methods,
+ pfunc: unsafe { METHODS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_getset,
+ pfunc: unsafe { GETSETTERS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_members,
+ pfunc: unsafe { MEMBERS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_richcompare,
+ pfunc: richcmp as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_hash,
+ pfunc: __hash__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_negative,
+ pfunc: __neg__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_repr,
+ pfunc: __repr__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_bool,
+ pfunc: __bool__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_str,
+ pfunc: __str__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_positive,
+ pfunc: identity as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_multiply,
+ pfunc: __mul__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_add,
+ pfunc: __add__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_subtract,
+ pfunc: __sub__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_absolute,
+ pfunc: __abs__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: 0,
+ pfunc: NULL(),
+ },
+];
+
+static mut MEMBERS: &[PyMemberDef] = &[PyMemberDef {
+ name: NULL(),
+ type_code: 0,
+ offset: 0,
+ flags: 0,
+ doc: NULL(),
+}];
+
+unsafe extern "C" fn identity(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ Py_INCREF(slf);
+ slf
+}
+
+unsafe extern "C" fn canonical_format(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ __str__(slf)
+}
+
+// parse the prefix of an ISO8601 duration, e.g. `P`, `-P`, `+P`,
+fn parse_prefix(s: &[u8]) -> Option<(bool, &[u8])> {
+ match s[0] {
+ b'P' => Some((false, &s[1..])),
+ b'-' => {
+ if s[1] == b'P' {
+ Some((true, &s[2..]))
+ } else {
+ None
+ }
+ }
+ b'+' => {
+ if s[1] == b'P' {
+ Some((false, &s[2..]))
+ } else {
+ None
+ }
+ }
+ _ => None,
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone)]
+enum Unit {
+ Years,
+ Months,
+ Weeks,
+ Days,
+}
+
+fn finish_parsing_component(s: &[u8], value: i32) -> Option<(i32, Unit, &[u8])> {
+ let sign = value.signum();
+ let mut tally = value * sign;
+ // We limit parsing to 8 digits to prevent overflow
+ for i in 0..min(s.len(), 8) {
+ match s[i] {
+ b'Y' if -MAX_YEAR as i32 <= tally && tally <= MAX_YEAR as i32 => {
+ return Some((tally * sign, Unit::Years, &s[i + 1..]))
+ }
+ b'M' if -MAX_MONTHS <= tally && tally <= MAX_MONTHS => {
+ return Some((tally * sign, Unit::Months, &s[i + 1..]))
+ }
+ b'W' if -MAX_WEEKS <= tally && tally <= MAX_WEEKS => {
+ return Some((tally * sign, Unit::Weeks, &s[i + 1..]))
+ }
+ b'D' if -MAX_DAYS <= tally && tally <= MAX_DAYS => {
+ return Some((tally * sign, Unit::Days, &s[i + 1..]))
+ }
+ c if c.is_ascii_digit() => tally = tally * 10 + (c - b'0') as i32,
+ _ => {
+ return None;
+ }
+ }
+ }
+ None
+}
+
+// parse a component of a ISO8601 duration, e.g. `6Y`, `-56M`, `+2W`, `0D`
+fn parse_component(s: &[u8]) -> Option<(i32, Unit, &[u8])> {
+ if s.len() < 2 {
+ return None;
+ }
+ match s[0] {
+ b'-' => finish_parsing_component(&s[2..], -(get_digit!(s, 1) as i32)),
+ b'+' => finish_parsing_component(&s[2..], get_digit!(s, 1) as i32),
+ c if c.is_ascii_digit() => finish_parsing_component(&s[1..], (c - b'0') as i32),
+ _ => None,
+ }
+}
+
+unsafe extern "C" fn from_canonical_format(
+ type_: *mut PyObject,
+ str: *mut PyObject,
+) -> *mut PyObject {
+ let mut s = pystr_to_utf8!(str, "argument must be str");
+ if s.len() == 0 {
+ raise!(PyExc_ValueError, "Invalid date delta format: %R", str);
+ }
+ let mut years = 0;
+ let mut months = 0;
+ let mut weeks = 0;
+ let mut days = 0;
+ let mut last_unit: Option = None;
+ let negated;
+
+ match parse_prefix(s) {
+ Some((neg, rest)) => {
+ negated = neg;
+ s = rest;
+ }
+ None => {
+ raise!(PyExc_ValueError, "Invalid date delta format: %R", str);
+ }
+ }
+
+ while s.len() > 0 {
+ // FUTURE: there's still some optimization to be done here...
+ if let Some((value, unit, rest)) = parse_component(s) {
+ match (unit, last_unit) {
+ (Unit::Years, None) => {
+ years = value;
+ last_unit = Some(Unit::Years);
+ }
+ (Unit::Months, None | Some(Unit::Years)) => {
+ months = value;
+ last_unit = Some(Unit::Months);
+ }
+ (Unit::Weeks, None | Some(Unit::Years | Unit::Months)) => {
+ weeks = value;
+ last_unit = Some(Unit::Weeks);
+ }
+ (Unit::Days, None | Some(Unit::Years | Unit::Months | Unit::Weeks)) => {
+ days = value;
+ last_unit = Some(Unit::Days);
+ }
+ _ => {
+ // i.e. the order of the components is wrong
+ raise!(PyExc_ValueError, "Invalid date delta format: %R", str);
+ }
+ }
+ s = rest;
+ } else {
+ // i.e. the component is invalid
+ raise!(PyExc_ValueError, "Invalid date delta format: %R", str);
+ }
+ }
+
+ // i.e. there must be at least one component (`P` alone is invalid)
+ if last_unit.is_none() {
+ raise!(PyExc_ValueError, "Invalid date delta format: %R", str);
+ }
+
+ new_unchecked(
+ type_.cast::(),
+ DateDelta {
+ years: if negated { -years } else { years } as i16,
+ months: if negated { -months } else { months },
+ weeks: if negated { -weeks } else { weeks },
+ days: if negated { -days } else { days },
+ },
+ )
+ .cast()
+}
+
+unsafe extern "C" fn as_tuple(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let delta = (*slf.cast::()).delta;
+ PyTuple_Pack(
+ 4,
+ PyLong_FromLong(delta.years as c_long),
+ PyLong_FromLong(delta.months as c_long),
+ PyLong_FromLong(delta.weeks as c_long),
+ PyLong_FromLong(delta.days as c_long),
+ )
+}
+
+unsafe extern "C" fn reduce(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ // All args are unused. We don't need to check this since __reduce__
+ // is only called internally by pickle (without arguments).
+ _: *const *mut PyObject,
+ _: Py_ssize_t,
+ _: *mut PyObject,
+) -> *mut PyObject {
+ let module = ModuleState::from(type_);
+ let delta = (*slf.cast::()).delta;
+ PyTuple_Pack(
+ 2,
+ (*module).unpickle_date_delta,
+ propagate_exc!(PyTuple_Pack(
+ 4,
+ PyLong_FromLong(delta.years as c_long),
+ PyLong_FromLong(delta.months as c_long),
+ PyLong_FromLong(delta.weeks as c_long),
+ PyLong_FromLong(delta.days as c_long)
+ )),
+ )
+}
+
+// OPTIMIZE: a more efficient pickle?
+pub(crate) unsafe extern "C" fn unpickle(
+ module: *mut PyObject,
+ args: *mut *mut PyObject,
+ nargs: Py_ssize_t,
+) -> *mut PyObject {
+ if PyVectorcall_NARGS(nargs as usize) != 4 {
+ raise!(PyExc_TypeError, "Invalid pickle data");
+ }
+ new_unchecked(
+ (*PyModule_GetState(module).cast::()).date_delta_type,
+ DateDelta {
+ years: try_get_long!(*args.offset(0)) as i16,
+ months: try_get_long!(*args.offset(1)) as i32,
+ weeks: try_get_long!(*args.offset(2)) as i32,
+ days: try_get_long!(*args.offset(3)) as i32,
+ },
+ )
+ .cast()
+}
+
+unsafe extern "C" fn replace(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ args: *const *mut PyObject,
+ nargs: Py_ssize_t,
+ kwnames: *mut PyObject,
+) -> *mut PyObject {
+ if PyVectorcall_NARGS(nargs as usize) != 0 {
+ raise!(PyExc_TypeError, "replace() takes no positional arguments");
+ }
+ let delta = (*slf.cast::()).delta;
+ let mut years = delta.years;
+ let mut months = delta.months;
+ let mut weeks = delta.weeks;
+ let mut days = delta.days;
+
+ if !kwnames.is_null() {
+ for i in 0..=Py_SIZE(kwnames).saturating_sub(1) {
+ let name = PyTuple_GET_ITEM(kwnames, i as Py_ssize_t);
+ let value = try_get_long!(*args.offset(i));
+ if name == PyUnicode_InternFromString(c_str!("years")) {
+ if value < -MAX_YEAR as c_long || value > MAX_YEAR as c_long {
+ raise!(PyExc_ValueError, "years out of bounds");
+ }
+ years = value as i16;
+ } else if name == PyUnicode_InternFromString(c_str!("months")) {
+ if value < -MAX_MONTHS as c_long || value > MAX_MONTHS as c_long {
+ raise!(PyExc_ValueError, "months out of bounds");
+ }
+ months = value as i32;
+ } else if name == PyUnicode_InternFromString(c_str!("weeks")) {
+ if value < -MAX_WEEKS as c_long || value > MAX_WEEKS as c_long {
+ raise!(PyExc_ValueError, "weeks out of bounds");
+ }
+ weeks = value as i32;
+ } else if name == PyUnicode_InternFromString(c_str!("days")) {
+ if value < -MAX_DAYS as c_long || value > MAX_DAYS as c_long {
+ raise!(PyExc_ValueError, "days out of bounds");
+ }
+ days = value as i32;
+ } else {
+ raise!(PyExc_TypeError, "Invalid keyword argument: %R", name);
+ }
+ }
+ }
+
+ new_unchecked(
+ type_,
+ DateDelta {
+ years,
+ months,
+ weeks,
+ days,
+ },
+ )
+ .cast()
+}
+
+static mut METHODS: &[PyMethodDef] = &[
+ PyMethodDef {
+ ml_name: c_str!("__copy__"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: identity,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("__deepcopy__"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: identity,
+ },
+ ml_flags: METH_O,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("canonical_format"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: canonical_format,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Return the canonical string representation"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("common_iso8601"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: canonical_format,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Return the ISO 8601 string representation"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_canonical_format"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_canonical_format,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Parse a canonical string representation"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_common_iso8601"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_canonical_format,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Parse from the common ISO8601 period format"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("as_tuple"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: as_tuple,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Return the date delta as a tuple"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("__reduce__"),
+ ml_meth: PyMethodDefPointer { PyCMethod: reduce },
+ ml_flags: METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("replace"),
+ ml_meth: PyMethodDefPointer { PyCMethod: replace },
+ ml_flags: METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
+ ml_doc: c_str!("Return a new date delta with the specified components replaced"),
+ },
+ PyMethodDef::zeroed(),
+];
+
+unsafe extern "C" fn get_years(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).delta.years as c_long)
+}
+
+unsafe extern "C" fn get_months(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).delta.months as c_long)
+}
+
+unsafe extern "C" fn get_weeks(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).delta.weeks as c_long)
+}
+
+unsafe extern "C" fn get_day(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).delta.days as c_long)
+}
+
+pub(crate) unsafe fn new_unchecked(type_: *mut PyTypeObject, d: DateDelta) -> *mut PyDateDelta {
+ let f: allocfunc = (*type_).tp_alloc.expect("tp_alloc is not set");
+ let slf = propagate_exc!(f(type_, 0).cast::());
+ ptr::addr_of_mut!((*slf).delta).write(d);
+ slf
+}
+
+pub(crate) unsafe fn is_in_range(d: DateDelta) -> bool {
+ d.years >= -MAX_YEAR as i16
+ && d.years <= MAX_YEAR as i16
+ && d.months >= -MAX_MONTHS
+ && d.months <= MAX_MONTHS
+ && d.weeks >= -MAX_WEEKS
+ && d.weeks <= MAX_WEEKS
+ && d.days >= -MAX_DAYS
+ && d.days <= MAX_DAYS
+}
+
+static mut GETSETTERS: &[PyGetSetDef] = &[
+ PyGetSetDef {
+ name: c_str!("years"),
+ get: Some(get_years),
+ set: None,
+ doc: c_str!("The year component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("months"),
+ get: Some(get_months),
+ set: None,
+ doc: c_str!("The month component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("weeks"),
+ get: Some(get_weeks),
+ set: None,
+ doc: c_str!("The week component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("days"),
+ get: Some(get_day),
+ set: None,
+ doc: c_str!("The day component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: NULL(),
+ get: None,
+ set: None,
+ doc: NULL(),
+ closure: NULL(),
+ },
+];
+
+pub(crate) static mut SPEC: PyType_Spec = PyType_Spec {
+ name: c_str!("whenever.DateDelta"),
+ basicsize: mem::size_of::() as c_int,
+ itemsize: 0,
+ flags: Py_TPFLAGS_DEFAULT as c_uint,
+ slots: unsafe { SLOTS as *const [_] as *mut _ },
+};
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_prefix() {
+ assert_eq!(parse_prefix(b"P56D"), Some((false, &b"56D"[..])));
+ assert_eq!(parse_prefix(b"-P"), Some((true, &b""[..])));
+ assert_eq!(parse_prefix(b"+P"), Some((false, &b""[..])));
+ assert_eq!(parse_prefix(b"X43"), None);
+ assert_eq!(parse_prefix(b"P"), Some((false, &b""[..])));
+ }
+
+ #[test]
+ fn test_parse_component() {
+ assert_eq!(parse_component(b"6Y"), Some((6, Unit::Years, &b""[..])));
+ assert_eq!(
+ parse_component(b"-56M9"),
+ Some((-56, Unit::Months, &b"9"[..]))
+ );
+ assert_eq!(parse_component(b"+2W"), Some((2, Unit::Weeks, &b""[..])));
+ assert_eq!(parse_component(b"0D98"), Some((0, Unit::Days, &b"98"[..])));
+ assert_eq!(parse_component(b"D"), None);
+ assert_eq!(parse_component(b"0"), None);
+ assert_eq!(parse_component(b"+"), None);
+ assert_eq!(parse_component(b"-"), None);
+ }
+}
diff --git a/src/interval.rs b/src/interval.rs
deleted file mode 100644
index aed227e5..00000000
--- a/src/interval.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-use core::ffi::{c_char, c_int, c_uint, c_void};
-use core::{mem, ptr};
-use pyo3_ffi::*;
-
-#[repr(C)]
-pub struct PyInterval {
- _ob_base: PyObject,
- data: (),
-}
-
-unsafe extern "C" fn new(
- subtype: *mut PyTypeObject,
- args: *mut PyObject,
- kwds: *mut PyObject,
-) -> *mut PyObject {
- if PyTuple_Size(args) != 0 || !kwds.is_null() {
- PyErr_SetString(
- PyExc_TypeError,
- "Interval() takes no arguments\0".as_ptr().cast::(),
- );
- return ptr::null_mut();
- }
-
- let f: allocfunc = (*subtype).tp_alloc.unwrap_or(PyType_GenericAlloc);
- let slf = f(subtype, 0);
-
- if slf.is_null() {
- return ptr::null_mut();
- } else {
- let slf = slf.cast::();
- ptr::addr_of_mut!((*slf).data).write(());
- }
- slf
-}
-
-#[cfg(Py_3_9)]
-extern "C" {
- #[cfg_attr(PyPy, link_name = "PyPy_GenericAlias")]
- fn Py_GenericAlias(origin: *mut PyObject, args: *mut PyObject) -> *mut PyObject;
-}
-
-#[cfg(Py_3_9)]
-unsafe extern "C" fn class_getitem(type_: *mut PyObject, item: *mut PyObject) -> *mut PyObject {
- Py_GenericAlias(type_, item)
-}
-
-#[cfg(not(Py_3_9))]
-unsafe extern "C" fn class_getitem(type_: *mut PyObject, _item: *mut PyObject) -> *mut PyObject {
- type_
-}
-
-unsafe extern "C" fn repr(_slf: *mut PyObject) -> *mut PyObject {
- let string = "Interval()\0";
- PyUnicode_FromStringAndSize(string.as_ptr().cast::(), string.len() as Py_ssize_t)
-}
-
-static mut METHODS: &[PyMethodDef] = &[
- PyMethodDef {
- ml_name: "__class_getitem__\0".as_ptr().cast::(),
- ml_meth: PyMethodDefPointer {
- PyCFunction: class_getitem,
- },
- ml_flags: METH_O | METH_CLASS,
- ml_doc: "See PEP 585\0".as_ptr().cast::(),
- },
- PyMethodDef::zeroed(),
-];
-
-static mut SLOTS: &[PyType_Slot] = &[
- PyType_Slot {
- slot: Py_tp_new,
- pfunc: new as *mut c_void,
- },
- PyType_Slot {
- slot: Py_tp_doc,
- pfunc: "An interval type\0".as_ptr() as *mut c_void,
- },
- PyType_Slot {
- slot: Py_tp_repr,
- pfunc: repr as *mut c_void,
- },
- PyType_Slot {
- slot: Py_tp_methods,
- pfunc: unsafe { METHODS.as_ptr() as *mut c_void },
- },
- PyType_Slot {
- slot: 0,
- pfunc: ptr::null_mut(),
- },
-];
-
-pub static mut SPEC: PyType_Spec = PyType_Spec {
- name: "whenever.Interval\0".as_ptr().cast::(),
- basicsize: mem::size_of::() as c_int,
- itemsize: 0,
- flags: Py_TPFLAGS_DEFAULT as c_uint,
- slots: unsafe { SLOTS as *const [PyType_Slot] as *mut PyType_Slot },
-};
diff --git a/src/lib.rs b/src/lib.rs
index aa395266..87187bf3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,202 +1,403 @@
-use core::ffi::{c_char, c_int, c_void};
+use core::ffi::{c_char, c_int, c_long, c_void};
+use core::ptr::null_mut as NULL;
use core::{mem, ptr};
use pyo3_ffi::*;
-mod interval;
-mod timedelta;
+use crate::common::{c_str, py_str};
+
+mod common;
+pub mod date;
+mod date_delta;
+pub mod naive_datetime;
+mod time;
+mod time_delta;
+mod zoned_datetime;
static mut MODULE_DEF: PyModuleDef = PyModuleDef {
m_base: PyModuleDef_HEAD_INIT,
- m_name: "whenever\0".as_ptr().cast::(),
- m_doc: "Sensible, fast, and typesafe datetimes.\0"
- .as_ptr()
- .cast::(),
- m_size: mem::size_of::() as Py_ssize_t,
- m_methods: unsafe { METHODS.as_mut_ptr().cast() },
- m_slots: unsafe { WHENEVER_SLOTS as *const [PyModuleDef_Slot] as *mut PyModuleDef_Slot },
- m_traverse: Some(whenever_traverse),
- m_clear: Some(whenever_clear),
- m_free: Some(whenever_free),
+ m_name: c_str!("whenever"),
+ m_doc: c_str!("Fast, correct, and typesafe datetimes."),
+ m_size: mem::size_of::() as Py_ssize_t,
+ m_methods: unsafe { METHODS as *const [_] as *mut _ },
+ m_slots: unsafe { MODULE_SLOTS as *const [_] as *mut _ },
+ m_traverse: Some(module_traverse),
+ m_clear: Some(module_clear),
+ m_free: Some(module_free),
};
-static mut METHODS: [PyMethodDef; 2] = [
+static mut METHODS: &[PyMethodDef] = &[
+ PyMethodDef {
+ ml_name: c_str!("_unpkl_date"),
+ ml_meth: PyMethodDefPointer {
+ _PyCFunctionFast: date::unpickle,
+ },
+ ml_flags: METH_FASTCALL,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("_unpkl_time"),
+ ml_meth: PyMethodDefPointer {
+ _PyCFunctionFast: time::unpickle,
+ },
+ ml_flags: METH_FASTCALL,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("_unpkl_tdelta"),
+ ml_meth: PyMethodDefPointer {
+ _PyCFunctionFast: time_delta::unpickle,
+ },
+ ml_flags: METH_FASTCALL,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("_unpkl_ddelta"),
+ ml_meth: PyMethodDefPointer {
+ _PyCFunctionFast: date_delta::unpickle,
+ },
+ ml_flags: METH_FASTCALL,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("_unpkl_naive"),
+ ml_meth: PyMethodDefPointer {
+ _PyCFunctionFast: naive_datetime::unpickle,
+ },
+ ml_flags: METH_FASTCALL,
+ ml_doc: NULL(),
+ },
PyMethodDef {
- ml_name: "sum_as_string\0".as_ptr().cast::(),
+ ml_name: c_str!("_unpkl_zoned"),
ml_meth: PyMethodDefPointer {
- _PyCFunctionFast: sum_as_string,
+ _PyCFunctionFast: zoned_datetime::unpickle,
},
ml_flags: METH_FASTCALL,
- ml_doc: "returns the sum of two integers as a string\0"
- .as_ptr()
- .cast::(),
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("years"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: date_delta::years,
+ },
+ ml_flags: METH_O,
+ ml_doc: c_str!("Create a new `DateDelta` representing the given number of years."),
+ },
+ PyMethodDef {
+ ml_name: c_str!("months"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: date_delta::months,
+ },
+ ml_flags: METH_O,
+ ml_doc: c_str!("Create a new `DateDelta` representing the given number of months."),
+ },
+ PyMethodDef {
+ ml_name: c_str!("weeks"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: date_delta::weeks,
+ },
+ ml_flags: METH_O,
+ ml_doc: c_str!("Create a new `DateDelta` representing the given number of weeks."),
+ },
+ PyMethodDef {
+ ml_name: c_str!("days"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: date_delta::days,
+ },
+ ml_flags: METH_O,
+ ml_doc: c_str!("Create a new `DateDelta` representing the given number of days."),
},
PyMethodDef::zeroed(),
];
-static mut WHENEVER_SLOTS: &[PyModuleDef_Slot] = &[
+static mut MODULE_SLOTS: &[PyModuleDef_Slot] = &[
PyModuleDef_Slot {
slot: Py_mod_exec,
- value: whenever_exec as *mut c_void,
+ value: module_exec as *mut c_void,
},
- // TODO: actually check if this is correct
#[cfg(Py_3_12)]
PyModuleDef_Slot {
slot: Py_mod_multiple_interpreters,
- value: Py_MOD_PER_INTERPRETER_GIL_SUPPORTED,
+ // awaiting https://github.com/python/cpython/pull/102995
+ value: Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED,
},
PyModuleDef_Slot {
slot: 0,
- value: ptr::null_mut(),
+ value: NULL(),
},
];
#[cfg(Py_3_10)]
macro_rules! add {
($mptr:expr, $name:expr, $obj:expr) => {
- PyModule_AddObjectRef($mptr, $name.as_ptr() as *const c_char, $obj);
+ PyModule_AddObjectRef($mptr, c_str!($name), $obj);
};
}
#[cfg(not(Py_3_10))]
macro_rules! add {
($mptr:expr, $name:expr, $obj:expr) => {
- PyModule_AddObject($mptr, $name.as_ptr() as *const c_char, $obj);
+ PyModule_AddObject($mptr, c_str!($name), $obj);
};
}
-unsafe extern "C" fn whenever_exec(module: *mut PyObject) -> c_int {
- let state: *mut WheneverState = PyModule_GetState(module).cast();
+macro_rules! add_int {
+ ($mptr:expr, $name:expr, $obj:expr) => {
+ PyModule_AddIntConstant($mptr, c_str!($name), $obj as c_long);
+ };
+}
- // PyType_FromModuleAndSpec -> how to set module?
+macro_rules! add_type {
+ ($module:ident,
+ $module_nameobj:expr,
+ $state:ident,
+ $submodule:ident,
+ $name:expr,
+ $varname:ident,
+ $unpickle_name:expr,
+ $unpickle_var:ident,
+ WITH_SINGLETONS) => {
+ add_type!(
+ $module,
+ $module_nameobj,
+ $state,
+ $submodule,
+ $name,
+ $varname,
+ $unpickle_name,
+ $unpickle_var
+ );
- // TimeDelta type
- let timedelta_type = PyType_FromSpec(ptr::addr_of_mut!(crate::timedelta::TIMEDELTA_SPEC));
- if timedelta_type.is_null() {
- return -1;
- }
- add!(module, "TimeDelta\0", timedelta_type);
- (*state).timedelta_type = timedelta_type.cast::();
+ for (name, value) in $submodule::SINGLETONS {
+ let pyvalue = $submodule::new_unchecked($varname.cast(), value);
+ PyDict_SetItemString(
+ (*$varname.cast::()).tp_dict,
+ name.as_ptr().cast(),
+ pyvalue.cast(),
+ );
+ }
+ };
+ ($module:ident,
+ $module_nameobj:expr,
+ $state:ident,
+ $submodule:ident,
+ $name:expr,
+ $varname:ident,
+ $unpickle_name:expr,
+ $unpickle_var:ident) => {
+ let $varname = PyType_FromModuleAndSpec(
+ $module,
+ ptr::addr_of_mut!($submodule::SPEC),
+ ptr::null_mut(),
+ );
+ if $varname.is_null() {
+ return -1;
+ }
+ add!($module, $name, $varname);
+ (*$state).$varname = $varname.cast();
- // Interval
- let interval_type = PyType_FromSpec(ptr::addr_of_mut!(crate::interval::SPEC));
- if interval_type.is_null() {
- return -1;
- }
- add!(module, "Interval\0", interval_type);
+ let unpickler = PyObject_GetAttrString($module, c_str!($unpickle_name));
+ PyObject_SetAttrString(unpickler, "__module__\0".as_ptr().cast(), $module_nameobj);
+ (*$state).$unpickle_var = unpickler;
+ };
+}
+
+unsafe extern "C" fn module_exec(module: *mut PyObject) -> c_int {
+ let state: *mut ModuleState = PyModule_GetState(module).cast();
+ let module_name = py_str("whenever");
- let ambiguoustime_type = PyErr_NewException(
- "whenever.AmbiguousTime\0".as_ptr().cast::(),
- std::ptr::null_mut(),
- std::ptr::null_mut(),
+ add_type!(
+ module,
+ module_name,
+ state,
+ date,
+ "Date",
+ date_type,
+ "_unpkl_date",
+ unpickle_date
);
- if ambiguoustime_type.is_null() {
- return -1;
- }
- add!(module, "AmbiguousTime\0", ambiguoustime_type);
- (*state).ambigoustime_type = ambiguoustime_type.cast::();
+ add_type!(
+ module,
+ module_name,
+ state,
+ time,
+ "Time",
+ time_type,
+ "_unpkl_time",
+ unpickle_time,
+ WITH_SINGLETONS
+ );
+ add_type!(
+ module,
+ module_name,
+ state,
+ date_delta,
+ "DateDelta",
+ date_delta_type,
+ "_unpkl_ddelta",
+ unpickle_date_delta,
+ WITH_SINGLETONS
+ );
+ add_type!(
+ module,
+ module_name,
+ state,
+ time_delta,
+ "TimeDelta",
+ time_delta_type,
+ "_unpkl_tdelta",
+ unpickle_time_delta,
+ WITH_SINGLETONS
+ );
+ add_type!(
+ module,
+ module_name,
+ state,
+ naive_datetime,
+ "NaiveDateTime",
+ naive_datetime_type,
+ "_unpkl_naive",
+ unpickle_naive_datetime,
+ WITH_SINGLETONS
+ );
+ add_type!(
+ module,
+ module_name,
+ state,
+ zoned_datetime,
+ "ZonedDateTime",
+ zoned_datetime_type,
+ "_unpkl_zoned",
+ unpickle_zoned_datetime
+ );
+
+ // zoneinfo module
+ let zoneinfo_module = PyImport_ImportModule(c_str!("zoneinfo"));
+ (*state).zoneinfo_type = PyObject_GetAttrString(zoneinfo_module, c_str!("ZoneInfo")).cast();
+ // TODO: refcount?
+ Py_DECREF(zoneinfo_module);
+
+ // datetime module
+ PyDateTime_IMPORT();
+ (*state).datetime_api = PyDateTimeAPI();
+
+ let datetime_py_module = PyImport_ImportModule(c_str!("datetime"));
+ // TODO: refcount?
+ (*state).strptime = PyObject_GetAttrString(
+ PyObject_GetAttrString(datetime_py_module, c_str!("datetime")),
+ c_str!("strptime"),
+ );
+
+ // TODO: a proper enum
+ add_int!(module, "MONDAY", 1);
+ add_int!(module, "TUESDAY", 2);
+ add_int!(module, "WEDNESDAY", 3);
+ add_int!(module, "THURSDAY", 4);
+ add_int!(module, "FRIDAY", 5);
+ add_int!(module, "SATURDAY", 6);
+ add_int!(module, "SUNDAY", 7);
0
}
-unsafe extern "C" fn whenever_traverse(
+unsafe extern "C" fn module_traverse(
module: *mut PyObject,
visit: visitproc,
arg: *mut c_void,
) -> c_int {
- let state: *mut WheneverState = PyModule_GetState(module.cast()).cast();
- let timedelta_type: *mut PyObject = (*state).timedelta_type.cast();
+ let state: *mut ModuleState = PyModule_GetState(module.cast()).cast();
- if timedelta_type.is_null() {
- 0
- } else {
- (visit)(timedelta_type, arg)
- }
-}
+ // types
+ let date_type: *mut PyObject = (*state).date_type.cast();
+ if !date_type.is_null() {
+ (visit)(date_type, arg);
+ };
+ let time_type: *mut PyObject = (*state).time_type.cast();
+ if !time_type.is_null() {
+ (visit)(time_type, arg);
+ };
+ let date_delta_type: *mut PyObject = (*state).date_delta_type.cast();
+ if !date_delta_type.is_null() {
+ (visit)(date_delta_type, arg);
+ };
+ let time_delta_type: *mut PyObject = (*state).time_delta_type.cast();
+ if !time_delta_type.is_null() {
+ (visit)(time_delta_type, arg);
+ };
+ let naive_datetime_type: *mut PyObject = (*state).naive_datetime_type.cast();
+ if !naive_datetime_type.is_null() {
+ (visit)(naive_datetime_type, arg);
+ };
+ let zoned_datetime_type: *mut PyObject = (*state).zoned_datetime_type.cast();
+ if !zoned_datetime_type.is_null() {
+ (visit)(zoned_datetime_type, arg);
+ };
+
+ // Imported modules
+ let zoneinfo_type: *mut PyObject = (*state).zoneinfo_type.cast();
+ if !zoneinfo_type.is_null() {
+ (visit)(zoneinfo_type, arg);
+ };
-unsafe extern "C" fn whenever_clear(module: *mut PyObject) -> c_int {
- let state: *mut WheneverState = PyModule_GetState(module.cast()).cast();
- Py_CLEAR(ptr::addr_of_mut!((*state).timedelta_type).cast());
0
}
-unsafe extern "C" fn whenever_free(module: *mut c_void) {
- whenever_clear(module.cast());
-}
+unsafe extern "C" fn module_clear(module: *mut PyObject) -> c_int {
+ let state: *mut ModuleState = PyModule_GetState(module.cast()).cast();
+ // types
+ Py_CLEAR(ptr::addr_of_mut!((*state).date_type).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).time_type).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).date_delta_type).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).zoned_datetime_type).cast());
-#[repr(C)]
-struct WheneverState {
- timedelta_type: *mut PyTypeObject,
- ambigoustime_type: *mut PyTypeObject,
+ // unpickling functions
+ Py_CLEAR(ptr::addr_of_mut!((*state).unpickle_date).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).unpickle_time).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).unpickle_date_delta).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).unpickle_zoned_datetime).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).unpickle_naive_datetime).cast());
+
+ // imported modules
+ Py_CLEAR(ptr::addr_of_mut!((*state).zoneinfo_type).cast());
+ Py_CLEAR(ptr::addr_of_mut!((*state).datetime_api).cast());
+ 0
}
-#[allow(non_snake_case)]
-#[no_mangle]
-pub unsafe extern "C" fn PyInit__whenever() -> *mut PyObject {
- let m = PyModuleDef_Init(ptr::addr_of_mut!(MODULE_DEF));
- if m.is_null() {
- return std::ptr::null_mut();
- };
- m
+unsafe extern "C" fn module_free(module: *mut c_void) {
+ module_clear(module.cast());
}
-pub unsafe extern "C" fn sum_as_string(
- _self: *mut PyObject,
- args: *mut *mut PyObject,
- nargs: Py_ssize_t,
-) -> *mut PyObject {
- if nargs != 2 {
- PyErr_SetString(
- PyExc_TypeError,
- "sum_as_string() expected 2 positional arguments\0"
- .as_ptr()
- .cast::(),
- );
- return std::ptr::null_mut();
- }
+#[repr(C)]
+struct ModuleState {
+ // types
+ date_type: *mut PyTypeObject,
+ time_type: *mut PyTypeObject,
+ date_delta_type: *mut PyTypeObject,
+ time_delta_type: *mut PyTypeObject,
+ naive_datetime_type: *mut PyTypeObject,
+ zoned_datetime_type: *mut PyTypeObject,
- let arg1 = *args;
- if PyLong_Check(arg1) == 0 {
- PyErr_SetString(
- PyExc_TypeError,
- "sum_as_string() expected an int for positional argument 1\0"
- .as_ptr()
- .cast::(),
- );
- return std::ptr::null_mut();
- }
+ // unpickling functions
+ unpickle_date: *mut PyObject,
+ unpickle_time: *mut PyObject,
+ unpickle_date_delta: *mut PyObject,
+ unpickle_time_delta: *mut PyObject,
+ unpickle_naive_datetime: *mut PyObject,
+ unpickle_zoned_datetime: *mut PyObject,
- let arg1 = PyLong_AsLong(arg1);
- if !PyErr_Occurred().is_null() {
- return ptr::null_mut();
- }
-
- let arg2 = *args.add(1);
- if PyLong_Check(arg2) == 0 {
- PyErr_SetString(
- PyExc_TypeError,
- "sum_as_string() expected an int for positional argument 2\0"
- .as_ptr()
- .cast::(),
- );
- return std::ptr::null_mut();
- }
+ // imported modules
+ zoneinfo_type: *mut PyTypeObject,
+ datetime_api: *mut PyDateTime_CAPI,
+ strptime: *mut PyObject,
+}
- let arg2 = PyLong_AsLong(arg2);
- if !PyErr_Occurred().is_null() {
- return ptr::null_mut();
+impl ModuleState {
+ unsafe fn from(tp: *mut PyTypeObject) -> *mut Self {
+ PyType_GetModuleState(tp).cast()
}
+}
- match arg1.checked_add(arg2) {
- Some(sum) => {
- let string = sum.to_string();
- PyUnicode_FromStringAndSize(string.as_ptr().cast::(), string.len() as isize)
- }
- None => {
- PyErr_SetString(
- PyExc_OverflowError,
- "arguments too large to add\0".as_ptr().cast::(),
- );
- std::ptr::null_mut()
- }
- }
+#[allow(non_snake_case)]
+#[no_mangle]
+pub unsafe extern "C" fn PyInit__whenever() -> *mut PyObject {
+ PyModuleDef_Init(ptr::addr_of_mut!(MODULE_DEF))
}
diff --git a/src/naive_datetime.rs b/src/naive_datetime.rs
new file mode 100644
index 00000000..da4451e4
--- /dev/null
+++ b/src/naive_datetime.rs
@@ -0,0 +1,815 @@
+use core::ffi::{c_char, c_int, c_long, c_uint, c_void};
+use core::{mem, ptr, ptr::null_mut as NULL};
+use pyo3_ffi::*;
+
+use crate::common::{c_str, identity, propagate_exc, py_str, pystr_to_utf8, raise, try_get_long};
+use crate::date;
+use crate::date_delta::{DateDelta, PyDateDelta};
+use crate::time;
+use crate::ModuleState;
+
+// TODO: still need repr C?
+#[repr(C)]
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)]
+pub(crate) struct DateTime {
+ date: date::Date,
+ time: time::Time,
+}
+
+#[repr(C)]
+pub(crate) struct PyNaiveDateTime {
+ _ob_base: PyObject,
+ dt: DateTime,
+}
+
+pub(crate) const SINGLETONS: [(&str, DateTime); 2] = [
+ (
+ "MIN\0",
+ DateTime {
+ date: date::Date {
+ year: 1,
+ month: 1,
+ day: 1,
+ },
+ time: time::Time {
+ hour: 0,
+ minute: 0,
+ second: 0,
+ nanos: 0,
+ },
+ },
+ ),
+ (
+ "MAX\0",
+ DateTime {
+ date: date::Date {
+ year: 9999,
+ month: 12,
+ day: 31,
+ },
+ time: time::Time {
+ hour: 23,
+ minute: 59,
+ second: 59,
+ nanos: 999_999_999,
+ },
+ },
+ ),
+];
+
+unsafe extern "C" fn __new__(
+ subtype: *mut PyTypeObject,
+ args: *mut PyObject,
+ kwargs: *mut PyObject,
+) -> *mut PyObject {
+ let mut year: c_long = 0;
+ let mut month: c_long = 0;
+ let mut day: c_long = 0;
+ let mut hour: c_long = 0;
+ let mut minute: c_long = 0;
+ let mut second: c_long = 0;
+ let mut nanos: c_long = 0;
+
+ // FUTURE: parse them manually, which is more efficient
+ if PyArg_ParseTupleAndKeywords(
+ args,
+ kwargs,
+ c_str!("lll|llll:NaiveDateTime"),
+ vec![
+ c_str!("year") as *mut c_char,
+ c_str!("month") as *mut c_char,
+ c_str!("day") as *mut c_char,
+ c_str!("hour") as *mut c_char,
+ c_str!("minute") as *mut c_char,
+ c_str!("second") as *mut c_char,
+ c_str!("nanosecond") as *mut c_char,
+ NULL(),
+ ]
+ .as_mut_ptr(),
+ &mut year,
+ &mut month,
+ &mut day,
+ &mut hour,
+ &mut minute,
+ &mut second,
+ &mut nanos,
+ ) == 0
+ {
+ return NULL();
+ }
+
+ new_unchecked(
+ subtype,
+ DateTime {
+ date: match date::in_range(year, month, day) {
+ Ok(date) => date,
+ Err(err) => {
+ err.set_pyerr();
+ return NULL();
+ }
+ },
+ time: match time::in_range(hour, minute, second, nanos) {
+ Some(time) => time,
+ None => {
+ raise!(PyExc_ValueError, "Invalid time");
+ }
+ },
+ },
+ )
+ .cast()
+}
+
+pub(crate) unsafe fn new_unchecked(type_: *mut PyTypeObject, dt: DateTime) -> *mut PyNaiveDateTime {
+ let f: allocfunc = (*type_).tp_alloc.expect("tp_alloc is not set");
+ let slf = propagate_exc!(f(type_, 0).cast::());
+ ptr::addr_of_mut!((*slf).dt).write(dt);
+ slf
+}
+
+unsafe extern "C" fn dealloc(slf: *mut PyObject) {
+ let tp_free = PyType_GetSlot(Py_TYPE(slf), Py_tp_free);
+ debug_assert_ne!(tp_free, NULL());
+ let f: freefunc = std::mem::transmute(tp_free);
+ f(slf.cast());
+}
+
+fn _canonical_fmt(dt: DateTime) -> String {
+ if dt.time.nanos == 0 {
+ format!(
+ "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
+ dt.date.year, dt.date.month, dt.date.day, dt.time.hour, dt.time.minute, dt.time.second,
+ )
+ } else {
+ format!(
+ "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:09}",
+ dt.date.year,
+ dt.date.month,
+ dt.date.day,
+ dt.time.hour,
+ dt.time.minute,
+ dt.time.second,
+ dt.time.nanos,
+ )
+ .trim_end_matches('0')
+ .to_string()
+ }
+}
+
+unsafe extern "C" fn __repr__(slf: *mut PyObject) -> *mut PyObject {
+ py_str(
+ format!(
+ "NaiveDateTime({})",
+ _canonical_fmt((*slf.cast::()).dt)
+ )
+ .as_str(),
+ )
+}
+
+unsafe extern "C" fn __str__(slf: *mut PyObject) -> *mut PyObject {
+ py_str(_canonical_fmt((*slf.cast::()).dt).as_str())
+}
+
+unsafe extern "C" fn canonical_format(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ py_str(_canonical_fmt((*slf.cast::()).dt).as_str())
+}
+
+unsafe extern "C" fn __richcmp__(
+ slf: *mut PyObject,
+ other: *mut PyObject,
+ op: c_int,
+) -> *mut PyObject {
+ let result = if Py_TYPE(other) == Py_TYPE(slf) {
+ let a = (*slf.cast::()).dt;
+ let b = (*other.cast::()).dt;
+ let cmp = match op {
+ pyo3_ffi::Py_LT => a < b,
+ pyo3_ffi::Py_LE => a <= b,
+ pyo3_ffi::Py_EQ => a == b,
+ pyo3_ffi::Py_NE => a != b,
+ pyo3_ffi::Py_GT => a > b,
+ pyo3_ffi::Py_GE => a >= b,
+ _ => unreachable!(),
+ };
+ if cmp {
+ Py_True()
+ } else {
+ Py_False()
+ }
+ } else {
+ Py_NotImplemented()
+ };
+ Py_INCREF(result);
+ result
+}
+
+unsafe extern "C" fn __hash__(slf: *mut PyObject) -> Py_hash_t {
+ let dt = (*slf.cast::()).dt;
+ #[cfg(target_pointer_width = "64")]
+ {
+ (dt.date.hash() as u64 ^ dt.time.hash64()) as Py_hash_t
+ }
+ #[cfg(target_pointer_width = "32")]
+ {
+ (dt.date.hash() as u32 ^ dt.time.hash32()) as Py_hash_t
+ }
+}
+
+unsafe extern "C" fn __add__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> *mut PyObject {
+ let slf = (*obj_a.cast::()).dt;
+ if Py_TYPE(obj_b) != (*ModuleState::from(Py_TYPE(obj_a))).date_delta_type {
+ let result = Py_NotImplemented();
+ Py_INCREF(result);
+ result
+ } else {
+ let delta = (*obj_b.cast::()).delta;
+ match _add_datedelta(slf, delta) {
+ Some(dt) => new_unchecked(Py_TYPE(obj_a), dt).cast(),
+ None => raise!(PyExc_ValueError, "Resulting date out of range"),
+ }
+ }
+}
+
+unsafe extern "C" fn __sub__(obj_a: *mut PyObject, obj_b: *mut PyObject) -> *mut PyObject {
+ let slf = (*obj_a.cast::()).dt;
+ if Py_TYPE(obj_b) != (*ModuleState::from(Py_TYPE(obj_a))).date_delta_type {
+ let result = Py_NotImplemented();
+ Py_INCREF(result);
+ result
+ } else {
+ let delta = (*obj_b.cast::()).delta;
+ match _add_datedelta(slf, -delta) {
+ Some(dt) => new_unchecked(Py_TYPE(obj_a), dt).cast(),
+ None => raise!(PyExc_ValueError, "Resulting date out of range"),
+ }
+ }
+}
+
+fn _add_datedelta(dt: DateTime, delta: DateDelta) -> Option {
+ date::add(
+ dt.date,
+ delta.years as c_long,
+ delta.months as c_long,
+ (delta.weeks * 7 + delta.days) as c_long,
+ )
+ .map(|date| DateTime { date, ..dt })
+}
+
+static mut SLOTS: &[PyType_Slot] = &[
+ PyType_Slot {
+ slot: Py_tp_new,
+ pfunc: __new__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_doc,
+ pfunc: "A calendar date type\0".as_ptr() as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_repr,
+ pfunc: __repr__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_str,
+ pfunc: __str__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_richcompare,
+ pfunc: __richcmp__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_add,
+ pfunc: __add__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_nb_subtract,
+ pfunc: __sub__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_hash,
+ pfunc: __hash__ as *mut c_void,
+ },
+ PyType_Slot {
+ slot: Py_tp_methods,
+ pfunc: unsafe { METHODS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_getset,
+ pfunc: unsafe { GETSETTERS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_members,
+ pfunc: unsafe { MEMBERS.as_ptr() as *mut c_void },
+ },
+ PyType_Slot {
+ slot: Py_tp_dealloc,
+ pfunc: dealloc as *mut c_void,
+ },
+ PyType_Slot {
+ slot: 0,
+ pfunc: NULL(),
+ },
+];
+
+static mut MEMBERS: &[PyMemberDef] = &[PyMemberDef {
+ name: NULL(),
+ type_code: 0,
+ offset: 0,
+ flags: 0,
+ doc: NULL(),
+}];
+
+unsafe extern "C" fn replace(
+ slf: *mut PyObject,
+ type_: *mut PyTypeObject,
+ args: *const *mut PyObject,
+ nargs: Py_ssize_t,
+ kwnames: *mut PyObject,
+) -> *mut PyObject {
+ if PyVectorcall_NARGS(nargs as usize) != 0 {
+ raise!(PyExc_TypeError, "replace() takes no positional arguments");
+ }
+ if !kwnames.is_null() {
+ let dt = (*slf.cast::()).dt;
+ let mut year = dt.date.year as c_long;
+ let mut month = dt.date.month as c_long;
+ let mut day = dt.date.day as c_long;
+ let mut hour = dt.time.hour as c_long;
+ let mut minute = dt.time.minute as c_long;
+ let mut second = dt.time.second as c_long;
+ let mut nanos = dt.time.nanos as c_long;
+ for i in 0..=Py_SIZE(kwnames).saturating_sub(1) {
+ let name = PyTuple_GET_ITEM(kwnames, i as Py_ssize_t);
+ if name == PyUnicode_InternFromString(c_str!("year")) {
+ year = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("month")) {
+ month = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("day")) {
+ day = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("hour")) {
+ hour = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("minute")) {
+ minute = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("second")) {
+ second = try_get_long!(*args.offset(i));
+ } else if name == PyUnicode_InternFromString(c_str!("nanosecond")) {
+ nanos = try_get_long!(*args.offset(i));
+ } else {
+ raise!(
+ PyExc_TypeError,
+ "replace() got an unexpected keyword argument %R",
+ name
+ );
+ }
+ }
+ new_unchecked(
+ type_,
+ DateTime {
+ date: match date::in_range(year, month, day) {
+ Ok(date) => date,
+ Err(err) => {
+ err.set_pyerr();
+ return NULL();
+ }
+ },
+ time: match time::in_range(hour, minute, second, nanos) {
+ Some(time) => time,
+ None => {
+ raise!(PyExc_ValueError, "Invalid time");
+ }
+ },
+ },
+ )
+ .cast()
+ } else {
+ Py_INCREF(slf);
+ slf
+ }
+}
+
+unsafe extern "C" fn __reduce__(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let dt = (*slf.cast::()).dt;
+ PyTuple_Pack(
+ 2,
+ (*ModuleState::from(Py_TYPE(slf))).unpickle_naive_datetime,
+ propagate_exc!(PyTuple_Pack(
+ 7,
+ PyLong_FromLong(dt.date.year as c_long),
+ PyLong_FromLong(dt.date.month as c_long),
+ PyLong_FromLong(dt.date.day as c_long),
+ PyLong_FromLong(dt.time.hour as c_long),
+ PyLong_FromLong(dt.time.minute as c_long),
+ PyLong_FromLong(dt.time.second as c_long),
+ PyLong_FromLong(dt.time.nanos as c_long),
+ )),
+ )
+}
+
+pub(crate) unsafe extern "C" fn unpickle(
+ module: *mut PyObject,
+ args: *mut *mut PyObject,
+ nargs: Py_ssize_t,
+) -> *mut PyObject {
+ if PyVectorcall_NARGS(nargs as usize) != 7 {
+ raise!(PyExc_TypeError, "Invalid pickle data");
+ }
+ new_unchecked(
+ (*PyModule_GetState(module).cast::()).naive_datetime_type,
+ DateTime {
+ date: date::Date {
+ year: try_get_long!(*args.offset(0)) as u16,
+ month: try_get_long!(*args.offset(1)) as u8,
+ day: try_get_long!(*args.offset(2)) as u8,
+ },
+ time: time::Time {
+ hour: try_get_long!(*args.offset(3)) as u8,
+ minute: try_get_long!(*args.offset(4)) as u8,
+ second: try_get_long!(*args.offset(5)) as u8,
+ nanos: try_get_long!(*args.offset(6)) as u32,
+ },
+ },
+ )
+ .cast()
+}
+
+unsafe extern "C" fn from_py_datetime(type_: *mut PyObject, dt: *mut PyObject) -> *mut PyObject {
+ if PyDateTime_Check(dt) == 0 {
+ raise!(PyExc_TypeError, "argument must be datetime.datetime");
+ }
+ let tzinfo = PyDateTime_DATE_GET_TZINFO(dt);
+ if tzinfo != Py_None() {
+ raise!(
+ PyExc_ValueError,
+ "datetime must be naive, but got tzinfo=%R",
+ tzinfo
+ );
+ }
+ new_unchecked(
+ type_.cast(),
+ DateTime {
+ date: date::Date {
+ year: PyDateTime_GET_YEAR(dt) as u16,
+ month: PyDateTime_GET_MONTH(dt) as u8,
+ day: PyDateTime_GET_DAY(dt) as u8,
+ },
+ time: time::Time {
+ hour: PyDateTime_DATE_GET_HOUR(dt) as u8,
+ minute: PyDateTime_DATE_GET_MINUTE(dt) as u8,
+ second: PyDateTime_DATE_GET_SECOND(dt) as u8,
+ nanos: PyDateTime_DATE_GET_MICROSECOND(dt) as u32 * 1_000,
+ },
+ },
+ )
+ .cast()
+}
+
+unsafe extern "C" fn py_datetime(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let dt = (*slf.cast::()).dt;
+ let py_api = *(*ModuleState::from(Py_TYPE(slf))).datetime_api;
+ propagate_exc!((py_api.DateTime_FromDateAndTime)(
+ dt.date.year as c_int,
+ dt.date.month as c_int,
+ dt.date.day as c_int,
+ dt.time.hour as c_int,
+ dt.time.minute as c_int,
+ dt.time.second as c_int,
+ dt.time.nanos as c_int / 1_000,
+ Py_None(),
+ py_api.DateTimeType,
+ ))
+}
+
+unsafe extern "C" fn get_date(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let dt = (*slf.cast::()).dt;
+ date::new_unchecked((*ModuleState::from(Py_TYPE(slf))).date_type, dt.date).cast()
+}
+
+unsafe extern "C" fn get_time(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let dt = (*slf.cast::()).dt;
+ time::new_unchecked((*ModuleState::from(Py_TYPE(slf))).time_type, dt.time).cast()
+}
+
+pub fn parse(s: &[u8]) -> Option<(date::Date, time::Time)> {
+ // This should have already been checked by caller
+ debug_assert!(s.len() >= 19 && (s[10] == b' ' || s[10] == b'T'));
+ Some((
+ date::parse(&s[..10])
+ .and_then(|(y, m, d)| date::in_range(y as c_long, m as c_long, d as c_long).ok())?,
+ time::parse(&s[11..]).and_then(|(h, m, s, ns)| {
+ time::in_range(h as c_long, m as c_long, s as c_long, ns as c_long)
+ })?,
+ ))
+}
+
+unsafe extern "C" fn from_canonical_format(
+ cls: *mut PyObject,
+ arg: *mut PyObject,
+) -> *mut PyObject {
+ let s = pystr_to_utf8!(arg, "Expected a string");
+ if s.len() < 19 || s[10] != b' ' {
+ raise!(PyExc_ValueError, "Invalid canonical format: %R", arg);
+ }
+ match parse(s) {
+ Some((date, time)) => new_unchecked(cls.cast(), DateTime { date, time }).cast(),
+ None => raise!(PyExc_ValueError, "Invalid canonical format: %R", arg),
+ }
+}
+
+unsafe extern "C" fn common_iso8601(slf: *mut PyObject, _: *mut PyObject) -> *mut PyObject {
+ let dt = (*slf.cast::()).dt;
+ py_str(format!("{}T{}", dt.date, dt.time).as_str())
+}
+
+unsafe extern "C" fn from_common_iso8601(cls: *mut PyObject, obj: *mut PyObject) -> *mut PyObject {
+ let s = pystr_to_utf8!(obj, "Expected a string");
+ if s.len() < 19 || s[10] != b'T' {
+ raise!(PyExc_ValueError, "Invalid common ISO 8601 format: %R", obj);
+ }
+ match parse(s) {
+ Some((date, time)) => new_unchecked(cls.cast(), DateTime { date, time }).cast(),
+ None => raise!(PyExc_ValueError, "Invalid common ISO 8601 format: %R", obj),
+ }
+}
+
+unsafe extern "C" fn strptime(cls: *mut PyObject, args: *mut PyObject) -> *mut PyObject {
+ // FUTURE: get this working with vectorcall
+ let module = ModuleState::from(cls.cast());
+ let parsed = propagate_exc!(PyObject_Call((*module).strptime, args, NULL()));
+ let tzinfo = PyDateTime_DATE_GET_TZINFO(parsed);
+ if tzinfo != Py_None() {
+ raise!(
+ PyExc_ValueError,
+ "datetime must be naive, but got tzinfo=%R",
+ tzinfo
+ );
+ }
+ new_unchecked(
+ cls.cast(),
+ DateTime {
+ date: date::Date {
+ year: PyDateTime_GET_YEAR(parsed) as u16,
+ month: PyDateTime_GET_MONTH(parsed) as u8,
+ day: PyDateTime_GET_DAY(parsed) as u8,
+ },
+ time: time::Time {
+ hour: PyDateTime_DATE_GET_HOUR(parsed) as u8,
+ minute: PyDateTime_DATE_GET_MINUTE(parsed) as u8,
+ second: PyDateTime_DATE_GET_SECOND(parsed) as u8,
+ nanos: PyDateTime_DATE_GET_MICROSECOND(parsed) as u32 * 1_000,
+ },
+ },
+ )
+ .cast()
+}
+
+static mut METHODS: &[PyMethodDef] = &[
+ PyMethodDef {
+ ml_name: c_str!("__copy__"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: identity,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("__deepcopy__"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: identity,
+ },
+ ml_flags: METH_O,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_py_datetime"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_py_datetime,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Create an instance from a datetime.datetime"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("py_datetime"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: py_datetime,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Convert to a datetime.datetime"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("date"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: get_date,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Get the date component"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("time"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: get_time,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Get the time component"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("canonical_format"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: canonical_format,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Get the canonical string representation"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_canonical_format"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_canonical_format,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Create an instance from the canonical string representation"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("common_iso8601"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: common_iso8601,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: c_str!("Get the common ISO 8601 string representation"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("from_common_iso8601"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: from_common_iso8601,
+ },
+ ml_flags: METH_O | METH_CLASS,
+ ml_doc: c_str!("Create an instance from the common ISO 8601 string representation"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("__reduce__"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: __reduce__,
+ },
+ ml_flags: METH_NOARGS,
+ ml_doc: NULL(),
+ },
+ PyMethodDef {
+ ml_name: c_str!("strptime"),
+ ml_meth: PyMethodDefPointer {
+ PyCFunction: strptime,
+ },
+ ml_flags: METH_CLASS | METH_VARARGS,
+ ml_doc: c_str!("Parse a string into a NaiveDateTime"),
+ },
+ PyMethodDef {
+ ml_name: c_str!("replace"),
+ ml_meth: PyMethodDefPointer { PyCMethod: replace },
+ ml_flags: METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
+ ml_doc: c_str!("Return a new instance with the specified fields replaced"),
+ },
+ PyMethodDef::zeroed(),
+];
+
+unsafe extern "C" fn get_year(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).dt.date.year as c_long)
+}
+
+unsafe extern "C" fn get_month(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).dt.date.month as c_long)
+}
+
+unsafe extern "C" fn get_day(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).dt.date.day as c_long)
+}
+
+unsafe extern "C" fn get_hour(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).dt.time.hour as c_long)
+}
+
+unsafe extern "C" fn get_minute(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).dt.time.minute as c_long)
+}
+
+unsafe extern "C" fn get_second(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).dt.time.second as c_long)
+}
+
+unsafe extern "C" fn get_nanos(slf: *mut PyObject, _: *mut c_void) -> *mut PyObject {
+ PyLong_FromLong((*slf.cast::()).dt.time.nanos as c_long)
+}
+
+static mut GETSETTERS: &[PyGetSetDef] = &[
+ PyGetSetDef {
+ name: c_str!("year"),
+ get: Some(get_year),
+ set: None,
+ doc: c_str!("The year component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("month"),
+ get: Some(get_month),
+ set: None,
+ doc: c_str!("The month component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("day"),
+ get: Some(get_day),
+ set: None,
+ doc: c_str!("The day component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("hour"),
+ get: Some(get_hour),
+ set: None,
+ doc: c_str!("The hour component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("minute"),
+ get: Some(get_minute),
+ set: None,
+ doc: c_str!("The minute component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("second"),
+ get: Some(get_second),
+ set: None,
+ doc: c_str!("The second component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: c_str!("nanosecond"),
+ get: Some(get_nanos),
+ set: None,
+ doc: c_str!("The nanosecond component"),
+ closure: NULL(),
+ },
+ PyGetSetDef {
+ name: NULL(),
+ get: None,
+ set: None,
+ doc: NULL(),
+ closure: NULL(),
+ },
+];
+
+pub(crate) static mut SPEC: PyType_Spec = PyType_Spec {
+ name: c_str!("whenever.NaiveDateTime"),
+ basicsize: mem::size_of::() as c_int,
+ itemsize: 0,
+ flags: Py_TPFLAGS_DEFAULT as c_uint,
+ slots: unsafe { SLOTS as *const [PyType_Slot] as *mut PyType_Slot },
+};
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_valid() {
+ assert_eq!(
+ parse(b"2023-03-02 02:09:09"),
+ Some((
+ date::Date {
+ year: 2023,
+ month: 3,
+ day: 2,
+ },
+ time::Time {
+ hour: 2,
+ minute: 9,
+ second: 9,
+ nanos: 0,
+ },
+ ))
+ );
+ assert_eq!(
+ parse(b"2023-03-02 02:09:09.123456789"),
+ Some((
+ date::Date {
+ year: 2023,
+ month: 3,
+ day: 2,
+ },
+ time::Time {
+ hour: 2,
+ minute: 9,
+ second: 9,
+ nanos: 123_456_789,
+ },
+ ))
+ );
+ }
+
+ #[test]
+ fn test_parse_invalid() {
+ // dot but no fractional digits
+ assert_eq!(parse(b"2023-03-02 02:09:09."), None);
+ // too many fractions
+ assert_eq!(parse(b"2023-03-02 02:09:09.1234567890"), None);
+ // invalid minute
+ assert_eq!(parse(b"2023-03-02 02:69:09.123456789"), None);
+ // invalid date
+ assert_eq!(parse(b"2023-02-29 02:29:09.123456789"), None);
+ }
+}
diff --git a/src/time.rs b/src/time.rs
new file mode 100644
index 00000000..92c56d2c
--- /dev/null
+++ b/src/time.rs
@@ -0,0 +1,536 @@
+use core::ffi::{c_char, c_int, c_long, c_uint, c_void};
+use core::{mem, ptr};
+use pyo3_ffi::*;
+use std::fmt::{self, Display, Formatter};
+use std::ptr::null_mut as NULL;
+
+use crate::common::{c_str, get_digit, propagate_exc, py_str, pystr_to_utf8, raise, try_get_long};
+use crate::ModuleState;
+
+#[repr(C)]
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)]
+pub struct Time {
+ pub(crate) hour: u8,
+ pub(crate) minute: u8,
+ pub(crate) second: u8,
+ pub(crate) nanos: u32,
+}
+
+#[repr(C)]
+pub(crate) struct PyTime {
+ _ob_base: PyObject,
+ time: Time,
+}
+
+impl Time {
+ pub(crate) fn hash32(&self) -> u32 {
+ ((self.hour as u32) << 16)
+ ^ ((self.minute as u32) << 8)
+ ^ (self.second as u32)
+ ^ (self.nanos as u32)
+ }
+
+ pub(crate) fn hash64(&self) -> u64 {
+ ((self.hour as u64) << 48)
+ | ((self.minute as u64) << 40)
+ | ((self.second as u64) << 32)
+ | (self.nanos as u64)
+ }
+}
+
+impl Display for Time {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ if self.nanos == 0 {
+ write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
+ } else {
+ f.write_str(
+ format!(
+ "{:02}:{:02}:{:02}.{:09}",
+ self.hour, self.minute, self.second, self.nanos
+ )
+ .trim_end_matches('0'),
+ )
+ }
+ }
+}
+
+pub(crate) const SINGLETONS: [(&str, Time); 3] = [
+ (
+ "MIDNIGHT\0",
+ Time {
+ hour: 0,
+ minute: 0,
+ second: 0,
+ nanos: 0,
+ },
+ ),
+ (
+ "NOON\0",
+ Time {
+ hour: 12,
+ minute: 0,
+ second: 0,
+ nanos: 0,
+ },
+ ),
+ (
+ "MAX\0",
+ Time {
+ hour: 23,
+ minute: 59,
+ second: 59,
+ nanos: 999_999_999,
+ },
+ ),
+];
+
+unsafe extern "C" fn __new__(
+ subtype: *mut PyTypeObject,
+ args: *mut PyObject,
+ kwargs: *mut PyObject,
+) -> *mut PyObject {
+ let mut hour: c_long = 0;
+ let mut minute: c_long = 0;
+ let mut second: c_long = 0;
+ let mut nanos: c_long = 0;
+
+ // FUTURE: parse them manually, which is more efficient
+ if PyArg_ParseTupleAndKeywords(
+ args,
+ kwargs,
+ c_str!("|llll:Time"),
+ vec![
+ c_str!("hour") as *mut c_char,
+ c_str!("minute") as *mut c_char,
+ c_str!("second") as *mut c_char,
+ c_str!("nanosecond") as *mut c_char,
+ NULL(),
+ ]
+ .as_mut_ptr(),
+ &mut hour,
+ &mut minute,
+ &mut second,
+ &mut nanos,
+ ) == 0
+ {
+ return NULL();
+ }
+
+ match in_range(hour, minute, second, nanos) {
+ Some(time) => new_unchecked(subtype, time).cast(),
+ None => raise!(PyExc_ValueError, "Invalid time component value"),
+ }
+}
+
+pub(crate) fn in_range(
+ hour: c_long,
+ minute: c_long,
+ second: c_long,
+ nanos: c_long,
+) -> Option