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. + +

+ + + + + + + + Shows a bar chart with benchmark results. + +

+ +

+ 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 @@ +0.1s1s10s100sWheneverdatetimeArrowPendulum0.64s38.8s69.7s0.41s \ 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 @@ +0.1s1s10s100sWheneverdatetimeArrowPendulum0.64s38.8s69.7s0.41s \ 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