diff --git a/MODULE.bazel b/MODULE.bazel index 6486634370..c0de092ef5 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -50,9 +50,13 @@ python.defaults( python.toolchain( python_version = "3.11", ) +python.toolchain( + python_version = "3.14", +) use_repo( python, "python_3_11", + "python_3_14_host", "pythons_hub", python = "python_versions", ) @@ -341,7 +345,12 @@ dev_pip.parse( python_version = "3.11", requirements_lock = "//tests/multi_pypi/beta:requirements.txt", ) -use_repo(dev_pip, "dev_pip", "pypi_alpha", "pypi_beta", "pypiserver") +dev_pip.parse( + hub_name = "uv_pypi", + python_version = "3.12", + uv_lock = "//tests/uv_pypi:uv.lock", +) +use_repo(dev_pip, "dev_pip", "pypi_alpha", "pypi_beta", "pypiserver", "uv_pypi") # Bazel integration test setup below diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..06099516e1 --- /dev/null +++ b/plan.md @@ -0,0 +1,42 @@ +# Plan for adding uv.lock support to pip.parse + +## Part 1: Analyze and Plan (Done) +- Analyzed `python/extensions/pip.bzl`, `python/private/pypi/hub_builder.bzl`, `python/private/pypi/uv_lock.bzl`. +- Confirmed `uv.lock` structure contains wheel URLs and resolution markers. +- Identified need to modify `pip_repository_attrs.bzl` and `hub_builder.bzl`. + +## Part 2: Basic Implementation +1. **Modify `python/private/pypi/pip_repository_attrs.bzl`**: + - Add `uv_lock` attribute (label, allow_single_file=True). + - Add `_toml2json` attribute (label, default pointing to a tool). Note: Need to verify if `_toml2json` is already available or needs to be added. The `uv_lock.bzl` helper uses `attr._toml2json`, so it must be present on the calling rule/tag. + +2. **Modify `python/private/pypi/hub_builder.bzl`**: + - In `_pip_parse`: + - Check if `pip_attr.uv_lock` is set. + - If set, call `convert_uv_lock_to_json`. + - Parse the JSON result. + - Iterate over packages in the JSON. + - Group packages by name. + - For this step, select the highest version for each package name. + - Select the first wheel URL for that version. + - Create `whl_library` repositories for these wheels. + - Ensure these `whl_library` calls are integrated into `self._whl_libraries`. + +3. **Verify**: + - Run `bazel run //tests/uv_pypi:bin`. + +## Part 3: Advanced Implementation (Multiple Versions) +1. **Handle Resolution Markers**: + - Parse `resolution-markers` from `uv.lock` packages. + - Instead of picking one version, keep all versions that have distinct resolution markers. + +2. **Use `wheel_tags_settings`**: + - In `hub_builder.bzl`, when constructing the hub repository content (via `hub_repository`), we need to pass information about these multiple versions. + - The `hub_repository` rule (or the macros creating it) needs to generate `define_wheel_tag_settings` in the `BUILD.bazel` of the hub. + - Generate `alias` targets using `select()` based on the defined settings. + +3. **Refactor Hub Generation**: + - Update `hub_repository.bzl` (or the template it uses) to support this new "multi-version via select" pattern, if it doesn't already. The prompt suggests modifying the "hub build file for a package". + +4. **Verify**: + - Run the test again. It should correctly pick `absl-py` 2.3.1 or 2.4.0 based on the environment/platform. diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000000..c94e513b96 --- /dev/null +++ b/prompt.md @@ -0,0 +1,71 @@ + +We're adding uv.lock support to pip.parse. + +A test has been added to `//test/uv_pypi` + +Verify functionality by running `bazel run //tests/uv_pypi:bin` + +The desired interface is by setting a `uv_lock` attribute in `pip.parse`. This +has been configured in MODULE.bazel for the test already: + +``` +dev_pip.parse( + hub_name = "uv_pypi", + python_version = "3.12", + uv_lock = "//tests/uv_pypi:uv.lock", +) +``` + +A key helper to accomplish this is to use the toml2json tool, which handles +converting the toml-format of uv.lock to JSON, which can then be processed +by Starlark code. The name of this helper is `convert_uv_lock_to_json` +in `python/private/pypi/uv_lock.bzl` + +Some background: + +The pypi integration uses a "hub" and "spokes" design. The hub is the +`@uv_pypi` repo. This is basically a collection of BUILD files that +have aliases that point to spoke repos. Spoke repos use the `whl_library` +repository rule which downloads and extracts that actual wheel. + +Part 1: + +Analyze the pypi extension code base and write a plan of changes to make to +`plan.md` that accomplish the different parts of what needs to be done. + +Part 2: + +Modify the pip.parse extension to convert the uv.lock file to JSON. + +From that, create a whl_library for each wheel URL. For now, if there +are multiple versions of a package, take the highest version package. If +there are multiple wheel URLs, use the first one. + +Part 3: + +Modify the whl_library generation logic to handle when there are multiple +versions of a package available. This should use the "resolution-markers" +information to generate select() expressions to pick between the different +packages available. The wheel_tags_settings rule is a key helper for this: +it can take a resolution-markers expression and evaluate it so that select() +expressions can match it. + +As part of implementing this logic, the BUILD file for a package in the hub +repo should use wheel_tags_settings. For example, if the absl_py package +has two wheels, then the hub build file should look something like: + +``` +# File: @uv_pypi//absl_py:BUILD.bazel + +define_wheel_tag_settings([ + ("@absl_py_a//:pkg", "resolution marker expr for a"), + ("@absl_py_b//:pkg", "resolution marker expr for b"), +]) +alias( + name = "absl_py", + actual = select({ + "pick_0": "@absl_py_a//:pkg", + "pick_1": "@absl_py_b//:pkg", + }) +) +``` diff --git a/python/extensions/uv_external_deps.bzl b/python/extensions/uv_external_deps.bzl new file mode 100644 index 0000000000..4c095eeea7 --- /dev/null +++ b/python/extensions/uv_external_deps.bzl @@ -0,0 +1,152 @@ +"""A module extension for uv external dependencies. + +This extension allows users to define external dependencies using uv. +""" + +load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils") +load("//python/private:text_util.bzl", "render") + +def _wheel_repo_impl(rctx): + rctx.download(rctx.attr.urls, output = "output.zip") + rctx.file("BUILD.bazel", "exports_files(glob(['**']))") + +wheel_repo = repository_rule( + implementation = _wheel_repo_impl, + attrs = { + "urls": attr.string_list(), + }, +) + +_PACKAGE_BUILD_TEMPLATE = """ + +load("@rules_python//python/private/pypi:uv_lock_targets.bzl", "define_targets") + +package( + default_visibility = ["//visibility:public"] +) +exports_files(glob(["**"])) + +define_targets( + name = "{package}", + selectors = {selectors} +) +""" + +def _hub_package_build_file(rctx, package, selectors): + rctx.file( + "{}/BUILD.bazel".format(package), + _PACKAGE_BUILD_TEMPLATE.format( + package = package, + selectors = render.list(selectors), + ), + ) + +def _hub_repo_impl(rctx): + hub_selectors = json.decode(rctx.attr.selectors) + + for package, selectors in hub_selectors.items(): + _hub_package_build_file(rctx, package, selectors) + + rctx.file("BUILD.bazel", "") + +hub_repo = repository_rule( + implementation = _hub_repo_impl, + attrs = { + "selectors": attr.string(), + }, +) + +def wheel_tags_from_wheel_url(url): + _, _, basename = url.rpartition("/") + basename = basename.removesuffix(".whl") + distro, _, tail = basename.partition("-") + version, _, tail = tail.partition("-") + py_tag, _, tail = tail.partition("-") + abi, _, tail = tail.partition("-") + platform, _, tail = tail.partition("-") + return { + "python_tag": py_tag, + "abi_tag": abi, + "platform_tag": platform, + } + #charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", + +def _uv_external_deps_extension_impl(mctx): + logger = repo_utils.logger(mctx, "uvextdeps") + + # sources[distro][version][type][url] = any_of_conditions + # url -> info + sources = {} + hub_name = None + for mod in mctx.modules: + for hub in mod.tags.hub: + out = _convert_uv_lock_to_json(mctx, hub, logger) + lock_info = json.decode(out) + break + + # Basically what we're doing is: + # URLs are given name R (repo name). Thus, we never duplicate a download. + # Repo R is used if conditions C are met (wheel tags, marker, and + # custom config settings for that lock file). + url_to_repo = {} + hub_selectors = {} + + packages = lock_info["package"] + for distro in packages: + # todo: handle source{virtual = "."} + if "wheels" not in distro: + continue + for i, wheel in enumerate(distro["wheels"]): + url = wheel["url"] + if url not in url_to_repo: + # todo: normalize dash to underscore etc + name = "{distro}_{version}_{i}".format( + distro = distro["name"], + version = distro["version"], + i = i, + ) + wheel_repo( + name = name, + urls = [url], + ) + else: + name = url_to_repo[url] + + hub_selectors.setdefault(distro["name"], []) + wheel_tags = wheel_tags_from_wheel_url(url) + resolution_markers = distro.get("resolution-markers") + if resolution_markers: + for marker in resolution_markers: + hub_selectors[distro["name"]].append(struct( + wheel_tags = wheel_tags, + config_settings = hub.config_settings, + marker = marker, + actual_repo = name, + )) + else: + hub_selectors[distro["name"]].append(struct( + wheel_tags = wheel_tags, + config_settings = hub.config_settings, + marker = None, + actual_repo = name, + )) + + hub_repo( + name = hub.name, + selectors = json.encode(hub_selectors), + ) + +uv_external_deps = module_extension( + implementation = _uv_external_deps_extension_impl, + tag_classes = { + "hub": tag_class(attrs = { + "name": attr.string(), + "srcs": attr.label_list(), + "config_settings": attr.label_list(), + "_toml2json": attr.label(default = "//tools/toml2json:toml2json.py"), + "_python_interpreter_target": attr.label( + default = "@python_3_14_host//:python", + ), + }), + }, +) diff --git a/python/private/pypi/extension.bzl b/python/private/pypi/extension.bzl index 3927f61c00..f820d4af6a 100644 --- a/python/private/pypi/extension.bzl +++ b/python/private/pypi/extension.bzl @@ -272,6 +272,7 @@ You cannot use both the additive_build_content and additive_build_content_file a exposed_packages = {} extra_aliases = {} whl_libraries = {} + uv_selectors = {} for hub in pip_hub_map.values(): out = hub.build() @@ -285,6 +286,7 @@ You cannot use both the additive_build_content and additive_build_content_file a extra_aliases[hub.name] = out.extra_aliases hub_group_map[hub.name] = out.group_map hub_whl_map[hub.name] = out.whl_map + uv_selectors[hub.name] = out.uv_selectors return struct( config = config, @@ -294,6 +296,7 @@ You cannot use both the additive_build_content and additive_build_content_file a hub_whl_map = hub_whl_map, whl_libraries = whl_libraries, whl_mods = whl_mods, + uv_selectors = uv_selectors, platform_config_settings = { hub_name: { platform_name: sorted([str(Label(cv)) for cv in p.config_settings]) @@ -386,6 +389,7 @@ def _pip_impl(module_ctx): key: whl_config_settings_to_json(values) for key, values in whl_map.items() }, + uv_selectors = mods.uv_selectors.get(hub_name, {}), packages = mods.exposed_packages.get(hub_name, []), platform_config_settings = mods.platform_config_settings.get(hub_name, {}), groups = mods.hub_group_map.get(hub_name), diff --git a/python/private/pypi/hub_builder.bzl b/python/private/pypi/hub_builder.bzl index f54d02d8b0..b70d7225e0 100644 --- a/python/private/pypi/hub_builder.bzl +++ b/python/private/pypi/hub_builder.bzl @@ -11,8 +11,10 @@ load(":evaluate_markers.bzl", "evaluate_markers_py", evaluate_markers_star = "ev load(":parse_requirements.bzl", "parse_requirements") load(":pep508_env.bzl", "env") load(":pep508_evaluate.bzl", "evaluate") +load(":pypi_repo_utils.bzl", "pypi_repo_utils") load(":python_tag.bzl", "python_tag") load(":requirements_files_by_platform.bzl", "requirements_files_by_platform") +load(":uv_lock.bzl", "convert_uv_lock_to_json") load(":whl_config_setting.bzl", "whl_config_setting") load(":whl_repo_name.bzl", "pypi_repo_name", "whl_repo_name") @@ -65,6 +67,7 @@ def hub_builder( _group_map = {}, # modified by _add_group_map _whl_libraries = {}, # modified by _add_whl_library _whl_map = {}, # modified by _add_whl_library + _uv_selectors = {}, # modified by _process_uv_lock # internal _platforms = {}, _group_name_by_whl = {}, @@ -111,6 +114,7 @@ def _build(self): }, exposed_packages = sorted(self._exposed_packages), whl_libraries = self._whl_libraries, + uv_selectors = self._uv_selectors, ) def _pip_parse(self, module_ctx, pip_attr): @@ -157,6 +161,11 @@ def _pip_parse(self, module_ctx, pip_attr): # to `{os}_{arch}`. target_platforms = pip_attr.target_platforms or ([] if default_cross_setup else ["{os}_{arch}"]), ) + + if pip_attr.uv_lock: + _process_uv_lock(self, module_ctx, pip_attr) + return + _add_group_map(self, pip_attr.experimental_requirement_cycles) _add_extra_aliases(self, pip_attr.extra_hub_aliases) _create_whl_repos( @@ -167,6 +176,97 @@ def _pip_parse(self, module_ctx, pip_attr): enable_pipstar_extract = self._config.enable_pipstar_extract or self._get_index_urls.get(pip_attr.python_version), ) +def _process_uv_lock(self, module_ctx, pip_attr): + interpreter = _detect_interpreter(self, pip_attr) + + real_interpreter = pypi_repo_utils.resolve_python_interpreter( + module_ctx, + python_interpreter = interpreter.path, + python_interpreter_target = interpreter.target, + ) + + json_str = convert_uv_lock_to_json(module_ctx, pip_attr, self._logger, python_interpreter = real_interpreter) + lock_data = json.decode(json_str) + + packages = lock_data.get("package", []) + packages_by_name = {} + for pkg in packages: + name = normalize_name(pkg["name"]) + packages_by_name.setdefault(name, []).append(pkg) + + # Common args logic + common_args = _common_args( + self, + module_ctx, + pip_attr = pip_attr, + enable_pipstar = False, # TODO: Support pipstar + ) + + for name, pkgs in packages_by_name.items(): + package_settings = [] + for pkg in pkgs: + version = pkg["version"] + wheels = pkg.get("wheels", []) + if not wheels: + continue + + # Use first wheel + wheel = wheels[0] + wheel_url = wheel["url"] + wheel_hash = wheel.get("hash") + + # Create repo + src = struct( + requirement_line = "{}=={}".format(name, version), + filename = wheel_url.split("/")[-1], + url = wheel_url, + sha256 = wheel_hash.replace("sha256:", "") if wheel_hash else None, + distribution = name, + target_platforms = [], + extra_pip_args = [], + is_multiple_versions = False, + ) + + whl_library_args = dict(common_args) + # Add extra args if needed + whl_library_args.update(dict( + dep_template = "@{}//{{name}}:{{target}}".format(self.name), + )) + + repo = _whl_repo( + src = src, + whl_library_args = whl_library_args, + download_only = pip_attr.download_only, + netrc = self._config.netrc or pip_attr.netrc, + auth_patterns = self._config.auth_patterns or pip_attr.auth_patterns, + python_version = _major_minor_version(pip_attr.python_version), + is_multiple_versions = False, + use_downloader = True, + interpreter = interpreter, + enable_pipstar = False, + enable_pipstar_extract = False, + ) + + repo_name = repo.repo_name + if repo_name not in self._whl_libraries: + self._whl_libraries[repo_name] = repo.args + + markers = pkg.get("resolution-markers", []) + if not markers: + package_settings.append((repo_name, None)) + else: + for m in markers: + package_settings.append((repo_name, m)) + + if package_settings: + self._uv_selectors[name] = json.encode(package_settings) + + # We also need to add exposed packages for the hub + _add_exposed_packages(self, { + name: None + for name in packages_by_name.keys() + }) + ### end of PUBLIC methods ### setters for build outputs diff --git a/python/private/pypi/hub_repository.bzl b/python/private/pypi/hub_repository.bzl index f915aa1c77..fdfa62e40d 100644 --- a/python/private/pypi/hub_repository.bzl +++ b/python/private/pypi/hub_repository.bzl @@ -45,6 +45,68 @@ def _impl(rctx): # `requirement`, et al. macros. macro_tmpl = "@@{name}//{{}}:{{}}".format(name = rctx.attr.name) + if rctx.attr.uv_selectors: + for pkg_name, selectors_json in rctx.attr.uv_selectors.items(): + settings = json.decode(selectors_json) + # settings is list of [repo, marker] + + select_dict = {} + for i, entry in enumerate(settings): + repo = entry[0] + marker = entry[1] + if marker: + select_dict[":pick_{}".format(i)] = "@" + repo + else: + select_dict["//conditions:default"] = "@" + repo + + # We create aliases for pkg, whl, data, dist_info + # repo points to the whl_library which has these targets. + # So actual should be repo + "//:pkg" etc. + + def make_select(suffix): + d = {} + for k, v in select_dict.items(): + d[k] = v + suffix + return render.dict(d) + + content = """ +load("@rules_python//python/private/pypi:uv_lock_targets.bzl", "define_wheel_tag_settings") + +package(default_visibility = ["//visibility:public"]) + +define_wheel_tag_settings({settings}) + +alias( + name = "{pkg_name}", + actual = ":pkg", +) +alias( + name = "pkg", + actual = select({select_pkg}), +) +alias( + name = "whl", + actual = select({select_whl}), +) +alias( + name = "data", + actual = select({select_data}), +) +alias( + name = "dist_info", + actual = select({select_dist_info}), +) +""".format( + pkg_name = pkg_name, + settings = render.list(settings), + select_pkg = make_select("//:pkg"), + select_whl = make_select("//:whl"), + select_data = make_select("//:data"), + select_dist_info = make_select("//:dist_info"), + ) + rctx.file("{}/BUILD.bazel".format(pkg_name), content) + + rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS) rctx.template( "config.bzl", @@ -99,6 +161,10 @@ The wheel map where values are json.encoded strings of the whl_map constructed in the pip.parse tag class. """, ), + "uv_selectors": attr.string_dict( + mandatory = False, + doc = "Map of package name to JSON list of (repo, marker) for uv.lock support", + ), "_config_template": attr.label( default = ":config.bzl.tmpl", ), diff --git a/python/private/pypi/pip_repository_attrs.bzl b/python/private/pypi/pip_repository_attrs.bzl index 23000869e9..24e0f6ddcb 100644 --- a/python/private/pypi/pip_repository_attrs.bzl +++ b/python/private/pypi/pip_repository_attrs.bzl @@ -70,4 +70,20 @@ True will become default in a subsequent release. ), } +UV_ATTRS = { + "uv_lock": attr.label( + allow_single_file = True, + doc = """\ +The uv.lock file to use for resolving dependencies. +""", + ), + "_toml2json": attr.label( + default = Label("//tools/toml2json:toml2json.py"), + allow_single_file = True, + cfg = "exec", + executable = True, + ), +} + +ATTRS.update(**UV_ATTRS) ATTRS.update(**COMMON_ATTRS) diff --git a/python/private/pypi/uv_lock.bzl b/python/private/pypi/uv_lock.bzl new file mode 100644 index 0000000000..a2adf2ec80 --- /dev/null +++ b/python/private/pypi/uv_lock.bzl @@ -0,0 +1,43 @@ +load("//python/private/pypi:pypi_repo_utils.bzl", "pypi_repo_utils") + +def convert_uv_lock_to_json(mrctx, attr, logger, python_interpreter = None): + """Converts a uv.lock file to json. + + Args: + mrctx: a module_ctx or repository_ctx object. + attr: The attribute struct for mrctx. It must have an + `_python_interpreter_target` attribute of the interpreter + to use. + logger: a logger object to use + python_interpreter: (optional) The resolved python interpreter object. + + Returns: + The command output, which is a json string. + """ + if not python_interpreter: + python_interpreter_target = getattr(attr, "_python_interpreter_target", None) + if not python_interpreter_target: + python_interpreter_target = getattr(attr, "python_interpreter_target", None) + + python_interpreter = pypi_repo_utils.resolve_python_interpreter( + mrctx, + python_interpreter_target = python_interpreter_target, + ) + toml2json = mrctx.path(attr._toml2json) + if hasattr(attr, "uv_lock") and attr.uv_lock: + src_path = mrctx.path(attr.uv_lock) + else: + src_path = mrctx.path(attr.srcs[0]) + + stdout = pypi_repo_utils.execute_checked_stdout( + mrctx, + logger = logger, + op = "toml2json", + python = python_interpreter, + arguments = [ + str(toml2json), + str(src_path), + ], + srcs = [toml2json, src_path], + ) + return stdout diff --git a/python/private/pypi/uv_lock_targets.bzl b/python/private/pypi/uv_lock_targets.bzl new file mode 100644 index 0000000000..7277457898 --- /dev/null +++ b/python/private/pypi/uv_lock_targets.bzl @@ -0,0 +1,69 @@ +load("@bazel_skylib//lib:selects.bzl", "selects") +load("//python/private/pypi:env_marker_setting.bzl", "env_marker_setting") +load("//python/private/pypi:wheel_tags_setting.bzl", "wheel_tags_setting") + +def gen_package_config_settings(name, config_settings, marker, wheel_tags): + match_all = list(config_settings) + wt_name = name + "_wheeltags" + wheel_tags_setting( + name = wt_name, + **wheel_tags + ) + match_all.append("is_{}_true".format(wt_name)) + if marker: + em_name = name + "_marker" + env_marker_setting( + name = em_name, + expression = marker, + ) + match_all("is_{}_true".format(em_name)) + + selects.config_setting_group( + name = name, + match_all = match_all, + ) + +def define_targets(name, selectors): + select_map = {} + for i, selector in enumerate(selectors): + actual_repo = selector["actual_repo"] + actual = "@{}//:output.zip".format(actual_repo) + condition_name = "pick_{}_{}".format(i, actual_repo) + gen_package_config_settings( + name = condition_name, + config_settings = selector["config_settings"], + marker = selector["marker"], + wheel_tags = selector["wheel_tags"], + ) + select_map[condition_name] = actual + + native.alias( + name = name, + actual = select(select_map), + ) + +def define_wheel_tag_settings(settings): + """Defines the wheel tag settings and config settings. + + Args: + settings: list of (repo_name, marker_expression). + """ + for i, (repo, marker) in enumerate(settings): + # name for the marker rule + marker_name = "marker_{}".format(i) + # name for the config setting (used in select keys) + config_name = "pick_{}".format(i) + + if marker: + env_marker_setting( + name = marker_name, + expression = marker, + ) + native.config_setting( + name = config_name, + flag_values = { ":" + marker_name : "TRUE" } + ) + else: + # If no marker, we can't create a config setting that matches "everything" easily + # without a flag. But maybe we don't need to if we use //conditions:default in the select. + pass diff --git a/python/private/pypi/wheel_tags_setting.bzl b/python/private/pypi/wheel_tags_setting.bzl new file mode 100644 index 0000000000..e29c1f757e --- /dev/null +++ b/python/private/pypi/wheel_tags_setting.bzl @@ -0,0 +1,116 @@ +load("//python/private:toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE") + +_WHEEL_TAGS_TRUE = "TRUE" +_WHEEL_TAGS_FALSE = "FALSE" + +def wheel_tags_setting(*, name, **wheel_tags): + """Creates an env_marker setting. + + Generated targets: + + * `is_{name}_true`: config_setting that matches when the expression is true. + * `{name}`: env marker target that evalutes the expression. + + Args: + name: {type}`str` target name + expression: {type}`str` the environment marker string to evaluate + **kwargs: {type}`dict` additional common kwargs. + """ + native.config_setting( + name = "is_{}_true".format(name), + flag_values = { + ":{}".format(name): _WHEEL_TAGS_TRUE, + }, + ) + _wheel_tags_setting( + name = name, + os = select({ + "@platforms//os:linux": "linux", + "//conditions:default": "other", + }), + arch = select({ + "@platforms//cpu:x86_64": "x86", + "//conditions:default": "other", + }), + **wheel_tags + ) + +def _wheel_tags_setting_impl(ctx): + runtime = ctx.toolchains[TARGET_TOOLCHAIN_TYPE].py3_runtime + + pyimpl_is_compatible = False + abi_is_compatible = False + platform_is_compatible = False + + python_tag = ctx.attr.python_tag + if python_tag in ("py3", "any"): + pyimpl_is_compatible = True + elif python_tag.startswith("cp"): + ptv = python_tag[2:] + major = runtime.interpreter_version_info.major + minor = runtime.interpreter_version_info.minor + mm = "{}{}".format(major, minor) + pyimpl_is_compatible = mm == ptv + else: + pass + + abi_tag = ctx.attr.abi_tag + if abi_tag == "none": + abi_is_compatible = True + elif abi_tag.startswith("cp"): + abv = abi_tag[2:] + major = runtime.interpreter_version_info.major + minor = runtime.interpreter_version_info.minor + mm = "{}{}".format(major, minor) + abi_is_compatible = mm == abv + else: + pass + + """ + (musl|many)linux_arch + """ + platform_tag = ctx.attr.platform_tag + if platform_tag == "any": + platform_is_compatible = True + else: + libc = "musl" if "musl" in platform_tag else "glibc" + if "linux" in platform_tag: + os = "linux" + else: + os = "other" + if "x86" in platform_tag: + arch = "x86" + elif "arm64" in platform_tag or "aarch64" in platform_tag: + arch = "arm" + else: + arch = "other" + + platform_is_compatible = ( + os == ctx.attr.os and + arch == ctx.attr.arch and + libc == ctx.attr._libc[config_common.FeatureFlagInfo].value + ) + + compatible = (pyimpl_is_compatible and + abi_is_compatible and + platform_is_compatible) + value = "TRUE" if compatible else "FALSE" + return [config_common.FeatureFlagInfo(value = value)] + +_wheel_tags_setting = rule( + implementation = _wheel_tags_setting_impl, + attrs = { + "python_tag": attr.string(), + "abi_tag": attr.string(), + "platform_tag": attr.string(), + "_libc": attr.label( + default = "//python/config_settings:py_linux_libc", + ), + }, + toolchains = [ + config_common.toolchain_type( + TARGET_TOOLCHAIN_TYPE, + mandatory = False, + ), + ], +) diff --git a/tests/tools/toml2json/BUILD.bazel b/tests/tools/toml2json/BUILD.bazel new file mode 100644 index 0000000000..6256e372af --- /dev/null +++ b/tests/tools/toml2json/BUILD.bazel @@ -0,0 +1,10 @@ +load("@rules_python//python:defs.bzl", "py_test") + +py_test( + name = "toml2json_test", + srcs = ["toml2json_test.py"], + main = "toml2json_test.py", + deps = [ + "//tools/toml2json", + ], +) \ No newline at end of file diff --git a/tests/tools/toml2json/toml2json_test.py b/tests/tools/toml2json/toml2json_test.py new file mode 100644 index 0000000000..c68b0b566e --- /dev/null +++ b/tests/tools/toml2json/toml2json_test.py @@ -0,0 +1,62 @@ +import io +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +from tools.toml2json import toml2json + +class Toml2JsonTest(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + + def _create_temp_toml_file(self, content): + fd, path = tempfile.mkstemp(suffix=".toml", dir=self.temp_dir.name) + with os.fdopen(fd, "wb") as f: + f.write(content) + return path + + def test_basic_conversion(self): + toml_content = b""" +[owner] +name = "Tom Preston-Werner" +dob = 1979-05-27T07:32:00-08:00 +""" + expected_json = { + "owner": { + "name": "Tom Preston-Werner", + "dob": "1979-05-27T07:32:00-08:00" + } + } + + toml_file_path = self._create_temp_toml_file(toml_content) + + with patch('sys.stdout', new=io.StringIO()) as mock_stdout: + with patch('sys.argv', ['toml2json.py', toml_file_path]): + toml2json.main() + actual_json = json.loads(mock_stdout.getvalue()) + self.assertEqual(actual_json, expected_json) + + def test_invalid_toml(self): + toml_content = b""" +[owner +name = "Tom Preston-Werner" +""" + + toml_file_path = self._create_temp_toml_file(toml_content) + + with patch('sys.stderr', new=io.StringIO()) as mock_stderr: + with patch('sys.stdout', new=io.StringIO()): # We don't expect stdout for errors + with patch('sys.exit') as mock_exit: + with patch('sys.argv', ['toml2json.py', toml_file_path]): + toml2json.main() + mock_exit.assert_called_with(1) + self.assertIn("Error decoding TOML", mock_stderr.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/uv_pypi/BUILD.bazel b/tests/uv_pypi/BUILD.bazel new file mode 100644 index 0000000000..ec2682ed5c --- /dev/null +++ b/tests/uv_pypi/BUILD.bazel @@ -0,0 +1,9 @@ +load("//python:py_binary.bzl", "py_binary") + +py_binary( + name = "bin", + srcs = ["bin.py"], + deps = [ + "@uv_pypi//absl_py", + ], +) diff --git a/tests/uv_pypi/bin.py b/tests/uv_pypi/bin.py new file mode 100644 index 0000000000..f60766aef6 --- /dev/null +++ b/tests/uv_pypi/bin.py @@ -0,0 +1,4 @@ +print("Hello") + +import absl +print("absl:", absl) diff --git a/tests/uv_pypi/pyproject.toml b/tests/uv_pypi/pyproject.toml new file mode 100644 index 0000000000..bf136cc7bf --- /dev/null +++ b/tests/uv_pypi/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "uv_pypi_test" +version = "0.0.0" +requires-python = ">= 3.9" + +dependencies = [ + # NOTE: This is only used as input to create the resolved requirements.txt + # file, which is what builds, both Bazel and Readthedocs, both use. + ##"sphinx-autodoc2", + ##"sphinx", + ##"myst-parser", + ##"sphinx_rtd_theme >=2.0", # uv insists on downgrading for some reason + ##"readthedocs-sphinx-ext", + "absl-py", + ##"typing-extensions", + ##"sphinx-reredirects", + ##"pefile", + ##"pyelftools", + ##"macholib", +] + +[tool.uv] +environments = [ + "sys_platform == 'linux'", + ##"sys_platform == 'darwin'", +] diff --git a/tests/uv_pypi/uv.lock b/tests/uv_pypi/uv.lock new file mode 100644 index 0000000000..411c54fd35 --- /dev/null +++ b/tests/uv_pypi/uv.lock @@ -0,0 +1,46 @@ +version = 1 +revision = 2 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10' and sys_platform == 'linux'", + "python_full_version < '3.10' and sys_platform == 'linux'", +] +supported-markers = [ + "sys_platform == 'linux'", +] + +[[package]] +name = "absl-py" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and sys_platform == 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, +] + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10' and sys_platform == 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "uv-pypi-test" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "absl-py", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, + { name = "absl-py", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, +] + +[package.metadata] +requires-dist = [{ name = "absl-py" }] diff --git a/tools/toml2json/BUILD.bazel b/tools/toml2json/BUILD.bazel new file mode 100644 index 0000000000..428f512923 --- /dev/null +++ b/tools/toml2json/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_python//python:defs.bzl", "py_binary") + +exports_files(["toml2json.py"]) + +py_binary( + name = "toml2json", + srcs = ["toml2json.py"], + visibility = ["//visibility:public"], +) diff --git a/tools/toml2json/toml2json.py b/tools/toml2json/toml2json.py new file mode 100644 index 0000000000..4198a93a5a --- /dev/null +++ b/tools/toml2json/toml2json.py @@ -0,0 +1,34 @@ +import json +import sys +import datetime +import tomllib + + +def json_serializer(obj): + if isinstance(obj, datetime.datetime): + return obj.isoformat() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + +def main(): + if len(sys.argv) < 2: + print("Usage: toml2json ", file=sys.stderr) + sys.exit(1) + + toml_file_path = sys.argv[1] + + try: + with open(toml_file_path, "rb") as f: + data = tomllib.load(f) + json.dump(data, sys.stdout, indent=2, default=json_serializer) + print() + except FileNotFoundError: + print(f"Error: File not found: {toml_file_path}", file=sys.stderr) + sys.exit(1) + except tomllib.TOMLDecodeError as e: + print(f"Error decoding TOML: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()