diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a52fdb60..2ba62031 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,16 @@ jobs: rust-toolchain: stable docker-options: -e CI + - name: build free-threaded wheels + uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4 + if: ${{ !contains(matrix.interpreter || '', 'pypy') }} + with: + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux || 'auto' }} + args: --release --out dist --interpreter 3.13t 3.14t + rust-toolchain: stable + docker-options: -e CI + - run: ${{ matrix.ls || 'ls -lh' }} dist/ - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ccf6a585..fc93d95d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,7 @@ jobs: strategy: matrix: os: [Ubuntu, MacOS, Windows] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.13t", "3.14t"] defaults: run: shell: bash @@ -73,16 +73,23 @@ jobs: - name: Install runtime, testing, and typing dependencies run: poetry install --only main --only test --only typing --only build --no-root -vvv + if: ${{ !endsWith(matrix.python-version, 't') }} + + - name: Install runtime and testing dependencies (free-threaded) + run: poetry install --only main --only test --only build --no-root -vvv + if: ${{ endsWith(matrix.python-version, 't') }} - name: Install project run: poetry run maturin develop - name: Run type checking run: poetry run mypy + if: ${{ !endsWith(matrix.python-version, 't') }} - name: Uninstall typing dependencies # This ensures pendulum runs without typing_extensions installed run: poetry sync --only main --only test --only build --no-root -vvv + if: ${{ !endsWith(matrix.python-version, 't') }} - name: Test Pure Python run: | diff --git a/rust/src/python/mod.rs b/rust/src/python/mod.rs index 6adb0277..80085360 100644 --- a/rust/src/python/mod.rs +++ b/rust/src/python/mod.rs @@ -8,7 +8,7 @@ use helpers::{days_in_year, is_leap, is_long_year, local_time, precise_diff, wee use parsing::parse_iso8601; use types::{Duration, PreciseDiff}; -#[pymodule] +#[pymodule(gil_used = false)] pub fn _pendulum(_py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_function(wrap_pyfunction!(days_in_year, m)?)?; m.add_function(wrap_pyfunction!(is_leap, m)?)?; diff --git a/tests/test_thread_safety.py b/tests/test_thread_safety.py new file mode 100644 index 00000000..dbc86e64 --- /dev/null +++ b/tests/test_thread_safety.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import concurrent.futures + +import pendulum + + +ITERATIONS = 200 +WORKERS = 8 + + +def _run_parallel(fn, *args): + with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as pool: + futures = [pool.submit(fn, *args) for _ in range(ITERATIONS)] + return [f.result() for f in futures] + + +def test_parse_iso8601_threaded(): + results = _run_parallel(pendulum.parse, "2024-01-15T10:30:00+00:00") + expected = results[0] + assert all(r == expected for r in results) + + +def test_now_threaded(): + results = _run_parallel(pendulum.now) + assert all(isinstance(r, pendulum.DateTime) for r in results) + + +def test_duration_threaded(): + def make_duration(): + return pendulum.duration(years=1, months=2, days=3) + + results = _run_parallel(make_duration) + assert all(r.years == 1 and r.months == 2 for r in results) + + +def test_diff_threaded(): + dt1 = pendulum.datetime(2024, 1, 1) + dt2 = pendulum.datetime(2024, 6, 15) + + def compute_diff(): + return dt1.diff(dt2) + + results = _run_parallel(compute_diff) + expected = results[0] + assert all(r.in_days() == expected.in_days() for r in results) + + +def test_format_threaded(): + dt = pendulum.datetime(2024, 1, 15, 10, 30, 0) + + def format_dt(): + return dt.format("YYYY-MM-DD HH:mm:ss") + + results = _run_parallel(format_dt) + assert all(r == "2024-01-15 10:30:00" for r in results)