Skip to content

Commit 627bfb3

Browse files
committed
feat: enable pyproject.toml as single source of truth for Python version
1 parent dc0d977 commit 627bfb3

15 files changed

Lines changed: 417 additions & 16 deletions

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ python/private/runtimes_manifest_workspace.bzl text eol=lf
55
python/private/runtimes_manifest.txt text eol=lf
66

77
*.bat text eol=crlf
8+
requirements_lock.txt linguist-generated=true

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ user.bazelrc
4848
# CLion
4949
.clwb
5050

51-
# Python cache
51+
# Python artifacts
5252
**/__pycache__/
53+
*.egg
54+
*.egg-info
5355

5456
# MODULE.bazel.lock is ignored for now as per recommendation from upstream.
5557
# See https://github.com/bazelbuild/bazel/issues/20369

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ and [#1975](https://github.com/bazel-contrib/rules_python/issues/1975).
145145
* (toolchains) Support dynamically fetching and registering Python runtimes
146146
from a python-build-standalone manifest file using
147147
`python.override(add_runtime_manifest_urls = ..., runtime_manifest_sha = ...)`.
148+
* (pip,python) Added `pyproject_toml` attribute to `pip.default()` and `python.defaults()`
149+
to read Python version from pyproject.toml `requires-python` field (must be `==X.Y.Z` format).
148150
* (toolchain) Added {obj}`python.override.toolchain_target_settings` to allow
149151
adding `config_setting` labels to all registered toolchains.
150152
* (windows) Full venv support for Windows is available. Set

python/private/BUILD.bazel

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,8 @@ bzl_library(
709709
":full_version",
710710
":pbs_manifest",
711711
":platform_info",
712+
":pyproject_repo",
713+
":pyproject_utils",
712714
":python_register_toolchains",
713715
":pythons_hub",
714716
":repo_utils",
@@ -941,6 +943,16 @@ bzl_library(
941943
srcs = ["py_runtime_info.bzl"],
942944
)
943945

946+
bzl_library(
947+
name = "pyproject_repo",
948+
srcs = ["pyproject_repo.bzl"],
949+
)
950+
951+
bzl_library(
952+
name = "pyproject_utils",
953+
srcs = ["pyproject_utils.bzl"],
954+
)
955+
944956
bzl_library(
945957
name = "repo_utils",
946958
srcs = ["repo_utils.bzl"],

python/private/pypi/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ bzl_library(
125125
":whl_library",
126126
"//python/private:auth",
127127
"//python/private:normalize_name",
128+
"//python/private:pyproject_utils",
128129
"//python/private:repo_utils",
129130
"@pythons_hub//:interpreters",
130131
"@pythons_hub//:versions",

python/private/pypi/extension.bzl

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ load("@rules_python_internal//:rules_python_config.bzl", rp_config = "config")
2020
load("@toml.bzl", "toml")
2121
load("//python/private:auth.bzl", "AUTH_ATTRS")
2222
load("//python/private:normalize_name.bzl", "normalize_name")
23+
load("//python/private:pyproject_utils.bzl", "read_pyproject_version")
2324
load("//python/private:repo_utils.bzl", "repo_utils")
2425
load(":hub_builder.bzl", "hub_builder")
2526
load(":hub_repository.bzl", "hub_repository", "whl_config_settings_to_json")
@@ -208,6 +209,7 @@ def build_config(
208209
default_hub = None
209210
defaults = {
210211
"platforms": default_platforms(),
212+
"python_version": None,
211213
}
212214
for mod in module_ctx.modules:
213215
if not (mod.is_root or mod.name == "rules_python"):
@@ -219,6 +221,15 @@ def build_config(
219221
if default_hub:
220222
fail("Duplicate pip.default tag: only one explicit default PyPI hub is allowed.")
221223
default_hub = tag.default_hub
224+
pyproject_toml = tag.pyproject_toml
225+
if pyproject_toml:
226+
pyproject_version = read_pyproject_version(
227+
module_ctx,
228+
pyproject_toml,
229+
logger = None,
230+
)
231+
if pyproject_version:
232+
defaults["python_version"] = pyproject_version
222233

223234
platform = tag.platform
224235
if platform:
@@ -256,6 +267,7 @@ def build_config(
256267
default_hub = default_hub,
257268
index_url = defaults.get("index_url", "https://pypi.org/simple").rstrip("/"),
258269
netrc = defaults.get("netrc", None),
270+
python_version = defaults.get("python_version", None),
259271
platforms = {
260272
name: _plat(**values)
261273
for name, values in defaults["platforms"].items()
@@ -359,6 +371,10 @@ You cannot use both the additive_build_content and additive_build_content_file a
359371

360372
for mod in module_ctx.modules:
361373
for pip_attr in mod.tags.parse:
374+
python_version = pip_attr.python_version or config.python_version
375+
if not python_version:
376+
_fail("pip.parse() requires either python_version attribute or pip.default(pyproject_toml=...) to be set")
377+
362378
hub_name = pip_attr.hub_name
363379
if hub_name == "pypi":
364380
if is_pypi_hub_reserved:
@@ -422,6 +438,7 @@ You cannot use both the additive_build_content and additive_build_content_file a
422438
builder.pip_parse(
423439
module_ctx,
424440
pip_attr = pip_attr,
441+
python_version = python_version,
425442
)
426443

427444
# Keeps track of all the hub's whl repos across the different versions.
@@ -699,6 +716,21 @@ If you are defining custom platforms in your project and don't want things to cl
699716
[isolation] feature.
700717
701718
[isolation]: https://bazel.build/rules/lib/globals/module#use_extension.isolate
719+
""",
720+
),
721+
"pyproject_toml": attr.label(
722+
mandatory = False,
723+
doc = """\
724+
Label pointing to pyproject.toml file to read the default Python version from.
725+
When specified, reads the `requires-python` field from pyproject.toml and uses
726+
it as the default python_version for all `pip.parse()` calls that don't
727+
explicitly specify one.
728+
729+
The version must be specified as `==X.Y.Z` (exact version with full semver).
730+
This is designed to work with dependency management tools like Renovate.
731+
732+
:::{versionadded} VERSION_NEXT_FEATURE
733+
:::
702734
""",
703735
),
704736
"whl_abi_tags": attr.string_list(
@@ -870,14 +902,18 @@ find in case extra indexes are specified.
870902
default = True,
871903
),
872904
"python_version": attr.string(
873-
mandatory = True,
905+
mandatory = False,
874906
doc = """
875907
The Python version the dependencies are targetting, in Major.Minor format
876908
(e.g., "3.11") or patch level granularity (e.g. "3.11.1").
877909
878910
If an interpreter isn't explicitly provided (using `python_interpreter` or
879911
`python_interpreter_target`), then the version specified here must have
880912
a corresponding `python.toolchain()` configured.
913+
914+
:::{seealso}
915+
The {obj}`pyproject_toml` attribute for getting the version from a project file.
916+
:::
881917
""",
882918
),
883919
"simpleapi_skip": attr.string_list(

python/private/pypi/hub_builder.bzl

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ def _build(self):
144144
whl_libraries = self._whl_libraries,
145145
)
146146

147-
def _pip_parse(self, module_ctx, pip_attr):
148-
python_version = pip_attr.python_version
147+
def _pip_parse(self, module_ctx, pip_attr, python_version = None):
148+
python_version = python_version or pip_attr.python_version
149149
if python_version in self._platforms:
150150
fail((
151151
"Duplicate pip python version '{version}' for hub " +
@@ -191,7 +191,8 @@ def _pip_parse(self, module_ctx, pip_attr):
191191
self,
192192
module_ctx,
193193
pip_attr = pip_attr,
194-
enable_pipstar_extract = bool(self._config.enable_pipstar_extract or self._get_index_urls.get(pip_attr.python_version)),
194+
python_version = python_version,
195+
enable_pipstar_extract = bool(self._config.enable_pipstar_extract or self._get_index_urls.get(python_version)),
195196
)
196197

197198
### end of PUBLIC methods
@@ -393,11 +394,11 @@ def _set_get_index_urls(self, mctx, pip_attr):
393394
)
394395
return True
395396

396-
def _detect_interpreter(self, pip_attr):
397+
def _detect_interpreter(self, pip_attr, python_version):
397398
python_interpreter_target = pip_attr.python_interpreter_target
398399
if python_interpreter_target == None and not pip_attr.python_interpreter:
399400
python_name = "python_{}_host".format(
400-
pip_attr.python_version.replace(".", "_"),
401+
python_version.replace(".", "_"),
401402
)
402403
if python_name not in self._available_interpreters:
403404
fail((
@@ -407,7 +408,7 @@ def _detect_interpreter(self, pip_attr):
407408
"Expected to find {python_name} among registered versions:\n {labels}"
408409
).format(
409410
hub_name = self.name,
410-
version = pip_attr.python_version,
411+
version = python_version,
411412
python_name = python_name,
412413
labels = " \n".join(self._available_interpreters),
413414
))
@@ -476,17 +477,19 @@ def _create_whl_repos(
476477
module_ctx,
477478
*,
478479
pip_attr,
480+
python_version,
479481
enable_pipstar_extract = False):
480482
"""create all of the whl repositories
481483
482484
Args:
483485
self: the builder.
484486
module_ctx: {type}`module_ctx`.
485487
pip_attr: {type}`struct` - the struct that comes from the tag class iteration.
488+
python_version: {type}`str` - the resolved python version for this pip.parse call.
486489
enable_pipstar_extract: {type}`bool` - enable the pipstar extraction or not.
487490
"""
488491
logger = self._logger
489-
platforms = self._platforms[pip_attr.python_version]
492+
platforms = self._platforms[python_version]
490493
requirements_by_platform = parse_requirements(
491494
module_ctx,
492495
requirements_by_platform = requirements_files_by_platform(
@@ -498,7 +501,7 @@ def _create_whl_repos(
498501
extra_pip_args = pip_attr.extra_pip_args,
499502
platforms = sorted(platforms), # here we only need keys
500503
python_version = full_version(
501-
version = pip_attr.python_version,
504+
version = python_version,
502505
minor_mapping = self._minor_mapping,
503506
),
504507
logger = logger,
@@ -528,7 +531,7 @@ def _create_whl_repos(
528531
pip_attr = pip_attr,
529532
)
530533

531-
interpreter = _detect_interpreter(self, pip_attr)
534+
interpreter = _detect_interpreter(self, pip_attr, python_version)
532535

533536
for whl in requirements_by_platform:
534537
whl_library_args = common_args | _whl_library_args(
@@ -543,16 +546,16 @@ def _create_whl_repos(
543546
whl_library_args = whl_library_args,
544547
download_only = pip_attr.download_only,
545548
netrc = self._config.netrc or pip_attr.netrc,
546-
use_downloader = src.url and _use_downloader(self, pip_attr.python_version, whl.name),
549+
use_downloader = src.url and _use_downloader(self, python_version, whl.name),
547550
auth_patterns = self._config.auth_patterns or pip_attr.auth_patterns,
548-
python_version = _major_minor_version(pip_attr.python_version),
551+
python_version = _major_minor_version(python_version),
549552
is_multiple_versions = whl.is_multiple_versions,
550553
interpreter = interpreter,
551554
enable_pipstar_extract = enable_pipstar_extract,
552555
)
553556
_add_whl_library(
554557
self,
555-
python_version = pip_attr.python_version,
558+
python_version = python_version,
556559
whl = whl,
557560
repo = repo,
558561
)

python/private/pyproject_repo.bzl

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Repository rule to expose Python version from pyproject.toml."""
2+
3+
_TOML2JSON = Label("//tools/private/toml2json:toml2json.py")
4+
5+
def _parse_requires_python(requires_python):
6+
"""Parse and validate the requires-python field."""
7+
if not requires_python.startswith("=="):
8+
fail("requires-python must use '==' for exact version, got: {}".format(requires_python))
9+
10+
bare_version = requires_python[2:].strip()
11+
parts = bare_version.split(".")
12+
if len(parts) != 3:
13+
fail("requires-python must be in X.Y.Z format, got: {}".format(bare_version))
14+
for part in parts:
15+
if not part.isdigit():
16+
fail("requires-python must be in X.Y.Z format, got: {}".format(bare_version))
17+
18+
return bare_version
19+
20+
def _pyproject_version_repo_impl(rctx):
21+
"""Create a repository that exports PYTHON_VERSION from pyproject.toml."""
22+
pyproject_path = rctx.path(rctx.attr.pyproject_toml)
23+
rctx.read(pyproject_path, watch = "yes")
24+
25+
toml2json = rctx.path(_TOML2JSON)
26+
result = rctx.execute([
27+
"python3",
28+
str(toml2json),
29+
str(pyproject_path),
30+
])
31+
32+
if result.return_code != 0:
33+
fail("Failed to parse pyproject.toml: " + result.stderr)
34+
35+
data = json.decode(result.stdout)
36+
requires_python = data.get("project", {}).get("requires-python")
37+
if not requires_python:
38+
fail("pyproject.toml must contain [project] requires-python field")
39+
40+
version = _parse_requires_python(requires_python)
41+
42+
rctx.file("version.bzl", """\
43+
\"\"\"Python version from pyproject.toml.
44+
45+
This file is automatically generated. Do not edit.
46+
\"\"\"
47+
48+
PYTHON_VERSION = "{version}"
49+
""".format(version = version))
50+
51+
rctx.file("BUILD.bazel", """\
52+
# Automatically generated from pyproject.toml
53+
exports_files(["version.bzl"])
54+
""")
55+
56+
pyproject_version_repo = repository_rule(
57+
implementation = _pyproject_version_repo_impl,
58+
attrs = {
59+
"pyproject_toml": attr.label(
60+
mandatory = True,
61+
doc = "Label pointing to pyproject.toml file.",
62+
),
63+
},
64+
doc = """Repository rule that reads Python version from pyproject.toml.
65+
66+
This rule creates a repository with a `version.bzl` file that exports
67+
`PYTHON_VERSION` constant.
68+
69+
Example:
70+
```python
71+
load("@python_version_from_pyproject//:version.bzl", "PYTHON_VERSION")
72+
73+
compile_pip_requirements(
74+
name = "requirements",
75+
python_version = PYTHON_VERSION,
76+
requirements_txt = "requirements.txt",
77+
)
78+
```
79+
""",
80+
)

0 commit comments

Comments
 (0)