diff --git a/README.md b/README.md index ad585e8..1f6b71d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,28 @@ docker run --rm -i -v $(pwd):/io polkasource/maturin build ``` +### Free-threaded Python + +The extension supports CPython 3.14's free-threaded build. Its public API has +no shared mutable state, and the `tiny-bip39` word-list caches are synchronized +and immutable after initialization. + +Run the concurrent regression test with a free-threaded interpreter: + +```shell script +maturin develop --release --interpreter python3.14t +python3.14t -Xgil=0 -m pytest -q tests.py +``` + +To check native data races, build both CPython and this extension with ThreadSanitizer, then run: + +```shell script +TSAN_OPTIONS='allocator_may_return_null=1 halt_on_error=1' python3.14t -Xgil=0 -m pytest -s -q tests.py +``` + +See the [Python free-threading ThreadSanitizer guide](https://py-free-threading.github.io/thread_sanitizer/) +for building the TSan-instrumented interpreter and extension. + ## Examples ```python diff --git a/pyproject.toml b/pyproject.toml index ba6fb96..bbf9fb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,9 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Free Threading :: 3 - Stable" ] [project.urls] diff --git a/src/lib.rs b/src/lib.rs index ce5259b..d05baf8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,7 +147,9 @@ pub fn bip39_validate(phrase: &str, language_code: Option<&str>) -> PyResult) -> PyResult<()> { m.add_function(wrap_pyfunction!(bip39_to_mini_secret, m)?)?; m.add_function(wrap_pyfunction!(bip39_generate, m)?)?; diff --git a/tests.py b/tests.py index 80e9134..3f2c1e8 100644 --- a/tests.py +++ b/tests.py @@ -14,6 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import concurrent.futures +import subprocess +import sys +import sysconfig +import threading import unittest import bip39 @@ -26,6 +31,17 @@ class MyTestCase(unittest.TestCase): seed = [97, 142, 41, 83, 73, 179, 98, 128, 176, 134, 250, 222, 64, 184, 51, 176, 121, 119, 215, 115, 220, 77, 28, 15, 253, 64, 10, 1, 213, 54, 239, 124] + @staticmethod + def call_all_apis(args): + start, mnemonic = args + start.wait() + generated = bip39.bip39_generate(12) + return ( + bip39.bip39_validate(generated), + bip39.bip39_to_seed(mnemonic, ''), + bip39.bip39_to_mini_secret(mnemonic, ''), + ) + def test_generate_mnemonic(self): mnemonic = bip39.bip39_generate(12) self.assertTrue(bip39.bip39_validate(mnemonic)) @@ -94,6 +110,37 @@ def test_invalid_language_code(self): self.assertEqual('Invalid language_code', str(e.exception)) + def test_concurrent_calls(self): + """Exercise the public API concurrently, including lazy word-map setup.""" + worker_count = 16 + start = threading.Barrier(worker_count) + + with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as executor: + results = list(executor.map(self.call_all_apis, [(start, self.mnemonic)] * worker_count)) + + expected_seed = bytes(self.seed) + expected_mini_secret = bytes(self.mini_secret) + self.assertTrue(all( + is_valid and seed == expected_seed and mini_secret == expected_mini_secret + for is_valid, seed, mini_secret in results + )) + + def test_free_threaded_import_keeps_gil_disabled(self): + if sysconfig.get_config_var('Py_GIL_DISABLED') != 1: + self.skipTest('requires a free-threaded CPython build') + + result = subprocess.run( + [ + sys.executable, + '-Xgil=0', + '-c', + 'import bip39; import sys; assert not sys._is_gil_enabled()', + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + if __name__ == '__main__': unittest.main()