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
3 changes: 3 additions & 0 deletions .bazelrc.deleted_packages
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ common --deleted_packages=tests/integration/pip_parse/empty
common --deleted_packages=tests/integration/pip_parse_isolated
common --deleted_packages=tests/integration/prebuilt_toolchain
common --deleted_packages=tests/integration/py_cc_toolchain_registered
common --deleted_packages=tests/integration/python_version_scope
common --deleted_packages=tests/integration/python_version_scope/bindings
common --deleted_packages=tests/integration/python_version_scope/native
common --deleted_packages=tests/integration/runtime_manifests
common --deleted_packages=tests/integration/toolchain_target_settings
common --deleted_packages=tests/integration/unified_pypi
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ self
getting-started
pypi/index
Toolchains <toolchains>
Python version project scoping <python-version-project-scope>
coverage
precompiling
gazelle/docs/index
Expand Down
87 changes: 87 additions & 0 deletions docs/python-version-project-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Python version project scoping

A Python binding and its build tools need the selected interpreter version; a
C++ or Rust library underneath the binding usually does not. Bazel's project
scope lets the root module name the packages that own the
`//python/config_settings:python_version` flag. Targets outside them see the
version given on the command line, so binaries transitioned to different Python
versions share one configuration of their Python-independent native
dependencies.

Project scope is off by default, and only the root module can enable it. Tags in
dependency modules are ignored, so a library can use it in its own builds
without imposing a policy on its consumers.

```starlark
# MODULE.bazel
config = use_extension("@rules_python//python/extensions:config.bzl", "config")
config.python_version_scope(scope = "project")
```

`scope` accepts:

* `"universal"` (the default): the version reaches every target, including exec
tools such as the precompiler.
* `"project"`: the root module's `//:PROJECT.scl` decides which targets keep the
version.

Project scope requires Bazel 9.2 or later. Older releases drop project-scoped
flags on exec transitions, so even owned exec tools would lose the version
([bazelbuild/bazel#28320](https://github.com/bazelbuild/bazel/issues/28320)).
The extension fails on older versions.

## Ownership

Under project scope, `//python/config_settings:python_version` is an alias to
a flag in rules_python's generated configuration repository. That repository's
`PROJECT.scl` forwards to the root module's `//:PROJECT.scl` with Bazel's
`actual` field, which may itself redirect with `actual`. Without the tag nothing
forwards there, so other builds don't need a root `PROJECT.scl`. List every
package that must see the selected version in `active_directories`:

```starlark
# PROJECT.scl
project = {
"active_directories": {
"default": [
"//bindings",
"@@rules_python+",
],
},
}
```

Repositories are listed by canonical name. `bazel mod dump_repo_mapping ""`
prints the canonical names visible to the root module.

The example owns only `//bindings` and rules_python itself. A real project must
also own every other Python-sensitive target: interpreter and header
repositories, toolchains, version-sensitive generators, and any alias or
wrapper in between. Every dependency path from a transitioned target must stay
inside the owned packages until it reaches Python-independent code. Owned exec
tools keep the selected version. Bazel does not infer ownership from
dependencies and cannot detect an omitted consumer: such a target sees the
command-line version and can select the wrong interpreter.

If you also use aspect_rules_py, its terminals set this flag as well as their
own `@aspect_rules_py//py:python_version`. Scope both flags and own both
rulesets' repositories, or native dependencies still get one configuration per
Python version.

## Command-line baseline

Pass a fixed `--@rules_python//python/config_settings:python_version=<version>`
and let binaries select their versions with transitions, for example through
`python_version` attributes. Outside the owned packages, Bazel restores that
command-line value, not the flag's declared default. Invocations with different
command-line versions therefore still build native dependencies in different
configurations.

## Limitations

Only the Python version flag is scoped. ABI, platform, free-threading,
build-script environment, and other settings are unaffected.

See Bazel's
[project-scoped flags example](https://github.com/gregestren/snippets/tree/master/project_scoped_flags)
and the [configurability roadmap](https://bazel.build/about/roadmap#configurability).
1 change: 1 addition & 0 deletions news/4375.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `config.python_version_scope(scope = "project")` extension tag lets the root module opt into Python version project scoping using its `//:PROJECT.scl`. The default remains universal.
1 change: 1 addition & 0 deletions python/config_settings/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ construct_config_settings(
":py_pbs_distribution",
],
minor_mapping = MINOR_MAPPING,
python_version_scope = config.python_version_scope,
versions = PYTHON_VERSIONS,
)

Expand Down
24 changes: 24 additions & 0 deletions python/extensions/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,34 @@ Internal: value of TOOL_DEPS_DEV_ONLY from rules_python's MODULE.bazel.
},
)

_python_version_scope = tag_class(
doc = """Opt into project scoping of the Python version flag (root module only).

With `scope = "project"`, the root module's `//:PROJECT.scl` owns the scope.
Targets outside its active directories reset to the invocation's Python version,
allowing native dependencies to be shared between version-transitioned binaries.
Tags from dependency modules are ignored. The default remains `"universal"`. See {doc}`/python-version-project-scope`.
""",
attrs = {
"scope": attr.string(mandatory = True, values = ["universal", "project"]),
},
)

def _config_impl(module_ctx):
transition_setting_generators = {}
transition_settings = []
explicit_init_py_modules = {}
tool_deps_dev_only = True
python_version_scope = "universal"
scope_configured = False
for mod in module_ctx.modules:
for tag in mod.tags.python_version_scope:
if not mod.is_root:
continue
if scope_configured:
fail("python_version_scope may only be configured once")
scope_configured = True
python_version_scope = tag.scope
if mod.name == "rules_python":
for tag in mod.tags.internal_configure:
tool_deps_dev_only = tag.tool_deps_dev_only
Expand All @@ -95,6 +117,7 @@ def _config_impl(module_ctx):
transition_settings = transition_settings,
explicit_init_py_modules = explicit_init_py_modules,
tool_deps_dev_only = tool_deps_dev_only,
python_version_scope = python_version_scope,
)

pypi_deps()
Expand All @@ -115,5 +138,6 @@ config = module_extension(
"add_transition_setting": _add_transition_setting,
"explicit_init_py": _explicit_init_py,
"internal_configure": _internal_configure,
"python_version_scope": _python_version_scope,
},
)
38 changes: 23 additions & 15 deletions python/private/config_settings.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ def construct_config_settings(
versions,
minor_mapping,
compat_lowest_version = "3.8",
documented_flags): # buildifier: disable=function-docstring
documented_flags,
python_version_scope = "universal"): # buildifier: disable=function-docstring
"""Create a 'python_version' config flag and construct all config settings used in rules_python.

This mainly includes the targets that are used in the toolchain and pip hub
Expand All @@ -55,21 +56,28 @@ def construct_config_settings(
version for `is_python_3.X` flags.
documented_flags: {type}`list[str]` The labels of the documented settings
that affect build configuration.
python_version_scope: Scope of the Python version flag. Defaults to universal.
"""
_ = name # @unused
_python_version_flag(
name = _PYTHON_VERSION_FLAG.name,
build_setting_default = default_version,
# Propagate the flag into exec configurations so exec tools (e.g. the
# precompiler) use the target's Python version. Bazel 10 resets
# Starlark flags in exec configurations unless they have universal
# scope (https://github.com/bazelbuild/bazel/issues/26909). It's set
# here, not as an attr default, because Bazel ignores scope attr
# defaults for flags set on the command line
# (https://github.com/bazelbuild/bazel/issues/31806).
scope = "universal",
visibility = ["//visibility:public"],
)
if python_version_scope == "project":
# Bazel finds a scoped flag's project from the flag's package, so the
# project-scoped flag lives in the generated repository, which ships
# the PROJECT.scl that forwards to the root module's definition.
native.alias(
name = _PYTHON_VERSION_FLAG.name,
actual = "@rules_python_internal//:python_version",
visibility = ["//visibility:public"],
)
else:
python_version_flag(
name = _PYTHON_VERSION_FLAG.name,
build_setting_default = default_version,
# Universal scope preserves exec-tool version selection. Set scope
# here: Bazel ignores attr defaults for command-line flags
# (bazel#31806).
scope = python_version_scope,
visibility = ["//visibility:public"],
)

_python_version_major_minor_flag(
name = _PYTHON_VERSION_MAJOR_MINOR_FLAG.name,
Expand Down Expand Up @@ -211,7 +219,7 @@ def _python_version_flag_impl(ctx):
config_common.FeatureFlagInfo(value = value),
]

_python_version_flag = rule(
python_version_flag = rule(
implementation = _python_version_flag_impl,
build_setting = config.string(flag = True),
attrs = {
Expand Down
38 changes: 36 additions & 2 deletions python/private/internal_config_repo.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ config = struct(
build_python_zip_default = {build_python_zip_default},
supports_whl_extraction = {supports_whl_extraction},
enable_pystar = True,
python_version_scope = {python_version_scope},
enable_deprecation_warnings = {enable_deprecation_warnings},
extract_needs_chmod = {extract_needs_chmod},
bazel_8_or_later = {bazel_8_or_later},
Expand Down Expand Up @@ -71,6 +72,30 @@ _EXTRA_TRANSITIONS_TEMPLATE = """
EXTRA_TRANSITION_SETTINGS = {labels}
"""

# Under project scope, //python/config_settings:python_version aliases this
# flag. Bazel looks for its project from this package, so only this mode ships
# a PROJECT.scl and other builds don't need one in the root module.
_PYTHON_VERSION_FLAG_LOADS = """
load("@pythons_hub//:versions.bzl", "DEFAULT_PYTHON_VERSION")
load("@rules_python//python/private:config_settings.bzl", "python_version_flag") # buildifier: disable=bzl-visibility
"""

_PYTHON_VERSION_FLAG_TARGET = """
python_version_flag(
name = "python_version",
build_setting_default = DEFAULT_PYTHON_VERSION,
scope = "project",
visibility = ["//visibility:public"],
)
"""

_PYTHON_VERSION_PROJECT = """\
# Generated by @rules_python//python/private:internal_config_repo.bzl
# Bazel's project alias: the root module's PROJECT.scl owns the version flag.

project = {"actual": "//:PROJECT.scl"}
"""

_TRANSITION_SETTINGS_DEBUG_TEMPLATE = """
# Generated by @rules_python//python/private:internal_config_repo.bzl

Expand All @@ -88,6 +113,9 @@ def _internal_config_repo_impl(rctx):
bazel_major_version = 99999
bazel_minor_version = 99999

if rctx.attr.python_version_scope == "project" and (bazel_major_version, bazel_minor_version) < (9, 2):
fail("Python version project scope requires Bazel 9.2 or later")

supports_whl_extraction = False
extract_needs_chmod = bazel_major_version < 8 or (bazel_major_version == 8 and bazel_minor_version < 6)
if bazel_major_version >= 8:
Expand All @@ -105,6 +133,7 @@ def _internal_config_repo_impl(rctx):
explicit_init_py_modules = {k: str(v) == "True" for k, v in rctx.attr.explicit_init_py_modules.items()}

rctx.file("rules_python_config.bzl", _CONFIG_TEMPLATE.format(
python_version_scope = repr(rctx.attr.python_version_scope),
build_python_zip_default = repo_utils.get_platforms_os_name(rctx) == "windows",
enable_deprecation_warnings = _bool_from_environ(rctx, _ENABLE_DEPRECATION_WARNINGS_ENVVAR_NAME, _ENABLE_DEPRECATION_WARNINGS_DEFAULT),
builtin_py_info_symbol = builtin_py_info_symbol,
Expand All @@ -119,9 +148,13 @@ def _internal_config_repo_impl(rctx):
bazel_10_or_later = str(bazel_major_version > 9),
))

rctx.file("BUILD", ROOT_BUILD_TEMPLATE.format(
build_content = ROOT_BUILD_TEMPLATE.format(
visibility = "@rules_python//:__subpackages__",
))
)
if rctx.attr.python_version_scope == "project":
build_content = _PYTHON_VERSION_FLAG_LOADS + build_content + _PYTHON_VERSION_FLAG_TARGET
rctx.file("PROJECT.scl", _PYTHON_VERSION_PROJECT)
rctx.file("BUILD", build_content)

rctx.file(
"extra_transition_settings.bzl",
Expand All @@ -146,6 +179,7 @@ internal_config_repo = repository_rule(
environ = [],
attrs = {
"explicit_init_py_modules": attr.string_dict(),
"python_version_scope": attr.string(default = "universal", values = ["universal", "project"]),
"tool_deps_dev_only": attr.bool(default = True),
"transition_setting_generators": attr.string_list_dict(),
"transition_settings": attr.string_list(),
Expand Down
6 changes: 6 additions & 0 deletions tests/integration/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,9 @@ py_binary(
srcs = ["uv_lock_pypi_server.py"],
deps = [":uv_lock_pypi_server_lib"],
)

rules_python_integration_test(
name = "python_version_scope_test",
bazel_versions = ["10.0.0rc2"],
py_main = "python_version_scope_test.py",
)
4 changes: 4 additions & 0 deletions tests/integration/python_version_scope/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
exports_files([
"PROJECT.scl",
"probe.bzl",
])
10 changes: 10 additions & 0 deletions tests/integration/python_version_scope/MODULE.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module(name = "python_version_scope_test")

bazel_dep(name = "rules_python", version = "0.0.0")
local_path_override(
module_name = "rules_python",
path = "../../..",
)

config = use_extension("@rules_python//python/extensions:config.bzl", "config")
config.python_version_scope(scope = "project")
3 changes: 3 additions & 0 deletions tests/integration/python_version_scope/PROJECT.scl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Own bindings and the flag definition; leave the native core outside."""

project = {"active_directories": {"default": ["//bindings", "@@rules_python+"]}}
1 change: 1 addition & 0 deletions tests/integration/python_version_scope/WORKSPACE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Workspace marker required by the Bazel integration test harness.
52 changes: 52 additions & 0 deletions tests/integration/python_version_scope/bindings/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
load("//:probe.bzl", "check", "probe")

config_setting(
name = "py311",
flag_values = {"@rules_python//python/config_settings:python_version": "3.11"},
)

config_setting(
name = "py312",
flag_values = {"@rules_python//python/config_settings:python_version": "3.12"},
)

probe(
name = "tool",
value = select({
":py310": "3.10",
":py311": "3.11",
":py312": "3.12",
"//conditions:default": "unexpected",
}),
)

probe(
name = "binding",
tool = ":tool",
value = select({
":py310": "3.10",
":py311": "3.11",
":py312": "3.12",
"//conditions:default": "unexpected",
}),
deps = ["//native:core"],
)

check(
name = "check311",
native_version = "3.10",
target = ":binding",
version = "3.11",
)

check(
name = "check312",
native_version = "3.10",
target = ":binding",
version = "3.12",
)

config_setting(
name = "py310",
flag_values = {"@rules_python//python/config_settings:python_version": "3.10"},
)
Loading
Loading