From 4d86638c36702e3956103eb50771678ae9b488bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Eustace?= Date: Wed, 9 Aug 2023 19:43:59 +0200 Subject: [PATCH] Rewrite extensions in Rust (#721) --- .cargo/config.toml | 15 + .github/workflows/codspeed.yml | 13 +- .github/workflows/tests.yml | 14 + .gitignore | 1 + .pre-commit-config.yaml | 9 + Cargo.lock | 318 ++++ Cargo.toml | 22 + Makefile | 10 + .../_extensions/_helpers.pyi => _pendulum.pyi | 28 +- build.py | 37 +- meson.build | 20 - pendulum/_extensions/__init__.py | 0 pendulum/_extensions/_helpers.c | 931 ----------- .../{_extensions/helpers.py => _helpers.py} | 22 - pendulum/helpers.py | 31 +- pendulum/parser.py | 13 +- pendulum/parsing/__init__.py | 5 +- pendulum/parsing/_iso8601.c | 1361 ----------------- pendulum/parsing/_iso8601.pyi | 21 - pendulum/parsing/iso8601.py | 2 +- poetry.lock | 46 +- pyproject.toml | 22 +- rust/constants.rs | 56 + rust/helpers.rs | 122 ++ rust/lib.rs | 12 + rust/parsing.rs | 905 +++++++++++ rust/python/helpers.rs | 388 +++++ rust/python/mod.rs | 27 + rust/python/parsing.rs | 117 ++ rust/python/types/duration.rs | 59 + rust/python/types/mod.rs | 7 + rust/python/types/precise_diff.rs | 53 + rust/python/types/timezone.rs | 52 + tests/localization/test_tr.py | 4 +- tests/parsing/test_parse_iso8601.py | 484 ++---- tests/parsing/test_parsing.py | 15 +- tests/test_helpers.py | 5 + 37 files changed, 2403 insertions(+), 2844 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml rename pendulum/_extensions/_helpers.pyi => _pendulum.pyi (66%) delete mode 100644 meson.build delete mode 100644 pendulum/_extensions/__init__.py delete mode 100644 pendulum/_extensions/_helpers.c rename pendulum/{_extensions/helpers.py => _helpers.py} (95%) delete mode 100644 pendulum/parsing/_iso8601.c delete mode 100644 pendulum/parsing/_iso8601.pyi create mode 100644 rust/constants.rs create mode 100644 rust/helpers.rs create mode 100644 rust/lib.rs create mode 100644 rust/parsing.rs create mode 100644 rust/python/helpers.rs create mode 100644 rust/python/mod.rs create mode 100644 rust/python/parsing.rs create mode 100644 rust/python/types/duration.rs create mode 100644 rust/python/types/mod.rs create mode 100644 rust/python/types/precise_diff.rs create mode 100644 rust/python/types/timezone.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..f0ba8af4 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,15 @@ +[build] +rustflags = [] + +# see https://pyo3.rs/main/building_and_distribution.html#macos +[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/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 3d601cea..df1754b8 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -25,21 +25,22 @@ jobs: - name: Install poetry run: | - curl -fsS https://install.python-poetry.org | python - --preview -y + curl -fsS https://install.python-poetry.org | python - -y - name: Update PATH if: ${{ matrix.os != 'Windows' }} run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - name: Update Path for Windows - if: ${{ matrix.os == 'Windows' }} - run: echo "$APPDATA\Python\Scripts" >> $GITHUB_PATH - - name: Configure poetry run: poetry config virtualenvs.create false - name: Install dependencies - run: poetry install --only main --only test --only benchmark -vvv + run: poetry install --only test --only benchmark -vvv --no-root + + - name: Install pendulum and check extensions + run: | + MATURIN_BUILD_ARGS="--no-default-features -vv" pip install -e . -v + python -c 'import _pendulum; assert _pendulum.__pendulum_default_allocator__' - name: Run benchmarks uses: CodSpeedHQ/action@v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ddbb21a2..e8c604f3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,20 @@ on: - '**' jobs: + Linting: + name: Linting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: "Install pre-commit" + run: pip install pre-commit + - name: "Install Rust toolchain" + run: rustup component add rustfmt clippy + - run: pre-commit run --all-files + Tests: name: ${{ matrix.os }} / ${{ matrix.python-version }} runs-on: ${{ matrix.os }}-latest diff --git a/.gitignore b/.gitignore index bb25f8b3..d7309a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ setup.py # editor .vscode +/target diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 99c13ec6..c5bd461f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,12 @@ repos: rev: v0.0.283 hooks: - id: ruff + + - repo: local + hooks: + - id: lint-rust + name: Lint Rust + entry: make lint-rust + types: [rust] + language: rust + pass_filenames: false diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..4d0fd72b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,318 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + +[[package]] +name = "libc" +version = "0.2.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ac0e912c8ef1b735e92369695618dc5b1819f5a7bf3f167301a3ba1cea515e" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mimalloc" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2894987a3459f3ffb755608bd82188f8ed00d0ae077f1edea29c068d639d98" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "once_cell" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + +[[package]] +name = "pendulum" +version = "3.0.0-alpha-1" +dependencies = [ + "mimalloc", + "pyo3", +] + +[[package]] +name = "proc-macro2" +version = "1.0.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb88ae05f306b4bfcde40ac4a51dc0b05936a9207a4b75b798c7729c4258a59" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554db24f0b3c180a9c0b1268f91287ab3f17c162e15b54caaae5a6b3773396b0" +dependencies = [ + "once_cell", + "python3-dll-a", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "922ede8759e8600ad4da3195ae41259654b9c55da4f7eec84a0ccc7d067a70a4" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5caec6a1dd355964a841fcbeeb1b89fe4146c87295573f94228911af3cc5a2" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b78ccbb160db1556cdb6fd96c50334c5d4ec44dc5e0a968d0a1208fa0efa8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "python3-dll-a" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f07cd4412be8fa09a721d40007c483981bbe072cd6a21f2e83e04ec8f8343f" +dependencies = [ + "cc", +] + +[[package]] +name = "quote" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae9980cab1db3fceee2f6c6f643d5d8de2997c58ee8d25fb0cc8a9e9e7348e5" + +[[package]] +name = "unicode-ident" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" + +[[package]] +name = "unindent" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" +dependencies = [ + "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]] +name = "windows_aarch64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..0c3176a4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pendulum" +version = "3.0.0-alpha-1" +edition = "2021" + +[lib] +name = "_pendulum" +crate-type = ["cdylib", "rlib"] +path = "rust/lib.rs" + +[profile.release] +lto = "fat" +codegen-units = 1 +strip = true + +[dependencies] +pyo3 = { version = "0.19.0", features = ["extension-module", "generate-import-lib"] } +mimalloc = { version = "0.1.30", optional = true, default-features = false } + +[features] +extension-module = ["pyo3/extension-module"] +default = ["mimalloc"] diff --git a/Makefile b/Makefile index e68b94c1..468cd9a1 100644 --- a/Makefile +++ b/Makefile @@ -51,3 +51,13 @@ build_wheels_i686: # run tests against all supported python versions tox: @tox + + +lint-rust: + cargo fmt --all -- --check + cargo clippy --tests -- -D warnings + + +format-rust: + cargo fmt --all + cargo clippy --tests --fix --allow-dirty -- -D warnings diff --git a/pendulum/_extensions/_helpers.pyi b/_pendulum.pyi similarity index 66% rename from pendulum/_extensions/_helpers.pyi rename to _pendulum.pyi index f7763557..74d7d830 100644 --- a/pendulum/_extensions/_helpers.pyi +++ b/_pendulum.pyi @@ -2,14 +2,20 @@ from __future__ import annotations from datetime import date from datetime import datetime +from datetime import time from typing import NamedTuple -def days_in_year(year: int) -> int: ... -def is_leap(year: int) -> bool: ... -def is_long_year(year: int) -> bool: ... -def local_time( - unix_time: int, utc_offset: int, microseconds: int -) -> tuple[int, int, int, int, int, int, int]: ... +class Duration: + years: int = 0 + months: int = 0 + weeks: int = 0 + days: int = 0 + remaining_days: int = 0 + hours: int = 0 + minutes: int = 0 + seconds: int = 0 + remaining_seconds: int = 0 + microseconds: int = 0 class PreciseDiff(NamedTuple): years: int @@ -21,6 +27,14 @@ class PreciseDiff(NamedTuple): microseconds: int total_days: int +def parse_iso8601( + text: str, +) -> datetime | date | time | Duration: ... +def days_in_year(year: int) -> int: ... +def is_leap(year: int) -> bool: ... +def is_long_year(year: int) -> bool: ... +def local_time( + unix_time: int, utc_offset: int, microseconds: int +) -> tuple[int, int, int, int, int, int, int]: ... def precise_diff(d1: datetime | date, d2: datetime | date) -> PreciseDiff: ... -def timestamp(dt: datetime) -> int: ... def week_day(year: int, month: int, day: int) -> int: ... diff --git a/build.py b/build.py index 8a72beb9..60c880c4 100644 --- a/build.py +++ b/build.py @@ -1,19 +1,41 @@ +import os +import shlex +import shutil import subprocess +import zipfile from pathlib import Path -def meson(*args): - subprocess.call(["meson", *list(args)]) +def maturin(*args): + subprocess.call(["maturin", *list(args)]) def _build(): build_dir = Path(__file__).parent.joinpath("build") build_dir.mkdir(parents=True, exist_ok=True) - meson("setup", build_dir.as_posix()) - meson("compile", "-C", build_dir.as_posix()) - meson("install", "-C", build_dir.as_posix()) + wheels_dir = Path(__file__).parent.joinpath("target/wheels") + if wheels_dir.exists(): + shutil.rmtree(wheels_dir) + + cargo_args = [] + if os.getenv("MATURIN_BUILD_ARGS"): + cargo_args = shlex.split(os.getenv("MATURIN_BUILD_ARGS", "")) + + maturin("build", "-r", *cargo_args) + + # We won't use the wheel built by maturin directly since + # we want Poetry to build it but we need to retrieve the + # compiled extensions from the maturin wheel. + wheel = list(wheels_dir.glob("*.whl"))[0] + with zipfile.ZipFile(wheel.as_posix()) as whl: + whl.extractall(wheels_dir.as_posix()) + + for extension in wheels_dir.rglob("**/*.so"): + shutil.copyfile(extension, Path(__file__).parent.joinpath(extension.name)) + + shutil.rmtree(wheels_dir) def build(setup_kwargs): @@ -22,11 +44,12 @@ def build(setup_kwargs): """ try: _build() - except Exception: + except Exception as e: print( - " Unable to build C extensions, " + " Unable to build Rust extensions, " "Pendulum will use the pure python version of the extensions." ) + print(e) if __name__ == "__main__": diff --git a/meson.build b/meson.build deleted file mode 100644 index 666c2810..00000000 --- a/meson.build +++ /dev/null @@ -1,20 +0,0 @@ -project('pendulum C extensions', 'c') - -py_mod = import('python') -py = py_mod.find_installation() -py_dep = py.dependency() - -extensions = [ - ['_helpers', 'pendulum/_extensions/_helpers.c', meson.source_root() / 'pendulum/_extensions/'], - ['_iso8601', 'pendulum/parsing/_iso8601.c', meson.source_root() / 'pendulum/parsing/'], -] - -foreach extension : extensions - py.extension_module( - extension[0], - extension[1], - dependencies : py_dep, - install : true, - install_dir: extension[2] - ) -endforeach diff --git a/pendulum/_extensions/__init__.py b/pendulum/_extensions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pendulum/_extensions/_helpers.c b/pendulum/_extensions/_helpers.c deleted file mode 100644 index a3114d9d..00000000 --- a/pendulum/_extensions/_helpers.c +++ /dev/null @@ -1,931 +0,0 @@ -/* ------------------------------------------------------------------------- */ - -#include -#include -#include -#include -#include -#include -#include -#include - -/* ------------------------------------------------------------------------- */ - -#define EPOCH_YEAR 1970 - -#define DAYS_PER_N_YEAR 365 -#define DAYS_PER_L_YEAR 366 - -#define USECS_PER_SEC 1000000 - -#define SECS_PER_MIN 60 -#define SECS_PER_HOUR (60 * SECS_PER_MIN) -#define SECS_PER_DAY (SECS_PER_HOUR * 24) - -// 400-year chunks always have 146097 days (20871 weeks). -#define DAYS_PER_400_YEARS 146097L -#define SECS_PER_400_YEARS ((int64_t)DAYS_PER_400_YEARS * (int64_t)SECS_PER_DAY) - -// The number of seconds in an aligned 100-year chunk, for those that -// do not begin with a leap year and those that do respectively. -const int64_t SECS_PER_100_YEARS[2] = { - (uint64_t)(76L * DAYS_PER_N_YEAR + 24L * DAYS_PER_L_YEAR) * SECS_PER_DAY, - (uint64_t)(75L * DAYS_PER_N_YEAR + 25L * DAYS_PER_L_YEAR) * SECS_PER_DAY}; - -// The number of seconds in an aligned 4-year chunk, for those that -// do not begin with a leap year and those that do respectively. -const int32_t SECS_PER_4_YEARS[2] = { - (4 * DAYS_PER_N_YEAR + 0 * DAYS_PER_L_YEAR) * SECS_PER_DAY, - (3 * DAYS_PER_N_YEAR + 1 * DAYS_PER_L_YEAR) * SECS_PER_DAY}; - -// The number of seconds in non-leap and leap years respectively. -const int32_t SECS_PER_YEAR[2] = { - DAYS_PER_N_YEAR * SECS_PER_DAY, - DAYS_PER_L_YEAR *SECS_PER_DAY}; - -#define MONTHS_PER_YEAR 12 - -// The month lengths in non-leap and leap years respectively. -const int32_t DAYS_PER_MONTHS[2][13] = { - {-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, - {-1, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}}; - -// The day offsets of the beginning of each (1-based) month in non-leap -// and leap years respectively. -// For example, in a leap year there are 335 days before December. -const int32_t MONTHS_OFFSETS[2][14] = { - {-1, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365}, - {-1, 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366}}; - -const int DAY_OF_WEEK_TABLE[12] = { - 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; - -#define TM_SUNDAY 0 -#define TM_MONDAY 1 -#define TM_TUESDAY 2 -#define TM_WEDNESDAY 3 -#define TM_THURSDAY 4 -#define TM_FRIDAY 5 -#define TM_SATURDAY 6 - -#define TM_JANUARY 0 -#define TM_FEBRUARY 1 -#define TM_MARCH 2 -#define TM_APRIL 3 -#define TM_MAY 4 -#define TM_JUNE 5 -#define TM_JULY 6 -#define TM_AUGUST 7 -#define TM_SEPTEMBER 8 -#define TM_OCTOBER 9 -#define TM_NOVEMBER 10 -#define TM_DECEMBER 11 - -/* ------------------------------------------------------------------------- */ - -int _p(int y) -{ - return y + y / 4 - y / 100 + y / 400; -} - -int _is_leap(int year) -{ - return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); -} - -int _is_long_year(int year) -{ - return (_p(year) % 7 == 4) || (_p(year - 1) % 7 == 3); -} - -int _week_day(int year, int month, int day) -{ - int y; - int w; - - y = year - (month < 3); - - w = (_p(y) + DAY_OF_WEEK_TABLE[month - 1] + day) % 7; - - if (!w) - { - w = 7; - } - - return w; -} - -int _days_in_year(int year) -{ - if (_is_leap(year)) - { - return DAYS_PER_L_YEAR; - } - - return DAYS_PER_N_YEAR; -} - -int _day_number(int year, int month, int day) -{ - month = (month + 9) % 12; - year = year - month / 10; - - return ( - 365 * year + year / 4 - year / 100 + year / 400 + (month * 306 + 5) / 10 + (day - 1)); -} - -int _get_offset(PyObject *dt) -{ - PyObject *tzinfo; - PyObject *offset; - - tzinfo = ((PyDateTime_DateTime *)(dt))->tzinfo; - - if (tzinfo != Py_None) - { - offset = PyObject_CallMethod(tzinfo, "utcoffset", "O", dt); - - return PyDateTime_DELTA_GET_DAYS(offset) * SECS_PER_DAY + PyDateTime_DELTA_GET_SECONDS(offset); - } - - return 0; -} - -int _has_tzinfo(PyObject *dt) -{ - return ((_PyDateTime_BaseTZInfo *)(dt))->hastzinfo; -} - -char *_get_tz_name(PyObject *dt) -{ - PyObject *tzinfo; - char *tz = ""; - - tzinfo = ((PyDateTime_DateTime *)(dt))->tzinfo; - - if (tzinfo != Py_None) - { - if (PyObject_HasAttrString(tzinfo, "key")) - { - // zoneinfo timezone - tz = (char *)PyUnicode_AsUTF8( - PyObject_GetAttrString(tzinfo, "name")); - } - else if (PyObject_HasAttrString(tzinfo, "name")) - { - // Pendulum timezone - tz = (char *)PyUnicode_AsUTF8( - PyObject_GetAttrString(tzinfo, "name")); - } - else if (PyObject_HasAttrString(tzinfo, "zone")) - { - // pytz timezone - tz = (char *)PyUnicode_AsUTF8( - PyObject_GetAttrString(tzinfo, "zone")); - } - } - - return tz; -} - -/* ------------------------ Custom Types ------------------------------- */ - -/* - * class Diff(): - */ -typedef struct -{ - PyObject_HEAD int years; - int months; - int days; - int hours; - int minutes; - int seconds; - int microseconds; - int total_days; -} Diff; - -/* - * def __init__(self, years, months, days, hours, minutes, seconds, microseconds, total_days): - * self.years = years - * # ... -*/ -static int Diff_init(Diff *self, PyObject *args, PyObject *kwargs) -{ - int years; - int months; - int days; - int hours; - int minutes; - int seconds; - int microseconds; - int total_days; - - if (!PyArg_ParseTuple(args, "iiiiiii", &years, &months, &days, &hours, &minutes, &seconds, µseconds, &total_days)) - return -1; - - self->years = years; - self->months = months; - self->days = days; - self->hours = hours; - self->minutes = minutes; - self->seconds = seconds; - self->microseconds = microseconds; - self->total_days = total_days; - - return 0; -} - -/* - * def __repr__(self): - * return '{} years {} months {} days {} hours {} minutes {} seconds {} microseconds'.format( - * self.years, self.months, self.days, self.minutes, self.hours, self.seconds, self.microseconds - * ) - */ -static PyObject *Diff_repr(Diff *self) -{ - return PyUnicode_FromFormat( - "%d years %d months %d days %d hours %d minutes %d seconds %d microseconds", - self->years, - self->months, - self->days, - self->hours, - self->minutes, - self->seconds, - self->microseconds); -} - -/* - * Instantiate new Diff_type object - * Skip overhead of calling PyObject_New and PyObject_Init. - * Directly allocate object. - */ -static PyObject *new_diff_ex(int years, int months, int days, int hours, int minutes, int seconds, int microseconds, int total_days, PyTypeObject *type) -{ - Diff *self = (Diff *)(type->tp_alloc(type, 0)); - - if (self != NULL) - { - self->years = years; - self->months = months; - self->days = days; - self->hours = hours; - self->minutes = minutes; - self->seconds = seconds; - self->microseconds = microseconds; - self->total_days = total_days; - } - - return (PyObject *)self; -} - -/* - * Class member / class attributes - */ -static PyMemberDef Diff_members[] = { - {"years", T_INT, offsetof(Diff, years), 0, "years in diff"}, - {"months", T_INT, offsetof(Diff, months), 0, "months in diff"}, - {"days", T_INT, offsetof(Diff, days), 0, "days in diff"}, - {"hours", T_INT, offsetof(Diff, hours), 0, "hours in diff"}, - {"minutes", T_INT, offsetof(Diff, minutes), 0, "minutes in diff"}, - {"seconds", T_INT, offsetof(Diff, seconds), 0, "seconds in diff"}, - {"microseconds", T_INT, offsetof(Diff, microseconds), 0, "microseconds in diff"}, - {"total_days", T_INT, offsetof(Diff, total_days), 0, "total days in diff"}, - {NULL}}; - -static PyTypeObject Diff_type = { - PyVarObject_HEAD_INIT(NULL, 0) "PreciseDiff", /* tp_name */ - sizeof(Diff), /* tp_basicsize */ - 0, /* tp_itemsize */ - 0, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_as_async */ - (reprfunc)Diff_repr, /* tp_repr */ - 0, /* tp_as_number */ - 0, /* tp_as_sequence */ - 0, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - (reprfunc)Diff_repr, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ - "Precise difference between two datetime objects", /* tp_doc */ -}; - -#define new_diff(years, months, days, hours, minutes, seconds, microseconds, total_days) new_diff_ex(years, months, days, hours, minutes, seconds, microseconds, total_days, &Diff_type) - -/* -------------------------- Functions --------------------------*/ - -PyObject *is_leap(PyObject *self, PyObject *args) -{ - PyObject *leap; - int year; - - if (!PyArg_ParseTuple(args, "i", &year)) - { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters"); - return NULL; - } - - leap = PyBool_FromLong(_is_leap(year)); - - return leap; -} - -PyObject *is_long_year(PyObject *self, PyObject *args) -{ - PyObject *is_long; - int year; - - if (!PyArg_ParseTuple(args, "i", &year)) - { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters"); - return NULL; - } - - is_long = PyBool_FromLong(_is_long_year(year)); - - return is_long; -} - -PyObject *week_day(PyObject *self, PyObject *args) -{ - PyObject *wd; - int year; - int month; - int day; - - if (!PyArg_ParseTuple(args, "iii", &year, &month, &day)) - { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters"); - return NULL; - } - - wd = PyLong_FromLong(_week_day(year, month, day)); - - return wd; -} - -PyObject *days_in_year(PyObject *self, PyObject *args) -{ - PyObject *ndays; - int year; - - if (!PyArg_ParseTuple(args, "i", &year)) - { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters"); - return NULL; - } - - ndays = PyLong_FromLong(_days_in_year(year)); - - return ndays; -} - -PyObject *timestamp(PyObject *self, PyObject *args) -{ - int64_t result; - PyObject *dt; - - if (!PyArg_ParseTuple(args, "O", &dt)) - { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters"); - return NULL; - } - - int year = (double)PyDateTime_GET_YEAR(dt); - int month = PyDateTime_GET_MONTH(dt); - int day = PyDateTime_GET_DAY(dt); - int hour = PyDateTime_DATE_GET_HOUR(dt); - int minute = PyDateTime_DATE_GET_MINUTE(dt); - int second = PyDateTime_DATE_GET_SECOND(dt); - - result = (year - 1970) * 365 + MONTHS_OFFSETS[0][month]; - result += (int)floor((double)(year - 1968) / 4); - result -= (year - 1900) / 100; - result += (year - 1600) / 400; - - if (_is_leap(year) && month < 3) - { - result -= 1; - } - - result += day - 1; - result *= 24; - result += hour; - result *= 60; - result += minute; - result *= 60; - result += second; - - return PyLong_FromSsize_t(result); -} - -PyObject *local_time(PyObject *self, PyObject *args) -{ - double unix_time; - int32_t utc_offset; - int32_t year; - int32_t microsecond; - int64_t seconds; - int32_t leap_year; - int64_t sec_per_100years; - int64_t sec_per_4years; - int32_t sec_per_year; - int32_t month; - int32_t day; - int32_t month_offset; - int32_t hour; - int32_t minute; - int32_t second; - - if (!PyArg_ParseTuple(args, "dii", &unix_time, &utc_offset, µsecond)) - { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters"); - return NULL; - } - - year = EPOCH_YEAR; - seconds = (int64_t)floor(unix_time); - - // Shift to a base year that is 400-year aligned. - if (seconds >= 0) - { - seconds -= 10957L * SECS_PER_DAY; - year += 30; // == 2000; - } - else - { - seconds += (int64_t)(146097L - 10957L) * SECS_PER_DAY; - year -= 370; // == 1600; - } - - seconds += utc_offset; - - // Handle years in chunks of 400/100/4/1 - year += 400 * (seconds / SECS_PER_400_YEARS); - seconds %= SECS_PER_400_YEARS; - if (seconds < 0) - { - seconds += SECS_PER_400_YEARS; - year -= 400; - } - - leap_year = 1; // 4-century aligned - - sec_per_100years = SECS_PER_100_YEARS[leap_year]; - - while (seconds >= sec_per_100years) - { - seconds -= sec_per_100years; - year += 100; - leap_year = 0; // 1-century, non 4-century aligned - sec_per_100years = SECS_PER_100_YEARS[leap_year]; - } - - sec_per_4years = SECS_PER_4_YEARS[leap_year]; - while (seconds >= sec_per_4years) - { - seconds -= sec_per_4years; - year += 4; - leap_year = 1; // 4-year, non century aligned - sec_per_4years = SECS_PER_4_YEARS[leap_year]; - } - - sec_per_year = SECS_PER_YEAR[leap_year]; - while (seconds >= sec_per_year) - { - seconds -= sec_per_year; - year += 1; - leap_year = 0; // non 4-year aligned - sec_per_year = SECS_PER_YEAR[leap_year]; - } - - // Handle months and days - month = TM_DECEMBER + 1; - day = seconds / SECS_PER_DAY + 1; - seconds %= SECS_PER_DAY; - while (month != TM_JANUARY + 1) - { - month_offset = MONTHS_OFFSETS[leap_year][month]; - if (day > month_offset) - { - day -= month_offset; - break; - } - - month -= 1; - } - - // Handle hours, minutes and seconds - hour = seconds / SECS_PER_HOUR; - seconds %= SECS_PER_HOUR; - minute = seconds / SECS_PER_MIN; - second = seconds % SECS_PER_MIN; - - return Py_BuildValue("NNNNNNN", - PyLong_FromLong(year), - PyLong_FromLong(month), - PyLong_FromLong(day), - PyLong_FromLong(hour), - PyLong_FromLong(minute), - PyLong_FromLong(second), - PyLong_FromLong(microsecond)); -} - -// Calculate a precise difference between two datetimes. -PyObject *precise_diff(PyObject *self, PyObject *args) -{ - PyObject *dt1; - PyObject *dt2; - - if (!PyArg_ParseTuple(args, "OO", &dt1, &dt2)) - { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters"); - return NULL; - } - - int year_diff = 0; - int month_diff = 0; - int day_diff = 0; - int hour_diff = 0; - int minute_diff = 0; - int second_diff = 0; - int microsecond_diff = 0; - int sign = 1; - int year; - int month; - int leap; - int days_in_last_month; - int days_in_month; - int dt1_year = PyDateTime_GET_YEAR(dt1); - int dt2_year = PyDateTime_GET_YEAR(dt2); - int dt1_month = PyDateTime_GET_MONTH(dt1); - int dt2_month = PyDateTime_GET_MONTH(dt2); - int dt1_day = PyDateTime_GET_DAY(dt1); - int dt2_day = PyDateTime_GET_DAY(dt2); - int dt1_hour = 0; - int dt2_hour = 0; - int dt1_minute = 0; - int dt2_minute = 0; - int dt1_second = 0; - int dt2_second = 0; - int dt1_microsecond = 0; - int dt2_microsecond = 0; - int dt1_total_seconds = 0; - int dt2_total_seconds = 0; - int dt1_offset = 0; - int dt2_offset = 0; - int dt1_is_datetime = PyDateTime_Check(dt1); - int dt2_is_datetime = PyDateTime_Check(dt2); - char *tz1 = ""; - char *tz2 = ""; - int in_same_tz = 0; - int total_days = (_day_number(dt2_year, dt2_month, dt2_day) - _day_number(dt1_year, dt1_month, dt1_day)); - - // If both dates are datetimes, we check - // If we are in the same timezone - if (dt1_is_datetime && dt2_is_datetime) - { - if (_has_tzinfo(dt1)) - { - tz1 = _get_tz_name(dt1); - dt1_offset = _get_offset(dt1); - } - - if (_has_tzinfo(dt2)) - { - tz2 = _get_tz_name(dt2); - dt2_offset = _get_offset(dt2); - } - - in_same_tz = tz1 == tz2 && strncmp(tz1, "", 1); - } - - // If we have datetimes (and not only dates) - // we get the information we need - if (dt1_is_datetime) - { - dt1_hour = PyDateTime_DATE_GET_HOUR(dt1); - dt1_minute = PyDateTime_DATE_GET_MINUTE(dt1); - dt1_second = PyDateTime_DATE_GET_SECOND(dt1); - dt1_microsecond = PyDateTime_DATE_GET_MICROSECOND(dt1); - - if ((!in_same_tz && dt1_offset != 0) || total_days == 0) - { - dt1_hour -= dt1_offset / SECS_PER_HOUR; - dt1_offset %= SECS_PER_HOUR; - dt1_minute -= dt1_offset / SECS_PER_MIN; - dt1_offset %= SECS_PER_MIN; - dt1_second -= dt1_offset; - - if (dt1_second < 0) - { - dt1_second += 60; - dt1_minute -= 1; - } - else if (dt1_second > 60) - { - dt1_second -= 60; - dt1_minute += 1; - } - - if (dt1_minute < 0) - { - dt1_minute += 60; - dt1_hour -= 1; - } - else if (dt1_minute > 60) - { - dt1_minute -= 60; - dt1_hour += 1; - } - - if (dt1_hour < 0) - { - dt1_hour += 24; - dt1_day -= 1; - } - else if (dt1_hour > 24) - { - dt1_hour -= 24; - dt1_day += 1; - } - } - - dt1_total_seconds = (dt1_hour * SECS_PER_HOUR + dt1_minute * SECS_PER_MIN + dt1_second); - } - - if (dt2_is_datetime) - { - dt2_hour = PyDateTime_DATE_GET_HOUR(dt2); - dt2_minute = PyDateTime_DATE_GET_MINUTE(dt2); - dt2_second = PyDateTime_DATE_GET_SECOND(dt2); - dt2_microsecond = PyDateTime_DATE_GET_MICROSECOND(dt2); - - if ((!in_same_tz && dt2_offset != 0) || total_days == 0) - { - dt2_hour -= dt2_offset / SECS_PER_HOUR; - dt2_offset %= SECS_PER_HOUR; - dt2_minute -= dt2_offset / SECS_PER_MIN; - dt2_offset %= SECS_PER_MIN; - dt2_second -= dt2_offset; - - if (dt2_second < 0) - { - dt2_second += 60; - dt2_minute -= 1; - } - else if (dt2_second > 60) - { - dt2_second -= 60; - dt2_minute += 1; - } - - if (dt2_minute < 0) - { - dt2_minute += 60; - dt2_hour -= 1; - } - else if (dt2_minute > 60) - { - dt2_minute -= 60; - dt2_hour += 1; - } - - if (dt2_hour < 0) - { - dt2_hour += 24; - dt2_day -= 1; - } - else if (dt2_hour > 24) - { - dt2_hour -= 24; - dt2_day += 1; - } - } - - dt2_total_seconds = (dt2_hour * SECS_PER_HOUR + dt2_minute * SECS_PER_MIN + dt2_second); - } - - // Direct comparison between two datetimes does not work - // so we need to check by properties - int dt1_gt_dt2 = (dt1_year > dt2_year || (dt1_year == dt2_year && dt1_month > dt2_month) || (dt1_year == dt2_year && dt1_month == dt2_month && dt1_day > dt2_day) || (dt1_year == dt2_year && dt1_month == dt2_month && dt1_day == dt2_day && dt1_total_seconds > dt2_total_seconds) || (dt1_year == dt2_year && dt1_month == dt2_month && dt1_day == dt2_day && dt1_total_seconds == dt2_total_seconds && dt1_microsecond > dt2_microsecond)); - - if (dt1_gt_dt2) - { - PyObject *temp; - temp = dt1; - dt1 = dt2; - dt2 = temp; - sign = -1; - - // Retrieving properties - dt1_year = PyDateTime_GET_YEAR(dt1); - dt2_year = PyDateTime_GET_YEAR(dt2); - dt1_month = PyDateTime_GET_MONTH(dt1); - dt2_month = PyDateTime_GET_MONTH(dt2); - dt1_day = PyDateTime_GET_DAY(dt1); - dt2_day = PyDateTime_GET_DAY(dt2); - - if (dt2_is_datetime) - { - dt1_hour = PyDateTime_DATE_GET_HOUR(dt1); - dt1_minute = PyDateTime_DATE_GET_MINUTE(dt1); - dt1_second = PyDateTime_DATE_GET_SECOND(dt1); - dt1_microsecond = PyDateTime_DATE_GET_MICROSECOND(dt1); - } - - if (dt1_is_datetime) - { - dt2_hour = PyDateTime_DATE_GET_HOUR(dt2); - dt2_minute = PyDateTime_DATE_GET_MINUTE(dt2); - dt2_second = PyDateTime_DATE_GET_SECOND(dt2); - dt2_microsecond = PyDateTime_DATE_GET_MICROSECOND(dt2); - } - - total_days = (_day_number(dt2_year, dt2_month, dt2_day) - _day_number(dt1_year, dt1_month, dt1_day)); - } - - year_diff = dt2_year - dt1_year; - month_diff = dt2_month - dt1_month; - day_diff = dt2_day - dt1_day; - hour_diff = dt2_hour - dt1_hour; - minute_diff = dt2_minute - dt1_minute; - second_diff = dt2_second - dt1_second; - microsecond_diff = dt2_microsecond - dt1_microsecond; - - if (microsecond_diff < 0) - { - microsecond_diff += 1e6; - second_diff -= 1; - } - - if (second_diff < 0) - { - second_diff += 60; - minute_diff -= 1; - } - - if (minute_diff < 0) - { - minute_diff += 60; - hour_diff -= 1; - } - - if (hour_diff < 0) - { - hour_diff += 24; - day_diff -= 1; - } - - if (day_diff < 0) - { - // If we have a difference in days, - // we have to check if they represent months - year = dt2_year; - month = dt2_month; - - if (month == 1) - { - month = 12; - year -= 1; - } - else - { - month -= 1; - } - - leap = _is_leap(year); - - days_in_last_month = DAYS_PER_MONTHS[leap][month]; - days_in_month = DAYS_PER_MONTHS[_is_leap(dt2_year)][dt2_month]; - - if (day_diff < days_in_month - days_in_last_month) - { - // We don't have a full month, we calculate days - if (days_in_last_month < dt1_day) - { - day_diff += dt1_day; - } - else - { - day_diff += days_in_last_month; - } - } - else if (day_diff == days_in_month - days_in_last_month) - { - // We have exactly a full month - // We remove the days difference - // and add one to the months difference - day_diff = 0; - month_diff += 1; - } - else - { - // We have a full month - day_diff += days_in_last_month; - } - - month_diff -= 1; - } - - if (month_diff < 0) - { - month_diff += 12; - year_diff -= 1; - } - - return new_diff( - year_diff * sign, - month_diff * sign, - day_diff * sign, - hour_diff * sign, - minute_diff * sign, - second_diff * sign, - microsecond_diff * sign, - total_days * sign); -} - -/* ------------------------------------------------------------------------- */ - -static PyMethodDef helpers_methods[] = { - {"is_leap", - (PyCFunction)is_leap, - METH_VARARGS, - PyDoc_STR("Checks if a year is a leap year.")}, - {"is_long_year", - (PyCFunction)is_long_year, - METH_VARARGS, - PyDoc_STR("Checks if a year is a long year.")}, - {"week_day", - (PyCFunction)week_day, - METH_VARARGS, - PyDoc_STR("Returns the weekday number.")}, - {"days_in_year", - (PyCFunction)days_in_year, - METH_VARARGS, - PyDoc_STR("Returns the number of days in the given year.")}, - {"timestamp", - (PyCFunction)timestamp, - METH_VARARGS, - PyDoc_STR("Returns the timestamp of the given datetime.")}, - {"local_time", - (PyCFunction)local_time, - METH_VARARGS, - PyDoc_STR("Returns a UNIX time as a broken down time for a particular transition type.")}, - {"precise_diff", - (PyCFunction)precise_diff, - METH_VARARGS, - PyDoc_STR("Calculate a precise difference between two datetimes.")}, - {NULL}}; - -/* ------------------------------------------------------------------------- */ - -static struct PyModuleDef moduledef = { - PyModuleDef_HEAD_INIT, - "_helpers", - NULL, - -1, - helpers_methods, - NULL, - NULL, - NULL, - NULL, -}; - -PyMODINIT_FUNC -PyInit__helpers(void) -{ - PyObject *module; - - PyDateTime_IMPORT; - - module = PyModule_Create(&moduledef); - - if (module == NULL) - return NULL; - - // Diff declaration - Diff_type.tp_new = PyType_GenericNew; - Diff_type.tp_members = Diff_members; - Diff_type.tp_init = (initproc)Diff_init; - - if (PyType_Ready(&Diff_type) < 0) - return NULL; - - PyModule_AddObject(module, "PreciseDiff", (PyObject *)&Diff_type); - - return module; -} diff --git a/pendulum/_extensions/helpers.py b/pendulum/_helpers.py similarity index 95% rename from pendulum/_extensions/helpers.py rename to pendulum/_helpers.py index 4dd4c726..1b586d31 100644 --- a/pendulum/_extensions/helpers.py +++ b/pendulum/_helpers.py @@ -84,28 +84,6 @@ def days_in_year(year: int) -> int: return DAYS_PER_N_YEAR -def timestamp(dt: datetime.datetime) -> int: - year = dt.year - - result = (year - 1970) * 365 + MONTHS_OFFSETS[0][dt.month] - result += (year - 1968) // 4 - result -= (year - 1900) // 100 - result += (year - 1600) // 400 - - if is_leap(year) and dt.month < 3: - result -= 1 - - result += dt.day - 1 - result *= 24 - result += dt.hour - result *= 60 - result += dt.minute - result *= 60 - result += dt.second - - return result - - def local_time( unix_time: int, utc_offset: int, microseconds: int ) -> tuple[int, int, int, int, int, int, int]: diff --git a/pendulum/helpers.py b/pendulum/helpers.py index 6b62fe77..e9390ca9 100644 --- a/pendulum/helpers.py +++ b/pendulum/helpers.py @@ -31,23 +31,21 @@ if not with_extensions or struct.calcsize("P") == 4: raise ImportError() - from pendulum._extensions._helpers import PreciseDiff - from pendulum._extensions._helpers import days_in_year - from pendulum._extensions._helpers import is_leap - from pendulum._extensions._helpers import is_long_year - from pendulum._extensions._helpers import local_time - from pendulum._extensions._helpers import precise_diff - from pendulum._extensions._helpers import timestamp - from pendulum._extensions._helpers import week_day + from _pendulum import PreciseDiff + from _pendulum import days_in_year + from _pendulum import is_leap + from _pendulum import is_long_year + from _pendulum import local_time + from _pendulum import precise_diff + from _pendulum import week_day except ImportError: - from pendulum._extensions.helpers import PreciseDiff # type: ignore[assignment] - from pendulum._extensions.helpers import days_in_year - from pendulum._extensions.helpers import is_leap - from pendulum._extensions.helpers import is_long_year - from pendulum._extensions.helpers import local_time - from pendulum._extensions.helpers import precise_diff # type: ignore[assignment] - from pendulum._extensions.helpers import timestamp - from pendulum._extensions.helpers import week_day + from pendulum._helpers import PreciseDiff # type: ignore[assignment] + from pendulum._helpers import days_in_year + from pendulum._helpers import is_leap + from pendulum._helpers import is_long_year + from pendulum._helpers import local_time + from pendulum._helpers import precise_diff # type: ignore[assignment] + from pendulum._helpers import week_day difference_formatter = DifferenceFormatter() @@ -211,7 +209,6 @@ def week_ends_at(wday: int) -> None: "is_long_year", "local_time", "precise_diff", - "timestamp", "week_day", "add_duration", "format_diff", diff --git a/pendulum/parser.py b/pendulum/parser.py index d39842f6..2cb5ef95 100644 --- a/pendulum/parser.py +++ b/pendulum/parser.py @@ -5,6 +5,7 @@ import pendulum +from pendulum.duration import Duration from pendulum.parsing import _Interval from pendulum.parsing import parse as base_parse from pendulum.tz.timezone import UTC @@ -13,14 +14,13 @@ if t.TYPE_CHECKING: from pendulum.date import Date from pendulum.datetime import DateTime - from pendulum.duration import Duration from pendulum.interval import Interval from pendulum.time import Time try: - from pendulum.parsing._iso8601 import Duration as CDuration + from _pendulum import Duration as RustDuration except ImportError: - CDuration = None # type: ignore[misc, assignment] + RustDuration = None # type: ignore[assignment,misc] def parse(text: str, **options: t.Any) -> Date | Time | DateTime | Duration: @@ -110,7 +110,10 @@ def _parse(text: str, **options: t.Any) -> Date | DateTime | Time | Duration | I ), ) - if CDuration and isinstance(parsed, CDuration): # type: ignore[truthy-function] + if isinstance(parsed, Duration): + return parsed + + if RustDuration is not None and isinstance(parsed, RustDuration): return pendulum.duration( years=parsed.years, months=parsed.months, @@ -122,4 +125,4 @@ def _parse(text: str, **options: t.Any) -> Date | DateTime | Time | Duration | I microseconds=parsed.microseconds, ) - return parsed + raise NotImplementedError diff --git a/pendulum/parsing/__init__.py b/pendulum/parsing/__init__.py index 3b64994a..908b670d 100644 --- a/pendulum/parsing/__init__.py +++ b/pendulum/parsing/__init__.py @@ -24,12 +24,13 @@ if not with_extensions or struct.calcsize("P") == 4: raise ImportError() - from pendulum.parsing._iso8601 import Duration - from pendulum.parsing._iso8601 import parse_iso8601 + from _pendulum import Duration + from _pendulum import parse_iso8601 except ImportError: from pendulum.duration import Duration # type: ignore[assignment] from pendulum.parsing.iso8601 import parse_iso8601 # type: ignore[assignment] + COMMON = re.compile( # Date (optional) # noqa: ERA001 "^" diff --git a/pendulum/parsing/_iso8601.c b/pendulum/parsing/_iso8601.c deleted file mode 100644 index 1322423a..00000000 --- a/pendulum/parsing/_iso8601.c +++ /dev/null @@ -1,1361 +0,0 @@ -/* ------------------------------------------------------------------------- */ - -#include -#include -#include -#include -#include -#include -#include - -#ifndef PyVarObject_HEAD_INIT -#define PyVarObject_HEAD_INIT(type, size) PyObject_HEAD_INIT(type) size, -#endif - - -/* ------------------------------------------------------------------------- */ - -#define EPOCH_YEAR 1970 - -#define DAYS_PER_N_YEAR 365 -#define DAYS_PER_L_YEAR 366 - -#define USECS_PER_SEC 1000000 - -#define SECS_PER_MIN 60 -#define SECS_PER_HOUR (60 * SECS_PER_MIN) -#define SECS_PER_DAY (SECS_PER_HOUR * 24) - -// 400-year chunks always have 146097 days (20871 weeks). -#define DAYS_PER_400_YEARS 146097L -#define SECS_PER_400_YEARS ((int64_t)DAYS_PER_400_YEARS * (int64_t)SECS_PER_DAY) - -// The number of seconds in an aligned 100-year chunk, for those that -// do not begin with a leap year and those that do respectively. -const int64_t SECS_PER_100_YEARS[2] = { - (uint64_t)(76L * DAYS_PER_N_YEAR + 24L * DAYS_PER_L_YEAR) * SECS_PER_DAY, - (uint64_t)(75L * DAYS_PER_N_YEAR + 25L * DAYS_PER_L_YEAR) * SECS_PER_DAY -}; - -// The number of seconds in an aligned 4-year chunk, for those that -// do not begin with a leap year and those that do respectively. -const int32_t SECS_PER_4_YEARS[2] = { - (4 * DAYS_PER_N_YEAR + 0 * DAYS_PER_L_YEAR) * SECS_PER_DAY, - (3 * DAYS_PER_N_YEAR + 1 * DAYS_PER_L_YEAR) * SECS_PER_DAY -}; - -// The number of seconds in non-leap and leap years respectively. -const int32_t SECS_PER_YEAR[2] = { - DAYS_PER_N_YEAR * SECS_PER_DAY, - DAYS_PER_L_YEAR * SECS_PER_DAY -}; - -#define MONTHS_PER_YEAR 12 - -// The month lengths in non-leap and leap years respectively. -const int32_t DAYS_PER_MONTHS[2][13] = { - {-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, - {-1, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} -}; - -// The day offsets of the beginning of each (1-based) month in non-leap -// and leap years respectively. -// For example, in a leap year there are 335 days before December. -const int32_t MONTHS_OFFSETS[2][14] = { - {-1, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365}, - {-1, 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366} -}; - -const int DAY_OF_WEEK_TABLE[12] = { - 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4 -}; - -#define TM_SUNDAY 0 -#define TM_MONDAY 1 -#define TM_TUESDAY 2 -#define TM_WEDNESDAY 3 -#define TM_THURSDAY 4 -#define TM_FRIDAY 5 -#define TM_SATURDAY 6 - -#define TM_JANUARY 0 -#define TM_FEBRUARY 1 -#define TM_MARCH 2 -#define TM_APRIL 3 -#define TM_MAY 4 -#define TM_JUNE 5 -#define TM_JULY 6 -#define TM_AUGUST 7 -#define TM_SEPTEMBER 8 -#define TM_OCTOBER 9 -#define TM_NOVEMBER 10 -#define TM_DECEMBER 11 - -// Parsing errors -const int PARSER_INVALID_ISO8601 = 0; -const int PARSER_INVALID_DATE = 1; -const int PARSER_INVALID_TIME = 2; -const int PARSER_INVALID_WEEK_DATE = 3; -const int PARSER_INVALID_WEEK_NUMBER = 4; -const int PARSER_INVALID_WEEKDAY_NUMBER = 5; -const int PARSER_INVALID_ORDINAL_DAY_FOR_YEAR = 6; -const int PARSER_INVALID_MONTH_OR_DAY = 7; -const int PARSER_INVALID_MONTH = 8; -const int PARSER_INVALID_DAY_FOR_MONTH = 9; -const int PARSER_INVALID_HOUR = 10; -const int PARSER_INVALID_MINUTE = 11; -const int PARSER_INVALID_SECOND = 12; -const int PARSER_INVALID_SUBSECOND = 13; -const int PARSER_INVALID_TZ_OFFSET = 14; -const int PARSER_INVALID_DURATION = 15; -const int PARSER_INVALID_DURATION_FLOAT_YEAR_MONTH_NOT_SUPPORTED = 16; - -const char PARSER_ERRORS[17][80] = { - "Invalid ISO 8601 string", - "Invalid date", - "Invalid time", - "Invalid week date", - "Invalid week number", - "Invalid weekday number", - "Invalid ordinal day for year", - "Invalid month and/or day", - "Invalid month", - "Invalid day for month", - "Invalid hour", - "Invalid minute", - "Invalid second", - "Invalid subsecond", - "Invalid timezone offset", - "Invalid duration", - "Float years and months are not supported" -}; - -/* ------------------------------------------------------------------------- */ - - -int p(int y) { - return y + y/4 - y/100 + y/400; -} - -int is_leap(int year) { - return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); -} - -int week_day(int year, int month, int day) { - int y; - int w; - - y = year - (month < 3); - - w = (p(y) + DAY_OF_WEEK_TABLE[month - 1] + day) % 7; - - if (!w) { - w = 7; - } - - return w; -} - -int days_in_year(int year) { - if (is_leap(year)) { - return DAYS_PER_L_YEAR; - } - - return DAYS_PER_N_YEAR; -} - -int is_long_year(int year) { - return (p(year) % 7 == 4) || (p(year - 1) % 7 == 3); -} - - -/* ------------------------ Custom Types ------------------------------- */ - - -/* - * class FixedOffset(tzinfo): - */ -typedef struct { - PyObject_HEAD - int offset; - char *tzname; -} FixedOffset; - -/* - * def __init__(self, offset): - * self.offset = offset -*/ -static int FixedOffset_init(FixedOffset *self, PyObject *args, PyObject *kwargs) { - int offset; - char *tzname = NULL; - - static char *kwlist[] = {"offset", "tzname", NULL}; - - if (!PyArg_ParseTupleAndKeywords(args, kwargs, "i|s", kwlist, &offset, &tzname)) - return -1; - - self->offset = offset; - self->tzname = tzname; - - return 0; -} - -/* - * def utcoffset(self, dt): - * return timedelta(seconds=self.offset * 60) - */ -static PyObject *FixedOffset_utcoffset(FixedOffset *self, PyObject *args) { - return PyDelta_FromDSU(0, self->offset, 0); -} - -/* - * def dst(self, dt): - * return timedelta(seconds=self.offset * 60) - */ -static PyObject *FixedOffset_dst(FixedOffset *self, PyObject *args) { - return PyDelta_FromDSU(0, self->offset, 0); -} - -/* - * def tzname(self, dt): - * sign = '+' - * if self.offset < 0: - * sign = '-' - * return f"{sign}{self.offset / 60}:{self.offset % 60}" - */ -static PyObject *FixedOffset_tzname(FixedOffset *self, PyObject *args) { - if (self->tzname != NULL) { - return PyUnicode_FromString(self->tzname); - } - - char sign = '+'; - int offset = self->offset; - - if (offset < 0) { - sign = '-'; - offset *= -1; - } - - return PyUnicode_FromFormat( - "%c%02d:%02d", - sign, - offset / SECS_PER_HOUR, - offset / SECS_PER_MIN % SECS_PER_MIN - ); -} - -/* - * def __repr__(self): - * return self.tzname() - */ -static PyObject *FixedOffset_repr(FixedOffset *self) { - return FixedOffset_tzname(self, NULL); -} - -/* - * Class member / class attributes - */ -static PyMemberDef FixedOffset_members[] = { - {"offset", T_INT, offsetof(FixedOffset, offset), 0, "UTC offset"}, - {NULL} -}; - -/* - * Class methods - */ -static PyMethodDef FixedOffset_methods[] = { - {"utcoffset", (PyCFunction)FixedOffset_utcoffset, METH_VARARGS, ""}, - {"dst", (PyCFunction)FixedOffset_dst, METH_VARARGS, ""}, - {"tzname", (PyCFunction)FixedOffset_tzname, METH_VARARGS, ""}, - {NULL} -}; - -static PyTypeObject FixedOffset_type = { - PyVarObject_HEAD_INIT(NULL, 0) - "FixedOffset_type", /* tp_name */ - sizeof(FixedOffset), /* tp_basicsize */ - 0, /* tp_itemsize */ - 0, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_as_async */ - (reprfunc)FixedOffset_repr, /* tp_repr */ - 0, /* tp_as_number */ - 0, /* tp_as_sequence */ - 0, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - (reprfunc)FixedOffset_repr, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE, /* tp_flags */ - "TZInfo with fixed offset", /* tp_doc */ -}; - -/* - * Instantiate new FixedOffset_type object - * Skip overhead of calling PyObject_New and PyObject_Init. - * Directly allocate object. - */ -static PyObject *new_fixed_offset_ex(int offset, char *name, PyTypeObject *type) { - FixedOffset *self = (FixedOffset *) (type->tp_alloc(type, 0)); - - if (self != NULL) { - self->offset = offset; - self->tzname = name; - } - - return (PyObject *) self; -} - -#define new_fixed_offset(offset, name) new_fixed_offset_ex(offset, name, &FixedOffset_type) - - -/* - * class Duration(): - */ -typedef struct { - PyObject_HEAD - int years; - int months; - int weeks; - int days; - int hours; - int minutes; - int seconds; - int microseconds; -} Duration; - -/* - * def __init__(self, years, months, days, hours, minutes, seconds, microseconds): - * self.years = years - * # ... -*/ -static int Duration_init(Duration *self, PyObject *args, PyObject *kwargs) { - int years; - int months; - int weeks; - int days; - int hours; - int minutes; - int seconds; - int microseconds; - - if (!PyArg_ParseTuple(args, "iiiiiiii", &years, &months, &weeks, &days, &hours, &minutes, &seconds, µseconds)) - return -1; - - self->years = years; - self->months = months; - self->weeks = weeks; - self->days = days; - self->hours = hours; - self->minutes = minutes; - self->seconds = seconds; - self->microseconds = microseconds; - - return 0; -} - -/* - * def __repr__(self): - * return '{} years {} months {} days {} hours {} minutes {} seconds {} microseconds'.format( - * self.years, self.months, self.days, self.minutes, self.hours, self.seconds, self.microseconds - * ) - */ -static PyObject *Duration_repr(Duration *self) { - return PyUnicode_FromFormat( - "%d years %d months %d weeks %d days %d hours %d minutes %d seconds %d microseconds", - self->years, - self->months, - self->weeks, - self->days, - self->hours, - self->minutes, - self->seconds, - self->microseconds - ); -} - -/* - * Instantiate new Duration_type object - * Skip overhead of calling PyObject_New and PyObject_Init. - * Directly allocate object. - */ -static PyObject *new_duration_ex(int years, int months, int weeks, int days, int hours, int minutes, int seconds, int microseconds, PyTypeObject *type) { - Duration *self = (Duration *) (type->tp_alloc(type, 0)); - - if (self != NULL) { - self->years = years; - self->months = months; - self->weeks = weeks; - self->days = days; - self->hours = hours; - self->minutes = minutes; - self->seconds = seconds; - self->microseconds = microseconds; - } - - return (PyObject *) self; -} - -/* - * Class member / class attributes - */ -static PyMemberDef Duration_members[] = { - {"years", T_INT, offsetof(Duration, years), 0, "years in duration"}, - {"months", T_INT, offsetof(Duration, months), 0, "months in duration"}, - {"weeks", T_INT, offsetof(Duration, weeks), 0, "weeks in duration"}, - {"days", T_INT, offsetof(Duration, days), 0, "days in duration"}, - {"remaining_days", T_INT, offsetof(Duration, days), 0, "days in duration"}, - {"hours", T_INT, offsetof(Duration, hours), 0, "hours in duration"}, - {"minutes", T_INT, offsetof(Duration, minutes), 0, "minutes in duration"}, - {"seconds", T_INT, offsetof(Duration, seconds), 0, "seconds in duration"}, - {"remaining_seconds", T_INT, offsetof(Duration, seconds), 0, "seconds in duration"}, - {"microseconds", T_INT, offsetof(Duration, microseconds), 0, "microseconds in duration"}, - {NULL} -}; - -static PyTypeObject Duration_type = { - PyVarObject_HEAD_INIT(NULL, 0) - "Duration", /* tp_name */ - sizeof(Duration), /* tp_basicsize */ - 0, /* tp_itemsize */ - 0, /* tp_dealloc */ - 0, /* tp_print */ - 0, /* tp_getattr */ - 0, /* tp_setattr */ - 0, /* tp_as_async */ - (reprfunc)Duration_repr, /* tp_repr */ - 0, /* tp_as_number */ - 0, /* tp_as_sequence */ - 0, /* tp_as_mapping */ - 0, /* tp_hash */ - 0, /* tp_call */ - (reprfunc)Duration_repr, /* tp_str */ - 0, /* tp_getattro */ - 0, /* tp_setattro */ - 0, /* tp_as_buffer */ - Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE, /* tp_flags */ - "Duration", /* tp_doc */ -}; - -#define new_duration(years, months, weeks, days, hours, minutes, seconds, microseconds) new_duration_ex(years, months, weeks, days, hours, minutes, seconds, microseconds, &Duration_type) - -typedef struct { - int is_date; - int is_time; - int is_datetime; - int is_duration; - int is_period; - int ambiguous; - int year; - int month; - int day; - int hour; - int minute; - int second; - int microsecond; - int offset; - int has_offset; - char *tzname; - int years; - int months; - int weeks; - int days; - int hours; - int minutes; - int seconds; - int microseconds; - int error; -} Parsed; - - -Parsed* new_parsed() { - Parsed *parsed; - - if((parsed = malloc(sizeof *parsed)) != NULL) { - parsed->is_date = 0; - parsed->is_time = 0; - parsed->is_datetime = 0; - parsed->is_duration = 0; - parsed->is_period = 0; - - parsed->ambiguous = 0; - parsed->year = 0; - parsed->month = 1; - parsed->day = 1; - parsed->hour = 0; - parsed->minute = 0; - parsed->second = 0; - parsed->microsecond = 0; - parsed->offset = 0; - parsed->has_offset = 0; - parsed->tzname = NULL; - - parsed->years = 0; - parsed->months = 0; - parsed->weeks = 0; - parsed->days = 0; - parsed->hours = 0; - parsed->minutes = 0; - parsed->seconds = 0; - parsed->microseconds = 0; - - parsed->error = -1; - } - - return parsed; -} - - -/* -------------------------- Functions --------------------------*/ - -Parsed* _parse_iso8601_datetime(char *str, Parsed *parsed) { - char* c; - int monthday = 0; - int week = 0; - int weekday = 1; - int ordinal; - int tz_sign = 0; - int leap = 0; - int separators = 0; - int time = 0; - int i; - int j; - - // Assuming date only for now - parsed->is_date = 1; - - c = str; - - for (i = 0; i < 4; i++) { - if (*c >= '0' && *c <= '9') { - parsed->year = 10 * parsed->year + *c++ - '0'; - } else { - parsed->error = PARSER_INVALID_ISO8601; - - return NULL; - } - } - - leap = is_leap(parsed->year); - - // Optional separator - if (*c == '-') { - separators++; - c++; - } - - // Checking for week dates - if (*c == 'W') { - c++; - - i = 0; - while (*c != '\0' && *c != ' ' && *c != 'T') { - if (*c == '-') { - separators++; - c++; - continue; - } - - week = 10 * week + *c++ - '0'; - - i++; - } - - switch (i) { - case 2: - // Only week number - break; - case 3: - // Week with weekday - if (!(separators == 0 || separators == 2)) { - // We should have 2 or no separator - parsed->error = PARSER_INVALID_WEEK_DATE; - - return NULL; - } - - weekday = week % 10; - week /= 10; - - break; - default: - // Any other case is wrong - parsed->error = PARSER_INVALID_WEEK_DATE; - - return NULL; - } - - // Checks - if (week > 53 || (week > 52 && !is_long_year(parsed->year))) { - parsed->error = PARSER_INVALID_WEEK_NUMBER; - - return NULL; - } - - if (weekday > 7) { - parsed->error = PARSER_INVALID_WEEKDAY_NUMBER; - - return NULL; - } - - // Calculating ordinal day - ordinal = week * 7 + weekday - (week_day(parsed->year, 1, 4) + 3); - - if (ordinal < 1) { - // Previous year - ordinal += days_in_year(parsed->year - 1); - parsed->year -= 1; - leap = is_leap(parsed->year); - } - - if (ordinal > days_in_year(parsed->year)) { - // Next year - ordinal -= days_in_year(parsed->year); - parsed->year += 1; - leap = is_leap(parsed->year); - } - - for (j = 1; j < 14; j++) { - if (ordinal <= MONTHS_OFFSETS[leap][j]) { - parsed->day = ordinal - MONTHS_OFFSETS[leap][j - 1]; - parsed->month = j - 1; - - break; - } - } - } else { - // At this point we need to check the number - // of characters until the end of the date part - // (or the end of the string). - // - // If two, we have only a month if there is a separator, it may be a time otherwise. - // If three, we have an ordinal date. - // If four, we have a complete date - i = 0; - while (*c != '\0' && *c != ' ' && *c != 'T') { - if (*c == '-') { - separators++; - c++; - continue; - } - - if (!(*c >= '0' && *c <='9')) { - parsed->error = PARSER_INVALID_DATE; - - return NULL; - } - - monthday = 10 * monthday + *c++ - '0'; - - i++; - } - - switch (i) { - case 0: - // No month/day specified (only a year) - break; - case 2: - if (!separators) { - // The date looks like 201207 - // which is invalid for a date - // But it might be a time in the form hhmmss - parsed->ambiguous = 1; - } else if (separators > 1) { - parsed->error = PARSER_INVALID_DATE; - - return NULL; - } - - parsed->month = monthday; - break; - case 3: - // Ordinal day - if (separators > 1) { - parsed->error = PARSER_INVALID_DATE; - - return NULL; - } - - if (monthday < 1 || monthday > MONTHS_OFFSETS[leap][13]) { - parsed->error = PARSER_INVALID_ORDINAL_DAY_FOR_YEAR; - - return NULL; - } - - for (j = 1; j < 14; j++) { - if (monthday <= MONTHS_OFFSETS[leap][j]) { - parsed->day = monthday - MONTHS_OFFSETS[leap][j - 1]; - parsed->month = j - 1; - - break; - } - } - - break; - case 4: - // Month and day - parsed->month = monthday / 100; - parsed->day = monthday % 100; - - break; - default: - parsed->error = PARSER_INVALID_MONTH_OR_DAY; - - return NULL; - } - } - - // Checks - if (separators && !monthday && !week) { - parsed->error = PARSER_INVALID_DATE; - - return NULL; - } - - if (parsed->month > 12) { - parsed->error = PARSER_INVALID_MONTH; - - return NULL; - } - - if (parsed->day > DAYS_PER_MONTHS[leap][parsed->month]) { - parsed->error = PARSER_INVALID_DAY_FOR_MONTH; - - return NULL; - } - - separators = 0; - if (*c == 'T' || *c == ' ') { - if (parsed->ambiguous) { - parsed->error = PARSER_INVALID_DATE; - - return NULL; - } - - // We have time so we have a datetime - parsed->is_datetime = 1; - parsed->is_date = 0; - - c++; - - // Grabbing time information - i = 0; - while (*c != '\0' && *c != '.' && *c != ',' && *c != 'Z' && *c != '+' && *c != '-') { - if (*c == ':') { - separators++; - c++; - continue; - } - - if (!(*c >= '0' && *c <='9')) { - parsed->error = PARSER_INVALID_TIME; - - return NULL; - } - - time = 10 * time + *c++ - '0'; - i++; - } - - switch (i) { - case 2: - // Hours only - if (separators > 0) { - // Extraneous separators - parsed->error = PARSER_INVALID_TIME; - - return NULL; - } - - parsed->hour = time; - break; - case 4: - // Hours and minutes - if (separators > 1) { - // Extraneous separators - parsed->error = PARSER_INVALID_TIME; - - return NULL; - } - - parsed->hour = time / 100; - parsed->minute = time % 100; - break; - case 6: - // Hours, minutes and seconds - if (!(separators == 0 || separators == 2)) { - // We should have either two separators or none - parsed->error = PARSER_INVALID_TIME; - - return NULL; - } - - parsed->hour = time / 10000; - parsed->minute = time / 100 % 100; - parsed->second = time % 100; - break; - default: - // Any other case is wrong - parsed->error = PARSER_INVALID_TIME; - - return NULL; - } - - // Checks - if (parsed->hour > 23) { - parsed->error = PARSER_INVALID_HOUR; - - return NULL; - } - - if (parsed->minute > 59) { - parsed->error = PARSER_INVALID_MINUTE; - - return NULL; - } - - if (parsed->second > 59) { - parsed->error = PARSER_INVALID_SECOND; - - return NULL; - } - - // Subsecond - if (*c == '.' || *c == ',') { - c++; - - time = 0; - i = 0; - while (*c != '\0' && *c != 'Z' && *c != '+' && *c != '-') { - if (!(*c >= '0' && *c <='9')) { - parsed->error = PARSER_INVALID_SUBSECOND; - - return NULL; - } - - time = 10 * time + *c++ - '0'; - i++; - } - - // adjust to microseconds - if (i > 6) { - parsed->microsecond = time / pow(10, i - 6); - } else if (i <= 6) { - parsed->microsecond = time * pow(10, 6 - i); - } - } - - // Timezone - if (*c == 'Z') { - parsed->has_offset = 1; - parsed->tzname = "UTC"; - c++; - } else if (*c == '+' || *c == '-') { - tz_sign = 1; - if (*c == '-') { - tz_sign = -1; - } - - parsed->has_offset = 1; - c++; - - i = 0; - time = 0; - separators = 0; - while (*c != '\0') { - if (*c == ':') { - separators++; - c++; - continue; - } - - if (!(*c >= '0' && *c <= '9')) { - parsed->error = PARSER_INVALID_TZ_OFFSET; - - return NULL; - } - - time = 10 * time + *c++ - '0'; - i++; - } - - switch (i) { - case 2: - // hh Format - if (separators) { - // Extraneous separators - parsed->error = PARSER_INVALID_TZ_OFFSET; - - return NULL; - } - - parsed->offset = tz_sign * (time * 3600); - break; - case 4: - // hhmm Format - if (separators > 1) { - // Extraneous separators - parsed->error = PARSER_INVALID_TZ_OFFSET; - - return NULL; - } - - parsed->offset = tz_sign * ((time / 100 * 3600) + (time % 100 * 60)); - break; - default: - // Wrong format - parsed->error = PARSER_INVALID_TZ_OFFSET; - - return NULL; - } - } - } - - // At this point we should be at the end of the string - // If not, the string is invalid - if (*c != '\0') { - parsed->error = PARSER_INVALID_ISO8601; - - return NULL; - } - - return parsed; -} - - -Parsed* _parse_iso8601_duration(char *str, Parsed *parsed) { - char* c; - int value = 0; - int grabbed = 0; - int in_time = 0; - int in_fraction = 0; - int fraction_length = 0; - int has_fractional = 0; - int fraction = 0; - int has_ymd = 0; - int has_week = 0; - int has_month = 0; - int has_day = 0; - int has_hour = 0; - int has_minute = 0; - int has_second = 0; - - c = str; - - // Removing P operator - c++; - - parsed->is_duration = 1; - - for (; *c != '\0'; c++) { - switch (*c) { - case 'Y': - if (!grabbed || in_time || has_week || has_ymd) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (fraction) { - parsed->error = PARSER_INVALID_DURATION_FLOAT_YEAR_MONTH_NOT_SUPPORTED; - - return NULL; - } - - parsed->years = value; - - grabbed = 0; - value = 0; - fraction = 0; - in_fraction = 0; - has_ymd = 1; - - break; - case 'M': - if (!grabbed || has_week) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (in_time) { - if (has_second) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (has_fractional) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - parsed->minutes = value; - if (fraction) { - parsed->seconds = fraction * 6; - has_fractional = 1; - } - - has_minute = 1; - } else { - if (fraction) { - parsed->error = PARSER_INVALID_DURATION_FLOAT_YEAR_MONTH_NOT_SUPPORTED; - - return NULL; - } - - if (has_month || has_day) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - parsed->months = value; - has_ymd = 1; - has_month = 1; - } - - grabbed = 0; - value = 0; - fraction = 0; - in_fraction = 0; - - break; - case 'D': - if (!grabbed || in_time || has_week) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (has_day) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - parsed->days = value; - if (fraction) { - parsed->hours = fraction * 2.4; - has_fractional = 1; - } - - grabbed = 0; - value = 0; - fraction = 0; - in_fraction = 0; - has_ymd = 1; - has_day = 1; - - break; - case 'T': - if (grabbed) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - in_time = 1; - - break; - case 'H': - if (!grabbed || !in_time || has_week) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (has_hour || has_second || has_minute) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (has_fractional) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - parsed->hours = value; - if (fraction) { - parsed->minutes = fraction * 6; - has_fractional = 1; - } - - grabbed = 0; - value = 0; - fraction = 0; - in_fraction = 0; - has_hour = 1; - - break; - case 'S': - if (!grabbed || !in_time || has_week) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (has_second) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (has_fractional) { - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - if (fraction) { - parsed->seconds = value; - if (fraction_length > 6) { - parsed->microseconds = fraction / pow(10, fraction_length - 6); - } else { - parsed->microseconds = fraction * pow(10, 6 - fraction_length); - } - has_fractional = 1; - } else { - parsed->seconds = value; - } - - grabbed = 0; - value = 0; - fraction = 0; - in_fraction = 0; - has_second = 1; - - break; - case 'W': - if (!grabbed || in_time || has_ymd) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - parsed->weeks = value; - if (fraction) { - float days; - days = fraction * 0.7; - parsed->hours = (int) ((days - (int) days) * 24); - parsed->days = (int) days; - } - - grabbed = 0; - value = 0; - fraction = 0; - in_fraction = 0; - has_week = 1; - - break; - case '.': - if (!grabbed || has_fractional) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - in_fraction = 1; - - break; - case ',': - if (!grabbed || has_fractional) { - // No value grabbed - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - - in_fraction = 1; - - break; - default: - if (*c >= '0' && *c <='9') { - if (in_fraction) { - fraction = 10 * fraction + *c - '0'; - fraction_length++; - } else { - value = 10 * value + *c - '0'; - grabbed = 1; - } - break; - } - - parsed->error = PARSER_INVALID_DURATION; - - return NULL; - } - } - - return parsed; -} - - -PyObject* parse_iso8601(PyObject *self, PyObject *args) { - char* str; - PyObject *obj; - PyObject *tzinfo; - Parsed *parsed = new_parsed(); - - if (!PyArg_ParseTuple(args, "s", &str)) { - PyErr_SetString( - PyExc_ValueError, "Invalid parameters" - ); - free(parsed); - return NULL; - } - - if (*str == 'P') { - // Duration (or interval) - if (_parse_iso8601_duration(str, parsed) == NULL) { - PyErr_SetString( - PyExc_ValueError, PARSER_ERRORS[parsed->error] - ); - - free(parsed); - return NULL; - } - } else if (_parse_iso8601_datetime(str, parsed) == NULL) { - PyErr_SetString( - PyExc_ValueError, PARSER_ERRORS[parsed->error] - ); - - free(parsed); - return NULL; - } - - if (parsed->is_date) { - // Date only - if (parsed->ambiguous) { - // We can "safely" assume that the ambiguous - // date was actually a time in the form hhmmss - parsed->hour = parsed->year / 100; - parsed->minute = parsed->year % 100; - parsed->second = parsed->month; - - obj = PyDateTimeAPI->Time_FromTime( - parsed->hour, parsed->minute, parsed->second, parsed->microsecond, - Py_BuildValue(""), - PyDateTimeAPI->TimeType - ); - } else { - obj = PyDateTimeAPI->Date_FromDate( - parsed->year, parsed->month, parsed->day, - PyDateTimeAPI->DateType - ); - } - } else if (parsed->is_datetime) { - if (!parsed->has_offset) { - tzinfo = Py_BuildValue(""); - } else { - tzinfo = new_fixed_offset(parsed->offset, parsed->tzname); - } - - obj = PyDateTimeAPI->DateTime_FromDateAndTime( - parsed->year, - parsed->month, - parsed->day, - parsed->hour, - parsed->minute, - parsed->second, - parsed->microsecond, - tzinfo, - PyDateTimeAPI->DateTimeType - ); - - Py_DECREF(tzinfo); - } else if (parsed->is_duration) { - obj = new_duration( - parsed->years, parsed->months, parsed->weeks, parsed->days, - parsed->hours, parsed->minutes, parsed->seconds, parsed->microseconds - ); - } else { - free(parsed); - return NULL; - } - - free(parsed); - - return obj; -} - - -/* ------------------------------------------------------------------------- */ - -static PyMethodDef helpers_methods[] = { - { - "parse_iso8601", - (PyCFunction) parse_iso8601, - METH_VARARGS, - PyDoc_STR("Parses a ISO8601 string into a tuple.") - }, - {NULL} -}; - - -/* ------------------------------------------------------------------------- */ - -static struct PyModuleDef moduledef = { - PyModuleDef_HEAD_INIT, - "_iso8601", - NULL, - -1, - helpers_methods, - NULL, - NULL, - NULL, - NULL, -}; - -PyMODINIT_FUNC -PyInit__iso8601(void) -{ - PyObject *module; - - PyDateTime_IMPORT; - - module = PyModule_Create(&moduledef); - - if (module == NULL) - return NULL; - - // FixedOffset declaration - FixedOffset_type.tp_new = PyType_GenericNew; - FixedOffset_type.tp_base = PyDateTimeAPI->TZInfoType; - FixedOffset_type.tp_methods = FixedOffset_methods; - FixedOffset_type.tp_members = FixedOffset_members; - FixedOffset_type.tp_init = (initproc)FixedOffset_init; - - if (PyType_Ready(&FixedOffset_type) < 0) - return NULL; - - // Duration declaration - Duration_type.tp_new = PyType_GenericNew; - Duration_type.tp_members = Duration_members; - Duration_type.tp_init = (initproc)Duration_init; - - if (PyType_Ready(&Duration_type) < 0) - return NULL; - - Py_INCREF(&FixedOffset_type); - Py_INCREF(&Duration_type); - - PyModule_AddObject(module, "TZFixedOffset", (PyObject *)&FixedOffset_type); - PyModule_AddObject(module, "Duration", (PyObject *)&Duration_type); - - return module; -} diff --git a/pendulum/parsing/_iso8601.pyi b/pendulum/parsing/_iso8601.pyi deleted file mode 100644 index 761fe6b3..00000000 --- a/pendulum/parsing/_iso8601.pyi +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from datetime import date -from datetime import datetime -from datetime import time - -class Duration: - years: int = 0 - months: int = 0 - weeks: int = 0 - days: int = 0 - remaining_days: int = 0 - hours: int = 0 - minutes: int = 0 - seconds: int = 0 - remaining_seconds: int = 0 - microseconds: int = 0 - -def parse_iso8601( - text: str, -) -> datetime | date | time | Duration: ... diff --git a/pendulum/parsing/iso8601.py b/pendulum/parsing/iso8601.py index e5a98688..cc4dd7aa 100644 --- a/pendulum/parsing/iso8601.py +++ b/pendulum/parsing/iso8601.py @@ -255,7 +255,7 @@ def parse_iso8601( tzinfo = FixedTimezone(offset) if is_time: - return datetime.time(hour, minute, second, microsecond) + return datetime.time(hour, minute, second, microsecond, tzinfo=tzinfo) return datetime.datetime( year, month, day, hour, minute, second, microsecond, tzinfo=tzinfo diff --git a/poetry.lock b/poetry.lock index c74a55c5..4addbfb4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -421,22 +421,6 @@ files = [ {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] -[[package]] -name = "meson" -version = "0.63.2" -description = "A high performance build system" -optional = false -python-versions = ">=3.7" -files = [ - {file = "meson-0.63.2-py3-none-any.whl", hash = "sha256:64a83ef257b2962b52c8b07ad9ec536c2de1b72fd9f14bcd9c21fe45730edd46"}, - {file = "meson-0.63.2.tar.gz", hash = "sha256:16222f17ef76be0542c91c07994f9676ae879f46fc21c0c786a21ef2cb518bbf"}, -] - -[package.extras] -ninja = ["ninja (>=1.8.2)"] -progress = ["tqdm"] -typing = ["mypy", "typing-extensions"] - [[package]] name = "mkdocs" version = "1.3.0" @@ -521,32 +505,6 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] -[[package]] -name = "ninja" -version = "1.10.2.3" -description = "Ninja is a small build system with a focus on speed" -optional = false -python-versions = "*" -files = [ - {file = "ninja-1.10.2.3-py2.py3-none-macosx_10_9_universal2.macosx_10_9_x86_64.macosx_11_0_arm64.macosx_11_0_universal2.whl", hash = "sha256:d5e0275d28997a750a4f445c00bdd357b35cc334c13cdff13edf30e544704fbd"}, - {file = "ninja-1.10.2.3-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ea785bf6a15727040835256577239fa3cf5da0d60e618c307aa5efc31a1f0ce"}, - {file = "ninja-1.10.2.3-py2.py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29570a18d697fc84d361e7e6330f0021f34603ae0fcb0ef67ae781e9814aae8d"}, - {file = "ninja-1.10.2.3-py2.py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a1d84d4c7df5881bfd86c25cce4cf7af44ba2b8b255c57bc1c434ec30a2dfc"}, - {file = "ninja-1.10.2.3-py2.py3-none-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9ca8dbece144366d5f575ffc657af03eb11c58251268405bc8519d11cf42f113"}, - {file = "ninja-1.10.2.3-py2.py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:279836285975e3519392c93c26e75755e8a8a7fafec9f4ecbb0293119ee0f9c6"}, - {file = "ninja-1.10.2.3-py2.py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cc8b31b5509a2129e4d12a35fc21238c157038022560aaf22e49ef0a77039086"}, - {file = "ninja-1.10.2.3-py2.py3-none-musllinux_1_1_i686.whl", hash = "sha256:688167841b088b6802e006f911d911ffa925e078c73e8ef2f88286107d3204f8"}, - {file = "ninja-1.10.2.3-py2.py3-none-musllinux_1_1_ppc64le.whl", hash = "sha256:840a0b042d43a8552c4004966e18271ec726e5996578f28345d9ce78e225b67e"}, - {file = "ninja-1.10.2.3-py2.py3-none-musllinux_1_1_s390x.whl", hash = "sha256:84be6f9ec49f635dc40d4b871319a49fa49b8d55f1d9eae7cd50d8e57ddf7a85"}, - {file = "ninja-1.10.2.3-py2.py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6bd76a025f26b9ae507cf8b2b01bb25bb0031df54ed685d85fc559c411c86cf4"}, - {file = "ninja-1.10.2.3-py2.py3-none-win32.whl", hash = "sha256:740d61fefb4ca13573704ee8fe89b973d40b8dc2a51aaa4e9e68367233743bb6"}, - {file = "ninja-1.10.2.3-py2.py3-none-win_amd64.whl", hash = "sha256:0560eea57199e41e86ac2c1af0108b63ae77c3ca4d05a9425a750e908135935a"}, - {file = "ninja-1.10.2.3.tar.gz", hash = "sha256:e1b86ad50d4e681a7dbdff05fc23bb52cb773edb90bc428efba33fa027738408"}, -] - -[package.extras] -test = ["codecov (>=2.0.5)", "coverage (>=4.2)", "flake8 (>=3.0.4)", "pytest (>=4.5.0)", "pytest-cov (>=2.7.1)", "pytest-runner (>=5.1)", "pytest-virtualenv (>=1.7.0)", "virtualenv (>=15.0.3)"] - [[package]] name = "nodeenv" version = "1.7.0" @@ -655,7 +613,7 @@ name = "pycparser" version = "2.21" description = "C parser in Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = "*" files = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, @@ -1237,4 +1195,4 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "7a4cee3a29551de99a31bcf181c7a90cdf9c2979fb3cfbb3a3981d6a0b1c7488" +content-hash = "658e22a0828244e01d1639cbacfbaa84a6ad345aade89e5e28722fb39401db37" diff --git a/pyproject.toml b/pyproject.toml index 062eea71..f5c5e715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,13 +15,14 @@ packages = [ { include = "tests", format = "sdist" }, ] include = [ - { path = "meson.build", format = "sdist" }, { path = "pendulum/py.typed" }, - # C extensions must be included in the wheel distributions - { path = "pendulum/_extensions/*.so", format = "wheel" }, - { path = "pendulum/_extensions/*.pyd", format = "wheel" }, - { path = "pendulum/parsing/*.so", format = "wheel" }, - { path = "pendulum/parsing/*.pyd", format = "wheel" }, + # Typing stubs + { path = "*.pyi"}, + # Rust source + { path = "rust", format = "sdist" }, + # Rust extension + { path = "_pendulum*.so", format = "wheel" }, + { path = "_pendulum*.pyd", format = "wheel" }, ] @@ -58,13 +59,6 @@ babel = "^2.10.3" cleo = ">=1,<3" tox = "^3.25.1" -[tool.poetry.group.build] -optional = true - -[tool.poetry.group.build.dependencies] -meson = "^0.63.2" -ninja = "^1.10.2.3" - [tool.poetry.group.benchmark.dependencies] pytest-codspeed = "^1.2.2" @@ -220,5 +214,5 @@ omit = [ ] [build-system] -requires = ["poetry-core>=1.1.0a6", "meson", "ninja"] +requires = ["poetry-core>=1.6.1", "maturin>=1,<2"] build-backend = "poetry.core.masonry.api" diff --git a/rust/constants.rs b/rust/constants.rs new file mode 100644 index 00000000..3fea9c02 --- /dev/null +++ b/rust/constants.rs @@ -0,0 +1,56 @@ +pub const EPOCH_YEAR: u32 = 1970; + +pub const DAYS_PER_N_YEAR: u32 = 365; +pub const DAYS_PER_L_YEAR: u32 = 366; + +pub const SECS_PER_MIN: u32 = 60; +pub const SECS_PER_HOUR: u32 = SECS_PER_MIN * 60; +pub const SECS_PER_DAY: u32 = SECS_PER_HOUR * 24; + +// 400-year chunks always have 146097 days (20871 weeks). +pub const DAYS_PER_400_YEARS: u32 = 146_097; +pub const SECS_PER_400_YEARS: u64 = DAYS_PER_400_YEARS as u64 * SECS_PER_DAY as u64; + +// The number of seconds in an aligned 100-year chunk, for those that +// do not begin with a leap year and those that do respectively. +pub const SECS_PER_100_YEARS: [u64; 2] = [ + (76 * DAYS_PER_N_YEAR as u64 + 24 * DAYS_PER_L_YEAR as u64) * SECS_PER_DAY as u64, + (75 * DAYS_PER_N_YEAR as u64 + 25 * DAYS_PER_L_YEAR as u64) * SECS_PER_DAY as u64, +]; + +// The number of seconds in an aligned 4-year chunk, for those that +// do not begin with a leap year and those that do respectively. +#[allow(clippy::erasing_op)] +pub const SECS_PER_4_YEARS: [u32; 2] = [ + (4 * DAYS_PER_N_YEAR + 0 * DAYS_PER_L_YEAR) * SECS_PER_DAY, + (3 * DAYS_PER_N_YEAR + DAYS_PER_L_YEAR) * SECS_PER_DAY, +]; + +// The number of seconds in non-leap and leap years respectively. +pub const SECS_PER_YEAR: [u32; 2] = [ + DAYS_PER_N_YEAR * SECS_PER_DAY, + DAYS_PER_L_YEAR * SECS_PER_DAY, +]; + +// The month lengths in non-leap and leap years respectively. +pub const DAYS_PER_MONTHS: [[i32; 13]; 2] = [ + [-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], + [-1, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], +]; + +// The day offsets of the beginning of each (1-based) month in non-leap +// and leap years respectively. +// For example, in a leap year there are 335 days before December. +pub const MONTHS_OFFSETS: [[i32; 14]; 2] = [ + [ + -1, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365, + ], + [ + -1, 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366, + ], +]; + +pub const DAY_OF_WEEK_TABLE: [u32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + +pub const TM_JANUARY: usize = 0; +pub const TM_DECEMBER: usize = 11; diff --git a/rust/helpers.rs b/rust/helpers.rs new file mode 100644 index 00000000..364075ac --- /dev/null +++ b/rust/helpers.rs @@ -0,0 +1,122 @@ +use crate::constants::{ + DAYS_PER_L_YEAR, DAYS_PER_N_YEAR, DAY_OF_WEEK_TABLE, EPOCH_YEAR, MONTHS_OFFSETS, + SECS_PER_100_YEARS, SECS_PER_400_YEARS, SECS_PER_4_YEARS, SECS_PER_DAY, SECS_PER_HOUR, + SECS_PER_MIN, SECS_PER_YEAR, TM_DECEMBER, TM_JANUARY, +}; + +fn p(year: i32) -> i32 { + year + year / 4 - year / 100 + year / 400 +} + +pub fn is_leap(year: i32) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} + +pub fn is_long_year(year: i32) -> bool { + (p(year) % 7 == 4) || (p(year - 1) % 7 == 3) +} + +pub fn days_in_year(year: i32) -> u32 { + if is_leap(year) { + return DAYS_PER_L_YEAR; + } + + DAYS_PER_N_YEAR +} + +pub fn week_day(year: i32, month: u32, day: u32) -> u32 { + let y: i32 = year - i32::from(month < 3); + + let w: i32 = (p(y) + DAY_OF_WEEK_TABLE[(month - 1) as usize] as i32 + day as i32) % 7; + + if w == 0 { + return 7; + } + + w.unsigned_abs() +} + +pub fn day_number(year: i32, month: u8, day: u8) -> i32 { + let m = i32::from((month + 9) % 12); + let y = year - m / 10; + + 365 * y + y / 4 - y / 100 + y / 400 + (m * 306 + 5) / 10 + (i32::from(day) - 1) +} + +pub fn local_time( + unix_time: f64, + utc_offset: isize, + microsecond: usize, +) -> (usize, usize, usize, usize, usize, usize, usize) { + let mut year: usize = EPOCH_YEAR as usize; + let mut seconds: isize = unix_time.floor() as isize; + + // Shift to a base year that is 400-year aligned. + if seconds >= 0 { + seconds -= (10957 * SECS_PER_DAY as usize) as isize; + year += 30; // == 2000 + } else { + seconds += ((146_097 - 10957) * SECS_PER_DAY as usize) as isize; + year -= 370; // == 1600 + } + + seconds += utc_offset; + + // Handle years in chunks of 400/100/4/1 + year += 400 * (seconds / SECS_PER_400_YEARS as isize) as usize; + seconds %= SECS_PER_400_YEARS as isize; + if seconds < 0 { + seconds += SECS_PER_400_YEARS as isize; + year -= 400; + } + + let mut leap_year = 1; // 4-century aligned + let mut sec_per_100years = SECS_PER_100_YEARS[leap_year] as isize; + + while seconds >= sec_per_100years { + seconds -= sec_per_100years; + year += 100; + leap_year = 0; // 1-century, non 4-century aligned + sec_per_100years = SECS_PER_100_YEARS[leap_year] as isize; + } + + let mut sec_per_4years = SECS_PER_4_YEARS[leap_year] as isize; + while seconds >= sec_per_4years { + seconds -= sec_per_4years; + year += 4; + leap_year = 1; // 4-year, non century aligned + sec_per_4years = SECS_PER_4_YEARS[leap_year] as isize; + } + + let mut sec_per_year = SECS_PER_YEAR[leap_year] as isize; + while seconds >= sec_per_year { + seconds -= sec_per_year; + year += 1; + leap_year = 0; // non 4-year aligned + sec_per_year = SECS_PER_YEAR[leap_year] as isize; + } + + // Handle months and days + let mut month = TM_DECEMBER + 1; + let mut day: usize = (seconds / (SECS_PER_DAY as isize) + 1) as usize; + seconds %= SECS_PER_DAY as isize; + + let mut month_offset: usize; + while month != (TM_JANUARY + 1) { + month_offset = MONTHS_OFFSETS[leap_year][month] as usize; + if day > month_offset { + day -= month_offset; + break; + } + + month -= 1; + } + + // Handle hours, minutes and seconds + let hour: usize = (seconds / SECS_PER_HOUR as isize) as usize; + seconds %= SECS_PER_HOUR as isize; + let minute: usize = (seconds / SECS_PER_MIN as isize) as usize; + let second: usize = (seconds % SECS_PER_MIN as isize) as usize; + + (year, month, day, hour, minute, second, microsecond) +} diff --git a/rust/lib.rs b/rust/lib.rs new file mode 100644 index 00000000..bd0f1f6f --- /dev/null +++ b/rust/lib.rs @@ -0,0 +1,12 @@ +extern crate core; + +#[cfg(feature = "mimalloc")] +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +mod constants; +mod helpers; +mod parsing; +mod python; + +pub use python::_pendulum; diff --git a/rust/parsing.rs b/rust/parsing.rs new file mode 100644 index 00000000..757a3e3b --- /dev/null +++ b/rust/parsing.rs @@ -0,0 +1,905 @@ +use core::str; +use std::{fmt, str::CharIndices}; + +use crate::{ + constants::MONTHS_OFFSETS, + helpers::{days_in_year, is_leap, is_long_year, week_day}, +}; + +#[derive(Debug, Clone)] +pub struct ParseError { + index: usize, + message: String, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} (Position: {})", self.message, self.index) + } +} + +pub struct ParsedDateTime { + pub year: u32, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + pub second: u32, + pub microsecond: u32, + pub offset: Option, + pub has_offset: bool, + pub tzname: Option, + pub has_date: bool, + pub has_time: bool, + pub extended_date_format: bool, + pub time_is_midnight: bool, +} + +impl ParsedDateTime { + pub fn new() -> ParsedDateTime { + ParsedDateTime { + year: 0, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + microsecond: 0, + offset: None, + has_offset: false, + tzname: None, + has_date: false, + has_time: false, + extended_date_format: false, + time_is_midnight: false, + } + } +} + +pub struct ParsedDuration { + pub years: u32, + pub months: u32, + pub weeks: u32, + pub days: u32, + pub hours: u32, + pub minutes: u32, + pub seconds: u32, + pub microseconds: u32, +} + +impl ParsedDuration { + pub fn new() -> ParsedDuration { + ParsedDuration { + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + microseconds: 0, + } + } +} + +pub struct Parsed { + pub datetime: Option, + pub duration: Option, + pub second_datetime: Option, +} + +impl Parsed { + pub fn new() -> Parsed { + Parsed { + datetime: None, + duration: None, + second_datetime: None, + } + } +} + +pub struct Parser<'a> { + /// Input to parse. + src: &'a str, + /// Iterator used for getting characters from `src`. + chars: CharIndices<'a>, + /// Current byte offset into `src`. + idx: usize, + /// Current character + current: char, +} + +impl<'a> Parser<'a> { + /// Creates a new parser from a &str. + pub fn new(input: &'a str) -> Parser<'a> { + let mut p = Parser { + src: input, + chars: input.char_indices(), + idx: 0, + current: '\0', + }; + p.inc(); + p + } + + /// Increments the parser if the end of the input has not been reached. + /// Returns whether or not it was able to advance. + fn inc(&mut self) -> Option { + if let Some((i, ch)) = self.chars.next() { + self.idx = i; + self.current = ch; + Some(ch) + } else { + self.idx = self.src.len(); + self.current = '\0'; + None + } + } + + fn parse_error(&mut self, message: String) -> ParseError { + ParseError { + index: self.idx, + message, + } + } + + fn unexpected_character_error( + &mut self, + field_name: &str, + expected_character_count: usize, + ) -> ParseError { + if self.end() { + return self.parse_error(format!( + "Unexpected end of string while parsing {}. Expected {} more character{}.", + field_name, + expected_character_count, + if expected_character_count == 1 { + "" + } else { + "s" + } + )); + } + + self.parse_error(format!( + "Invalid character while parsing {}: {}.", + field_name, self.current, + )) + } + + /// Returns true if the parser has reached the end of the input. + fn end(&self) -> bool { + self.idx >= self.src.len() + } + + fn parse_integer(&mut self, length: usize, field_name: &str) -> Result { + let mut value: u32 = 0; + + for i in 0..length { + if self.end() { + return Err(self.parse_error(format!( + "Unexpected end of string while parsing \"{}\". Expected {} more character{}", + field_name, + length - i, + if (length - i) != 1 { "s" } else { "" } + ))); + } + + if let Some(digit) = self.current.to_digit(10) { + value = 10 * value + digit; + self.inc(); + } else { + return Err(self.unexpected_character_error(field_name, length - i)); + } + } + + Ok(value) + } + + pub fn parse(&mut self) -> Result { + let mut parsed = Parsed::new(); + + if self.current == 'P' { + // Duration (and possibly time interval) + self.parse_duration(&mut parsed)?; + } else { + self.parse_datetime(&mut parsed)?; + } + + Ok(parsed) + } + + fn parse_datetime(&mut self, parsed: &mut Parsed) -> Result<(), ParseError> { + let mut datetime = ParsedDateTime::new(); + + if self.current == 'T' { + self.parse_time(&mut datetime, false)?; + + if !self.end() { + return Err(self.parse_error("Unconverted data remains".to_string())); + } + + match &parsed.datetime { + Some(_) => { + parsed.second_datetime = Some(datetime); + } + None => match &parsed.duration { + Some(_) => { + parsed.second_datetime = Some(datetime); + } + None => { + parsed.datetime = Some(datetime); + } + }, + } + + return Ok(()); + } + + datetime.year = self.parse_integer(2, "year")?; + + if self.current == ':' { + // Time in extended format + datetime.hour = datetime.year; + datetime.year = 0; + datetime.extended_date_format = true; + self.parse_time(&mut datetime, true)?; + + if !self.end() { + return Err(self.parse_error("Unconverted data remains".to_string())); + } + + match &parsed.datetime { + Some(_) => { + parsed.second_datetime = Some(datetime); + } + None => match &parsed.duration { + Some(_) => { + parsed.second_datetime = Some(datetime); + } + None => { + parsed.datetime = Some(datetime); + } + }, + } + + return Ok(()); + } + + datetime.has_date = true; + datetime.year = datetime.year * 100 + self.parse_integer(2, "year")?; + + if self.current == '-' { + self.inc(); + datetime.extended_date_format = true; + + if self.current == 'W' { + // ISO week and day in extended format (i.e. Www-D) + self.inc(); + + let iso_week = self.parse_integer(2, "iso week")?; + let mut iso_day: u32 = 1; + + if !self.end() && self.current != ' ' && self.current != 'T' { + // Optional day + if self.current != '-' { + return Err(self.parse_error(format!( + "Invalid character \"{}\" while parsing {}", + self.current, "date separator" + ))); + } + + self.inc(); + + iso_day = self.parse_integer(1, "iso day")?; + } + + let (year, month, day) = self.iso_to_ymd(datetime.year, iso_week, iso_day)?; + + datetime.year = year; + datetime.month = month; + datetime.day = day; + } else { + /* + Month and day in extended format (MM-DD) or ordinal date (DDD) + We'll assume first that the next part is a month and adjust if not. + */ + datetime.month = self.parse_integer(2, "month")?; + + if !self.end() && self.current != ' ' && self.current != 'T' { + if self.current == '-' { + // Optional day + self.inc(); + datetime.day = self.parse_integer(2, "day")?; + } else { + // Ordinal day + let ordinal_day = + (datetime.month * 10 + self.parse_integer(1, "ordinal day")?) as i32; + + let (year, month, day) = + self.ordinal_to_ymd(datetime.year, ordinal_day, false)?; + + datetime.year = year; + datetime.month = month; + datetime.day = day; + } + } else { + datetime.day = 1; + } + } + } else if self.current == 'W' { + // Compact ISO week and day (WwwD) + self.inc(); + + let iso_week = self.parse_integer(2, "iso week")?; + let mut iso_day: u32 = 1; + + if !self.end() && self.current != ' ' && self.current != 'T' { + iso_day = self.parse_integer(1, "iso day")?; + } + + match self.iso_to_ymd(datetime.year, iso_week, iso_day) { + Ok((year, month, day)) => { + datetime.year = year; + datetime.month = month; + datetime.day = day; + } + Err(error) => return Err(error), + } + } else { + /* + Month and day in compact format (MMDD) or ordinal date (DDD) + We'll assume first that the next part is a month and adjust if not. + */ + datetime.month = self.parse_integer(2, "month")?; + let mut ordinal_day = self.parse_integer(1, "ordinal day")? as i32; + + if self.end() || self.current == ' ' || self.current == 'T' { + // Ordinal day + ordinal_day += datetime.month as i32 * 10; + + let (year, month, day) = self.ordinal_to_ymd(datetime.year, ordinal_day, false)?; + + datetime.year = year; + datetime.month = month; + datetime.day = day; + } else { + // Day + datetime.day = ordinal_day as u32 * 10 + self.parse_integer(1, "day")?; + } + } + + if !self.end() { + self.parse_time(&mut datetime, false)?; + } + + if !self.end() { + if self.current == '/' && parsed.datetime.is_none() && parsed.duration.is_none() { + // Interval + parsed.datetime = Some(datetime); + + self.inc(); + + if self.current == 'P' { + // Duration + self.parse_duration(parsed)?; + } else { + self.parse_datetime(parsed)?; + } + + return Ok(()); + } + + return Err(self.parse_error("Unconverted data remains".to_string())); + } + + match &parsed.datetime { + Some(_) => { + parsed.second_datetime = Some(datetime); + } + None => match &parsed.duration { + Some(_) => { + parsed.second_datetime = Some(datetime); + } + None => { + parsed.datetime = Some(datetime); + } + }, + } + + Ok(()) + } + + fn parse_time( + &mut self, + datetime: &mut ParsedDateTime, + skip_hour: bool, + ) -> Result<(), ParseError> { + // TODO: Add support for decimal units + + // Date/Time separator + if self.current != 'T' && self.current != ' ' && !skip_hour { + return Err(self.parse_error(format!( + "Invalid character \"{}\" while parsing {}", + self.current, "date and time separator (\"T\" or \" \")" + ))); + } + + datetime.has_time = true; + + if !skip_hour { + self.inc(); + + // Hour + datetime.hour = self.parse_integer(2, "hour")?; + } + + if !self.end() && self.current != 'Z' && self.current != '+' && self.current != '-' { + // Optional minute and second + if self.current == ':' { + // Minute and second in extended format (mm:ss) + self.inc(); + + // Minute + datetime.minute = self.parse_integer(2, "minute")?; + + if !self.end() && self.current != 'Z' && self.current != '+' && self.current != '-' + { + // Optional second + if self.current != ':' { + return Err(self.parse_error(format!( + "Invalid character \"{}\" while parsing {}", + self.current, "time separator (\":\")" + ))); + } + + self.inc(); + + // Second + datetime.second = self.parse_integer(2, "second")?; + + if self.current == '.' || self.current == ',' { + // Optional fractional second + self.inc(); + + datetime.microsecond = 0; + let mut i: u8 = 0; + + while i < 6 { + if let Some(digit) = self.current.to_digit(10) { + datetime.microsecond = datetime.microsecond * 10 + digit; + } else if i == 0 { + // One digit minimum is required + return Err(self.unexpected_character_error("subsecond", 1)); + } else { + break; + } + + self.inc(); + i += 1; + } + + // Drop extraneous digits + while self.current.is_ascii_digit() { + self.inc(); + } + + // Expand missing microsecond + while i < 6 { + datetime.microsecond *= 10; + i += 1; + } + } + + if !datetime.extended_date_format { + return Err(self.parse_error("Cannot combine \"basic\" date format with \"extended\" time format (Should be either `YYYY-MM-DDThh:mm:ss` or `YYYYMMDDThhmmss`).".to_string())); + } + } + } else { + // Minute and second in compact format (mmss) + + // Minute + datetime.minute = self.parse_integer(2, "minute")?; + + if !self.end() && self.current != 'Z' && self.current != '+' && self.current != '-' + { + // Optional second + + datetime.second = self.parse_integer(2, "second")?; + + if self.current == '.' || self.current == ',' { + // Optional fractional second + self.inc(); + + datetime.microsecond = 0; + let mut i: u8 = 0; + + while i < 6 { + if let Some(digit) = self.current.to_digit(10) { + datetime.microsecond = datetime.microsecond * 10 + digit; + } else if i == 0 { + // One digit minimum is required + return Err(self.unexpected_character_error("subsecond", 1)); + } else { + break; + } + + self.inc(); + i += 1; + } + + // Drop extraneous digits + while self.current.is_ascii_digit() { + self.inc(); + } + + // Expand missing microsecond + while i < 6 { + datetime.microsecond *= 10; + i += 1; + } + } + } + + if datetime.extended_date_format { + return Err(self.parse_error("Cannot combine \"extended\" date format with \"basic\" time format (Should be either `YYYY-MM-DDThh:mm:ss` or `YYYYMMDDThhmmss`).".to_string())); + } + } + } + + if datetime.hour == 24 + && datetime.minute == 0 + && datetime.second == 0 + && datetime.microsecond == 0 + { + // Special case for 24:00:00, which is valid for ISO 8601. + // This is equivalent to 00:00:00 the next day. + // We will store the information for now. + datetime.time_is_midnight = true; + } + + if self.current == 'Z' { + // UTC + datetime.offset = Some(0); + datetime.tzname = Some("UTC".to_string()); + self.inc(); + } else if matches!(self.current, '+' | '-') { + // Optional timezone offset + let tzsign = if self.current == '+' { 1 } else { -1 }; + self.inc(); + // Offset hour + let tzhour = self.parse_integer(2, "timezone hour")? as i32; + if self.current == ':' { + // Optional separator + self.inc(); + } + let mut tzminute = if self.end() { + 0 + } else { + // Optional minute + self.parse_integer(2, "timezone minute")? as i32 + }; + tzminute += tzhour * 60; + tzminute *= tzsign; + if tzminute > 24 * 60 { + return Err(self.parse_error("Timezone offset is too large".to_string())); + } + datetime.offset = Some(tzminute * 60); + } + + Ok(()) + } + + fn parse_duration(&mut self, parsed: &mut Parsed) -> Result<(), ParseError> { + // Removing P operator + self.inc(); + + let mut duration: ParsedDuration = ParsedDuration::new(); + let mut got_t: bool = false; + let mut last_had_fraction = false; + + loop { + match self.current { + 'T' => { + if got_t { + return Err( + self.parse_error("Repeated time declaration in duration".to_string()) + ); + } + + got_t = true; + } + _c => { + let (value, op_fraction) = self.parse_duration_number_frac()?; + if last_had_fraction { + return Err(self.parse_error("Invalid duration fraction".to_string())); + } + + if op_fraction.is_some() { + last_had_fraction = true; + } + + if got_t { + match self.current { + 'H' => { + if duration.minutes != 0 + || duration.seconds != 0 + || duration.microseconds != 0 + { + return Err( + self.parse_error("Duration units out of order".to_string()) + ); + } + + duration.hours += value; + + if let Some(fraction) = op_fraction { + let extra_minutes = fraction * 60_f64; + let extra_full_minutes: f64 = extra_minutes.trunc(); + duration.minutes += extra_full_minutes as u32; + let extra_seconds = + ((extra_minutes - extra_full_minutes) * 60.0).round(); + let extra_full_seconds = extra_seconds.trunc(); + duration.seconds += extra_full_seconds as u32; + let micro_extra = ((extra_seconds - extra_full_seconds) + * 1_000_000.0) + .round() + as u32; + duration.microseconds += micro_extra; + } + } + 'M' => { + if duration.seconds != 0 || duration.microseconds != 0 { + return Err( + self.parse_error("Duration units out of order".to_string()) + ); + } + + duration.minutes += value; + + if let Some(fraction) = op_fraction { + let extra_seconds = fraction * 60_f64; + let extra_full_seconds = extra_seconds.trunc(); + duration.seconds += extra_full_seconds as u32; + let micro_extra = ((extra_seconds - extra_full_seconds) + * 1_000_000.0) + .round() + as u32; + duration.microseconds += micro_extra; + } + } + 'S' => { + duration.seconds = value; + + if let Some(fraction) = op_fraction { + duration.microseconds += + (fraction * 1_000_000.0).round() as u32; + } + } + _ => { + return Err( + self.parse_error("Invalid duration time unit".to_string()) + ) + } + } + } else { + match self.current { + 'Y' => { + if last_had_fraction { + return Err(self.parse_error( + "Fractional years in duration are not supported" + .to_string(), + )); + } + + if duration.months != 0 || duration.days != 0 { + return Err( + self.parse_error("Duration units out of order".to_string()) + ); + } + + duration.years = value; + } + 'M' => { + if last_had_fraction { + return Err(self.parse_error( + "Fractional months in duration are not supported" + .to_string(), + )); + } + + if duration.days != 0 { + return Err( + self.parse_error("Duration units out of order".to_string()) + ); + } + + duration.months = value; + } + 'W' => { + if duration.years != 0 || duration.months != 0 { + return Err(self.parse_error( + "Basic format durations cannot have weeks".to_string(), + )); + } + + duration.weeks = value; + + if let Some(fraction) = op_fraction { + let extra_days = fraction * 7_f64; + let extra_full_days = extra_days.trunc(); + duration.days += extra_full_days as u32; + let extra_hours = (extra_days - extra_full_days) * 24.0; + let extra_full_hours = extra_hours.trunc(); + duration.hours += extra_full_hours as u32; + let extra_minutes = + ((extra_hours - extra_full_hours) * 60.0).round(); + let extra_full_minutes: f64 = extra_minutes.trunc(); + duration.minutes += extra_full_minutes as u32; + let extra_seconds = + ((extra_minutes - extra_full_minutes) * 60.0).round(); + let extra_full_seconds = extra_seconds.trunc(); + duration.seconds += extra_full_seconds as u32; + let micro_extra = ((extra_seconds - extra_full_seconds) + * 1_000_000.0) + .round() + as u32; + duration.microseconds += micro_extra; + } + } + 'D' => { + if duration.weeks != 0 { + return Err(self.parse_error( + "Week format durations cannot have days".to_string(), + )); + } + + duration.days += value; + if let Some(fraction) = op_fraction { + let extra_hours = fraction * 24.0; + let extra_full_hours = extra_hours.trunc(); + duration.hours += extra_full_hours as u32; + let extra_minutes = + ((extra_hours - extra_full_hours) * 60.0).round(); + let extra_full_minutes: f64 = extra_minutes.trunc(); + duration.minutes += extra_full_minutes as u32; + let extra_seconds = + ((extra_minutes - extra_full_minutes) * 60.0).round(); + let extra_full_seconds = extra_seconds.trunc(); + duration.seconds += extra_full_seconds as u32; + let micro_extra = ((extra_seconds - extra_full_seconds) + * 1_000_000.0) + .round() + as u32; + duration.microseconds += micro_extra; + } + } + _ => { + return Err( + self.parse_error("Invalid duration time unit".to_string()) + ) + } + } + } + } + } + self.inc(); + + if self.end() { + break; + } + } + + parsed.duration = Some(duration); + + Ok(()) + } + + fn parse_duration_number_frac(&mut self) -> Result<(u32, Option), ParseError> { + let value = self.parse_duration_number()?; + let fraction = matches!(self.current, '.' | ',').then(|| { + let mut decimal = 0_f64; + let mut denominator = 1_f64; + + while let Some(digit) = self.inc().and_then(|ch| ch.to_digit(10)) { + decimal *= 10.0; + decimal += f64::from(digit); + denominator *= 10.0; + } + + decimal / denominator + }); + + Ok((value, fraction)) + } + + fn parse_duration_number(&mut self) -> Result { + let Some(mut value) = self.current.to_digit(10) else { + return Err(self.parse_error("Invalid number in duration".to_string())); + }; + + while let Some(digit) = self.inc().and_then(|ch| ch.to_digit(10)) { + value *= 10; + value += digit; + } + + Ok(value) + } + + fn iso_to_ymd( + &mut self, + iso_year: u32, + iso_week: u32, + iso_day: u32, + ) -> Result<(u32, u32, u32), ParseError> { + if iso_week > 53 || iso_week > 52 && !is_long_year(iso_year as i32) { + return Err(ParseError { + index: self.idx, + message: format!( + "Invalid ISO date: week {iso_week} out of range for year {iso_year}" + ), + }); + } + + if iso_day > 7 { + return Err(ParseError { + index: self.idx, + message: "Invalid ISO date: week day is invalid".to_string(), + }); + } + + let ordinal: i32 = + iso_week as i32 * 7 + iso_day as i32 - (week_day(iso_year as i32, 1, 4) as i32 + 3); + + self.ordinal_to_ymd(iso_year, ordinal, true) + } + + fn ordinal_to_ymd( + &mut self, + year: u32, + ordinal: i32, + allow_out_of_bounds: bool, + ) -> Result<(u32, u32, u32), ParseError> { + let mut ord: i32 = ordinal; + let mut y: u32 = year; + let mut leap: usize = usize::from(is_leap(y as i32)); + + if ord < 1 { + if !allow_out_of_bounds { + return Err(self.parse_error(format!( + "Invalid ordinal day: {ordinal} is too small for year {year}" + ))); + } + // Previous year + ord += days_in_year((year - 1) as i32) as i32; + y -= 1; + leap = usize::from(is_leap(y as i32)); + } + + if ord > days_in_year(y as i32) as i32 { + if !allow_out_of_bounds { + return Err(self.parse_error(format!( + "Invalid ordinal day: {ordinal} is too large for year {year}" + ))); + } + + // Next year + ord -= days_in_year(y as i32) as i32; + y += 1; + leap = usize::from(is_leap(y as i32)); + } + + for i in 1..14 { + if ord < MONTHS_OFFSETS[leap][i] { + let day = ord as u32 - MONTHS_OFFSETS[leap][i - 1] as u32; + let month = (i - 1) as u32; + + return Ok((y, month, day)); + } + } + + Err(self.parse_error(format!( + "Invalid ordinal day: {ordinal} is too large for year {year}" + ))) + } +} diff --git a/rust/python/helpers.rs b/rust/python/helpers.rs new file mode 100644 index 00000000..4a53e595 --- /dev/null +++ b/rust/python/helpers.rs @@ -0,0 +1,388 @@ +use std::cmp::Ordering; + +use pyo3::{ + intern, + prelude::*, + types::{PyDate, PyDateAccess, PyDateTime, PyDelta, PyDeltaAccess, PyString, PyTimeAccess}, + PyTypeInfo, +}; + +use crate::{ + constants::{DAYS_PER_MONTHS, SECS_PER_DAY, SECS_PER_HOUR, SECS_PER_MIN}, + helpers, +}; + +use crate::python::types::PreciseDiff; + +struct DateTimeInfo<'py> { + pub year: i32, + pub month: i32, + pub day: i32, + pub hour: i32, + pub minute: i32, + pub second: i32, + pub microsecond: i32, + pub total_seconds: i32, + pub offset: i32, + pub tz: &'py str, + pub is_datetime: bool, +} + +impl PartialEq for DateTimeInfo<'_> { + fn eq(&self, other: &Self) -> bool { + ( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.microsecond, + ) + .eq(&( + other.year, + other.month, + other.day, + other.hour, + other.minute, + other.second, + other.microsecond, + )) + } +} + +impl PartialOrd for DateTimeInfo<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + ( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.microsecond, + ) + .partial_cmp(&( + other.year, + other.month, + other.day, + other.hour, + other.minute, + other.second, + other.microsecond, + )) + } +} + +pub fn get_tz_name<'py>(py: Python, dt: &'py PyAny) -> PyResult<&'py str> { + let tz: &str = ""; + + if !PyDateTime::is_type_of(dt) { + return Ok(tz); + } + + let tzinfo = dt.getattr("tzinfo"); + + match tzinfo { + Err(_) => Ok(tz), + Ok(tzinfo) => { + if tzinfo.is_none() { + return Ok(tz); + } + if tzinfo.hasattr(intern!(py, "key")).unwrap_or(false) { + // zoneinfo timezone + let tzname: &PyString = tzinfo + .getattr(intern!(py, "key")) + .unwrap() + .downcast() + .unwrap(); + + return tzname.to_str(); + } else if tzinfo.hasattr(intern!(py, "name")).unwrap_or(false) { + // Pendulum timezone + let tzname: &PyString = tzinfo + .getattr(intern!(py, "name")) + .unwrap() + .downcast() + .unwrap(); + + return tzname.to_str(); + } else if tzinfo.hasattr(intern!(py, "zone")).unwrap_or(false) { + // pytz timezone + let tzname: &PyString = tzinfo + .getattr(intern!(py, "zone")) + .unwrap() + .downcast() + .unwrap(); + + return tzname.to_str(); + } + + Ok(tz) + } + } +} + +pub fn get_offset(dt: &PyAny) -> PyResult { + if !PyDateTime::is_type_of(dt) { + return Ok(0); + } + + let tzinfo = dt.getattr("tzinfo")?; + + if tzinfo.is_none() { + return Ok(0); + } + + let offset: &PyDelta = tzinfo.call_method1("utcoffset", (dt,))?.downcast()?; + + Ok(offset.get_days() * SECS_PER_DAY as i32 + offset.get_seconds()) +} + +#[pyfunction] +pub fn is_leap(year: i32) -> PyResult { + Ok(helpers::is_leap(year)) +} + +#[pyfunction] +pub fn is_long_year(year: i32) -> PyResult { + Ok(helpers::is_long_year(year)) +} + +#[pyfunction] +pub fn week_day(year: i32, month: u32, day: u32) -> PyResult { + Ok(helpers::week_day(year, month, day)) +} + +#[pyfunction] +pub fn days_in_year(year: i32) -> PyResult { + Ok(helpers::days_in_year(year)) +} + +#[pyfunction] +pub fn local_time( + unix_time: f64, + utc_offset: isize, + microsecond: usize, +) -> PyResult<(usize, usize, usize, usize, usize, usize, usize)> { + Ok(helpers::local_time(unix_time, utc_offset, microsecond)) +} + +#[pyfunction] +pub fn precise_diff<'py>(py: Python, dt1: &'py PyAny, dt2: &'py PyAny) -> PyResult { + let mut sign = 1; + let mut dtinfo1 = DateTimeInfo { + year: dt1.downcast::()?.get_year(), + month: i32::from(dt1.downcast::()?.get_month()), + day: i32::from(dt1.downcast::()?.get_day()), + hour: 0, + minute: 0, + second: 0, + microsecond: 0, + total_seconds: 0, + tz: get_tz_name(py, dt1)?, + offset: get_offset(dt1)?, + is_datetime: PyDateTime::is_type_of(dt1), + }; + let mut dtinfo2 = DateTimeInfo { + year: dt2.downcast::()?.get_year(), + month: i32::from(dt2.downcast::()?.get_month()), + day: i32::from(dt2.downcast::()?.get_day()), + hour: 0, + minute: 0, + second: 0, + microsecond: 0, + total_seconds: 0, + tz: get_tz_name(py, dt2)?, + offset: get_offset(dt2)?, + is_datetime: PyDateTime::is_type_of(dt2), + }; + let in_same_tz: bool = dtinfo1.tz == dtinfo2.tz && !dtinfo1.tz.is_empty(); + let mut total_days = helpers::day_number(dtinfo2.year, dtinfo2.month as u8, dtinfo2.day as u8) + - helpers::day_number(dtinfo1.year, dtinfo1.month as u8, dtinfo1.day as u8); + + if dtinfo1.is_datetime { + let dt1dt: &PyDateTime = dt1.downcast()?; + + dtinfo1.hour = i32::from(dt1dt.get_hour()); + dtinfo1.minute = i32::from(dt1dt.get_minute()); + dtinfo1.second = i32::from(dt1dt.get_second()); + dtinfo1.microsecond = dt1dt.get_microsecond() as i32; + + if !in_same_tz && dtinfo1.offset != 0 || total_days == 0 { + dtinfo1.hour -= dtinfo1.offset / SECS_PER_HOUR as i32; + dtinfo1.offset %= SECS_PER_HOUR as i32; + dtinfo1.minute -= dtinfo1.offset / SECS_PER_MIN as i32; + dtinfo1.offset %= SECS_PER_MIN as i32; + dtinfo1.second -= dtinfo1.offset; + + if dtinfo1.second < 0 { + dtinfo1.second += 60; + dtinfo1.minute -= 1; + } else if dtinfo1.second > 60 { + dtinfo1.second -= 60; + dtinfo1.minute += 1; + } + + if dtinfo1.minute < 0 { + dtinfo1.minute += 60; + dtinfo1.hour -= 1; + } else if dtinfo1.minute > 60 { + dtinfo1.minute -= 60; + dtinfo1.hour += 1; + } + + if dtinfo1.hour < 0 { + dtinfo1.hour += 24; + dtinfo1.day -= 1; + } else if dtinfo1.hour > 24 { + dtinfo1.hour -= 24; + dtinfo1.day += 1; + } + } + + dtinfo1.total_seconds = dtinfo1.hour * SECS_PER_HOUR as i32 + + dtinfo1.minute * SECS_PER_MIN as i32 + + dtinfo1.second; + } + + if dtinfo2.is_datetime { + let dt2dt: &PyDateTime = dt2.downcast()?; + + dtinfo2.hour = i32::from(dt2dt.get_hour()); + dtinfo2.minute = i32::from(dt2dt.get_minute()); + dtinfo2.second = i32::from(dt2dt.get_second()); + dtinfo2.microsecond = dt2dt.get_microsecond() as i32; + + if !in_same_tz && dtinfo2.offset != 0 || total_days == 0 { + dtinfo2.hour -= dtinfo2.offset / SECS_PER_HOUR as i32; + dtinfo2.offset %= SECS_PER_HOUR as i32; + dtinfo2.minute -= dtinfo2.offset / SECS_PER_MIN as i32; + dtinfo2.offset %= SECS_PER_MIN as i32; + dtinfo2.second -= dtinfo2.offset; + + if dtinfo2.second < 0 { + dtinfo2.second += 60; + dtinfo2.minute -= 1; + } else if dtinfo2.second > 60 { + dtinfo2.second -= 60; + dtinfo2.minute += 1; + } + + if dtinfo2.minute < 0 { + dtinfo2.minute += 60; + dtinfo2.hour -= 1; + } else if dtinfo2.minute > 60 { + dtinfo2.minute -= 60; + dtinfo2.hour += 1; + } + + if dtinfo2.hour < 0 { + dtinfo2.hour += 24; + dtinfo2.day -= 1; + } else if dtinfo2.hour > 24 { + dtinfo2.hour -= 24; + dtinfo2.day += 1; + } + } + + dtinfo2.total_seconds = dtinfo2.hour * SECS_PER_HOUR as i32 + + dtinfo2.minute * SECS_PER_MIN as i32 + + dtinfo2.second; + } + + if dtinfo1 > dtinfo2 { + sign = -1; + (dtinfo1, dtinfo2) = (dtinfo2, dtinfo1); + + total_days = -total_days; + } + + let mut year_diff = dtinfo2.year - dtinfo1.year; + let mut month_diff = dtinfo2.month - dtinfo1.month; + let mut day_diff = dtinfo2.day - dtinfo1.day; + let mut hour_diff = dtinfo2.hour - dtinfo1.hour; + let mut minute_diff = dtinfo2.minute - dtinfo1.minute; + let mut second_diff = dtinfo2.second - dtinfo1.second; + let mut microsecond_diff = dtinfo2.microsecond - dtinfo1.microsecond; + + if microsecond_diff < 0 { + microsecond_diff += 1_000_000; + second_diff -= 1; + } + + if second_diff < 0 { + second_diff += 60; + minute_diff -= 1; + } + + if minute_diff < 0 { + minute_diff += 60; + hour_diff -= 1; + } + + if hour_diff < 0 { + hour_diff += 24; + day_diff -= 1; + } + + if day_diff < 0 { + // If we have a difference in days, + // we have to check if they represent months + let mut year = dtinfo2.year; + let mut month = dtinfo2.month; + + if month == 1 { + month = 12; + year -= 1; + } else { + month -= 1; + } + + let leap = helpers::is_leap(year); + + let days_in_last_month = DAYS_PER_MONTHS[usize::from(leap)][month as usize]; + let days_in_month = + DAYS_PER_MONTHS[usize::from(helpers::is_leap(dtinfo2.year))][dtinfo2.month as usize]; + + match day_diff.cmp(&(days_in_month - days_in_last_month)) { + Ordering::Less => { + // We don't have a full month, we calculate days + if days_in_last_month < dtinfo1.day { + day_diff += dtinfo1.day; + } else { + day_diff += days_in_last_month; + } + } + Ordering::Equal => { + // We have exactly a full month + // We remove the days difference + // and add one to the months difference + day_diff = 0; + month_diff += 1; + } + Ordering::Greater => { + // We have a full month + day_diff += days_in_last_month; + } + } + + month_diff -= 1; + } + + if month_diff < 0 { + month_diff += 12; + year_diff -= 1; + } + + Ok(PreciseDiff { + years: year_diff * sign, + months: month_diff * sign, + days: day_diff * sign, + hours: hour_diff * sign, + minutes: minute_diff * sign, + seconds: second_diff * sign, + microseconds: microsecond_diff * sign, + total_days: total_days * sign, + }) +} diff --git a/rust/python/mod.rs b/rust/python/mod.rs new file mode 100644 index 00000000..8d3cd41a --- /dev/null +++ b/rust/python/mod.rs @@ -0,0 +1,27 @@ +use pyo3::prelude::*; + +mod helpers; +mod parsing; +mod types; + +use helpers::{days_in_year, is_leap, is_long_year, local_time, precise_diff, week_day}; +use parsing::parse_iso8601; +use types::{Duration, PreciseDiff}; + +#[pymodule] +pub fn _pendulum(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(days_in_year, m)?)?; + m.add_function(wrap_pyfunction!(is_leap, m)?)?; + m.add_function(wrap_pyfunction!(is_long_year, m)?)?; + m.add_function(wrap_pyfunction!(local_time, m)?)?; + m.add_function(wrap_pyfunction!(week_day, m)?)?; + m.add_function(wrap_pyfunction!(parse_iso8601, m)?)?; + m.add_function(wrap_pyfunction!(precise_diff, m)?)?; + m.add_class::()?; + m.add_class::()?; + + #[cfg(not(feature = "mimalloc"))] + m.setattr("__pendulum_default_allocator__", true)?; // uses setattr so this is not in __all__ + + Ok(()) +} diff --git a/rust/python/parsing.rs b/rust/python/parsing.rs new file mode 100644 index 00000000..48fa64c9 --- /dev/null +++ b/rust/python/parsing.rs @@ -0,0 +1,117 @@ +use pyo3::exceptions; +use pyo3::prelude::*; +use pyo3::types::PyDate; +use pyo3::types::PyDateTime; +use pyo3::types::PyTime; + +use crate::parsing::Parser; +use crate::python::types::{Duration, FixedTimezone}; + +#[pyfunction] +pub fn parse_iso8601(py: Python, input: &str) -> PyResult { + let parsed = Parser::new(input).parse(); + + match parsed { + Ok(parsed) => match (parsed.datetime, parsed.duration, parsed.second_datetime) { + (Some(datetime), None, None) => match (datetime.has_date, datetime.has_time) { + (true, true) => match datetime.offset { + Some(offset) => { + let dt = PyDateTime::new( + py, + datetime.year as i32, + datetime.month as u8, + datetime.day as u8, + datetime.hour as u8, + datetime.minute as u8, + datetime.second as u8, + datetime.microsecond, + Some( + Py::new(py, FixedTimezone::new(offset, datetime.tzname))? + .to_object(py) + .extract(py)?, + ), + )?; + + Ok(dt.to_object(py)) + } + None => { + let dt = PyDateTime::new( + py, + datetime.year as i32, + datetime.month as u8, + datetime.day as u8, + datetime.hour as u8, + datetime.minute as u8, + datetime.second as u8, + datetime.microsecond, + None, + )?; + + Ok(dt.to_object(py)) + } + }, + (true, false) => { + let dt = PyDate::new( + py, + datetime.year as i32, + datetime.month as u8, + datetime.day as u8, + )?; + + Ok(dt.to_object(py)) + } + (false, true) => match datetime.offset { + Some(offset) => { + let dt = PyTime::new( + py, + datetime.hour as u8, + datetime.minute as u8, + datetime.second as u8, + datetime.microsecond, + Some( + Py::new(py, FixedTimezone::new(offset, datetime.tzname))? + .to_object(py) + .extract(py)?, + ), + )?; + + Ok(dt.to_object(py)) + } + None => { + let dt = PyTime::new( + py, + datetime.hour as u8, + datetime.minute as u8, + datetime.second as u8, + datetime.microsecond, + None, + )?; + + Ok(dt.to_object(py)) + } + }, + (_, _) => Err(exceptions::PyValueError::new_err( + "Parsing error".to_string(), + )), + }, + (None, Some(duration), None) => Ok(Py::new( + py, + Duration::new( + Some(duration.years), + Some(duration.months), + Some(duration.weeks), + Some(duration.days), + Some(duration.hours), + Some(duration.minutes), + Some(duration.seconds), + Some(duration.microseconds), + ), + )? + .to_object(py)), + (_, _, _) => Err(exceptions::PyValueError::new_err( + "Not yet implemented".to_string(), + )), + }, + Err(error) => Err(exceptions::PyValueError::new_err(error.to_string())), + } +} diff --git a/rust/python/types/duration.rs b/rust/python/types/duration.rs new file mode 100644 index 00000000..fc18f4eb --- /dev/null +++ b/rust/python/types/duration.rs @@ -0,0 +1,59 @@ +use pyo3::prelude::*; + +#[pyclass(module = "_pendulum")] +pub struct Duration { + #[pyo3(get, set)] + pub years: u32, + #[pyo3(get, set)] + pub months: u32, + #[pyo3(get, set)] + pub weeks: u32, + #[pyo3(get, set)] + pub days: u32, + #[pyo3(get, set)] + pub hours: u32, + #[pyo3(get, set)] + pub minutes: u32, + #[pyo3(get, set)] + pub seconds: u32, + #[pyo3(get, set)] + pub microseconds: u32, +} + +#[pymethods] +impl Duration { + #[new] + #[pyo3(signature = (years=0, months=0, weeks=0, days=0, hours=0, minutes=0, seconds=0, microseconds=0))] + #[allow(clippy::too_many_arguments)] + pub fn new( + years: Option, + months: Option, + weeks: Option, + days: Option, + hours: Option, + minutes: Option, + seconds: Option, + microseconds: Option, + ) -> Self { + Self { + years: years.unwrap_or(0), + months: months.unwrap_or(0), + weeks: weeks.unwrap_or(0), + days: days.unwrap_or(0), + hours: hours.unwrap_or(0), + minutes: minutes.unwrap_or(0), + seconds: seconds.unwrap_or(0), + microseconds: microseconds.unwrap_or(0), + } + } + + #[getter] + fn remaining_days(&self) -> PyResult { + Ok(self.days) + } + + #[getter] + fn remaining_seconds(&self) -> PyResult { + Ok(self.seconds) + } +} diff --git a/rust/python/types/mod.rs b/rust/python/types/mod.rs new file mode 100644 index 00000000..cba11dfe --- /dev/null +++ b/rust/python/types/mod.rs @@ -0,0 +1,7 @@ +mod duration; +mod precise_diff; +mod timezone; + +pub use duration::Duration; +pub use precise_diff::PreciseDiff; +pub use timezone::FixedTimezone; diff --git a/rust/python/types/precise_diff.rs b/rust/python/types/precise_diff.rs new file mode 100644 index 00000000..64ca3a65 --- /dev/null +++ b/rust/python/types/precise_diff.rs @@ -0,0 +1,53 @@ +use pyo3::prelude::*; + +#[pyclass(module = "_pendulum")] +pub struct PreciseDiff { + #[pyo3(get, set)] + pub years: i32, + #[pyo3(get, set)] + pub months: i32, + #[pyo3(get, set)] + pub days: i32, + #[pyo3(get, set)] + pub hours: i32, + #[pyo3(get, set)] + pub minutes: i32, + #[pyo3(get, set)] + pub seconds: i32, + #[pyo3(get, set)] + pub microseconds: i32, + #[pyo3(get, set)] + pub total_days: i32, +} + +#[pymethods] +impl PreciseDiff { + #[new] + #[pyo3(signature = (years=0, months=0, days=0, hours=0, minutes=0, seconds=0, microseconds=0, total_days=0))] + #[allow(clippy::too_many_arguments)] + pub fn new( + years: Option, + months: Option, + days: Option, + hours: Option, + minutes: Option, + seconds: Option, + microseconds: Option, + total_days: Option, + ) -> Self { + Self { + years: years.unwrap_or(0), + months: months.unwrap_or(0), + days: days.unwrap_or(0), + hours: hours.unwrap_or(0), + minutes: minutes.unwrap_or(0), + seconds: seconds.unwrap_or(0), + microseconds: microseconds.unwrap_or(0), + total_days: total_days.unwrap_or(0), + } + } + + fn __repr__(&self) -> String { + format!("PreciseDiff(years={}, months={}, days={}, hours={}, minutes={}, seconds={}, microseconds={}, total_days={})", self.years, self.months, self.days, self.hours, self.minutes, self.seconds, self.microseconds, self.total_days) + } +} diff --git a/rust/python/types/timezone.rs b/rust/python/types/timezone.rs new file mode 100644 index 00000000..1a8bbade --- /dev/null +++ b/rust/python/types/timezone.rs @@ -0,0 +1,52 @@ +use pyo3::prelude::*; +use pyo3::types::{PyDelta, PyDict, PyTzInfo}; + +#[pyclass(module = "_pendulum", extends = PyTzInfo)] +#[derive(Clone)] +pub struct FixedTimezone { + offset: i32, + name: Option, +} + +#[pymethods] +impl FixedTimezone { + #[new] + pub fn new(offset: i32, name: Option) -> Self { + Self { offset, name } + } + + fn utcoffset<'p>(&self, py: Python<'p>, _dt: &PyAny) -> PyResult<&'p PyDelta> { + PyDelta::new(py, 0, self.offset, 0, true) + } + + fn tzname(&self, _dt: &PyAny) -> String { + self.__str__() + } + + fn dst<'p>(&self, py: Python<'p>, _dt: &PyAny) -> PyResult<&'p PyDelta> { + PyDelta::new(py, 0, 0, 0, true) + } + + fn __repr__(&self) -> String { + format!( + "FixedTimezone({}, name=\"{}\")", + self.offset, + self.__str__() + ) + } + + fn __str__(&self) -> String { + if let Some(n) = &self.name { + n.clone() + } else { + let sign = if self.offset < 0 { "-" } else { "+" }; + let minutes = self.offset.abs() / 60; + let (hour, minute) = (minutes / 60, minutes % 60); + format!("{sign}{hour:.2}:{minute:.2}") + } + } + + fn __deepcopy__(&self, py: Python, _memo: &PyDict) -> PyResult> { + Py::new(py, self.clone()) + } +} diff --git a/tests/localization/test_tr.py b/tests/localization/test_tr.py index 5ec00eef..258a5de9 100644 --- a/tests/localization/test_tr.py +++ b/tests/localization/test_tr.py @@ -49,10 +49,10 @@ def diff_for_humans(): assert d.diff_for_humans(locale=locale) == "2 ay önce" d = pendulum.now().subtract(years=1) - assert d.diff_for_humans(locale=locale) == "1 yıl önce" + assert d.diff_for_humans(locale=locale) == "1 yıl önce" # noqa: RUF001 d = pendulum.now().subtract(years=2) - assert d.diff_for_humans(locale=locale) == "2 yıl önce" + assert d.diff_for_humans(locale=locale) == "2 yıl önce" # noqa: RUF001 d = pendulum.now().add(seconds=1) assert d.diff_for_humans(locale=locale) == "1 saniye sonra" diff --git a/tests/parsing/test_parse_iso8601.py b/tests/parsing/test_parse_iso8601.py index 83f28810..22c43a22 100644 --- a/tests/parsing/test_parse_iso8601.py +++ b/tests/parsing/test_parse_iso8601.py @@ -10,82 +10,84 @@ try: - from pendulum.parsing._extension import TZFixedOffset as FixedTimezone + from _pendulum import FixedTimezone except ImportError: from pendulum.tz.timezone import FixedTimezone -def test_parse_iso8601(): - # Date - assert date(2016, 1, 1) == parse_iso8601("2016") - assert date(2016, 10, 1) == parse_iso8601("2016-10") - assert date(2016, 10, 6) == parse_iso8601("2016-10-06") - assert date(2016, 10, 6) == parse_iso8601("20161006") - - # Time - assert time(20, 16, 10, 0) == parse_iso8601("201610") - - # Datetime - assert datetime(2016, 10, 6, 12, 34, 56, 123456) == parse_iso8601( - "2016-10-06T12:34:56.123456" - ) - assert datetime(2016, 10, 6, 12, 34, 56, 123000) == parse_iso8601( - "2016-10-06T12:34:56.123" - ) - assert datetime(2016, 10, 6, 12, 34, 56, 123) == parse_iso8601( - "2016-10-06T12:34:56.000123" - ) - assert datetime(2016, 10, 6, 12, 0, 0, 0) == parse_iso8601("2016-10-06T12") - assert datetime(2016, 10, 6, 12, 34, 56, 0) == parse_iso8601("2016-10-06T123456") - assert datetime(2016, 10, 6, 12, 34, 56, 123456) == parse_iso8601( - "2016-10-06T123456.123456" - ) - assert datetime(2016, 10, 6, 12, 34, 56, 123456) == parse_iso8601( - "20161006T123456.123456" - ) - assert datetime(2016, 10, 6, 12, 34, 56, 123456) == parse_iso8601( - "20161006 123456.123456" - ) - - # Datetime with offset - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(19800) - ) == parse_iso8601("2016-10-06T12:34:56.123456+05:30") - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(19800) - ) == parse_iso8601("2016-10-06T12:34:56.123456+0530") - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-19800) - ) == parse_iso8601("2016-10-06T12:34:56.123456-05:30") - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-19800) - ) == parse_iso8601("2016-10-06T12:34:56.123456-0530") - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(18000) - ) == parse_iso8601("2016-10-06T12:34:56.123456+05") - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-18000) - ) == parse_iso8601("2016-10-06T12:34:56.123456-05") - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-18000) - ) == parse_iso8601("20161006T123456,123456-05") - assert datetime( - 2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(+19800) - ) == parse_iso8601("2016-10-06T12:34:56.123456789+05:30") - - # Ordinal date - assert date(2012, 1, 7) == parse_iso8601("2012-007") - assert date(2012, 1, 7) == parse_iso8601("2012007") - assert date(2017, 3, 20) == parse_iso8601("2017-079") - - # Week date - assert date(2012, 1, 30) == parse_iso8601("2012-W05") - assert date(2008, 9, 27) == parse_iso8601("2008-W39-6") - assert date(2010, 1, 3) == parse_iso8601("2009-W53-7") - assert date(2008, 12, 29) == parse_iso8601("2009-W01-1") - - # Week date wth time - assert datetime(2008, 9, 27, 9, 0, 0, 0) == parse_iso8601("2008-W39-6T09") +@pytest.mark.parametrize( + ["text", "expected"], + [ + ("2016-10", date(2016, 10, 1)), + ("2016-10-06", date(2016, 10, 6)), + # Ordinal date + ("2012-007", date(2012, 1, 7)), + ("2012007", date(2012, 1, 7)), + ("2017-079", date(2017, 3, 20)), + # Week date + ("2012-W05", date(2012, 1, 30)), + ("2008-W39-6", date(2008, 9, 27)), + ("2009-W53-7", date(2010, 1, 3)), + ("2009-W01-1", date(2008, 12, 29)), + # Time + ("12:34", time(12, 34, 0)), + ("12:34:56", time(12, 34, 56)), + ("12:34:56.123", time(12, 34, 56, 123000)), + ("12:34:56.123456", time(12, 34, 56, 123456)), + ("12:34+05:30", time(12, 34, 0, tzinfo=FixedTimezone(19800))), + ("12:34:56+05:30", time(12, 34, 56, tzinfo=FixedTimezone(19800))), + ("12:34:56.123+05:30", time(12, 34, 56, 123000, tzinfo=FixedTimezone(19800))), + ( + "12:34:56.123456+05:30", + time(12, 34, 56, 123456, tzinfo=FixedTimezone(19800)), + ), + # Datetime + ("2016-10-06T12:34:56.123456", datetime(2016, 10, 6, 12, 34, 56, 123456)), + ("2016-10-06T12:34:56.123", datetime(2016, 10, 6, 12, 34, 56, 123000)), + ("2016-10-06T12:34:56.000123", datetime(2016, 10, 6, 12, 34, 56, 123)), + ("20161006T12", datetime(2016, 10, 6, 12, 0, 0, 0)), + ("20161006T123456", datetime(2016, 10, 6, 12, 34, 56, 0)), + ("20161006T123456.123456", datetime(2016, 10, 6, 12, 34, 56, 123456)), + ("20161006 123456.123456", datetime(2016, 10, 6, 12, 34, 56, 123456)), + # Datetime with offset + ( + "2016-10-06T12:34:56.123456+05:30", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(19800)), + ), + ( + "2016-10-06T12:34:56.123456+0530", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(19800)), + ), + ( + "2016-10-06T12:34:56.123456-05:30", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-19800)), + ), + ( + "2016-10-06T12:34:56.123456-0530", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-19800)), + ), + ( + "2016-10-06T12:34:56.123456+05", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(18000)), + ), + ( + "2016-10-06T12:34:56.123456-05", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-18000)), + ), + ( + "20161006T123456,123456-05", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(-18000)), + ), + ( + "2016-10-06T12:34:56.123456789+05:30", + datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(+19800)), + ), + # Week date with time + ("2008-W39-6T09", datetime(2008, 9, 27, 9, 0, 0, 0)), + ], +) +def test_parse_iso8601(text: str, expected: date) -> None: + assert parse_iso8601(text) == expected def test_parse_ios8601_invalid(): @@ -166,301 +168,43 @@ def test_parse_ios8601_invalid(): parse_iso8601("2012-W123") # Missing separator -def test_parse_ios8601_duration(): - text = "P2Y3M4DT5H6M7S" - parsed = parse_iso8601(text) - - assert parsed.years == 2 - assert parsed.months == 3 - assert parsed.weeks == 0 - assert parsed.remaining_days == 4 - assert parsed.hours == 5 - assert parsed.minutes == 6 - assert parsed.remaining_seconds == 7 - assert parsed.microseconds == 0 - - text = "P1Y2M3DT4H5M6.5S" - parsed = parse_iso8601(text) - - assert parsed.years == 1 - assert parsed.months == 2 - assert parsed.weeks == 0 - assert parsed.remaining_days == 3 - assert parsed.hours == 4 - assert parsed.minutes == 5 - assert parsed.remaining_seconds == 6 - assert parsed.microseconds == 500000 - - text = "P1Y2M3DT4H5M6,5S" - parsed = parse_iso8601(text) - - assert parsed.years == 1 - assert parsed.months == 2 - assert parsed.weeks == 0 - assert parsed.remaining_days == 3 - assert parsed.hours == 4 - assert parsed.minutes == 5 - assert parsed.remaining_seconds == 6 - assert parsed.microseconds == 500000 - - text = "P1Y2M3D" - parsed = parse_iso8601(text) - - assert parsed.years == 1 - assert parsed.months == 2 - assert parsed.weeks == 0 - assert parsed.remaining_days == 3 - assert parsed.hours == 0 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1Y2M3.5D" - parsed = parse_iso8601(text) - - assert parsed.years == 1 - assert parsed.months == 2 - assert parsed.weeks == 0 - assert parsed.remaining_days == 3 - assert parsed.hours == 12 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1Y2M3,5D" - parsed = parse_iso8601(text) - - assert parsed.years == 1 - assert parsed.months == 2 - assert parsed.weeks == 0 - assert parsed.remaining_days == 3 - assert parsed.hours == 12 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "PT4H54M6.5S" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 0 - assert parsed.hours == 4 - assert parsed.minutes == 54 - assert parsed.remaining_seconds == 6 - assert parsed.microseconds == 500000 - - text = "PT4H54M6,5S" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 0 - assert parsed.hours == 4 - assert parsed.minutes == 54 - assert parsed.remaining_seconds == 6 - assert parsed.microseconds == 500000 - - text = "P1Y" - parsed = parse_iso8601(text) - - assert parsed.years == 1 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 0 - assert parsed.hours == 0 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1.5Y" - with pytest.raises(ValueError): - parse_iso8601(text) - - text = "P1,5Y" - with pytest.raises(ValueError): - parse_iso8601(text) - - text = "P1M" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 1 - assert parsed.weeks == 0 - assert parsed.remaining_days == 0 - assert parsed.hours == 0 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1.5M" - with pytest.raises(ValueError): - parse_iso8601(text) - - text = "P1,5M" - with pytest.raises(ValueError): - parse_iso8601(text) - - text = "P1W" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 1 - assert parsed.remaining_days == 0 - assert parsed.hours == 0 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1.5W" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 1 - assert parsed.remaining_days == 3 - assert parsed.hours == 12 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1,5W" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 1 - assert parsed.remaining_days == 3 - assert parsed.hours == 12 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1D" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 1 - assert parsed.hours == 0 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1.5D" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 1 - assert parsed.hours == 12 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "P1,5D" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 1 - assert parsed.hours == 12 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "PT1H" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 0 - assert parsed.hours == 1 - assert parsed.minutes == 0 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "PT1.5H" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 0 - assert parsed.hours == 1 - assert parsed.minutes == 30 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - text = "PT1,5H" - parsed = parse_iso8601(text) - - assert parsed.years == 0 - assert parsed.months == 0 - assert parsed.weeks == 0 - assert parsed.remaining_days == 0 - assert parsed.hours == 1 - assert parsed.minutes == 30 - assert parsed.remaining_seconds == 0 - assert parsed.microseconds == 0 - - # Double digit with 0 - text = "P2Y30M4DT5H6M7S" - parsed = parse_iso8601(text) - - assert parsed.years == 2 - assert parsed.months == 30 - assert parsed.weeks == 0 - assert parsed.remaining_days == 4 - assert parsed.hours == 5 - assert parsed.minutes == 6 - assert parsed.remaining_seconds == 7 - assert parsed.microseconds == 0 - - # No P operator - with pytest.raises(ValueError): - parse_iso8601("2Y3M4DT5H6M7S") - - # Week and other units combined - with pytest.raises(ValueError): - parse_iso8601("P1Y2W") - - # Invalid units order - with pytest.raises(ValueError): - parse_iso8601("P1S") - - with pytest.raises(ValueError): - parse_iso8601("P1D1S") - - with pytest.raises(ValueError): - parse_iso8601("1Y2M3D1SPT1M") - - with pytest.raises(ValueError): - parse_iso8601("P1Y2M3D2MT1S") - - with pytest.raises(ValueError): - parse_iso8601("P2M3D1ST1Y1M") - - with pytest.raises(ValueError): - parse_iso8601("P1Y2M2MT3D1S") - - with pytest.raises(ValueError): - parse_iso8601("P1D1Y1M") - - with pytest.raises(ValueError): - parse_iso8601("PT1S1H") - - # Invalid - with pytest.raises(ValueError): - parse_iso8601("P1Dasdfasdf") - - # Invalid fractional - with pytest.raises(ValueError): - parse_iso8601("P2Y3M4DT5.5H6M7S") +@pytest.mark.parametrize( + ["text", "expected"], + [ + ("P2Y3M4DT5H6M7S", (2, 3, 0, 4, 5, 6, 7, 0)), + ("P1Y2M3DT4H5M6.5S", (1, 2, 0, 3, 4, 5, 6, 500_000)), + ("P1Y2M3DT4H5M6,5S", (1, 2, 0, 3, 4, 5, 6, 500_000)), + ("P1Y2M3D", (1, 2, 0, 3, 0, 0, 0, 0)), + ("P1Y2M3.5D", (1, 2, 0, 3, 12, 0, 0, 0)), + ("P1Y2M3,5D", (1, 2, 0, 3, 12, 0, 0, 0)), + ("PT4H54M6.5S", (0, 0, 0, 0, 4, 54, 6, 500_000)), + ("PT4H54M6,5S", (0, 0, 0, 0, 4, 54, 6, 500_000)), + ("P1Y", (1, 0, 0, 0, 0, 0, 0, 0)), + ("P1M", (0, 1, 0, 0, 0, 0, 0, 0)), + ("P1W", (0, 0, 1, 0, 0, 0, 0, 0)), + ("P1.5W", (0, 0, 1, 3, 12, 0, 0, 0)), + ("P1,5W", (0, 0, 1, 3, 12, 0, 0, 0)), + ("P1D", (0, 0, 0, 1, 0, 0, 0, 0)), + ("P1.5D", (0, 0, 0, 1, 12, 0, 0, 0)), + ("P1,5D", (0, 0, 0, 1, 12, 0, 0, 0)), + ("PT1H", (0, 0, 0, 0, 1, 0, 0, 0)), + ("PT1.5H", (0, 0, 0, 0, 1, 30, 0, 0)), + ("PT1,5H", (0, 0, 0, 0, 1, 30, 0, 0)), + ("P2Y30M4DT5H6M7S", (2, 30, 0, 4, 5, 6, 7, 0)), + ], +) +def test_parse_ios8601_duration( + text: str, expected: tuple[int, int, int, int, int, int, int, int] +) -> None: + parsed = parse_iso8601(text) + + assert ( + parsed.years, + parsed.months, + parsed.weeks, + parsed.remaining_days, + parsed.hours, + parsed.minutes, + parsed.remaining_seconds, + parsed.microseconds, + ) == expected diff --git a/tests/parsing/test_parsing.py b/tests/parsing/test_parsing.py index 35dcf868..d57b82f8 100644 --- a/tests/parsing/test_parsing.py +++ b/tests/parsing/test_parsing.py @@ -276,19 +276,6 @@ def test_iso8601_datetime(): assert parsed.microsecond == 0 assert parsed.utcoffset().total_seconds() == 19800 - text = "20161001T1430,4+0530" - - parsed = parse(text) - - assert parsed.year == 2016 - assert parsed.month == 10 - assert parsed.day == 1 - assert parsed.hour == 14 - assert parsed.minute == 30 - assert parsed.second == 0 - assert parsed.microsecond == 400000 - assert parsed.utcoffset().total_seconds() == 19800 - text = "2008-09-03T20:56:35.450686+01" parsed = parse(text) @@ -480,7 +467,7 @@ def test_iso8601_ordinal(): def test_iso8601_time(): now = pendulum.datetime(2015, 11, 12) - text = "201205" + text = "T201205" parsed = parse(text, now=now) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 7756a063..b9ab63f8 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -48,6 +48,7 @@ def test_precise_diff() -> None: diff = precise_diff(dt1, dt2) assert_diff(diff, months=-1, seconds=-1) + assert diff.total_days == -30 diff = precise_diff(dt2, dt1) assert_diff(diff, months=1, seconds=1) @@ -93,11 +94,13 @@ def test_precise_diff_timezone() -> None: diff = precise_diff(dt1, dt2) assert_diff(diff, days=1, hours=0) + assert diff.total_days == 1 dt2 = toronto.datetime(2013, 4, 1, 1, 30) diff = precise_diff(dt1, dt2) assert_diff(diff, days=1, hours=5) + assert diff.total_days == 1 # pytz paris_pytz = pytz.timezone("Europe/Paris") @@ -108,6 +111,7 @@ def test_precise_diff_timezone() -> None: diff = precise_diff(dt1, dt2) assert_diff(diff, days=1, hours=0) + assert diff.total_days == 1 dt2 = toronto_pytz.localize(datetime(2013, 4, 1, 1, 30)) @@ -119,6 +123,7 @@ def test_precise_diff_timezone() -> None: dt2 = timezone("Europe/Paris").datetime(2018, 6, 20, 3, 40) # UTC+2 diff = precise_diff(dt1, dt2) assert_diff(diff, minutes=10) + assert diff.total_days == 0 def test_week_day() -> None: