From 7d166f70b4511392520fe8e1d5390c656eaa788f Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 11 Apr 2025 16:43:51 +0100 Subject: [PATCH 01/52] ci: reduce number of jobs & improve coverage measurement (#5052) * ci: run coverage as part of "build" job * merge free-threaded tests into main build jobs * make `test-introspection` jobs only run on PRs if requested * fix 3.7 & 3.8 build error * fix `check-feature-powerset` --- .github/workflows/build.yml | 53 +++++++- .github/workflows/ci.yml | 126 ++++++-------------- examples/setuptools-rust-starter/noxfile.py | 3 + noxfile.py | 8 +- 4 files changed, 95 insertions(+), 95 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95b667ac946..d6d246f0ed9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,15 @@ jobs: if: ${{ !(startsWith(inputs.python-version, 'graalpy') && startsWith(inputs.os, 'windows')) }} steps: - uses: actions/checkout@v4 + with: + # For PRs, we need to run on the real PR head, not the resultant merge of the PR into the target branch. + # + # This is necessary for coverage reporting to make sense; we then get exactly the coverage change + # between the base branch and the real PR head. + # + # If it were run on the merge commit the problem is that the coverage potentially does not align + # with the commit diff, because the merge may affect line numbers. + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Python ${{ inputs.python-version }} uses: actions/setup-python@v5 @@ -40,7 +49,7 @@ jobs: - name: Install nox run: python -m pip install --upgrade pip && pip install nox - - if: inputs.python-version == 'graalpy24.1' + - if: ${{ startsWith(inputs.python-version, 'graalpy24.1') }} name: Install GraalPy virtualenv (only GraalPy 24.1) run: python -m pip install 'git+https://github.com/oracle/graalpython#egg=graalpy_virtualenv_seeder&subdirectory=graalpy_virtualenv_seeder' @@ -49,8 +58,17 @@ jobs: with: toolchain: ${{ inputs.rust }} targets: ${{ inputs.rust-target }} - # needed to correctly format errors, see #1865 - components: rust-src + # rust-src needed to correctly format errors, see #1865 + components: rust-src,llvm-tools-preview + + # On windows 32 bit, we are running on an x64 host, so we need to specifically set the target + # NB we don't do this for *all* jobs because it breaks coverage of proc macros to have an + # explicit target set. + - name: Set Rust target for Windows 32-bit + if: inputs.os == 'windows-latest' && inputs.python-architecture == 'x86' + shell: bash + run: | + echo "CARGO_BUILD_TARGET=i686-pc-windows-msvc" >> $GITHUB_ENV - uses: Swatinem/rust-cache@v2 with: @@ -72,6 +90,16 @@ jobs: name: Prepare to test on nightly rust run: echo "MAYBE_NIGHTLY=nightly" >> "$GITHUB_ENV" + - if: ${{ github.event_name != 'merge_group' }} + name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - if: ${{ github.event_name != 'merge_group' }} + name: Prepare coverage environment + run: | + cargo llvm-cov clean --workspace --profraw-only + nox -s set-coverage-env + - name: Build docs run: nox -s docs @@ -154,9 +182,26 @@ jobs: if: ${{ endsWith(inputs.python-version, '-dev') || (steps.ffi-changes.outputs.changed == 'true' && inputs.rust == 'stable' && !startsWith(inputs.python-version, 'graalpy') && !(inputs.python-version == 'pypy3.9' && contains(inputs.os, 'windows'))) }} run: nox -s ffi-check + - if: ${{ github.event_name != 'merge_group' }} + name: Generate coverage report + run: cargo llvm-cov + --package=pyo3 + --package=pyo3-build-config + --package=pyo3-macros-backend + --package=pyo3-macros + --package=pyo3-ffi + report --codecov --output-path coverage.json + + - if: ${{ github.event_name != 'merge_group' }} + name: Upload coverage report + uses: codecov/codecov-action@v4 + with: + file: coverage.json + name: ${{ inputs.os }}/${{ inputs.python-version }}/${{ inputs.rust }} + token: ${{ secrets.CODECOV_TOKEN }} + env: CARGO_TERM_VERBOSE: true - CARGO_BUILD_TARGET: ${{ inputs.rust-target }} RUST_BACKTRACE: 1 RUSTFLAGS: "-D warnings" RUSTDOCFLAGS: "-D warnings" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 227dcdc3825..9c8bdce4d67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,6 +225,17 @@ jobs: python-architecture: "x64", rust-target: "x86_64-unknown-linux-gnu", } + # Also test free-threaded Python just for latest Python version, on ubuntu + # (run for all OSes on build-full) + - rust: stable + python-version: "3.13t" + platform: + { + os: "ubuntu-latest", + python-architecture: "x64", + rust-target: "x86_64-unknown-linux-gnu", + } + build-full: if: ${{ contains(github.event.pull_request.labels.*.name, 'CI-build-full') || github.event_name != 'pull_request' }} name: python${{ matrix.python-version }}-${{ matrix.platform.python-architecture }} ${{ matrix.platform.os }} rust-${{ matrix.rust }} @@ -252,6 +263,7 @@ jobs: "3.11", "3.12", "3.13", + "3.13t", "pypy3.9", "pypy3.10", "pypy3.11", @@ -460,35 +472,6 @@ jobs: components: rust-src - run: cargo rustdoc --lib --no-default-features --features full,jiff-02 -Zunstable-options --config "build.rustdocflags=[\"--cfg\", \"docsrs\"]" - coverage: - if: ${{ github.event_name != 'merge_group' }} - needs: [fmt] - name: coverage ${{ matrix.os }} - strategy: - matrix: - os: ["windows-latest", "macos-latest", "ubuntu-latest"] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'CI-save-pr-cache') }} - - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview,rust-src - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - run: python -m pip install --upgrade pip && pip install nox - - run: nox -s coverage - - uses: codecov/codecov-action@v5 - with: - files: coverage.json - name: ${{ matrix.os }} - token: ${{ secrets.CODECOV_TOKEN }} - emscripten: name: emscripten if: ${{ contains(github.event.pull_request.labels.*.name, 'CI-build-full') || github.event_name != 'pull_request' }} @@ -575,43 +558,6 @@ jobs: echo PYO3_CONFIG_FILE=$PYO3_CONFIG_FILE >> $GITHUB_ENV - run: python3 -m nox -s test - test-free-threaded: - needs: [fmt] - name: Free threaded tests - ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: ["ubuntu-latest", "macos-latest", "windows-latest"] - steps: - - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'CI-save-pr-cache') }} - - uses: dtolnay/rust-toolchain@stable - with: - components: rust-src - - uses: actions/setup-python@v5.5.0 - with: - python-version: "3.13t" - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - run: python3 -m sysconfig - - run: python3 -m pip install --upgrade pip && pip install nox - - name: Prepare coverage environment - run: | - cargo llvm-cov clean --workspace --profraw-only - nox -s set-coverage-env - - run: nox -s ffi-check - - run: nox - - name: Generate coverage report - run: nox -s generate-coverage-report - - name: Upload coverage report - uses: codecov/codecov-action@v5 - with: - files: coverage.json - name: ${{ matrix.os }}-test-free-threaded - token: ${{ secrets.CODECOV_TOKEN }} - test-version-limits: needs: [fmt] if: ${{ contains(github.event.pull_request.labels.*.name, 'CI-build-full') || github.event_name != 'pull_request' }} @@ -758,30 +704,32 @@ jobs: test-introspection: needs: [fmt] + if: ${{ contains(github.event.pull_request.labels.*.name, 'CI-test-introspection') || contains(github.event.pull_request.labels.*.name, 'CI-build-full') || github.event_name != 'pull_request' }} strategy: matrix: - platform: [ - { - os: "macos-latest", - python-architecture: "arm64", - rust-target: "aarch64-apple-darwin", - }, - { - os: "ubuntu-latest", - python-architecture: "x64", - rust-target: "x86_64-unknown-linux-gnu", - }, - { - os: "windows-latest", - python-architecture: "x64", - rust-target: "x86_64-pc-windows-msvc", - }, - { - os: "windows-latest", - python-architecture: "x86", - rust-target: "i686-pc-windows-msvc", - }, - ] + platform: + [ + { + os: "macos-latest", + python-architecture: "arm64", + rust-target: "aarch64-apple-darwin", + }, + { + os: "ubuntu-latest", + python-architecture: "x64", + rust-target: "x86_64-unknown-linux-gnu", + }, + { + os: "windows-latest", + python-architecture: "x64", + rust-target: "x86_64-pc-windows-msvc", + }, + { + os: "windows-latest", + python-architecture: "x86", + rust-target: "i686-pc-windows-msvc", + }, + ] runs-on: ${{ matrix.platform.os }} steps: - uses: actions/checkout@v4 @@ -811,10 +759,8 @@ jobs: - valgrind - careful - docsrs - - coverage - emscripten - test-debug - - test-free-threaded - test-version-limits - check-feature-powerset - test-cross-compilation diff --git a/examples/setuptools-rust-starter/noxfile.py b/examples/setuptools-rust-starter/noxfile.py index 222be921062..d3579912ea7 100644 --- a/examples/setuptools-rust-starter/noxfile.py +++ b/examples/setuptools-rust-starter/noxfile.py @@ -1,8 +1,11 @@ import nox +import sys @nox.session def python(session: nox.Session): + if sys.version_info < (3, 9): + session.skip("Python 3.9 or later is required for setuptools-rust 1.11") session.env["SETUPTOOLS_RUST_CARGO_PROFILE"] = "dev" session.install(".[dev]") session.run("pytest") diff --git a/noxfile.py b/noxfile.py index f17d40aff14..ea932efd414 100644 --- a/noxfile.py +++ b/noxfile.py @@ -72,6 +72,9 @@ def _supported_interpreter_versions( min_minor = int(min_version.split(".")[1]) max_minor = int(max_version.split(".")[1]) versions = [f"{major}.{minor}" for minor in range(min_minor, max_minor + 1)] + # Add free-threaded builds for 3.13+ + if python_impl == "cpython": + versions += [f"{major}.{minor}t" for minor in range(13, max_minor + 1)] return versions @@ -725,7 +728,10 @@ def check_feature_powerset(session: nox.Session): cargo_toml = toml.loads((PYO3_DIR / "Cargo.toml").read_text()) - EXPECTED_ABI3_FEATURES = {f"abi3-py3{ver.split('.')[1]}" for ver in PY_VERSIONS} + # free-threaded builds do not support ABI3 (yet) + EXPECTED_ABI3_FEATURES = { + f"abi3-py3{ver.split('.')[1]}" for ver in PY_VERSIONS if not ver.endswith("t") + } EXCLUDED_FROM_FULL = { "nightly", From 73136ea9425b2370c47714d62dc84a102d24e625 Mon Sep 17 00:00:00 2001 From: Owen Leung Date: Tue, 15 Apr 2025 00:22:41 +0800 Subject: [PATCH 02/52] Impl time crate into pyo3 (#5057) * First draft for implementing time crate into PyO3 * Refactor FromPyObject for PrimitiveDateTime. Disable proptest * Refactor FromPyObject for OffsetDateTime and UtcDateTime * Create extract_date_time function for reusable code * Fix all proptests * Fix doc test * Add changelog * Downgrade time crate version for msrv. Fix clippy error * Remove UtcDateTime * Fix fmt CI failure * Bring back UtcDateTime. Bump time crate to 0.3.38. Add features doc. * Add time feature to noxfile * use timezone_utc. add const SECONDS_PER_DAY. Revise doc example * Fix FromPyObject for UtcDateTime * `intern` only used on limited api --------- Co-authored-by: Icxolu <10486322+Icxolu@users.noreply.github.com> --- Cargo.toml | 1 + guide/src/features.md | 11 + newsfragments/5057.added.md | 1 + noxfile.py | 16 +- src/conversions/mod.rs | 1 + src/conversions/time.rs | 1508 +++++++++++++++++++++++++++++++++++ src/types/datetime.rs | 2 +- 7 files changed, 1535 insertions(+), 5 deletions(-) create mode 100644 newsfragments/5057.added.md create mode 100644 src/conversions/time.rs diff --git a/Cargo.toml b/Cargo.toml index f0047d1a162..da573adbd68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ num-bigint = { version = "0.4.2", optional = true } num-complex = { version = ">= 0.4.6, < 0.5", optional = true } num-rational = { version = "0.4.1", optional = true } rust_decimal = { version = "1.15", default-features = false, optional = true } +time = { version = "0.3.38", default-features = false, optional = true } serde = { version = "1.0", optional = true } smallvec = { version = "1.0", optional = true } uuid = { version = "1.11.0", optional = true } diff --git a/guide/src/features.md b/guide/src/features.md index 4ecd14e23fa..971a60b0e25 100644 --- a/guide/src/features.md +++ b/guide/src/features.md @@ -184,6 +184,17 @@ Adds a dependency on [num-rational](https://docs.rs/num-rational) and enables co Adds a dependency on [rust_decimal](https://docs.rs/rust_decimal) and enables conversions into its [`Decimal`](https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html) type. +### `time` + +Adds a dependency on [time](https://docs.rs/time) and requires MSRV 1.67.1. Enables conversions between [time](https://docs.rs/time)'s types and Python: +- [Date](https://docs.rs/time/0.3.38/time/struct.Date.html) -> [`PyDate`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDate.html) +- [Time](https://docs.rs/time/0.3.38/time/struct.Time.html) -> [`PyTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTime.html) +- [OffsetDateTime](https://docs.rs/time/0.3.38/time/struct.OffsetDateTime.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html) +- [PrimitiveDateTime](https://docs.rs/time/0.3.38/time/struct.PrimitiveDateTime.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html) +- [Duration](https://docs.rs/time/0.3.38/time/struct.Duration.html) -> [`PyDelta`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDelta.html) +- [UtcOffset](https://docs.rs/time/0.3.38/time/struct.UtcOffset.html) -> [`PyTzInfo`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTzInfo.html) +- [UtcDateTime](https://docs.rs/time/0.3.38/time/struct.UtcDateTime.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html) + ### `serde` Enables (de)serialization of `Py` objects via [serde](https://serde.rs/). diff --git a/newsfragments/5057.added.md b/newsfragments/5057.added.md new file mode 100644 index 00000000000..0bfcfaa9d10 --- /dev/null +++ b/newsfragments/5057.added.md @@ -0,0 +1 @@ +Integrate `time` crate into PyO3 \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index ea932efd414..08adc206172 100644 --- a/noxfile.py +++ b/noxfile.py @@ -101,9 +101,9 @@ def test_rust(session: nox.Session): if not FREE_THREADED_BUILD: _run_cargo_test(session, features="abi3") if "skip-full" not in session.posargs: - _run_cargo_test(session, features="full jiff-02") + _run_cargo_test(session, features="full jiff-02 time") if not FREE_THREADED_BUILD: - _run_cargo_test(session, features="abi3 full jiff-02") + _run_cargo_test(session, features="abi3 full jiff-02 time") @nox.session(name="test-py", venv_backend="none") @@ -429,6 +429,10 @@ def docs(session: nox.Session) -> None: features = "full" + if get_rust_version()[:2] >= (1, 67): + # time needs MSRC 1.67+ + features += ",time" + if get_rust_version()[:2] >= (1, 70): # jiff needs MSRC 1.70+ features += ",jiff-02" @@ -819,8 +823,8 @@ def update_ui_tests(session: nox.Session): env["TRYBUILD"] = "overwrite" command = ["test", "--test", "test_compile_error"] _run_cargo(session, *command, env=env) - _run_cargo(session, *command, "--features=full,jiff-02", env=env) - _run_cargo(session, *command, "--features=abi3,full,jiff-02", env=env) + _run_cargo(session, *command, "--features=full,jiff-02,time", env=env) + _run_cargo(session, *command, "--features=abi3,full,jiff-02,time", env=env) @nox.session(name="test-introspection") @@ -891,6 +895,10 @@ def _get_feature_sets() -> Generator[Tuple[str, ...], None, None]: # multiple-pymethods not supported on wasm features += ",multiple-pymethods" + if get_rust_version()[:2] >= (1, 67): + # time needs MSRC 1.67+ + features += ",time" + if get_rust_version()[:2] >= (1, 70): # jiff needs MSRC 1.70+ features += ",jiff-02" diff --git a/src/conversions/mod.rs b/src/conversions/mod.rs index 0afd39745c9..5403916ba8d 100644 --- a/src/conversions/mod.rs +++ b/src/conversions/mod.rs @@ -16,4 +16,5 @@ pub mod rust_decimal; pub mod serde; pub mod smallvec; mod std; +pub mod time; pub mod uuid; diff --git a/src/conversions/time.rs b/src/conversions/time.rs new file mode 100644 index 00000000000..fa2fb8a422a --- /dev/null +++ b/src/conversions/time.rs @@ -0,0 +1,1508 @@ +#![cfg(feature = "time")] + +//! Conversions to and from [time](https://docs.rs/time/)’s `Date`, +//! `Duration`, `OffsetDateTime`, `PrimitiveDateTime`, `Time`, `UtcDateTime` and `UtcOffset`. +//! +//! # Setup +//! +//! To use this feature, add this to your **`Cargo.toml`**: +//! +//! ```toml +//! [dependencies] +//! time = "0.3" +#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"time\"] }")] +//! ``` +//! +//! Note that you must use compatible versions of time and PyO3. +//! The required time version may vary based on the version of PyO3. +//! +//! ```rust +//! use time::{Duration, OffsetDateTime, PrimitiveDateTime, Date, Time, Month}; +//! use pyo3::{Python, PyResult, IntoPyObject, types::PyAnyMethods}; +//! +//! fn main() -> PyResult<()> { +//! pyo3::prepare_freethreaded_python(); +//! Python::with_gil(|py| { +//! // Create a fixed date and time (2022-01-01 12:00:00 UTC) +//! let date = Date::from_calendar_date(2022, Month::January, 1).unwrap(); +//! let time = Time::from_hms(12, 0, 0).unwrap(); +//! let primitive_dt = PrimitiveDateTime::new(date, time); +//! +//! // Convert to OffsetDateTime with UTC offset +//! let datetime = primitive_dt.assume_utc(); +//! +//! // Create a duration of 1 hour +//! let duration = Duration::hours(1); +//! +//! // Convert to Python objects +//! let py_datetime = datetime.into_pyobject(py)?; +//! let py_timedelta = duration.into_pyobject(py)?; +//! +//! // Add the duration to the datetime in Python +//! let py_result = py_datetime.add(py_timedelta)?; +//! +//! // Convert the result back to Rust +//! let result: OffsetDateTime = py_result.extract()?; +//! assert_eq!(result.hour(), 13); +//! +//! Ok(()) +//! }) +//! } +//! ``` + +use crate::exceptions::{PyTypeError, PyValueError}; +#[cfg(Py_LIMITED_API)] +use crate::intern; +use crate::types::datetime::timezone_from_offset; +#[cfg(not(Py_LIMITED_API))] +use crate::types::datetime::{PyDateAccess, PyDeltaAccess}; +use crate::types::{ + timezone_utc, PyAnyMethods, PyDate, PyDateTime, PyDelta, PyNone, PyTime, PyTzInfo, +}; +#[cfg(not(Py_LIMITED_API))] +use crate::types::{PyTimeAccess, PyTzInfoAccess}; +use crate::{Bound, FromPyObject, IntoPyObject, PyAny, PyErr, PyResult, Python}; +use time::{ + Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcDateTime, UtcOffset, +}; + +const SECONDS_PER_DAY: i64 = 86_400; + +// Macro for reference implementation +macro_rules! impl_into_py_for_ref { + ($type:ty, $target:ty) => { + impl<'py> IntoPyObject<'py> for &$type { + type Target = $target; + type Output = Bound<'py, Self::Target>; + type Error = PyErr; + + #[inline] + fn into_pyobject(self, py: Python<'py>) -> Result { + (*self).into_pyobject(py) + } + } + }; +} + +// Macro for month conversion +macro_rules! month_from_number { + ($month:expr) => { + match $month { + 1 => Month::January, + 2 => Month::February, + 3 => Month::March, + 4 => Month::April, + 5 => Month::May, + 6 => Month::June, + 7 => Month::July, + 8 => Month::August, + 9 => Month::September, + 10 => Month::October, + 11 => Month::November, + 12 => Month::December, + _ => return Err(PyValueError::new_err("invalid month value")), + } + }; +} + +fn extract_date_time(dt: &Bound<'_, PyAny>) -> PyResult<(Date, Time)> { + #[cfg(not(Py_LIMITED_API))] + { + let dt = dt.downcast::()?; + let date = Date::from_calendar_date( + dt.get_year(), + month_from_number!(dt.get_month()), + dt.get_day(), + ) + .map_err(|_| PyValueError::new_err("invalid or out-of-range date"))?; + + let time = Time::from_hms_micro( + dt.get_hour(), + dt.get_minute(), + dt.get_second(), + dt.get_microsecond(), + ) + .map_err(|_| PyValueError::new_err("invalid or out-of-range time"))?; + Ok((date, time)) + } + + #[cfg(Py_LIMITED_API)] + { + let date = Date::from_calendar_date( + dt.getattr(intern!(dt.py(), "year"))?.extract()?, + month_from_number!(dt.getattr(intern!(dt.py(), "month"))?.extract::()?), + dt.getattr(intern!(dt.py(), "day"))?.extract()?, + ) + .map_err(|_| PyValueError::new_err("invalid or out-of-range date"))?; + + let time = Time::from_hms_micro( + dt.getattr(intern!(dt.py(), "hour"))?.extract()?, + dt.getattr(intern!(dt.py(), "minute"))?.extract()?, + dt.getattr(intern!(dt.py(), "second"))?.extract()?, + dt.getattr(intern!(dt.py(), "microsecond"))?.extract()?, + ) + .map_err(|_| PyValueError::new_err("invalid or out-of-range time"))?; + + Ok((date, time)) + } +} + +impl<'py> IntoPyObject<'py> for Duration { + type Target = PyDelta; + type Output = Bound<'py, Self::Target>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let total_seconds = self.whole_seconds(); + let micro_seconds = self.subsec_microseconds(); + + // For negative durations, Python expects days to be negative and + // seconds/microseconds to be positive or zero + let (days, seconds) = if total_seconds < 0 && total_seconds % SECONDS_PER_DAY != 0 { + // For negative values, we need to round down (toward more negative) + // e.g., -10 seconds should be -1 days + 86390 seconds + let days = total_seconds.div_euclid(SECONDS_PER_DAY); + let seconds = total_seconds.rem_euclid(SECONDS_PER_DAY); + (days, seconds) + } else { + // For positive or exact negative days, use normal division + ( + total_seconds / SECONDS_PER_DAY, + total_seconds % SECONDS_PER_DAY, + ) + }; + // Create the timedelta with days, seconds, microseconds + // Safe to unwrap as we've verified the values are within bounds + PyDelta::new( + py, + days.try_into().expect("days overflow"), + seconds.try_into().expect("seconds overflow"), + micro_seconds, + true, + ) + } +} + +impl FromPyObject<'_> for Duration { + fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { + #[cfg(not(Py_LIMITED_API))] + let (days, seconds, microseconds) = { + let delta = ob.downcast::()?; + ( + delta.get_days().into(), + delta.get_seconds().into(), + delta.get_microseconds().into(), + ) + }; + + #[cfg(Py_LIMITED_API)] + let (days, seconds, microseconds) = { + ( + ob.getattr(intern!(ob.py(), "days"))?.extract()?, + ob.getattr(intern!(ob.py(), "seconds"))?.extract()?, + ob.getattr(intern!(ob.py(), "microseconds"))?.extract()?, + ) + }; + + Ok( + Duration::days(days) + + Duration::seconds(seconds) + + Duration::microseconds(microseconds), + ) + } +} + +impl<'py> IntoPyObject<'py> for Date { + type Target = PyDate; + type Output = Bound<'py, Self::Target>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let year = self.year(); + let month = self.month() as u8; + let day = self.day(); + + PyDate::new(py, year, month, day) + } +} + +impl FromPyObject<'_> for Date { + fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { + let (year, month, day) = { + #[cfg(not(Py_LIMITED_API))] + { + let date = ob.downcast::()?; + (date.get_year(), date.get_month(), date.get_day()) + } + + #[cfg(Py_LIMITED_API)] + { + let year = ob.getattr(intern!(ob.py(), "year"))?.extract()?; + let month: u8 = ob.getattr(intern!(ob.py(), "month"))?.extract()?; + let day = ob.getattr(intern!(ob.py(), "day"))?.extract()?; + (year, month, day) + } + }; + + // Convert the month number to time::Month enum + let month = month_from_number!(month); + + Date::from_calendar_date(year, month, day) + .map_err(|_| PyValueError::new_err("invalid or out-of-range date")) + } +} + +impl<'py> IntoPyObject<'py> for Time { + type Target = PyTime; + type Output = Bound<'py, Self::Target>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + let hour = self.hour(); + let minute = self.minute(); + let second = self.second(); + let microsecond = self.microsecond(); + + PyTime::new(py, hour, minute, second, microsecond, None) + } +} + +impl FromPyObject<'_> for Time { + fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult