Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ pub fn bip39_validate(phrase: &str, language_code: Option<&str>) -> PyResult<boo
}
}

#[pymodule]
// All functions operate only on call-local Rust data. The dependency's lazily
// initialized word lists are synchronized and read-only after initialization.
#[pymodule(gil_used = false)]
fn bip39(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(bip39_to_mini_secret, m)?)?;
m.add_function(wrap_pyfunction!(bip39_generate, m)?)?;
Expand Down
47 changes: 47 additions & 0 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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()
Loading