diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76c5ab06f29..63c6dd45d7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,6 +155,8 @@ jobs: save-if: ${{ github.event_name != 'merge_group' }} - run: python -m pip install --upgrade pip && pip install nox - run: nox -s clippy-all + env: + CARGO_TOOLCHAIN_VERSION: ${{ steps.rust-toolchain.outputs.name }} env: CARGO_BUILD_TARGET: ${{ matrix.platform.rust-target }} @@ -439,7 +441,7 @@ jobs: - uses: dtolnay/rust-toolchain@nightly with: components: rust-src - - run: cargo rustdoc --lib --no-default-features --features full -Zunstable-options --config "build.rustdocflags=[\"--cfg\", \"docsrs\"]" + - run: cargo rustdoc --lib --no-default-features --features full,jiff-01 -Zunstable-options --config "build.rustdocflags=[\"--cfg\", \"docsrs\"]" coverage: if: ${{ github.event_name != 'merge_group' }} diff --git a/Cargo.toml b/Cargo.toml index 18cceffbd0c..d1975f706f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ either = { version = "1.9", optional = true } eyre = { version = ">= 0.6.8, < 0.7", optional = true } hashbrown = { version = ">= 0.14.5, < 0.16", optional = true } indexmap = { version = ">= 2.5.0, < 3", optional = true } +jiff-01 = { package = "jiff", version = "0.1.18", optional = true } 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 } diff --git a/guide/src/features.md b/guide/src/features.md index d801e2dd1e4..b2423f01ec7 100644 --- a/guide/src/features.md +++ b/guide/src/features.md @@ -151,6 +151,19 @@ Adds a dependency on [hashbrown](https://docs.rs/hashbrown) and enables conversi Adds a dependency on [indexmap](https://docs.rs/indexmap) and enables conversions into its [`IndexMap`](https://docs.rs/indexmap/latest/indexmap/map/struct.IndexMap.html) type. +### `jiff-01` + +Adds a dependency on [jiff@0.1](https://docs.rs/jiff/0.1) and requires MSRV 1.70. Enables a conversion from [jiff](https://docs.rs/jiff)'s types to python: +- [Span](https://docs.rs/jiff/0.1/jiff/struct.Span.html) -> [`PyDelta`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDelta.html) +- [SignedDuration](https://docs.rs/jiff/0.1/jiff/struct.SignedDuration.html) -> [`PyDelta`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDelta.html) +- [TimeZone](https://docs.rs/jiff/0.1/jiff/tz/struct.TimeZone.html) -> [`PyTzInfo`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTzInfo.html) +- [Offset](https://docs.rs/jiff/0.1/jiff/tz/struct.Offset.html) -> [`PyTzInfo`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTzInfo.html) +- [Date](https://docs.rs/jiff/0.1/jiff/civil/struct.Date.html) -> [`PyDate`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDate.html) +- [Time](https://docs.rs/jiff/0.1/jiff/civil/struct.Time.html) -> [`PyTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyTime.html) +- [DateTime](https://docs.rs/jiff/0.1/jiff/civil/struct.DateTime.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html) +- [Zoned](https://docs.rs/jiff/0.1/jiff/struct.Zoned.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html) +- [Timestamp](https://docs.rs/jiff/0.1/jiff/struct.Timestamp.html) -> [`PyDateTime`]({{#PYO3_DOCS_URL}}/pyo3/types/struct.PyDateTime.html) + ### `num-bigint` Adds a dependency on [num-bigint](https://docs.rs/num-bigint) and enables conversions into its [`BigInt`](https://docs.rs/num-bigint/latest/num_bigint/struct.BigInt.html) and [`BigUint`](https://docs.rs/num-bigint/latest/num_bigint/struct.BigUint.html) types. diff --git a/newsfragments/4823.added.md b/newsfragments/4823.added.md new file mode 100644 index 00000000000..f51227a20b2 --- /dev/null +++ b/newsfragments/4823.added.md @@ -0,0 +1 @@ +Add jiff to/from python conversions. diff --git a/noxfile.py b/noxfile.py index ed7759f5f99..f55ef93bca8 100644 --- a/noxfile.py +++ b/noxfile.py @@ -10,7 +10,17 @@ from functools import lru_cache from glob import glob from pathlib import Path -from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Tuple, + Generator, +) import nox import nox.command @@ -55,9 +65,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") + _run_cargo_test(session, features="full jiff-01") if not FREE_THREADED_BUILD: - _run_cargo_test(session, features="abi3 full") + _run_cargo_test(session, features="abi3 full jiff-01") @nox.session(name="test-py", venv_backend="none") @@ -388,7 +398,7 @@ def docs(session: nox.Session) -> None: "doc", "--lib", "--no-default-features", - "--features=full", + "--features=full,jiff-01", "--no-deps", "--workspace", *cargo_flags, @@ -761,8 +771,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", env=env) - _run_cargo(session, *command, "--features=abi3,full", env=env) + _run_cargo(session, *command, "--features=full,jiff-01", env=env) + _run_cargo(session, *command, "--features=abi3,full,jiff-01", env=env) def _build_docs_for_ffi_check(session: nox.Session) -> None: @@ -795,30 +805,31 @@ def _get_rust_default_target() -> str: @lru_cache() -def _get_feature_sets() -> Tuple[Tuple[str, ...], ...]: +def _get_feature_sets() -> Generator[Tuple[str, ...], None, None]: """Returns feature sets to use for clippy job""" cargo_target = os.getenv("CARGO_BUILD_TARGET", "") + cargo_version = os.getenv("CARGO_TOOLCHAIN_VERSION", "") + + yield from ( + ("--no-default-features",), + ( + "--no-default-features", + "--features=abi3", + ), + ) + + features = "full" + if "wasm32-wasip1" not in cargo_target: # multiple-pymethods not supported on wasm - return ( - ("--no-default-features",), - ( - "--no-default-features", - "--features=abi3", - ), - ("--features=full multiple-pymethods",), - ("--features=abi3 full multiple-pymethods",), - ) - else: - return ( - ("--no-default-features",), - ( - "--no-default-features", - "--features=abi3", - ), - ("--features=full",), - ("--features=abi3 full",), - ) + features += ",multiple-pymethods" + + if tuple(int(part) for part in cargo_version.split(".")) >= (1, 70): + # jiff needs MSRC 1.70+ + features += ",jiff-01" + + yield (f"--features={features}",) + yield (f"--features=abi3,{features}",) _RELEASE_LINE_START = "release: " diff --git a/src/conversions/chrono.rs b/src/conversions/chrono.rs index 04febb43b78..342ed659e22 100644 --- a/src/conversions/chrono.rs +++ b/src/conversions/chrono.rs @@ -44,11 +44,13 @@ use crate::conversion::IntoPyObject; use crate::exceptions::{PyTypeError, PyUserWarning, PyValueError}; #[cfg(Py_LIMITED_API)] -use crate::sync::GILOnceCell; +use crate::intern; use crate::types::any::PyAnyMethods; #[cfg(not(Py_LIMITED_API))] use crate::types::datetime::timezone_from_offset; #[cfg(Py_LIMITED_API)] +use crate::types::datetime_abi3::{check_type, timezone_utc, DatetimeTypes}; +#[cfg(Py_LIMITED_API)] use crate::types::IntoPyDict; use crate::types::PyNone; #[cfg(not(Py_LIMITED_API))] @@ -57,8 +59,6 @@ use crate::types::{ PyTzInfo, PyTzInfoAccess, }; use crate::{ffi, Bound, FromPyObject, IntoPyObjectExt, PyAny, PyErr, PyObject, PyResult, Python}; -#[cfg(Py_LIMITED_API)] -use crate::{intern, DowncastError}; #[allow(deprecated)] use crate::{IntoPy, ToPyObject}; use chrono::offset::{FixedOffset, Utc}; @@ -811,54 +811,6 @@ fn py_time_to_naive_time(py_time: &Bound<'_, PyAny>) -> PyResult { .ok_or_else(|| PyValueError::new_err("invalid or out-of-range time")) } -#[cfg(Py_LIMITED_API)] -fn check_type(value: &Bound<'_, PyAny>, t: &PyObject, type_name: &'static str) -> PyResult<()> { - if !value.is_instance(t.bind(value.py()))? { - return Err(DowncastError::new(value, type_name).into()); - } - Ok(()) -} - -#[cfg(Py_LIMITED_API)] -struct DatetimeTypes { - date: PyObject, - datetime: PyObject, - time: PyObject, - timedelta: PyObject, - timezone: PyObject, - timezone_utc: PyObject, - tzinfo: PyObject, -} - -#[cfg(Py_LIMITED_API)] -impl DatetimeTypes { - fn get(py: Python<'_>) -> &Self { - Self::try_get(py).expect("failed to load datetime module") - } - - fn try_get(py: Python<'_>) -> PyResult<&Self> { - static TYPES: GILOnceCell = GILOnceCell::new(); - TYPES.get_or_try_init(py, || { - let datetime = py.import("datetime")?; - let timezone = datetime.getattr("timezone")?; - Ok::<_, PyErr>(Self { - date: datetime.getattr("date")?.into(), - datetime: datetime.getattr("datetime")?.into(), - time: datetime.getattr("time")?.into(), - timedelta: datetime.getattr("timedelta")?.into(), - timezone_utc: timezone.getattr("utc")?.into(), - timezone: timezone.into(), - tzinfo: datetime.getattr("tzinfo")?.into(), - }) - }) - } -} - -#[cfg(Py_LIMITED_API)] -fn timezone_utc(py: Python<'_>) -> Bound<'_, PyAny> { - DatetimeTypes::get(py).timezone_utc.bind(py).clone() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/conversions/jiff.rs b/src/conversions/jiff.rs new file mode 100644 index 00000000000..bf2c399dddb --- /dev/null +++ b/src/conversions/jiff.rs @@ -0,0 +1,1349 @@ +#![cfg(feature = "jiff-01")] + +//! Conversions to and from [jiff](https://docs.rs/jiff/)’s `Span`, `SignedDuration`, `TimeZone`, +//! `Offset`, `Date`, `Time`, `DateTime`, `Zoned`, and `Timestamp`. +//! +//! # Setup +//! +//! To use this feature, add this to your **`Cargo.toml`**: +//! +//! ```toml +//! [dependencies] +//! jiff = "0.1" +#![doc = concat!("pyo3 = { version = \"", env!("CARGO_PKG_VERSION"), "\", features = [\"jiff-01\"] }")] +//! ``` +//! +//! Note that you must use compatible versions of jiff and PyO3. +//! The required jiff version may vary based on the version of PyO3. Jiff also requires a MSRV +//! of 1.70. +//! +//! # Example: Convert a `datetime.datetime` to jiff `Zoned` +//! +//! ```rust +//! # #![cfg(not(windows))] +//! # use jiff_01 as jiff; +//! use jiff::{Zoned, ToSpan}; +//! use pyo3::{Python, PyResult, IntoPyObject, types::PyAnyMethods}; +//! +//! fn main() -> PyResult<()> { +//! pyo3::prepare_freethreaded_python(); +//! Python::with_gil(|py| { +//! // Build some jiff values +//! let jiff_zoned = Zoned::now(); +//! let jiff_span = 1.second(); +//! // Convert them to Python +//! let py_datetime = jiff_zoned.into_pyobject(py)?; +//! let py_timedelta = jiff_span.into_pyobject(py)?; +//! // Do an operation in Python +//! let py_sum = py_datetime.call_method1("__add__", (py_timedelta,))?; +//! // Convert back to Rust +//! let jiff_sum: Zoned = py_sum.extract()?; +//! println!("Zoned: {}", jiff_sum); +//! Ok(()) +//! }) +//! } +//! ``` +use crate::exceptions::{PyTypeError, PyValueError}; +use crate::pybacked::PyBackedStr; +use crate::sync::GILOnceCell; +#[cfg(not(Py_LIMITED_API))] +use crate::types::datetime::timezone_from_offset; +#[cfg(Py_LIMITED_API)] +use crate::types::datetime_abi3::{check_type, timezone_utc, DatetimeTypes}; +#[cfg(Py_LIMITED_API)] +use crate::types::IntoPyDict; +#[cfg(not(Py_LIMITED_API))] +use crate::types::{ + timezone_utc, PyDate, PyDateAccess, PyDateTime, PyDelta, PyDeltaAccess, PyTime, PyTimeAccess, + PyTzInfo, PyTzInfoAccess, +}; +use crate::types::{PyAnyMethods, PyNone, PyType}; +use crate::{intern, Bound, FromPyObject, IntoPyObject, Py, PyAny, PyErr, PyResult, Python}; +use jiff::civil::{Date, DateTime, Time}; +use jiff::tz::{Offset, TimeZone}; +use jiff::{SignedDuration, Span, Timestamp, Zoned}; +#[cfg(feature = "jiff-01")] +use jiff_01 as jiff; + +#[cfg(not(Py_LIMITED_API))] +fn datetime_to_pydatetime<'py>( + py: Python<'py>, + datetime: &DateTime, + fold: bool, + timezone: Option<&TimeZone>, +) -> PyResult> { + PyDateTime::new_with_fold( + py, + datetime.year().into(), + datetime.month().try_into()?, + datetime.day().try_into()?, + datetime.hour().try_into()?, + datetime.minute().try_into()?, + datetime.second().try_into()?, + (datetime.subsec_nanosecond() / 1000).try_into()?, + timezone + .map(|tz| tz.into_pyobject(py)) + .transpose()? + .as_ref(), + fold, + ) +} + +#[cfg(Py_LIMITED_API)] +fn datetime_to_pydatetime<'py>( + py: Python<'py>, + datetime: &DateTime, + fold: bool, + timezone: Option<&TimeZone>, +) -> PyResult> { + DatetimeTypes::try_get(py)?.datetime.bind(py).call( + ( + datetime.year(), + datetime.month(), + datetime.day(), + datetime.hour(), + datetime.minute(), + datetime.second(), + datetime.subsec_nanosecond() / 1000, + timezone, + ), + Some(&[("fold", fold as u8)].into_py_dict(py)?), + ) +} + +#[cfg(not(Py_LIMITED_API))] +fn pytime_to_time(time: &impl PyTimeAccess) -> PyResult