Skip to content
Draft
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
13 changes: 10 additions & 3 deletions python/private/config_settings.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ def construct_config_settings(

def _python_version_flag_impl(ctx):
value = ctx.build_setting_value
if value:
# FIXME @aignas 2025-12-27: is this the right thing to do here? Should we do this only for
# the `FeatureFlagInfo`?
#
# ensure that the values here are normalized
Comment on lines +188 to +191
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What sort of normalization is this applying that matters?

I think it's fair to say: the provided value is expected to be a valid python version string. If it's not, behavior is undefined.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to make sure that when the user specifies 3.15.0alpha0 it is normalized to 3.15.0a0 and then we depend on normalized values when creating our own config settings.

value = version.parse(value).string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

version.parse() with the default strict=False can return None for invalid versions, which would cause a crash when accessing .string. Using strict=True will ensure it fails with a descriptive error message for invalid versions, which is safer and provides better user feedback.

Suggested change
value = version.parse(value).string
value = version.parse(value, strict = True).string


return [
# BuildSettingInfo is the original provider returned, so continue to
# return it for compatibility
Expand All @@ -203,9 +210,9 @@ _python_version_flag = rule(
)

def _python_version_major_minor_flag_impl(ctx):
input = _flag_value(ctx.attr._python_version_flag)
if input:
ver = version.parse(input)
value = _flag_value(ctx.attr._python_version_flag)
if value:
ver = version.parse(value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

For robustness, it's better to parse with strict=True. Although the version string should already be normalized at this point, this provides defense-in-depth and ensures a clear failure instead of a potential crash if an invalid version string is somehow passed here.

Suggested change
ver = version.parse(value)
ver = version.parse(value, strict = True)

value = "{}.{}".format(ver.release[0], ver.release[1])
else:
value = ""
Expand Down
2 changes: 1 addition & 1 deletion python/private/pypi/config_settings.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def config_settings(
_dist_config_setting(
name = prefix + suffix,
flag_values = {
Label("//python/config_settings:python_version_major_minor"): python_version,
Label("//python/config_settings:python_version"): python_version,
},
config_settings = config_settings,
constraint_values = constraint_values,
Expand Down
10 changes: 6 additions & 4 deletions python/private/pypi/hub_builder.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ load(":requirements_files_by_platform.bzl", "requirements_files_by_platform")
load(":whl_config_setting.bzl", "whl_config_setting")
load(":whl_repo_name.bzl", "pypi_repo_name", "whl_repo_name")

def _major_minor_version(version_str):
def _normalize_version(version_str):
ver = version.parse(version_str)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

version.parse() with the default strict=False can return None for invalid versions, causing a crash on .string access on the next line. Using strict=True will cause it to fail with a descriptive error message for invalid versions, which is safer.

    ver = version.parse(version_str, strict = True)

return "{}.{}".format(ver.release[0], ver.release[1])
return ver.string

def hub_builder(
*,
Expand Down Expand Up @@ -288,7 +288,8 @@ def _detect_interpreter(self, pip_attr):
python_interpreter_target = pip_attr.python_interpreter_target
if python_interpreter_target == None and not pip_attr.python_interpreter:
python_name = "python_{}_host".format(
pip_attr.python_version.replace(".", "_"),
# normalize the version when getting the available interpreter
version.parse(pip_attr.python_version).string.replace(".", "_"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

version.parse() with the default strict=False can return None for invalid versions, causing a crash on .string access. Using strict=True will cause it to fail with a descriptive error message for invalid versions, which is safer.

            version.parse(pip_attr.python_version, strict = True).string.replace(".", "_"),

)
if python_name not in self._available_interpreters:
fail((
Expand Down Expand Up @@ -478,7 +479,8 @@ def _create_whl_repos(
netrc = self._config.netrc or pip_attr.netrc,
use_downloader = _use_downloader(self, pip_attr.python_version, whl.name),
auth_patterns = self._config.auth_patterns or pip_attr.auth_patterns,
python_version = _major_minor_version(pip_attr.python_version),
# TODO @aignas 2025-12-27: normalize version tests
python_version = _normalize_version(pip_attr.python_version),
is_multiple_versions = whl.is_multiple_versions,
interpreter = interpreter,
enable_pipstar = enable_pipstar,
Expand Down
8 changes: 5 additions & 3 deletions python/private/pypi/render_pkg_aliases.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,14 @@ def _major_minor(python_version):
minor, _, _ = tail.partition(".")
return "{}.{}".format(major, minor)

def _major_minor_versions(python_versions):
def _with_major_minor_versions(python_versions):
if not python_versions:
return []

# Use a dict as a simple set
return sorted({_major_minor(v): None for v in python_versions})
ret = {_major_minor(v): None for v in python_versions}
ret.update({v: None for v in python_versions})
return sorted(ret)

def render_multiplatform_pkg_aliases(*, aliases, platform_config_settings = {}, **kwargs):
"""Render the multi-platform pkg aliases.
Expand Down Expand Up @@ -178,7 +180,7 @@ def render_multiplatform_pkg_aliases(*, aliases, platform_config_settings = {},
**kwargs
)
contents["_config/BUILD.bazel"] = _render_config_settings(
python_versions = _major_minor_versions(flag_versions.get("python_versions", [])),
python_versions = _with_major_minor_versions(flag_versions.get("python_versions", [])),
platform_config_settings = platform_config_settings,
visibility = ["//:__subpackages__"],
)
Expand Down
15 changes: 9 additions & 6 deletions python/private/version_label.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@

""

load(":version.bzl", v = "version")

def version_label(version, *, sep = ""):
"""A version fragment derived from python minor version
"""A version fragment derived from python minor version.

This replaces dots with the provided separator.

Examples:
version_label("3.9") == "39"
version_label("3.9.12", sep="_") == "3_9"
version_label("3.9.12", sep="_") == "3_9_12"
version_label("3.11") == "311"
version_label("3.11.12") == "31112"

Args:
version: Python version.
Expand All @@ -30,7 +35,5 @@ def version_label(version, *, sep = ""):
Returns:
The fragment of the version.
"""
major, _, version = version.partition(".")
minor, _, _ = version.partition(".")

return major + sep + minor
parsed = v.parse(version)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

v.parse() with the default strict=False can return None for invalid versions, causing a crash on .string access on the next line. Using strict=True will cause it to fail with a descriptive error message for invalid versions, which is safer.

Suggested change
parsed = v.parse(version)
parsed = v.parse(version, strict = True)

return parsed.string.replace(".", sep)
86 changes: 53 additions & 33 deletions tests/pypi/hub_builder/hub_builder_tests.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
load("@rules_testing//lib:test_suite.bzl", "test_suite")
load("@rules_testing//lib:truth.bzl", "subjects")
load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "REPO_VERBOSITY_ENV_VAR", "repo_utils") # buildifier: disable=bzl-visibility
load("//python/private:version.bzl", _version = "version") # buildifier: disable=bzl-visibility
load("//python/private:version_label.bzl", "version_label") # buildifier: disable=bzl-visibility
load("//python/private/pypi:hub_builder.bzl", _hub_builder = "hub_builder") # buildifier: disable=bzl-visibility
load("//python/private/pypi:parse_simpleapi_html.bzl", "parse_simpleapi_html") # buildifier: disable=bzl-visibility
load("//python/private/pypi:platform.bzl", _plat = "platform") # buildifier: disable=bzl-visibility
Expand Down Expand Up @@ -114,40 +116,58 @@ def hub_builder(
return self

def _test_simple(env):
builder = hub_builder(env)
builder.pip_parse(
_mock_mctx(
os_name = "osx",
arch_name = "aarch64",
),
_parse(
hub_name = "pypi",
python_version = "3.15",
requirements_lock = "requirements.txt",
),
)
pypi = builder.build()
for python_version, _ in {
"3.15": "315",
"3.15.0alpha2": "3150a2",
"3.15.0rc2": "3150rc2",
"3.15.2": "3152",
}.items():
# All of the versions should be normalized
normalized_python_version = _version.parse(python_version).string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This call is unsafe as _version.parse() can return None. Using strict=True will make the test fail clearly if an invalid version string is ever added to the test cases, which is a good practice for test robustness.

Suggested change
normalized_python_version = _version.parse(python_version).string
normalized_python_version = _version.parse(python_version, strict = True).string


pypi.exposed_packages().contains_exactly(["simple"])
pypi.group_map().contains_exactly({})
pypi.whl_map().contains_exactly({
"simple": {
"pypi_315_simple": [
whl_config_setting(
version = "3.15",
),
],
},
})
pypi.whl_libraries().contains_exactly({
"pypi_315_simple": {
"config_load": "@pypi//:config.bzl",
"dep_template": "@pypi//{name}:{target}",
"python_interpreter_target": "unit_test_interpreter_target",
"requirement": "simple==0.0.1 --hash=sha256:deadbeef --hash=sha256:deadbaaf",
},
})
pypi.extra_aliases().contains_exactly({})
builder = hub_builder(
env,
available_interpreters = {
# We are using this instead of `version_label` to keep backwards compatibility
"python_{}_host".format(normalized_python_version.replace(".", "_")): "unit_test_interpreter_target",
},
)
builder.pip_parse(
_mock_mctx(
os_name = "osx",
arch_name = "aarch64",
),
_parse(
hub_name = "pypi",
# the input version can be whatever
python_version = python_version,
requirements_lock = "requirements.txt",
),
)
pypi = builder.build()

pypi.exposed_packages().contains_exactly(["simple"])
pypi.group_map().contains_exactly({})
pypi.whl_map().contains_exactly({
"simple": {
# this should normalize the version inside
"pypi_{}_simple".format(version_label(python_version)): [
whl_config_setting(
version = normalized_python_version,
),
],
},
})
pypi.whl_libraries().contains_exactly({
# this should normalize the version inside
"pypi_{}_simple".format(version_label(python_version)): {
"config_load": "@pypi//:config.bzl",
"dep_template": "@pypi//{name}:{target}",
"python_interpreter_target": "unit_test_interpreter_target",
"requirement": "simple==0.0.1 --hash=sha256:deadbeef --hash=sha256:deadbaaf",
},
})
pypi.extra_aliases().contains_exactly({})

_tests.append(_test_simple)

Expand Down
7 changes: 5 additions & 2 deletions tests/version_label/version_label_test.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ _tests.append(_test_version_label_from_major_minor_version)

def _test_version_label_from_major_minor_patch_version(env):
actual = version_label("3.9.3")
env.expect.that_str(actual).equals("39")
env.expect.that_str(actual).equals("393")

_tests.append(_test_version_label_from_major_minor_patch_version)

Expand All @@ -39,7 +39,10 @@ _tests.append(_test_version_label_from_major_minor_version_custom_sep)

def _test_version_label_from_complex_version(env):
actual = version_label("3.9.3-rc.0")
env.expect.that_str(actual).equals("39")
env.expect.that_str(actual).equals("393rc0")

actual = version_label("3.9.3-alpha1")
env.expect.that_str(actual).equals("393a1")

_tests.append(_test_version_label_from_complex_version)

Expand Down