Skip to content

Commit 5d6827e

Browse files
vonschultzaignas
andauthored
feat(python.toolchain): support file-based default Python version (bazel-contrib#2588)
This change adds a new `default_version_file` attribute to `python.toolchain`. If set, the toolchain compares the file's contents to its `python_version`, and if they match, treats that toolchain as default (ignoring `is_default`). This allows Bazel to synchronize the default Python version with external tools (e.g., pyenv) that use a `.python-version` file or environment variables. Fixes bazel-contrib#2587. --------- Co-authored-by: Ignas Anikevicius <[email protected]>
1 parent 09145b9 commit 5d6827e

File tree

4 files changed

+254
-5
lines changed

4 files changed

+254
-5
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ Unreleased changes template.
128128

129129
{#v1-3-0-added}
130130
### Added
131+
* (python) {attr}`python.defaults` has been added to allow users to
132+
set the default python version in the root module by reading the
133+
default version number from a file or an environment variable.
131134
* {obj}`//python/bin:python`: convenience target for directly running an
132135
interpreter. {obj}`--//python/bin:python_src` can be used to specify a
133136
binary whose interpreter to use.

examples/multi_python_versions/MODULE.bazel

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ local_path_override(
1010
)
1111

1212
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
13+
python.defaults(
14+
# The environment variable takes precedence if set.
15+
python_version = "3.9",
16+
python_version_env = "BAZEL_PYTHON_VERSION",
17+
)
1318
python.toolchain(
1419
configure_coverage_tool = True,
1520
# Only set when you have mulitple toolchain versions.

python/private/python.bzl

+153-3
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,47 @@ def parse_modules(*, module_ctx, _fail = fail):
7878

7979
config = _get_toolchain_config(modules = module_ctx.modules, _fail = _fail)
8080

81+
default_python_version = None
82+
for mod in module_ctx.modules:
83+
defaults_attr_structs = _create_defaults_attr_structs(mod = mod)
84+
default_python_version_env = None
85+
default_python_version_file = None
86+
87+
# Only the root module and rules_python are allowed to specify the default
88+
# toolchain for a couple reasons:
89+
# * It prevents submodules from specifying different defaults and only
90+
# one of them winning.
91+
# * rules_python needs to set a soft default in case the root module doesn't,
92+
# e.g. if the root module doesn't use Python itself.
93+
# * The root module is allowed to override the rules_python default.
94+
if mod.is_root or (mod.name == "rules_python" and not default_python_version):
95+
for defaults_attr in defaults_attr_structs:
96+
default_python_version = _one_or_the_same(
97+
default_python_version,
98+
defaults_attr.python_version,
99+
onerror = _fail_multiple_defaults_python_version,
100+
)
101+
default_python_version_env = _one_or_the_same(
102+
default_python_version_env,
103+
defaults_attr.python_version_env,
104+
onerror = _fail_multiple_defaults_python_version_env,
105+
)
106+
default_python_version_file = _one_or_the_same(
107+
default_python_version_file,
108+
defaults_attr.python_version_file,
109+
onerror = _fail_multiple_defaults_python_version_file,
110+
)
111+
if default_python_version_file:
112+
default_python_version = _one_or_the_same(
113+
default_python_version,
114+
module_ctx.read(default_python_version_file, watch = "yes").strip(),
115+
)
116+
if default_python_version_env:
117+
default_python_version = module_ctx.getenv(
118+
default_python_version_env,
119+
default_python_version,
120+
)
121+
81122
seen_versions = {}
82123
for mod in module_ctx.modules:
83124
module_toolchain_versions = []
@@ -104,7 +145,13 @@ def parse_modules(*, module_ctx, _fail = fail):
104145
# * rules_python needs to set a soft default in case the root module doesn't,
105146
# e.g. if the root module doesn't use Python itself.
106147
# * The root module is allowed to override the rules_python default.
107-
is_default = toolchain_attr.is_default
148+
if default_python_version:
149+
is_default = default_python_version == toolchain_version
150+
if toolchain_attr.is_default and not is_default:
151+
fail("The 'is_default' attribute doesn't work if you set " +
152+
"the default Python version with the `defaults` tag.")
153+
else:
154+
is_default = toolchain_attr.is_default
108155

109156
# Also only the root module should be able to decide ignore_root_user_error.
110157
# Modules being depended upon don't know the final environment, so they aren't
@@ -115,7 +162,7 @@ def parse_modules(*, module_ctx, _fail = fail):
115162
fail("Toolchains in the root module must have consistent 'ignore_root_user_error' attributes")
116163

117164
ignore_root_user_error = toolchain_attr.ignore_root_user_error
118-
elif mod.name == "rules_python" and not default_toolchain:
165+
elif mod.name == "rules_python" and not default_toolchain and not default_python_version:
119166
# We don't do the len() check because we want the default that rules_python
120167
# sets to be clearly visible.
121168
is_default = toolchain_attr.is_default
@@ -282,6 +329,19 @@ def _python_impl(module_ctx):
282329
else:
283330
return None
284331

332+
def _one_or_the_same(first, second, *, onerror = None):
333+
if not first:
334+
return second
335+
if not second or second == first:
336+
return first
337+
if onerror:
338+
return onerror(first, second)
339+
else:
340+
fail("Unique value needed, got both '{}' and '{}', which are different".format(
341+
first,
342+
second,
343+
))
344+
285345
def _fail_duplicate_module_toolchain_version(version, module):
286346
fail(("Duplicate module toolchain version: module '{module}' attempted " +
287347
"to use version '{version}' multiple times in itself").format(
@@ -305,6 +365,30 @@ def _warn_duplicate_global_toolchain_version(version, first, second_toolchain_na
305365
version = version,
306366
))
307367

368+
def _fail_multiple_defaults_python_version(first, second):
369+
fail(("Multiple python_version entries in defaults: " +
370+
"First default was python_version '{first}'. " +
371+
"Second was python_version '{second}'").format(
372+
first = first,
373+
second = second,
374+
))
375+
376+
def _fail_multiple_defaults_python_version_file(first, second):
377+
fail(("Multiple python_version_file entries in defaults: " +
378+
"First default was python_version_file '{first}'. " +
379+
"Second was python_version_file '{second}'").format(
380+
first = first,
381+
second = second,
382+
))
383+
384+
def _fail_multiple_defaults_python_version_env(first, second):
385+
fail(("Multiple python_version_env entries in defaults: " +
386+
"First default was python_version_env '{first}'. " +
387+
"Second was python_version_env '{second}'").format(
388+
first = first,
389+
second = second,
390+
))
391+
308392
def _fail_multiple_default_toolchains(first, second):
309393
fail(("Multiple default toolchains: only one toolchain " +
310394
"can have is_default=True. First default " +
@@ -526,6 +610,21 @@ def _get_toolchain_config(*, modules, _fail = fail):
526610
register_all_versions = register_all_versions,
527611
)
528612

613+
def _create_defaults_attr_structs(*, mod):
614+
arg_structs = []
615+
616+
for tag in mod.tags.defaults:
617+
arg_structs.append(_create_defaults_attr_struct(tag = tag))
618+
619+
return arg_structs
620+
621+
def _create_defaults_attr_struct(*, tag):
622+
return struct(
623+
python_version = getattr(tag, "python_version", None),
624+
python_version_env = getattr(tag, "python_version_env", None),
625+
python_version_file = getattr(tag, "python_version_file", None),
626+
)
627+
529628
def _create_toolchain_attr_structs(*, mod, config, seen_versions):
530629
arg_structs = []
531630

@@ -570,6 +669,49 @@ def _get_bazel_version_specific_kwargs():
570669

571670
return kwargs
572671

672+
_defaults = tag_class(
673+
doc = """Tag class to specify the default Python version.""",
674+
attrs = {
675+
"python_version": attr.string(
676+
mandatory = False,
677+
doc = """\
678+
String saying what the default Python version should be. If the string
679+
matches the {attr}`python_version` attribute of a toolchain, this
680+
toolchain is the default version. If this attribute is set, the
681+
{attr}`is_default` attribute of the toolchain is ignored.
682+
683+
:::{versionadded} VERSION_NEXT_FEATURE
684+
:::
685+
""",
686+
),
687+
"python_version_env": attr.string(
688+
mandatory = False,
689+
doc = """\
690+
Environment variable saying what the default Python version should be.
691+
If the string matches the {attr}`python_version` attribute of a
692+
toolchain, this toolchain is the default version. If this attribute is
693+
set, the {attr}`is_default` attribute of the toolchain is ignored.
694+
695+
:::{versionadded} VERSION_NEXT_FEATURE
696+
:::
697+
""",
698+
),
699+
"python_version_file": attr.label(
700+
mandatory = False,
701+
allow_single_file = True,
702+
doc = """\
703+
File saying what the default Python version should be. If the contents
704+
of the file match the {attr}`python_version` attribute of a toolchain,
705+
this toolchain is the default version. If this attribute is set, the
706+
{attr}`is_default` attribute of the toolchain is ignored.
707+
708+
:::{versionadded} VERSION_NEXT_FEATURE
709+
:::
710+
""",
711+
),
712+
},
713+
)
714+
573715
_toolchain = tag_class(
574716
doc = """Tag class used to register Python toolchains.
575717
Use this tag class to register one or more Python toolchains. This class
@@ -653,7 +795,14 @@ error to run with root access instead.
653795
),
654796
"is_default": attr.bool(
655797
mandatory = False,
656-
doc = "Whether the toolchain is the default version",
798+
doc = """\
799+
Whether the toolchain is the default version.
800+
801+
:::{versionchanged} VERSION_NEXT_FEATURE
802+
This setting is ignored if the default version is set using the `defaults`
803+
tag class.
804+
:::
805+
""",
657806
),
658807
"python_version": attr.string(
659808
mandatory = True,
@@ -852,6 +1001,7 @@ python = module_extension(
8521001
""",
8531002
implementation = _python_impl,
8541003
tag_classes = {
1004+
"defaults": _defaults,
8551005
"override": _override,
8561006
"single_version_override": _single_version_override,
8571007
"single_version_platform_override": _single_version_platform_override,

tests/python/python_tests.bzl

+93-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ load("//python/private:python.bzl", "parse_modules") # buildifier: disable=bzl-
2020

2121
_tests = []
2222

23-
def _mock_mctx(*modules, environ = {}):
23+
def _mock_mctx(*modules, environ = {}, mocked_files = {}):
2424
return struct(
25+
path = lambda x: struct(exists = x in mocked_files, _file = x),
26+
read = lambda x, watch = None: mocked_files[x._file if "_file" in dir(x) else x],
27+
getenv = environ.get,
2528
os = struct(environ = environ),
2629
modules = [
2730
struct(
@@ -39,10 +42,11 @@ def _mock_mctx(*modules, environ = {}):
3942
],
4043
)
4144

42-
def _mod(*, name, toolchain = [], override = [], single_version_override = [], single_version_platform_override = [], is_root = True):
45+
def _mod(*, name, defaults = [], toolchain = [], override = [], single_version_override = [], single_version_platform_override = [], is_root = True):
4346
return struct(
4447
name = name,
4548
tags = struct(
49+
defaults = defaults,
4650
toolchain = toolchain,
4751
override = override,
4852
single_version_override = single_version_override,
@@ -51,6 +55,13 @@ def _mod(*, name, toolchain = [], override = [], single_version_override = [], s
5155
is_root = is_root,
5256
)
5357

58+
def _defaults(python_version = None, python_version_env = None, python_version_file = None):
59+
return struct(
60+
python_version = python_version,
61+
python_version_env = python_version_env,
62+
python_version_file = python_version_file,
63+
)
64+
5465
def _toolchain(python_version, *, is_default = False, **kwargs):
5566
return struct(
5667
is_default = is_default,
@@ -273,6 +284,86 @@ def _test_default_non_rules_python_ignore_root_user_error_non_root_module(env):
273284

274285
_tests.append(_test_default_non_rules_python_ignore_root_user_error_non_root_module)
275286

287+
def _test_default_from_defaults(env):
288+
py = parse_modules(
289+
module_ctx = _mock_mctx(
290+
_mod(
291+
name = "my_root_module",
292+
defaults = [_defaults(python_version = "3.11")],
293+
toolchain = [_toolchain("3.10"), _toolchain("3.11"), _toolchain("3.12")],
294+
is_root = True,
295+
),
296+
),
297+
)
298+
299+
env.expect.that_str(py.default_python_version).equals("3.11")
300+
301+
want_toolchains = [
302+
struct(
303+
name = "python_3_" + minor_version,
304+
python_version = "3." + minor_version,
305+
register_coverage_tool = False,
306+
)
307+
for minor_version in ["10", "11", "12"]
308+
]
309+
env.expect.that_collection(py.toolchains).contains_exactly(want_toolchains)
310+
311+
_tests.append(_test_default_from_defaults)
312+
313+
def _test_default_from_defaults_env(env):
314+
py = parse_modules(
315+
module_ctx = _mock_mctx(
316+
_mod(
317+
name = "my_root_module",
318+
defaults = [_defaults(python_version = "3.11", python_version_env = "PYENV_VERSION")],
319+
toolchain = [_toolchain("3.10"), _toolchain("3.11"), _toolchain("3.12")],
320+
is_root = True,
321+
),
322+
environ = {"PYENV_VERSION": "3.12"},
323+
),
324+
)
325+
326+
env.expect.that_str(py.default_python_version).equals("3.12")
327+
328+
want_toolchains = [
329+
struct(
330+
name = "python_3_" + minor_version,
331+
python_version = "3." + minor_version,
332+
register_coverage_tool = False,
333+
)
334+
for minor_version in ["10", "11", "12"]
335+
]
336+
env.expect.that_collection(py.toolchains).contains_exactly(want_toolchains)
337+
338+
_tests.append(_test_default_from_defaults_env)
339+
340+
def _test_default_from_defaults_file(env):
341+
py = parse_modules(
342+
module_ctx = _mock_mctx(
343+
_mod(
344+
name = "my_root_module",
345+
defaults = [_defaults(python_version_file = "@@//:.python-version")],
346+
toolchain = [_toolchain("3.10"), _toolchain("3.11"), _toolchain("3.12")],
347+
is_root = True,
348+
),
349+
mocked_files = {"@@//:.python-version": "3.12\n"},
350+
),
351+
)
352+
353+
env.expect.that_str(py.default_python_version).equals("3.12")
354+
355+
want_toolchains = [
356+
struct(
357+
name = "python_3_" + minor_version,
358+
python_version = "3." + minor_version,
359+
register_coverage_tool = False,
360+
)
361+
for minor_version in ["10", "11", "12"]
362+
]
363+
env.expect.that_collection(py.toolchains).contains_exactly(want_toolchains)
364+
365+
_tests.append(_test_default_from_defaults_file)
366+
276367
def _test_first_occurance_of_the_toolchain_wins(env):
277368
py = parse_modules(
278369
module_ctx = _mock_mctx(

0 commit comments

Comments
 (0)