diff --git a/.github/workflows/ci_workflows.yml b/.github/workflows/ci_workflows.yml index 58740a4..91fb334 100644 --- a/.github/workflows/ci_workflows.yml +++ b/.github/workflows/ci_workflows.yml @@ -49,6 +49,13 @@ jobs: toxenv: test-devdeps toxargs: -v + - name: Test with freethreaded python + os: ubuntu-latest + python: '3.14t' + toxenv: test-freethreaded + toxargs: -v + toxposargs: --parallel-threads=100 + - name: Documentation build os: ubuntu-latest python: 3.x diff --git a/erfa/leap_seconds.py b/erfa/leap_seconds.py index 0d175b8..9a096f1 100644 --- a/erfa/leap_seconds.py +++ b/erfa/leap_seconds.py @@ -25,6 +25,7 @@ __all__ = ["get", "set", "update", "validate"] +import threading from datetime import datetime, timedelta from warnings import warn @@ -41,6 +42,10 @@ def __getattr__(name): return _expires_property() if name == "expired": return _expires_property() < datetime.now() + if name == "_expires": + return _expiration_data.expires + if name == "_expiration_days": + return _expiration_data.days raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -48,10 +53,15 @@ def __dir__(): return ["expired", "expires", "get", "set", "update", "validate"] -_expires = None -"""Explicit expiration date inferred from leap-second table.""" -_expiration_days = 180 -"""Number of days beyond last leap second at which table expires.""" +class _ExpirationData(threading.local): + def __init__(self): + # Explicit expiration date inferred from leap-second table. + self.expires = None + # Number of days beyond last leap second at which table expires. + self.days = 180 + + +_expiration_data = _ExpirationData() def get(): @@ -153,14 +163,13 @@ def set(table=None): If the leap seconds in the table are not on the 1st of January or July, or if the sorted TAI-UTC do not increase in increments of 1. """ - global _expires if table is None: expires = None else: table, expires = validate(table) set_leap_seconds(table) - _expires = expires + _expiration_data.expires = expires def _expires_property(): @@ -170,11 +179,11 @@ def _expires_property(): set the leap-second array, or a number of days beyond the last leap second. """ - if _expires is None: + if _expiration_data.expires is None: last = get()[-1] return (datetime(last["year"], last["month"], 1) + - timedelta(_expiration_days)) - return _expires + timedelta(_expiration_data.days)) + return _expiration_data.expires def update(table): @@ -204,7 +213,6 @@ def update(table): If the leap seconds in the table are not on the 1st of January or July, or if the sorted TAI-UTC do not increase in increments of 1. """ - global _expires table, expires = validate(table) # Get erfa table and check it is OK; if not, reset it. @@ -223,7 +231,7 @@ def update(table): # array is set, do not allow exceptions due to misformed expires). try: if expires is not None and expires > _expires_property(): - _expires = expires + _expiration_data.expires = expires except Exception as exc: warn("table 'expires' attribute ignored as comparing it " diff --git a/erfa/ufunc.c.templ b/erfa/ufunc.c.templ index 99aca2b..88c00fc 100644 --- a/erfa/ufunc.c.templ +++ b/erfa/ufunc.c.templ @@ -21,6 +21,7 @@ that do not support it (e.g., 3.13t) #include "numpy/ufuncobject.h" #include "erfa.h" #include "erfaextra.h" +#include "erfadatextra.h" // Backported NumPy 2 API (can be removed if numpy 2 is required) #if NPY_ABI_VERSION < 0x02000000 @@ -684,7 +685,11 @@ static PyObject * set_leap_seconds(PyObject *NPY_UNUSED(module), PyObject *args) { PyObject *leap_seconds = NULL; PyArrayObject *array; - static PyArrayObject *leap_second_array = NULL; + # ifdef ERFA_THREAD_LOCAL + static ERFA_THREAD_LOCAL PyArrayObject *leap_second_array = NULL; + # else + static PyArrayObject *leap_second_array = NULL; + # endif if (!PyArg_ParseTuple(args, "|O:set_leap_seconds", &leap_seconds)) { return NULL; @@ -790,6 +795,13 @@ PyMODINIT_FUNC PyInit_ufunc(void) if (m == NULL) { return NULL; } +// FIXME: also require ERFA_HAS_THREAD_LOCAL +#ifdef Py_GIL_DISABLED + # ifdef ERFA_THREAD_LOCAL + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); + # endif +#endif + d = PyModule_GetDict(m); /* borrowed ref. */ if (d == NULL) { goto fail; diff --git a/liberfa/erfa b/liberfa/erfa index 1d9738b..618946e 160000 --- a/liberfa/erfa +++ b/liberfa/erfa @@ -1 +1 @@ -Subproject commit 1d9738bed9954188722f976774d0903e5dae1857 +Subproject commit 618946ecce4299b5b5b68006b0ab05877de921ac diff --git a/tox.ini b/tox.ini index aab6e66..ee25275 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CC,CI setenv = devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + freethreaded: PYTHON_GIL = 0 # Run the tests in a temporary directory to make sure that we don't import # pyerfa from the source tree @@ -32,11 +33,13 @@ description = run tests devdeps: with the latest developer version of key dependencies oldestdeps: with the oldest supported version of key dependencies + freethreaded: with multi-threaded tests and a free-threaded python build # The following provides some specific pinnings for key packages deps = oldestdeps: numpy==1.21.* devdeps: numpy>=0.0.dev0 + freethreaded: pytest-run-parallel # The following indicates which extras_require from setup.cfg will be installed extras =