Skip to content

Commit 2be0c5e

Browse files
feat: Add Python 3.13t and 3.14t builds / wheels (#619)
* feat: Add Python 3.13t and 3.14t builds / wheels * Try to fix minio * Try a different approach with pytest-freethreaded * Bump the setup-uv version * Back to 1 * modify testss * cleaner delete * Enable `generate-import-lib` pyo3 feature on windows * fix: Use pytest-run-parallel --------- Co-authored-by: Kyle Barron <kyle@developmentseed.org> Co-authored-by: DisturbedOcean <DisturbedOcean@users.noreply.github.com>
1 parent dcf905a commit 2be0c5e

10 files changed

Lines changed: 172 additions & 76 deletions

File tree

.github/workflows/test-python.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,17 @@ jobs:
6464

6565
- name: Build rust submodules
6666
run: |
67-
uv run --python ${{ matrix.python-version }} maturin develop --uv -m obstore/Cargo.toml
67+
uv run --python ${{ matrix.python-version }} maturin develop -m obstore/Cargo.toml
6868
6969
- name: Run python tests
70+
if: "!endsWith(matrix.python-version, 't')"
7071
run: |
71-
uv run --python ${{ matrix.python-version }} pytest
72+
uv run --python ${{ matrix.python-version }} pytest tests
73+
74+
- name: Run python tests with --parallel-threads=2
75+
if: "endsWith(matrix.python-version, 't')"
76+
run: |
77+
uv run --python ${{ matrix.python-version }} pytest tests --parallel-threads=2
7278
7379
# Ensure docs build without warnings
7480
- name: Check docs

.github/workflows/wheels.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
version: "0.10.x"
5353

5454
- name: Install Python versions
55-
run: uv python install 3.10 3.11 pypy3.11
55+
run: uv python install 3.10 3.11 3.13t 3.14t pypy3.11
5656

5757
- name: Build abi3-py311 wheels
5858
uses: PyO3/maturin-action@v1
@@ -66,7 +66,7 @@ jobs:
6666
uses: PyO3/maturin-action@v1
6767
with:
6868
target: ${{ matrix.platform.target }}
69-
args: --release --out dist -i 3.10 -i pypy3.11 --manifest-path obstore/Cargo.toml
69+
args: --release --out dist -i 3.10 -i 3.13t -i 3.14t -i pypy3.11 --manifest-path obstore/Cargo.toml
7070
sccache: "true"
7171
manylinux: ${{ matrix.platform.manylinux }}
7272

@@ -99,7 +99,7 @@ jobs:
9999
version: "0.10.x"
100100

101101
- name: Install Python versions
102-
run: uv python install 3.10 3.11 pypy3.11
102+
run: uv python install 3.10 3.11 3.13t 3.14t pypy3.11
103103

104104
- name: Build abi3-py311 wheels
105105
uses: PyO3/maturin-action@v1
@@ -113,7 +113,7 @@ jobs:
113113
uses: PyO3/maturin-action@v1
114114
with:
115115
target: ${{ matrix.platform.target }}
116-
args: --release --out dist -i 3.10 -i pypy3.11 --manifest-path obstore/Cargo.toml
116+
args: --release --out dist -i 3.10 -i 3.13t -i 3.14t -i pypy3.11 --manifest-path obstore/Cargo.toml
117117
sccache: "true"
118118
manylinux: musllinux_1_2
119119

@@ -145,14 +145,14 @@ jobs:
145145
uses: PyO3/maturin-action@v1
146146
with:
147147
target: ${{ matrix.platform.target }}
148-
args: --release --out dist --features abi3-py311 -i 3.11 --manifest-path obstore/Cargo.toml
148+
args: --release --out dist --features abi3-py311 --features generate-import-lib -i 3.11 --manifest-path obstore/Cargo.toml
149149
sccache: "true"
150150

151151
- name: Build version-specific wheels
152152
uses: PyO3/maturin-action@v1
153153
with:
154154
target: ${{ matrix.platform.target }}
155-
args: --release --out dist -i 3.10 --manifest-path obstore/Cargo.toml
155+
args: --release --out dist --features generate-import-lib -i 3.10 -i 3.13t -i 3.14t --manifest-path obstore/Cargo.toml
156156
sccache: "true"
157157
- name: Upload wheels
158158
uses: actions/upload-artifact@v4
@@ -179,7 +179,7 @@ jobs:
179179
version: "0.10.x"
180180

181181
- name: Install Python versions
182-
run: uv python install 3.10 3.11 pypy3.11
182+
run: uv python install 3.10 3.11 3.13t 3.14t pypy3.11
183183

184184
- name: Build abi3-py311 wheels
185185
uses: PyO3/maturin-action@v1
@@ -192,7 +192,7 @@ jobs:
192192
uses: PyO3/maturin-action@v1
193193
with:
194194
target: ${{ matrix.platform.target }}
195-
args: --release --out dist -i 3.10 -i pypy3.11 --manifest-path obstore/Cargo.toml
195+
args: --release --out dist -i 3.10 -i 3.13t -i 3.14t -i pypy3.11 --manifest-path obstore/Cargo.toml
196196
sccache: "true"
197197
- name: Upload wheels
198198
uses: actions/upload-artifact@v4

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

obstore/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ crate-type = ["cdylib"]
1919

2020
[features]
2121
abi3-py311 = ["pyo3/abi3-py311"]
22+
# https://www.maturin.rs/distribution.html#cross-compile-to-windows
23+
generate-import-lib = ["pyo3/generate-import-lib"]
2224

2325
[dependencies]
2426
arrow = "57"

obstore/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ fn check_debug_build(_py: Python) -> PyResult<()> {
4343
}
4444

4545
/// A Python module implemented in Rust.
46-
#[pymodule]
46+
#[pymodule(gil_used = false)]
4747
fn _obstore(py: Python, m: &Bound<PyModule>) -> PyResult<()> {
4848
check_debug_build(py)?;
4949

pyproject.toml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ examples = ["fastapi>=0.115.12", "tqdm>=4.67.1"]
2727
dev = [
2828
"aiohttp-retry>=2.9.1",
2929
"aiohttp>=3.11.13",
30-
"arro3-core>=0.4.2",
30+
"arro3-core>=0.6.5",
3131
"azure-identity>=1.21.0",
3232
"boto3>=1.38.21",
3333
"docker>=7.1.0",
@@ -42,10 +42,12 @@ dev = [
4242
"pyarrow>=17.0.0",
4343
"pystac-client>=0.8.3",
4444
"pystac>=1.10.1",
45+
"pytest>=8.3.3",
4546
"pytest-asyncio>=0.24.0",
4647
"pytest-mypy-plugins>=3.2.0",
47-
"pytest>=8.3.3",
48+
"pytest-run-parallel>=0.3.0;python_version>='3.13'",
4849
"python-dotenv>=1.0.1",
50+
"pyzmq>=27.1.0",
4951
"ruff>=0.15.0",
5052
"types-boto3[s3,sts]>=1.36.23",
5153
"types-requests>=2.31.0.6",
@@ -108,7 +110,14 @@ executionEnvironments = [
108110
addopts = "-v --mypy-only-local-stub"
109111
asyncio_default_fixture_loop_scope = "function"
110112
testpaths = ["tests"]
111-
markers = ["network: mark the test as requiring a network connection"]
113+
markers = [
114+
"network: mark the test as requiring a network connection",
115+
"parallel_threads: mark test for concurrent thread execution",
116+
]
117+
thread_unsafe_fixtures = [
118+
"minio_bucket",
119+
"minio_store",
120+
]
112121

113122
[tool.mypy]
114123
files = ["obstore/python"]

tests/conftest.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import inspect
34
import socket
5+
import sysconfig
46
import time
57
import warnings
68
from typing import TYPE_CHECKING, Any
@@ -13,6 +15,37 @@
1315

1416
from obstore.store import S3Store
1517

18+
19+
def pytest_configure(config: pytest.Config) -> None:
20+
"""On free-threaded Python, enable parallel test execution by default."""
21+
if (
22+
sysconfig.get_config_var("Py_GIL_DISABLED")
23+
and hasattr(
24+
config.option,
25+
"parallel_threads",
26+
)
27+
and config.option.parallel_threads == 1
28+
):
29+
config.option.parallel_threads = "auto"
30+
31+
32+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
33+
"""Mark async tests as thread-unsafe.
34+
35+
pytest-asyncio and pytest-run-parallel are incompatible: run-parallel wraps
36+
the test function before asyncio can schedule it, producing an unawaited
37+
coroutine. Marking every async test as thread_unsafe keeps them on the main
38+
thread where pytest-asyncio expects to run them.
39+
40+
See: https://github.com/Quansight-Labs/pytest-run-parallel/issues/14
41+
"""
42+
for item in items:
43+
if isinstance(item, pytest.Function) and inspect.iscoroutinefunction(
44+
item.function,
45+
):
46+
item.add_marker(pytest.mark.thread_unsafe)
47+
48+
1649
if TYPE_CHECKING:
1750
from collections.abc import Generator
1851

@@ -116,13 +149,17 @@ def minio_config() -> Generator[tuple[S3Config, ClientConfig], Any, None]:
116149
def minio_bucket(
117150
minio_config: tuple[S3Config, ClientConfig],
118151
) -> Generator[tuple[S3Config, ClientConfig], Any, None]:
152+
# Clean bucket before each test so tests always start with empty state,
153+
# regardless of whether a previous test's teardown failed or was incomplete.
154+
store = S3Store(config=minio_config[0], client_options=minio_config[1])
155+
objects = store.list().collect()
156+
store.delete([obj["path"] for obj in objects])
157+
119158
yield minio_config
120159

121-
# Remove all files from bucket
122-
store = S3Store(config=minio_config[0], client_options=minio_config[1])
160+
# Best-effort cleanup after the test as well.
123161
objects = store.list().collect()
124-
paths = [obj["path"] for obj in objects]
125-
store.delete(paths)
162+
store.delete([obj["path"] for obj in objects])
126163

127164

128165
@pytest.fixture

tests/store/test_local.py

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pickle
22
from pathlib import Path
3+
from tempfile import TemporaryDirectory
34

45
import pytest
56

@@ -44,30 +45,36 @@ def test_local_from_url():
4445
store = LocalStore.from_url(url)
4546

4647

47-
def test_create_prefix(tmp_path: Path):
48-
tmpdir = tmp_path / "abc"
49-
assert not tmpdir.exists()
50-
LocalStore(tmpdir, mkdir=True)
51-
assert tmpdir.exists()
48+
def test_create_prefix():
49+
with TemporaryDirectory() as tmp:
50+
tmp_path = Path(tmp)
51+
tmpdir = tmp_path / "abc"
52+
assert not tmpdir.exists()
53+
LocalStore(tmpdir, mkdir=True)
54+
assert tmpdir.exists()
5255

53-
# Assert that mkdir=True works even when the dir already exists
54-
LocalStore(tmpdir, mkdir=True)
55-
assert tmpdir.exists()
56+
# Assert that mkdir=True works even when the dir already exists
57+
LocalStore(tmpdir, mkdir=True)
58+
assert tmpdir.exists()
5659

5760

58-
def test_prefix_property(tmp_path: Path):
59-
store = LocalStore(tmp_path)
60-
assert store.prefix == tmp_path
61-
assert isinstance(store.prefix, Path)
62-
# Can pass it back to the store init
63-
LocalStore(store.prefix)
61+
def test_prefix_property():
62+
with TemporaryDirectory() as tmp:
63+
tmp_path = Path(tmp)
64+
store = LocalStore(tmp_path)
65+
assert store.prefix == tmp_path
66+
assert isinstance(store.prefix, Path)
67+
# Can pass it back to the store init
68+
LocalStore(store.prefix)
6469

6570

66-
def test_pickle(tmp_path: Path):
67-
store = LocalStore(tmp_path)
68-
store.put("path.txt", b"foo")
69-
new_store: LocalStore = pickle.loads(pickle.dumps(store))
70-
assert new_store.get("path.txt").bytes() == b"foo"
71+
def test_pickle():
72+
with TemporaryDirectory() as tmp:
73+
tmp_path = Path(tmp)
74+
store = LocalStore(tmp_path)
75+
store.put("path.txt", b"foo")
76+
new_store: LocalStore = pickle.loads(pickle.dumps(store))
77+
assert new_store.get("path.txt").bytes() == b"foo"
7178

7279

7380
def test_eq():
@@ -79,18 +86,20 @@ def test_eq():
7986
assert store != store3
8087

8188

82-
def test_local_store_percent_encoded(tmp_path: Path):
83-
fname1 = "hello%20world.txt"
84-
content1 = b"Hello, World!"
85-
with (tmp_path / fname1).open("wb") as f:
86-
f.write(content1)
89+
def test_local_store_percent_encoded():
90+
with TemporaryDirectory() as tmp:
91+
tmp_path = Path(tmp)
92+
fname1 = "hello%20world.txt"
93+
content1 = b"Hello, World!"
94+
with (tmp_path / fname1).open("wb") as f:
95+
f.write(content1)
8796

88-
store = LocalStore(tmp_path)
89-
assert store.get(fname1).bytes() == content1
97+
store = LocalStore(tmp_path)
98+
assert store.get(fname1).bytes() == content1
9099

91-
fname2 = "hello world.txt"
92-
content2 = b"Hello, World! (with spaces)"
93-
with (tmp_path / fname2).open("wb") as f:
94-
f.write(content2)
100+
fname2 = "hello world.txt"
101+
content2 = b"Hello, World! (with spaces)"
102+
with (tmp_path / fname2).open("wb") as f:
103+
f.write(content2)
95104

96-
assert store.get(fname2).bytes() == content2
105+
assert store.get(fname2).bytes() == content2

0 commit comments

Comments
 (0)