Skip to content

Commit 4acb4d7

Browse files
committed
feat: Implement a python.defaults tag class
As an alternative to python.toolchain.is_default, introduce a python.defaults tag class with attributes python_version, python_version_env and python_version_file. This allows to read the default python version from your projects .python-version files, similar to other tools. It also allows using an environment variable, with a fallback if the environment variable is not set.
1 parent f4631dc commit 4acb4d7

File tree

4 files changed

+282
-28
lines changed

4 files changed

+282
-28
lines changed

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ Unreleased changes template.
7878

7979
{#v0-0-0-added}
8080
### Added
81-
* (python) {attr}`python.toolchain.default_version_file` has been added to
82-
allow users to set the default python version in the root module by reading
83-
the default version number from a file.
81+
* (python) {attr}`python.defaults` has been added to allow users to
82+
set the default python version in the root module by reading the
83+
default version number from a file or an environment variable.
8484
* {obj}`//python/bin:python`: convenience target for directly running an
8585
interpreter. {obj}`--//python/bin:python_src` can be used to specify a
8686
binary whose interpreter to use.

examples/multi_python_versions/MODULE.bazel

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ 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
python_version = "3.8",
1621
)
1722
python.toolchain(
1823
configure_coverage_tool = True,
19-
# Only set when you have mulitple toolchain versions.
20-
is_default = True,
2124
python_version = "3.9",
2225
)
2326
python.toolchain(

python/private/python.bzl

Lines changed: 155 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,55 @@ 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).strip(),
115+
)
116+
if default_python_version_env:
117+
# Bazel version 7.1.0 and later support module_ctx.getenv(name, default):
118+
# When building incrementally, any change to the value of the variable
119+
# named by `name` will cause this repository to be re-fetched.
120+
if "getenv" in dir(module_ctx):
121+
getenv = module_ctx.getenv
122+
else:
123+
getenv = module_ctx.os.environ.get
124+
default_python_version = getenv(default_python_version_env, default_python_version)
125+
if not default_python_version:
126+
fallback_python_version_file = module_ctx.path("@@//:.python-version")
127+
if fallback_python_version_file.exists:
128+
default_python_version = module_ctx.read(fallback_python_version_file).strip()
129+
81130
seen_versions = {}
82131
for mod in module_ctx.modules:
83132
module_toolchain_versions = []
@@ -104,13 +153,14 @@ def parse_modules(*, module_ctx, _fail = fail):
104153
# * rules_python needs to set a soft default in case the root module doesn't,
105154
# e.g. if the root module doesn't use Python itself.
106155
# * The root module is allowed to override the rules_python default.
107-
if toolchain_attr.default_version_file:
108-
version_from_file = module_ctx.read(toolchain_attr.default_version_file).strip()
109-
is_default = version_from_file == toolchain_version
156+
if default_python_version:
157+
is_default = default_python_version == toolchain_version
158+
if toolchain_attr.is_default and not is_default:
159+
fail("The 'is_default' attribute doesn't work if you set " +
160+
"the default Python version with the `defaults` tag " +
161+
"or the '.python-version' file.")
110162
else:
111163
is_default = toolchain_attr.is_default
112-
if toolchain_attr.is_default and not is_default:
113-
fail("The 'is_default' attribute doesn't work if you set 'default_version_file'.")
114164

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

123173
ignore_root_user_error = toolchain_attr.ignore_root_user_error
124-
elif mod.name == "rules_python" and not default_toolchain:
174+
elif mod.name == "rules_python" and not default_toolchain and not default_python_version:
125175
# We don't do the len() check because we want the default that rules_python
126176
# sets to be clearly visible.
127177
is_default = toolchain_attr.is_default
@@ -288,6 +338,19 @@ def _python_impl(module_ctx):
288338
else:
289339
return None
290340

341+
def _one_or_the_same(first, second, *, onerror = None):
342+
if not first:
343+
return second
344+
if not second or second == first:
345+
return first
346+
if onerror:
347+
return onerror(first, second)
348+
else:
349+
fail("Unique value needed, got both '{}' and '{}', which are different".format(
350+
first,
351+
second,
352+
))
353+
291354
def _fail_duplicate_module_toolchain_version(version, module):
292355
fail(("Duplicate module toolchain version: module '{module}' attempted " +
293356
"to use version '{version}' multiple times in itself").format(
@@ -311,6 +374,30 @@ def _warn_duplicate_global_toolchain_version(version, first, second_toolchain_na
311374
version = version,
312375
))
313376

377+
def _fail_multiple_defaults_python_version(first, second):
378+
fail(("Multiple python_version entries in defaults: " +
379+
"First default was python_version '{first}'. " +
380+
"Second was python_version '{second}'").format(
381+
first = first,
382+
second = second,
383+
))
384+
385+
def _fail_multiple_defaults_python_version_file(first, second):
386+
fail(("Multiple python_version_file entries in defaults: " +
387+
"First default was python_version_file '{first}'. " +
388+
"Second was python_version_file '{second}'").format(
389+
first = first,
390+
second = second,
391+
))
392+
393+
def _fail_multiple_defaults_python_version_env(first, second):
394+
fail(("Multiple python_version_env entries in defaults: " +
395+
"First default was python_version_env '{first}'. " +
396+
"Second was python_version_env '{second}'").format(
397+
first = first,
398+
second = second,
399+
))
400+
314401
def _fail_multiple_default_toolchains(first, second):
315402
fail(("Multiple default toolchains: only one toolchain " +
316403
"can have is_default=True. First default " +
@@ -532,6 +619,21 @@ def _get_toolchain_config(*, modules, _fail = fail):
532619
register_all_versions = register_all_versions,
533620
)
534621

622+
def _create_defaults_attr_structs(*, mod):
623+
arg_structs = []
624+
625+
for tag in mod.tags.defaults:
626+
arg_structs.append(_create_defaults_attr_struct(tag = tag))
627+
628+
return arg_structs
629+
630+
def _create_defaults_attr_struct(*, tag):
631+
return struct(
632+
python_version = getattr(tag, "python_version", None),
633+
python_version_env = getattr(tag, "python_version_env", None),
634+
python_version_file = getattr(tag, "python_version_file", None),
635+
)
636+
535637
def _create_toolchain_attr_structs(*, mod, config, seen_versions):
536638
arg_structs = []
537639

@@ -566,7 +668,6 @@ def _create_toolchain_attrs_struct(*, tag = None, python_version = None, toolcha
566668
python_version = python_version if python_version else tag.python_version,
567669
configure_coverage_tool = getattr(tag, "configure_coverage_tool", False),
568670
ignore_root_user_error = getattr(tag, "ignore_root_user_error", True),
569-
default_version_file = getattr(tag, "default_version_file", None),
570671
)
571672

572673
def _get_bazel_version_specific_kwargs():
@@ -577,6 +678,49 @@ def _get_bazel_version_specific_kwargs():
577678

578679
return kwargs
579680

681+
_defaults = tag_class(
682+
doc = """Tag class to specify the default Python version.""",
683+
attrs = {
684+
"python_version": attr.string(
685+
mandatory = False,
686+
doc = """\
687+
String saying what the default Python version should be. If the string
688+
matches the {attr}`python_version` attribute of a toolchain, this
689+
toolchain is the default version. If this attribute is set, the
690+
{attr}`is_default` attribute of the toolchain is ignored.
691+
692+
:::{versionadded} VERSION_NEXT_FEATURE
693+
:::
694+
""",
695+
),
696+
"python_version_env": attr.string(
697+
mandatory = False,
698+
doc = """\
699+
Environment variable saying what the default Python version should be.
700+
If the string matches the {attr}`python_version` attribute of a
701+
toolchain, this toolchain is the default version. If this attribute is
702+
set, the {attr}`is_default` attribute of the toolchain is ignored.
703+
704+
:::{versionadded} VERSION_NEXT_FEATURE
705+
:::
706+
""",
707+
),
708+
"python_version_file": attr.label(
709+
mandatory = False,
710+
allow_single_file = True,
711+
doc = """\
712+
File saying what the default Python version should be. If the contents
713+
of the file match the {attr}`python_version` attribute of a toolchain,
714+
this toolchain is the default version. If this attribute is set, the
715+
{attr}`is_default` attribute of the toolchain is ignored.
716+
717+
:::{versionadded} VERSION_NEXT_FEATURE
718+
:::
719+
""",
720+
),
721+
},
722+
)
723+
580724
_toolchain = tag_class(
581725
doc = """Tag class used to register Python toolchains.
582726
Use this tag class to register one or more Python toolchains. This class
@@ -642,18 +786,6 @@ Then the python interpreter will be available as `my_python_name`.
642786
mandatory = False,
643787
doc = "Whether or not to configure the default coverage tool provided by `rules_python` for the compatible toolchains.",
644788
),
645-
"default_version_file": attr.label(
646-
mandatory = False,
647-
allow_single_file = True,
648-
doc = """\
649-
File saying what the default Python version should be. If the contents of the
650-
file match the {attr}`python_version` attribute, this toolchain is the default version.
651-
If this attribute is set, the {attr}`is_default` attribute is ignored.
652-
653-
:::{versionadded} VERSION_NEXT_FEATURE
654-
:::
655-
""",
656-
),
657789
"ignore_root_user_error": attr.bool(
658790
default = True,
659791
doc = """\
@@ -673,10 +805,11 @@ error to run with root access instead.
673805
"is_default": attr.bool(
674806
mandatory = False,
675807
doc = """\
676-
Whether the toolchain is the default version.
808+
Whether the toolchain is the default version.
677809
678810
:::{versionchanged} VERSION_NEXT_FEATURE
679-
This setting is ignored if {attr}`default_version_file` is set.
811+
This setting is ignored if the default version is set using the `defaults`
812+
tag class.
680813
:::
681814
""",
682815
),
@@ -877,6 +1010,7 @@ python = module_extension(
8771010
""",
8781011
implementation = _python_impl,
8791012
tag_classes = {
1013+
"defaults": _defaults,
8801014
"override": _override,
8811015
"single_version_override": _single_version_override,
8821016
"single_version_platform_override": _single_version_platform_override,

0 commit comments

Comments
 (0)