Skip to content

Commit c375c8a

Browse files
committed
add maximum Python version check (#3821)
* add maximum Python version check * restore dependency of `pyo3-macros-backend` on `pyo3-build-config` * fix clippy-all noxfile job
1 parent 9d1b11f commit c375c8a

File tree

10 files changed

+165
-37
lines changed

10 files changed

+165
-37
lines changed

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,18 @@ jobs:
447447
echo PYO3_CONFIG_FILE=$PYO3_CONFIG_FILE >> $GITHUB_ENV
448448
- run: python3 -m nox -s test
449449

450+
test-version-limits:
451+
needs: [fmt]
452+
if: ${{ contains(github.event.pull_request.labels.*.name, 'CI-build-full') || (github.event_name != 'pull_request' && github.ref != 'refs/heads/main') }}
453+
runs-on: ubuntu-latest
454+
steps:
455+
- uses: actions/checkout@v4
456+
- uses: Swatinem/rust-cache@v2
457+
continue-on-error: true
458+
- uses: dtolnay/rust-toolchain@stable
459+
- run: python3 -m pip install --upgrade pip && pip install nox
460+
- run: python3 -m nox -s test-version-limits
461+
450462
conclusion:
451463
needs:
452464
- fmt
@@ -459,6 +471,8 @@ jobs:
459471
- docsrs
460472
- coverage
461473
- emscripten
474+
- test-debug
475+
- test-version-limits
462476
if: always()
463477
runs-on: ubuntu-latest
464478
steps:

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ multiple-pymethods = ["inventory", "pyo3-macros/multiple-pymethods"]
7979
extension-module = ["pyo3-ffi/extension-module"]
8080

8181
# Use the Python limited API. See https://www.python.org/dev/peps/pep-0384/ for more.
82-
abi3 = ["pyo3-build-config/abi3", "pyo3-ffi/abi3", "pyo3-macros/abi3"]
82+
abi3 = ["pyo3-build-config/abi3", "pyo3-ffi/abi3"]
8383

8484
# With abi3, we can manually set the minimum Python version.
8585
abi3-py37 = ["abi3-py38", "pyo3-build-config/abi3-py37", "pyo3-ffi/abi3-py37"]

newsfragments/3821.packaging.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Check maximum version of Python at build time and for versions not yet supported require opt-in to the `abi3` stable ABI by the environment variable `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`.

noxfile.py

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from contextlib import contextmanager
12
import json
23
import os
34
import re
@@ -7,9 +8,10 @@
78
from functools import lru_cache
89
from glob import glob
910
from pathlib import Path
10-
from typing import Any, Callable, Dict, List, Optional, Tuple
11+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
1112

1213
import nox
14+
import nox.command
1315

1416
nox.options.sessions = ["test", "clippy", "rustfmt", "ruff", "docs"]
1517

@@ -100,7 +102,7 @@ def _clippy(session: nox.Session, *, env: Dict[str, str] = None) -> bool:
100102
"--deny=warnings",
101103
env=env,
102104
)
103-
except Exception:
105+
except nox.command.CommandFailed:
104106
success = False
105107
return success
106108

@@ -552,6 +554,33 @@ def ffi_check(session: nox.Session):
552554
_run_cargo(session, "run", _FFI_CHECK)
553555

554556

557+
@nox.session(name="test-version-limits")
558+
def test_version_limits(session: nox.Session):
559+
env = os.environ.copy()
560+
with _config_file() as config_file:
561+
env["PYO3_CONFIG_FILE"] = config_file.name
562+
563+
assert "3.6" not in PY_VERSIONS
564+
config_file.set("CPython", "3.6")
565+
_run_cargo(session, "check", env=env, expect_error=True)
566+
567+
assert "3.13" not in PY_VERSIONS
568+
config_file.set("CPython", "3.13")
569+
_run_cargo(session, "check", env=env, expect_error=True)
570+
571+
# 3.13 CPython should build with forward compatibility
572+
env["PYO3_USE_ABI3_FORWARD_COMPATIBILITY"] = "1"
573+
_run_cargo(session, "check", env=env)
574+
575+
assert "3.6" not in PYPY_VERSIONS
576+
config_file.set("PyPy", "3.6")
577+
_run_cargo(session, "check", env=env, expect_error=True)
578+
579+
assert "3.11" not in PYPY_VERSIONS
580+
config_file.set("PyPy", "3.11")
581+
_run_cargo(session, "check", env=env, expect_error=True)
582+
583+
555584
def _build_docs_for_ffi_check(session: nox.Session) -> None:
556585
# pyo3-ffi-check needs to scrape docs of pyo3-ffi
557586
_run_cargo(session, "doc", _FFI_CHECK, "-p", "pyo3-ffi", "--no-deps")
@@ -640,7 +669,13 @@ def _run(session: nox.Session, *args: str, **kwargs: Any) -> None:
640669
print("::endgroup::", file=sys.stderr)
641670

642671

643-
def _run_cargo(session: nox.Session, *args: str, **kwargs: Any) -> None:
672+
def _run_cargo(
673+
session: nox.Session, *args: str, expect_error: bool = False, **kwargs: Any
674+
) -> None:
675+
if expect_error:
676+
if "success_codes" in kwargs:
677+
raise ValueError("expect_error overrides success_codes")
678+
kwargs["success_codes"] = [101]
644679
_run(session, "cargo", *args, **kwargs, external=True)
645680

646681

@@ -688,24 +723,14 @@ def _get_output(*args: str) -> str:
688723
def _for_all_version_configs(
689724
session: nox.Session, job: Callable[[Dict[str, str]], None]
690725
) -> None:
691-
with tempfile.NamedTemporaryFile("r+") as config:
692-
env = os.environ.copy()
693-
env["PYO3_CONFIG_FILE"] = config.name
694-
695-
def _job_with_config(implementation, version) -> bool:
696-
config.seek(0)
697-
config.truncate(0)
698-
config.write(
699-
f"""\
700-
implementation={implementation}
701-
version={version}
702-
suppress_build_script_link_lines=true
703-
"""
704-
)
705-
config.flush()
726+
env = os.environ.copy()
727+
with _config_file() as config_file:
728+
env["PYO3_CONFIG_FILE"] = config_file.name
706729

730+
def _job_with_config(implementation, version):
707731
session.log(f"{implementation} {version}")
708-
return job(env)
732+
config_file.set(implementation, version)
733+
job(env)
709734

710735
for version in PY_VERSIONS:
711736
_job_with_config("CPython", version)
@@ -714,5 +739,34 @@ def _job_with_config(implementation, version) -> bool:
714739
_job_with_config("PyPy", version)
715740

716741

742+
class _ConfigFile:
743+
def __init__(self, config_file) -> None:
744+
self._config_file = config_file
745+
746+
def set(self, implementation: str, version: str) -> None:
747+
"""Set the contents of this config file to the given implementation and version."""
748+
self._config_file.seek(0)
749+
self._config_file.truncate(0)
750+
self._config_file.write(
751+
f"""\
752+
implementation={implementation}
753+
version={version}
754+
suppress_build_script_link_lines=true
755+
"""
756+
)
757+
self._config_file.flush()
758+
759+
@property
760+
def name(self) -> str:
761+
return self._config_file.name
762+
763+
764+
@contextmanager
765+
def _config_file() -> Iterator[_ConfigFile]:
766+
"""Creates a temporary config file which can be repeatedly set to different values."""
767+
with tempfile.NamedTemporaryFile("r+") as config:
768+
yield _ConfigFile(config)
769+
770+
717771
_BENCHES = "--manifest-path=pyo3-benches/Cargo.toml"
718772
_FFI_CHECK = "--manifest-path=pyo3-ffi-check/Cargo.toml"

pyo3-build-config/src/impl_.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,7 @@ fn have_python_interpreter() -> bool {
708708
/// Must be called from a PyO3 crate build script.
709709
fn is_abi3() -> bool {
710710
cargo_env_var("CARGO_FEATURE_ABI3").is_some()
711+
|| env_var("PYO3_USE_ABI3_FORWARD_COMPATIBILITY").map_or(false, |os_str| os_str == "1")
711712
}
712713

713714
/// Gets the minimum supported Python version from PyO3 `abi3-py*` features.

pyo3-ffi/build.rs

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,76 @@ use pyo3_build_config::{
44
cargo_env_var, env_var, errors::Result, is_linking_libpython, resolve_interpreter_config,
55
InterpreterConfig, PythonVersion,
66
},
7+
PythonImplementation,
78
};
89

910
/// Minimum Python version PyO3 supports.
10-
const MINIMUM_SUPPORTED_VERSION: PythonVersion = PythonVersion { major: 3, minor: 7 };
11+
struct SupportedVersions {
12+
min: PythonVersion,
13+
max: PythonVersion,
14+
}
15+
16+
const SUPPORTED_VERSIONS_CPYTHON: SupportedVersions = SupportedVersions {
17+
min: PythonVersion { major: 3, minor: 7 },
18+
max: PythonVersion {
19+
major: 3,
20+
minor: 12,
21+
},
22+
};
23+
24+
const SUPPORTED_VERSIONS_PYPY: SupportedVersions = SupportedVersions {
25+
min: PythonVersion { major: 3, minor: 7 },
26+
max: PythonVersion {
27+
major: 3,
28+
minor: 10,
29+
},
30+
};
1131

1232
fn ensure_python_version(interpreter_config: &InterpreterConfig) -> Result<()> {
13-
ensure!(
14-
interpreter_config.version >= MINIMUM_SUPPORTED_VERSION,
15-
"the configured Python interpreter version ({}) is lower than PyO3's minimum supported version ({})",
16-
interpreter_config.version,
17-
MINIMUM_SUPPORTED_VERSION,
18-
);
33+
// This is an undocumented env var which is only really intended to be used in CI / for testing
34+
// and development.
35+
if std::env::var("UNSAFE_PYO3_SKIP_VERSION_CHECK").as_deref() == Ok("1") {
36+
return Ok(());
37+
}
38+
39+
match interpreter_config.implementation {
40+
PythonImplementation::CPython => {
41+
let versions = SUPPORTED_VERSIONS_CPYTHON;
42+
ensure!(
43+
interpreter_config.version >= versions.min,
44+
"the configured Python interpreter version ({}) is lower than PyO3's minimum supported version ({})",
45+
interpreter_config.version,
46+
versions.min,
47+
);
48+
ensure!(
49+
interpreter_config.version <= versions.max || env_var("PYO3_USE_ABI3_FORWARD_COMPATIBILITY").map_or(false, |os_str| os_str == "1"),
50+
"the configured Python interpreter version ({}) is newer than PyO3's maximum supported version ({})\n\
51+
= help: please check if an updated version of PyO3 is available. Current version: {}\n\
52+
= help: set PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 to suppress this check and build anyway using the stable ABI",
53+
interpreter_config.version,
54+
versions.max,
55+
std::env::var("CARGO_PKG_VERSION").unwrap(),
56+
);
57+
}
58+
PythonImplementation::PyPy => {
59+
let versions = SUPPORTED_VERSIONS_PYPY;
60+
ensure!(
61+
interpreter_config.version >= versions.min,
62+
"the configured PyPy interpreter version ({}) is lower than PyO3's minimum supported version ({})",
63+
interpreter_config.version,
64+
versions.min,
65+
);
66+
// PyO3 does not support abi3, so we cannot offer forward compatibility
67+
ensure!(
68+
interpreter_config.version <= versions.max,
69+
"the configured PyPy interpreter version ({}) is newer than PyO3's maximum supported version ({})\n\
70+
= help: please check if an updated version of PyO3 is available. Current version: {}",
71+
interpreter_config.version,
72+
versions.max,
73+
std::env::var("CARGO_PKG_VERSION").unwrap()
74+
);
75+
}
76+
}
1977

2078
Ok(())
2179
}

pyo3-macros-backend/Cargo.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,15 @@ edition = "2021"
1414
# not to depend on proc-macro itself.
1515
# See https://github.com/PyO3/pyo3/pull/810 for more.
1616
[dependencies]
17-
quote = { version = "1", default-features = false }
18-
proc-macro2 = { version = "1", default-features = false }
1917
heck = "0.4"
18+
proc-macro2 = { version = "1", default-features = false }
19+
pyo3-build-config = { path = "../pyo3-build-config", version = "=0.20.2", features = ["resolve-config"] }
20+
quote = { version = "1", default-features = false }
2021

2122
[dependencies.syn]
2223
version = "2"
2324
default-features = false
2425
features = ["derive", "parsing", "printing", "clone-impls", "full", "extra-traits"]
2526

26-
[features]
27-
abi3 = []
28-
2927
[lints]
3028
workspace = true

pyo3-macros-backend/src/method.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::params::impl_arg_params;
66
use crate::pyfunction::{FunctionSignature, PyFunctionArgPyO3Attributes};
77
use crate::pyfunction::{PyFunctionOptions, SignatureAttribute};
88
use crate::quotes;
9-
use crate::utils::{self, PythonDoc};
9+
use crate::utils::{self, is_abi3, PythonDoc};
1010
use proc_macro2::{Span, TokenStream};
1111
use quote::ToTokens;
1212
use quote::{quote, quote_spanned};
@@ -213,8 +213,8 @@ impl CallingConvention {
213213
} else if signature.python_signature.kwargs.is_some() {
214214
// for functions that accept **kwargs, always prefer varargs
215215
Self::Varargs
216-
} else if cfg!(not(feature = "abi3")) {
217-
// Not available in the Stable ABI as of Python 3.10
216+
} else if !is_abi3() {
217+
// FIXME: available in the stable ABI since 3.10
218218
Self::Fastcall
219219
} else {
220220
Self::Varargs

pyo3-macros-backend/src/utils.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,7 @@ pub fn apply_renaming_rule(rule: RenamingRule, name: &str) -> String {
176176
RenamingRule::Uppercase => name.to_uppercase(),
177177
}
178178
}
179+
180+
pub(crate) fn is_abi3() -> bool {
181+
pyo3_build_config::get().abi3
182+
}

pyo3-macros/Cargo.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ proc-macro = true
1616
[features]
1717
multiple-pymethods = []
1818

19-
abi3 = ["pyo3-macros-backend/abi3"]
20-
2119
[dependencies]
2220
proc-macro2 = { version = "1", default-features = false }
2321
quote = "1"

0 commit comments

Comments
 (0)