Skip to content

Commit 27e2edf

Browse files
committed
Add support for free-threaded Python (PEP 703)
Builds on #940 — completes the remaining work for free-threaded support. Rust: - Add `gil_used = false` to `#[pymodule]` (PyO3 0.27 defaults to true) - Codebase has zero global state, zero unsafe — all functions are pure CI (tests.yml): - Add 3.13t and 3.14t to test matrix - Skip mypy/typing deps for free-threaded builds (not yet compatible) CI (release.yml): - Add free-threaded wheel build step (3.13t/3.14t) in the build job - Skipped for PyPy matrix entries Tests: - Add concurrent thread-safety tests for parse, now, duration, diff, format
1 parent ae4c405 commit 27e2edf

File tree

4 files changed

+75
-2
lines changed

4 files changed

+75
-2
lines changed

.github/workflows/release.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ jobs:
7070
rust-toolchain: stable
7171
docker-options: -e CI
7272

73+
- name: build free-threaded wheels
74+
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4
75+
if: ${{ !contains(matrix.interpreter || '', 'pypy') }}
76+
with:
77+
target: ${{ matrix.target }}
78+
manylinux: ${{ matrix.manylinux || 'auto' }}
79+
args: --release --out dist --interpreter 3.13t 3.14t
80+
rust-toolchain: stable
81+
docker-options: -e CI
82+
7383
- run: ${{ matrix.ls || 'ls -lh' }} dist/
7484

7585
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0

.github/workflows/tests.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
strategy:
3434
matrix:
3535
os: [Ubuntu, MacOS, Windows]
36-
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
36+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.13t", "3.14t"]
3737
defaults:
3838
run:
3939
shell: bash
@@ -73,16 +73,23 @@ jobs:
7373

7474
- name: Install runtime, testing, and typing dependencies
7575
run: poetry install --only main --only test --only typing --only build --no-root -vvv
76+
if: ${{ !endsWith(matrix.python-version, 't') }}
77+
78+
- name: Install runtime and testing dependencies (free-threaded)
79+
run: poetry install --only main --only test --only build --no-root -vvv
80+
if: ${{ endsWith(matrix.python-version, 't') }}
7681

7782
- name: Install project
7883
run: poetry run maturin develop
7984

8085
- name: Run type checking
8186
run: poetry run mypy
87+
if: ${{ !endsWith(matrix.python-version, 't') }}
8288

8389
- name: Uninstall typing dependencies
8490
# This ensures pendulum runs without typing_extensions installed
8591
run: poetry sync --only main --only test --only build --no-root -vvv
92+
if: ${{ !endsWith(matrix.python-version, 't') }}
8693

8794
- name: Test Pure Python
8895
run: |

rust/src/python/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use helpers::{days_in_year, is_leap, is_long_year, local_time, precise_diff, wee
88
use parsing::parse_iso8601;
99
use types::{Duration, PreciseDiff};
1010

11-
#[pymodule]
11+
#[pymodule(gil_used = false)]
1212
pub fn _pendulum(_py: Python<'_>, m: &Bound<PyModule>) -> PyResult<()> {
1313
m.add_function(wrap_pyfunction!(days_in_year, m)?)?;
1414
m.add_function(wrap_pyfunction!(is_leap, m)?)?;

tests/test_thread_safety.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
import concurrent.futures
4+
5+
import pendulum
6+
7+
8+
ITERATIONS = 200
9+
WORKERS = 8
10+
11+
12+
def _run_parallel(fn, *args):
13+
with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as pool:
14+
futures = [pool.submit(fn, *args) for _ in range(ITERATIONS)]
15+
return [f.result() for f in futures]
16+
17+
18+
def test_parse_iso8601_threaded():
19+
results = _run_parallel(pendulum.parse, "2024-01-15T10:30:00+00:00")
20+
expected = results[0]
21+
assert all(r == expected for r in results)
22+
23+
24+
def test_now_threaded():
25+
results = _run_parallel(pendulum.now)
26+
assert all(isinstance(r, pendulum.DateTime) for r in results)
27+
28+
29+
def test_duration_threaded():
30+
def make_duration():
31+
return pendulum.duration(years=1, months=2, days=3)
32+
33+
results = _run_parallel(make_duration)
34+
assert all(r.years == 1 and r.months == 2 for r in results)
35+
36+
37+
def test_diff_threaded():
38+
dt1 = pendulum.datetime(2024, 1, 1)
39+
dt2 = pendulum.datetime(2024, 6, 15)
40+
41+
def compute_diff():
42+
return dt1.diff(dt2)
43+
44+
results = _run_parallel(compute_diff)
45+
expected = results[0]
46+
assert all(r.in_days() == expected.in_days() for r in results)
47+
48+
49+
def test_format_threaded():
50+
dt = pendulum.datetime(2024, 1, 15, 10, 30, 0)
51+
52+
def format_dt():
53+
return dt.format("YYYY-MM-DD HH:mm:ss")
54+
55+
results = _run_parallel(format_dt)
56+
assert all(r == "2024-01-15 10:30:00" for r in results)

0 commit comments

Comments
 (0)