Skip to content

Commit cf77506

Browse files
committed
Add support for upper bound in requires_python
Issue: #14804
1 parent c7d0fd9 commit cf77506

File tree

4 files changed

+70
-15
lines changed

4 files changed

+70
-15
lines changed

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,9 @@ supported:
191191
* `partial_stub` (optional): This field marks the type stub package as
192192
[partial](https://peps.python.org/pep-0561/#partial-stub-packages). This is for
193193
3rd-party stubs that don't cover the entirety of the package's public API.
194-
* `requires_python` (optional): The minimum version of Python required to install
195-
the type stub package. It must be in the form `>=3.*`. If omitted, the oldest
196-
Python version supported by typeshed is used.
194+
* `requires_python` (optional): The Python version required to install the
195+
the type stub package. It must be in the form `>=3.*`, `<3.*` or `>=3.*,<3.*`.
196+
If omitted, the oldest Python version supported by typeshed is used.
197197

198198
In addition, we specify configuration for stubtest in the `tool.stubtest` table.
199199
This has the following keys:

lib/ts_utils/metadata.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323

2424
import tomlkit
2525
from packaging.requirements import Requirement
26-
from packaging.specifiers import Specifier
26+
from packaging.specifiers import Specifier, SpecifierSet
27+
from packaging.version import Version
2728
from tomlkit.items import String
2829

2930
from .paths import PYPROJECT_PATH, STUBS_PATH, distribution_path
@@ -33,6 +34,7 @@
3334
"PackageDependencies",
3435
"StubMetadata",
3536
"StubtestSettings",
37+
"get_newest_supported_python",
3638
"get_oldest_supported_python",
3739
"get_recursive_requirements",
3840
"read_dependencies",
@@ -55,6 +57,51 @@ def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]:
5557
return isinstance(obj, dict) and all(isinstance(k, str) and isinstance(v, dict) for k, v in obj.items())
5658

5759

60+
def _bump_minor_version(version: str) -> str:
61+
v = Version(version)
62+
major, minor, *_ = v.release
63+
minor += 1
64+
return str(Version(f"{major}.{minor}"))
65+
66+
67+
def _is_specifier_subset(base: SpecifierSet, other: SpecifierSet) -> bool:
68+
"""
69+
Check `other` does not extend beyond `base`.
70+
`other` is allowed to have only one bound (>= or <).
71+
"""
72+
base_lower = None
73+
base_upper = None
74+
for spec in base:
75+
if spec.operator.startswith(">"):
76+
base_lower = Version(spec.version)
77+
elif spec.operator.startswith("<"):
78+
base_upper = Version(spec.version)
79+
assert isinstance(base_lower, Version)
80+
assert isinstance(base_upper, Version)
81+
82+
other_lower = None
83+
other_upper = None
84+
for spec in other:
85+
if spec.operator.startswith(">"):
86+
other_lower = Version(spec.version)
87+
elif spec.operator.startswith("<"):
88+
other_upper = Version(spec.version)
89+
90+
# Check lower bound
91+
if other_lower and other_lower < base_lower:
92+
return False
93+
# Check upper bound
94+
return not (other_upper and other_upper > base_upper)
95+
96+
97+
@functools.cache
98+
def get_newest_supported_python() -> str:
99+
with PYPROJECT_PATH.open("rb") as config:
100+
val = tomllib.load(config)["tool"]["typeshed"]["newest_supported_python"]
101+
assert type(val) is str
102+
return val
103+
104+
58105
@functools.cache
59106
def get_oldest_supported_python() -> str:
60107
with PYPROJECT_PATH.open("rb") as config:
@@ -183,7 +230,7 @@ class StubMetadata:
183230
uploaded_to_pypi: Annotated[bool, "Whether or not a distribution is uploaded to PyPI"]
184231
partial_stub: Annotated[bool, "Whether this is a partial type stub package as per PEP 561."]
185232
stubtest_settings: StubtestSettings
186-
requires_python: Annotated[Specifier, "Versions of Python supported by the stub package"]
233+
requires_python: Annotated[SpecifierSet, "Versions of Python supported by the stub package"]
187234

188235
@property
189236
def is_obsolete(self) -> bool:
@@ -310,18 +357,22 @@ def read_metadata(distribution: str) -> StubMetadata:
310357
assert type(partial_stub) is bool
311358
requires_python_str: object = data.get("requires_python") # pyright: ignore[reportUnknownMemberType]
312359
oldest_supported_python = get_oldest_supported_python()
313-
oldest_supported_python_specifier = Specifier(f">={oldest_supported_python}")
360+
newest_supported_python = get_newest_supported_python()
361+
newest_supported_python = _bump_minor_version(newest_supported_python)
362+
supported_python_specifier = SpecifierSet(f">={oldest_supported_python},<{newest_supported_python}")
314363
if requires_python_str is None:
315-
requires_python = oldest_supported_python_specifier
364+
requires_python = supported_python_specifier
316365
else:
317366
assert isinstance(requires_python_str, str)
318-
requires_python = Specifier(requires_python_str)
319-
assert requires_python != oldest_supported_python_specifier, f'requires_python="{requires_python}" is redundant'
320-
# Check minimum Python version is not less than the oldest version of Python supported by typeshed
321-
assert oldest_supported_python_specifier.contains(
322-
requires_python.version
323-
), f"'requires_python' contains versions lower than typeshed's oldest supported Python ({oldest_supported_python})"
324-
assert requires_python.operator == ">=", "'requires_python' should be a minimum version specifier, use '>=3.x'"
367+
requires_python = SpecifierSet(requires_python_str)
368+
369+
base_specs = {str(spec) for spec in supported_python_specifier}
370+
metadata_specs = {str(spec) for spec in requires_python}
371+
duplicates = base_specs & metadata_specs
372+
assert not duplicates, f"'requires_python' contains redundant specifier(s): {','.join(duplicates)}"
373+
assert _is_specifier_subset(
374+
supported_python_specifier, requires_python
375+
), f"'requires_python' contains versions beyond the typeshed's supported Python ({supported_python_specifier})"
325376

326377
empty_tools: dict[object, object] = {}
327378
tools_settings: object = data.get("tool", empty_tools) # pyright: ignore[reportUnknownMemberType]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,4 @@ known-first-party = ["_utils", "ts_utils"]
254254

255255
[tool.typeshed]
256256
oldest_supported_python = "3.9"
257+
newest_supported_python = "3.14"

tests/runtests.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ def main() -> None:
7171
if args.python_version:
7272
python_version: str = args.python_version
7373
elif folder in "stubs":
74-
python_version = read_metadata(stub).requires_python.version
74+
metadata_python_version = read_metadata(stub).requires_python
75+
for spec in metadata_python_version:
76+
if spec.operator.startswith(">"):
77+
python_version = spec.version
7578
else:
7679
python_version = get_oldest_supported_python()
7780

0 commit comments

Comments
 (0)