diff --git a/README.md b/README.md index 72dd93a49..182c670a7 100644 --- a/README.md +++ b/README.md @@ -438,7 +438,7 @@ bazel run //:gazelle 1. **Swap the rules**: Load `py_binary`, `py_library`, `py_test` from `@aspect_rules_py//py:defs.bzl` instead of `@rules_python//python:defs.bzl` -2. **Migrate dependencies**: Replace `pip.parse` with `uv.hub` and generate a `uv.lock` +2. **Migrate dependencies**: Replace `pip.parse` with `uv.declare_hub` and generate a `uv.lock` 3. **Optionally migrate toolchains**: Replace `rules_python` interpreter provisioning with the `aspect_rules_py` interpreter extension for fully independent hermetic interpreters diff --git a/docs/uv.md b/docs/uv.md index 6d4e90d7d..f89b00d05 100644 --- a/docs/uv.md +++ b/docs/uv.md @@ -307,9 +307,10 @@ py_library( ## A mental model ``` -@pypi # Your UV built hub repository -@pypi//requests:requests # The library for a requirement -@pypi//jinja2-cli/entrypoints:jinja2-cli # A requirement's declared entrypoint +@pypi # Your UV built hub repository +@pypi//requests:requests # The library for a requirement +@pypi//jinja2-cli/entrypoints:jinja2-cli # A requirement's declared entrypoint +@pypi//project/my_project:requests # Project-qualified label (see below) ``` This central hub wraps "spoke" internal dependency group repos. For instance if you have two @@ -319,6 +320,24 @@ over the dependency group targets in which that requirement is defined. Hub requirement targets are _incompatible_ with dependency group configurations in which the requirement in question is not defined. +### Project-qualified labels + +In a multi-project hub where two projects both provide the same package, the unqualified label +can produce a Bazel "multiple keys match" error when the active dep_group name is shared across +projects. The project-qualified form scopes the `select()` to a single project, eliminating +the ambiguity: + +```python +# Unqualified — works unless two projects have the same package + same dep_group name +deps = ["@pypi//requests"] + +# Project-qualified — always routes to exactly one project's resolution +deps = ["@pypi//project/my_project:requests"] +``` + +The `` in `//project/` is the [PEP 503-normalised](https://peps.python.org/pep-0503/#normalized-names) +form of the `[project].name` declared in that project's `pyproject.toml`. + ### Conditional dependencies and the empty target When a package is gated entirely behind an environment marker (e.g. diff --git a/e2e/cases/multi-project-hub/BUILD.bazel b/e2e/cases/multi-project-hub/BUILD.bazel index 4f12be1a0..d2d6c5b00 100644 --- a/e2e/cases/multi-project-hub/BUILD.bazel +++ b/e2e/cases/multi-project-hub/BUILD.bazel @@ -50,3 +50,32 @@ py_test( "@pypi_multi//cowsay", ], ) + +# Project-qualified labels: each project exposes its packages under +# @hub//project/:, scoping the select to that project only. +py_test( + name = "alpha_qualified", + srcs = ["__test__.py"], + dep_group = "dev", + main = "__test__.py", + python_version = "3.11", + deps = ["@pypi_multi//project/alpha:cowsay"], +) + +py_test( + name = "beta_qualified", + srcs = ["__test__.py"], + dep_group = "beta", + main = "__test__.py", + python_version = "3.11", + deps = ["@pypi_multi//project/beta:cowsay"], +) + +py_test( + name = "gamma_qualified", + srcs = ["__test__.py"], + dep_group = "gamma", + main = "__test__.py", + python_version = "3.11", + deps = ["@pypi_multi//project/gamma:cowsay"], +) diff --git a/e2e/cases/single-project-hub/BUILD.bazel b/e2e/cases/single-project-hub/BUILD.bazel index 5f4aae862..aed75aad9 100644 --- a/e2e/cases/single-project-hub/BUILD.bazel +++ b/e2e/cases/single-project-hub/BUILD.bazel @@ -14,3 +14,15 @@ py_test( "@pypi_single//cowsay", ], ) + +# Verifies @hub//project/: resolves correctly. +py_test( + name = "single_project_hub_qualified_label", + srcs = ["__test__.py"], + dep_group = "single_project_hub", + main = "__test__.py", + python_version = "3.11", + deps = [ + "@pypi_single//project/single_project_hub:cowsay", + ], +) diff --git a/e2e/cases/snapshots/pypi_multi.cowsay.BUILD.bazel b/e2e/cases/snapshots/pypi_multi.cowsay.BUILD.bazel index c749bc35e..74f968cb8 100644 --- a/e2e/cases/snapshots/pypi_multi.cowsay.BUILD.bazel +++ b/e2e/cases/snapshots/pypi_multi.cowsay.BUILD.bazel @@ -1,8 +1,6 @@ load("//:defs.bzl", "compatible_with") -# This target is for a "hard" dependency. -# Dependencies on this target will cause build failures if it's unavailable. alias( name = "lib", actual = "cowsay", diff --git a/e2e/cases/snapshots/pypi_single.cowsay.BUILD.bazel b/e2e/cases/snapshots/pypi_single.cowsay.BUILD.bazel index 39e51943f..19b79d742 100644 --- a/e2e/cases/snapshots/pypi_single.cowsay.BUILD.bazel +++ b/e2e/cases/snapshots/pypi_single.cowsay.BUILD.bazel @@ -1,8 +1,6 @@ load("//:defs.bzl", "compatible_with") -# This target is for a "hard" dependency. -# Dependencies on this target will cause build failures if it's unavailable. alias( name = "lib", actual = "cowsay", diff --git a/uv/private/extension/defs.bzl b/uv/private/extension/defs.bzl index e78b2830b..ed677448c 100644 --- a/uv/private/extension/defs.bzl +++ b/uv/private/extension/defs.bzl @@ -1,24 +1,20 @@ -"""A Bazel module extension for resolving Python dependencies from a `uv.lock` file. +"""A Bazel module extension that resolves Python dependencies from a `uv.lock`. -This extension provides a mechanism for resolving Python dependencies declared in a -`pyproject.toml` and locked in a `uv.lock` file. It generates a dependency graph, -handles platform-specific constraints, and creates repository rules for fetching -pre-built wheels (bdists) or building wheels from source (sdists). +Reads a `pyproject.toml` and its companion `uv.lock`, builds a dependency graph +annotated with PEP 508 markers, and generates repository rules to fetch +pre-built wheels or build wheels from source distributions. -The extension is designed to handle complex dependency scenarios, including: -- Cross-platform builds for different operating systems and architectures. +Handled scenarios: +- Cross-platform builds across OS/arch combinations. - Hermetic builds of source distributions. -- Dependency cycles, which are resolved by computing the strongly connected - components (SCCs) of the dependency graph. +- Dependency cycles, collapsed by computing the strongly connected components + (SCCs) of the dependency graph. ## Example -The following example shows how to use the `uv` module extension in a `MODULE.bazel` -file: - ```starlark uv = use_extension("@aspect_rules_py//uv:extension.bzl", "uv") -uv.hub(name = "uv") +uv.declare_hub(hub_name = "uv") uv.project( hub_name = "uv", name = "my_project", @@ -28,24 +24,17 @@ uv.project( use_repo(uv, "uv") ``` -This configuration declares a `uv` hub and registers a project with its -`pyproject.toml` and `uv.lock` files. The `use_repo` directive then makes the -resolved dependencies available in the `@uv` repository. - -## Common Types +`use_repo` then exposes the resolved dependencies under `@uv`. -- **Dependency:** A tuple of `(project_id, package_name, version, extra)` that - uniquely identifies a package within a lockfile. `project_id` is a unique - identifier for the lockfile, `package_name` is the normalized name of the - package, `version` is the package version, and `extra` is the optional extra - (or `__base__` for the base package). -- **Marker:** A string representing a PEP 508 marker, used to specify - environment-specific dependencies (e.g., - `"sys_platform == 'linux'"`). -- **SCC:** A Strongly Connected Component, which is a set of packages that have - cyclic dependencies on each other. +## Common types -## Appendix +- **Dependency:** `(project_id, package_name, version, extra)` tuple keying a + package within a lockfile; `extra` is the optional extra name, or `__base__` + for the base package. +- **Marker:** A PEP 508 marker string gating an edge on environment + (e.g. `"sys_platform == 'linux'"`); `""` is the always-true marker. +- **SCC:** A strongly connected component: mutually cyclic packages collapsed + into a single install target. [1] https://peps.python.org/pep-0751/ [2] https://peps.python.org/pep-0751/#locking-build-requirements-for-sdists @@ -73,12 +62,12 @@ load(":lockfile.bzl", "build_marker_graph", "collect_bdists", "collect_configura load(":projectfile.bzl", "collate_versions_by_name", "collect_activated_extras", "extract_requirement_marker_pairs") def _dist_sha256(dist): - """The distribution's sha256 for http_file, or None for other hash algorithms.""" + """The distribution's sha256 for `http_file`, or None for other algorithms.""" hash = dist.get("hash", "") return hash[len("sha256:"):] if hash.startswith("sha256:") else None def _deduplicate_whl_files(whls): - """Returns unique non-empty wheel labels while preserving order.""" + """Return unique non-empty wheel labels, preserving order.""" whl_files = [] seen = {} for whl in whls: @@ -89,14 +78,14 @@ def _deduplicate_whl_files(whls): return whl_files def parse_declared_console_script(name, entry_point): - """Canonicalize one override_package console-script declaration. + """Canonicalize one `override_package` console-script declaration. Args: name: Script name installed under the venv's bin directory. - entry_point: Python entry point encoded as module:object. + entry_point: Python entry point encoded as `module:object`. Returns: - The canonical name=module:object string, or None when invalid. + The canonical `name=module:object` string, or None when invalid. """ whitespace = [" ", "\t", "\n", "\r"] if ( @@ -115,59 +104,80 @@ def parse_declared_console_script(name, entry_point): return parsed[1] if parsed != None else None def _merge_scc_dep_markers_by_surface_package(marked_deps): + """Merge SCC external-dep markers onto surface-package keys. + + SCC external deps are keyed by the fully versioned lock tuple, but the + generated hub targets key on the surface package alias. Merging markers + across versions preserves full platform coverage for split dependencies + (e.g. chdb -> pyarrow) instead of letting one version overwrite another. + """ merged = {} for dep, markers in marked_deps.items(): - # SCC external deps are keyed by the fully versioned lock tuple, but the - # generated hub targets depend on the surface package alias. Merge - # markers for all versions so split dependencies like chdb -> pyarrow - # preserve their full platform coverage instead of overwriting each other. merged.setdefault(dep[1], {}).update(markers) return merged def _parse_hubs(module_ctx): - """Parses `uv.hub()` declarations from all modules. + """Collect `uv.declare_hub()` declarations across all modules. - This function iterates through all the modules in the Bazel dependency graph - and collects the `uv.hub()` declarations. It produces a dictionary of hub - specifications that is used to validate project registrations. + Hub names are globally unique, but a single hub name may be registered from + multiple modules: a conventional hub like `@pypi` can be referenced widely, + since build configuration is disambiguated per venv, not per hub. Args: module_ctx: The Bazel module context. Returns: - A dictionary of hub specifications, keyed by hub name. + A dict of declared hub names, used to validate project registrations. """ - - # As with `rules_python` hub names have to be globally unique :/ hub_specs = {} - for mod in module_ctx.modules: for hub in mod.tags.declare_hub: hub_specs[hub.hub_name] = True - - # Note that we ARE NOT validating that the same hub name is registered by - # one and only one repository. This allows `@pypi` which we think should be - # the one and only conventional hub to be referenced by many modules since - # we disambiguate the build configuration on the "venv" not the hub. - return hub_specs def _parse_projects(module_ctx, hub_specs): - """Parses all `uv.project()` declarations from all modules. - - This function is the core of the module extension's logic. It iterates - through all the `uv.project()` declarations, parses the `pyproject.toml` and - `uv.lock` files, and builds up the complete dependency graph. + """Parse every `uv.project()` declaration into the full dependency model. + + For each project, reads its `pyproject.toml` and `uv.lock`, normalizes + versions, resolves the extras activated per configuration group, collapses + cyclic dependencies into SCCs, and produces the install/sdist/bdist/build + catalogs consumed by `_uv_impl`. + + Design rationale (kept here instead of inline): + + - Build dependencies resolve lazily so bdist-only projects never have to + supply `default_build_dependencies`. Resolution fails eagerly only when + an sdist build is guaranteed (a `no-binary-package` override or an + sdist-only package with no wheels); platform mismatches are undetectable + here because the target build platform is unknown. + TODO: defer `[build-system] requires` introspection to the repo rule. + TODO: collect build-deps annotation files. + - `uv` may emit several lock records for one package/version + (resolution-marker forks), each with a different wheel subset; records are + merged per install rather than overwritten. + - All wheels of a package/version extract the same + `-.dist-info` directory; its name is derived from the + wheel filename and asserted consistent across wheels. + - `available_deps` is pre-computed per project to give the sdist configure + tool visibility into the project's full dependency perimeter. + - SCC ids are interned across configurations: identical content reuses one + id; content differing only in external deps/markers stays distinct. + Per-configuration markers are aggregated into one graph, a deliberate + simplification when markers diverge across configured graphs. + - The package loop resets `has_sbuild` each iteration and only sets it when + a build is configured. + - SCC and install structures are re-keyed to JSON strings without mangling + the structured keys, which are re-parsed downstream. + TODO: extract a re-keying helper. Args: module_ctx: The Bazel module context. - hub_specs: A dictionary of hub specifications, as returned by - `_parse_hubs`. + hub_specs: Declared hub names, as returned by `_parse_hubs`. Returns: - A struct containing all the parsed information, including the dependency - graph, SCCs, and configurations for all the repository rules that need - to be generated. + A struct of catalogs (`project_cfgs`, `hub_cfgs`, `install_cfgs`, + `sbuild_cfgs`, `whl_cfgs`, `sdist_cfgs`, `bdist_cfgs`) describing every + repository rule to generate. """ hub_cfgs = {} @@ -185,9 +195,6 @@ def _parse_projects(module_ctx, hub_specs): install_cfgs = {} install_table = {} - # FIXME: Collect build deps files/annotations - - # Collect all hubs, ensure we have no dupes for mod in module_ctx.modules: project_locks = {project.lock: True for project in mod.tags.project} for override in mod.tags.override_package: @@ -202,14 +209,9 @@ def _parse_projects(module_ctx, hub_specs): project_data = toml.decode_file(module_ctx, project.pyproject) lock_data = toml.decode_file(module_ctx, project.lock) - # This SHOULD be stable enough. - # We'll rebuild the lock hub whenever the toml changes. - # Reusing the name is fine. - # project_stamp = sha1(str(project.pyproject))[:16] project_stamp = normalize_name(project_data["project"]["name"]) project_id = "project__" + project_stamp - # Read these from the project or honor the module state project_name = project.name or project_data["project"]["name"] if project.hub_name not in hub_specs: @@ -238,7 +240,6 @@ def _parse_projects(module_ctx, hub_specs): for package in annotations.get("package", []): k = _resolve(package) if k == None: - # Allow a shared annotation file to include entries for other locks. continue if "native" in package: if type(package["native"]) != "bool": @@ -256,7 +257,6 @@ def _parse_projects(module_ctx, hub_specs): if not skip: lock_build_dep_anns[k] = deps - # Collect package overrides, validating no duplicates per (lock, name, version). package_overrides = {} package_console_scripts = {} for override in mod.tags.override_package: @@ -329,7 +329,6 @@ def _parse_projects(module_ctx, hub_specs): print("Overriding {}@{} in {} with {}".format(override.name, v, project_name, override.target)) install_table[k] = str(override.target) - # Lazily evaluated cache lock_build_deps = None marker_graph = build_marker_graph(project_id, lock_data) @@ -347,29 +346,17 @@ def _parse_projects(module_ctx, hub_specs): configuration_names, activated_extras = collect_activated_extras(project.lock, project_id, project_data, lock_data, default_versions, marker_graph, package_versions) version_activations = collate_versions_by_name(activated_extras) - # Mapping from SCC ID to marked SCC members scc_graph = {} - - # Mapping from SCC ID to marked SCC dependencies scc_deps = {} - - # Mapping from package to cfg to the SCC for that package in that cfg package_cfg_sccs = {} - - # Shared across configurations so identical SCC content keeps one - # id while content differing only in deps/markers stays distinct. scc_id_state = {} for cfg in configuration_names: cfgd_marker_graph = activate_extras(marker_graph, activated_extras, cfg) cfgd_dep_to_scc, cfgd_scc_graph, cfgd_scc_deps = collect_sccs(cfgd_marker_graph, scc_id_state) - # Aggregate the dependency graphs Note that this may be overly - # simplistic, since markers COULD vary per configured graph; - # ignoring that for now. scc_graph.update(cfgd_scc_graph) scc_deps.update(cfgd_scc_deps) - # This one's slightly tricky for package, scc in cfgd_dep_to_scc.items(): package_cfg_sccs.setdefault(package, {})[cfg] = scc @@ -377,12 +364,8 @@ def _parse_projects(module_ctx, hub_specs): for package, cfgs in version_activations.items(): for cfg, versions in cfgs.items(): for version, markers in versions.items(): - # Map the version to a scc in this configuration, while collecting version conditional markers marked_package_cfg_sccs.setdefault(package, {}).setdefault(cfg, {}).setdefault(package_cfg_sccs[version][cfg], {}).update(markers) - # Pre-build the per-project available_deps mapping from the - # lockfile. This gives each sdist configure tool visibility - # into the packages within this project's dependency perimeter. project_available_deps = {} for package in lock_data.get("package", []): if "editable" in package.get("source", {}) or "virtual" in package.get("source", {}): @@ -395,14 +378,12 @@ def _parse_projects(module_ctx, hub_specs): ) project_available_deps[pkg_name] = "@{}//:install".format(pkg_stamp) - # Translate the package lock into installs for this project for package in lock_data.get("package", []): install_key = (project_id, package["name"], package["version"], "__base__") k = "whl_install__{}__{}__{}".format(project_stamp, package["name"], normalize_version(package["version"])) install_target = "@{}//:install".format(k) existing_target = install_table.get(install_key) if existing_target != None and existing_target != install_target: - # Case of an overridden package continue elif "virtual" in package["source"]: override_key = (normalize_name(package["name"]), package["version"]) @@ -410,7 +391,6 @@ def _parse_projects(module_ctx, hub_specs): fail("Virtual package {} in lockfile {} cannot use a modification-only `uv.override_package()` annotation because it is not installed.".format(package["name"], project.lock)) continue elif "editable" in package["source"]: - # Case of the workspace self-package if normalize_name(package["name"]) == normalize_name(project_name): override_key = (normalize_name(package["name"]), package["version"]) if override_key in package_overrides: @@ -426,8 +406,6 @@ def _parse_projects(module_ctx, hub_specs): pkg_override = package_overrides.get(override_key) sbuild_console_scripts = package_console_scripts.get(override_key, []) - # WARNING: Loop invariant; this flag needs to be False by - # default and set if we do a build. has_sbuild = False is_no_binary = normalize_name(package["name"]) in no_binary_packages @@ -451,30 +429,12 @@ def _parse_projects(module_ctx, hub_specs): toolchains = pkg_override.toolchains, ) if sdist: - # HACK: Note that we resolve these LAZILY so that - # bdist-only or fully overridden configurations don't - # have to provide the build tools. - - # FIXME: We can read the [build-system] requires= - # property if it exists for the sdist. Question is how - # to defer choosing deps until the repo rule when we - # could do pyproject.toml introspection. ann_key = (project_id, normalize_name(package["name"]), package["version"], "__base__") build_deps = lock_build_dep_anns.get(ann_key) or [] is_native = "auto" if ann_key in lock_native_anns: is_native = "true" if lock_native_anns[ann_key] else "false" if lock_build_deps == None: - # For optional sdist fallbacks (sdist present but a - # wheel will be picked at install time), tolerate a - # lock that doesn't carry `default_build_dependencies` - # — the sbuild target is never selected, so demanding - # the build tools would force every project to pin - # them. Fail eagerly when sbuild is guaranteed to be - # selected: forced builds (`no-binary-package`) or - # sdist-only packages (no wheels in the lock). Other - # platform-mismatch cases can't be detected here - # because we don't know the target build platform. sbuild_required = is_no_binary or not package.get("wheels", []) lock_build_deps = [ it[0] @@ -484,16 +444,12 @@ def _parse_projects(module_ctx, hub_specs): build_deps = sets.to_list(sets.make(build_deps + lock_build_deps)) - # Look up pre-build patches for this package pre_build_patches = [] pre_build_patch_strip = 0 if pkg_override and pkg_override.pre_build_patches: pre_build_patches = [str(p) for p in pkg_override.pre_build_patches] pre_build_patch_strip = pkg_override.pre_build_patch_strip - # `toolchains` / `env` on `uv.override_package` augment - # the defaults baked into sdist_build's BUILD template — - # they don't replace them. Empty == no augmentation. extra_toolchains = [] extra_env = {} monitor_memory = False @@ -521,7 +477,6 @@ def _parse_projects(module_ctx, hub_specs): has_sbuild = True - # Look up post-install patches and BUILD modifications post_install_patches = [] post_install_patch_strip = 0 extra_deps = [] @@ -532,11 +487,6 @@ def _parse_projects(module_ctx, hub_specs): extra_deps = [str(d) for d in pkg_override.extra_deps] extra_data = [str(d) for d in pkg_override.extra_data] - # uv can emit multiple lock records for the same package/version - # (e.g. resolution-marker forks), each carrying a different - # subset of wheels. Merge with any previously translated record - # for the same install instead of overwriting (and thus - # dropping) it. whls = {} metadata_directory = None if not is_no_binary: @@ -548,16 +498,6 @@ def _parse_projects(module_ctx, hub_specs): basename = url_basename(whl["url"]) whls[basename] = bdist_table.get(whl["url"]) - # Every wheel of a package/version installs the same - # `-.dist-info` directory, so the - # repo rule only needs one name to `extract` the - # metadata regardless of which platform wheel is - # selected. A divergence means our derivation is wrong. - # - # Derive both halves from the wheel filename: it and - # the dist-info dir share the build backend's escaping, - # and URL-encoded `+` is literal in the archive member. - # The build tag (absent from dist-info) is dropped. whl_name = parse_whl_name(basename) candidate = "{}-{}.dist-info".format( whl_name.project, @@ -582,12 +522,6 @@ def _parse_projects(module_ctx, hub_specs): extra_data = extra_data, ) - # Frustratingly we have to re-key all these structures so that they - # can be jsonified later. Note that the _key is a structured string - # which we can re-parse on the other side (sigh) so we DO NOT mangle - # them at all. Mangling will be done as needed on the other side(s). - # - # FIXME: Can we make a re-keying helper? project_cfgs[project_id] = struct( dep_to_scc = marked_package_cfg_sccs, scc_deps = { @@ -598,7 +532,6 @@ def _parse_projects(module_ctx, hub_specs): scc_id: { install_table[m]: markers for m, markers in members.items() - # Extras etc. have no install table presence if m in install_table } for scc_id, members in scc_graph.items() @@ -614,13 +547,11 @@ def _parse_projects(module_ctx, hub_specs): if cfg in hub_cfg.configurations: fail("Conflict on configuration name {} in hub {}".format(cfg, project.hub_name)) - # Build a mapping from configurations to the project containing that configuration hub_cfg.configurations.update({ name: project_id for name in configuration_names.keys() }) - # Build a {requirement: {cfg: target mapping}} for package, cfgs in version_activations.items(): for cfg in cfgs.keys(): hub_cfg.packages.setdefault(package, {})[cfg] = "@{}//:{}".format(project_id, package) @@ -636,18 +567,13 @@ def _parse_projects(module_ctx, hub_specs): ) def _uv_impl(module_ctx): - """The implementation function for the `uv` module extension. - - This function is the main entry point for the module extension. It orchestrates - the entire dependency resolution process, which includes: - - Parsing `uv.hub()` and `uv.project()` declarations. - - Generating repository rules for fetching and building all the declared - dependencies. - - Generating a `uv_project` repository rule for each pyproject.toml, which - contains the resolved dependency graph for that project according to the - matching lockfile. - - Generating a `uv_hub` repository rule for each hub, which contains the - aggregated dependency information for all the projects in that hub. + """Module extension entry point. + + Orchestrates dependency resolution: parses hub and project declarations, + then generates one repository rule per fetched/installed distribution plus a + `uv_project` rule per project and a `uv_hub` rule per hub. The default sdist + configure tool is the bundled `detect_native.py` run on a host-platform + interpreter. Args: module_ctx: The Bazel module context. @@ -692,8 +618,6 @@ def _uv_impl(module_ctx): downloaded_file_path = url_basename(bdist_cfg["url"]), ) - # Resolve the sdist configure tool. The default is our bundled - # detect_native.py, run with a PBS interpreter for the host platform. default_configure_interpreter = resolve_host_interpreter_label(module_ctx) default_configure_command = [] if default_configure_interpreter: @@ -711,7 +635,6 @@ def _uv_impl(module_ctx): "version": sbuild_cfg.version, } - # Use per-project custom configure command if provided, otherwise the default. if sbuild_cfg.configure_command: sbuild_kwargs["configure_command"] = sbuild_cfg.configure_command elif default_configure_command: @@ -739,9 +662,6 @@ def _uv_impl(module_ctx): "sbuild": install_cfg.sbuild, "sbuild_console_scripts": install_cfg.sbuild_console_scripts, "whls": json.encode(install_cfg.whls), - # Parallel list of the same wheel labels as a real label_list, - # so the whl_install repo rule can `rctx.path()` them to peek - # at `*.dist-info/RECORD` for top-level metadata. "whl_files": _deduplicate_whl_files(install_cfg.whls.values()), } if install_cfg.post_install_patches: @@ -814,10 +734,11 @@ _override_package_tag = tag_class( "lock": attr.label(mandatory = True), "name": attr.string(mandatory = True), "version": attr.string(mandatory = False), - - # Full replacement: provide a target that substitutes for the package entirely. - # Mutually exclusive with patch/exclude attributes. - "target": attr.label(mandatory = False), + "target": attr.label( + mandatory = False, + doc = "Fully replaces the resolved package with `target`. Mutually exclusive " + + "with every modification attribute below.", + ), "console_scripts": attr.string_dict( doc = "Complete console scripts for a source-built wheel, mapping script names to module:object entry points.", ), @@ -825,37 +746,27 @@ _override_package_tag = tag_class( default = False, doc = "Report approximate Linux process-tree RSS while building this package's wheel.", ), - - # Per-package local execution resources for the wheel build action. - # Uses bazel-lib's predefined resource_set vocabulary (the same enum - # `ts_project` accepts), so Bazel reserves the named amount of RAM/CPU - # and won't overschedule concurrent native wheel builds. `"default"` - # reserves nothing extra. "resource_set": attr.string( default = "default", values = resource_set_values, - doc = "Local execution resources to reserve for this package's wheel build action. " + - "One of bazel-lib's predefined resource sets ('mem_512m', 'mem_1g', … 'mem_32g', " + - "'cpu_2', 'cpu_4', 'default'). Bazel rounds a memory request up to the named " + - "bucket.", + doc = "Local execution resources to reserve for this package's wheel build " + + "action, from bazel-lib's predefined set ('mem_512m', 'mem_1g', ... " + + "'mem_32g', 'cpu_2', 'cpu_4', 'default'). Bazel rounds a memory request " + + "up to the named bucket. 'default' reserves nothing extra.", ), - - # Per-package toolchain plumbing for native sdist builds. Both - # attributes AUGMENT the defaults baked into sdist_build's - # generated `pep517_native_whl(...)` call (the CC toolchain + - # CC/CXX/AR/LD/STRIP env) — they don't replace them. Use these - # to layer extra toolchains (Java runtime, Rust, …) and extra - # env vars on top of the defaults. "toolchains": attr.label_list( default = [], - doc = "Extra toolchain targets appended to the generated pep517_native_whl(...) call's `toolchains` list. Each target's TemplateVariableInfo make-variables become available for $(VAR) expansion in `env`.", + doc = "Extra toolchain targets appended to the generated `pep517_native_whl` " + + "call's `toolchains` list. Each target's TemplateVariableInfo make-vars " + + "become available for $(VAR) expansion in `env`. These augment the " + + "defaults (CC toolchain + CC/CXX/AR/LD/STRIP env); they do not replace them.", ), "env": attr.string_dict( default = {}, - doc = "Extra environment variables merged into the build action's `env` dict. Values may reference $(VAR) make-variables sourced from the default CC toolchain or any extra `toolchains` listed above.", + doc = "Extra environment variables merged into the build action's `env` dict. " + + "Values may reference $(VAR) make-vars from the default CC toolchain or " + + "any extra `toolchains` above.", ), - - # Pre-build patches: applied to extracted sdist source before wheel build. "pre_build_patches": attr.label_list( default = [], allow_files = [".patch", ".diff"], @@ -865,8 +776,6 @@ _override_package_tag = tag_class( default = 0, doc = "Strip count for pre-build patches (-p flag to the patch tool).", ), - - # Post-install patches: applied to the installed tree artifact after wheel unpacking. "post_install_patches": attr.label_list( default = [], allow_files = [".patch", ".diff"], @@ -876,24 +785,6 @@ _override_package_tag = tag_class( default = 0, doc = "Strip count for post-install patches (-p flag to the patch tool).", ), - - # BUILD-level modifications to the generated py_library target. - # - # FIXME: srcs_exclude_glob and data_exclude_glob are not yet implemented. - # Implementing them requires either extending the Rust unpack tool to - # accept exclusion patterns at install time, or adding a post-install - # tree-filtering action that can selectively remove files from a tree - # artifact. The attrs are commented out to avoid exposing a non-functional - # API surface. - # - # "srcs_exclude_glob": attr.string_list( - # default = [], - # doc = "Glob patterns to exclude from the package's srcs (e.g. '**/tests/**').", - # ), - # "data_exclude_glob": attr.string_list( - # default = [], - # doc = "Glob patterns to exclude from the package's data.", - # ), "extra_deps": attr.label_list( default = [], doc = "Additional deps to add to the package's py_library target.", @@ -905,9 +796,12 @@ _override_package_tag = tag_class( }, doc = """Override or modify a Python package resolved from a lockfile. -Use `target` for full replacement, or use the patch/exclude attributes -for surgical modifications. Specifying `target` is mutually exclusive with -all other modification attributes.""", +Use `target` for full replacement, or the patch/data attributes for surgical +modifications; the two modes are mutually exclusive. + +TODO: `srcs_exclude_glob` and `data_exclude_glob` are not yet implemented and +would require either a patch-aware unpack tool or a post-install tree-filtering +action.""", ) uv = module_extension( diff --git a/uv/private/uv_hub/repository.bzl b/uv/private/uv_hub/repository.bzl index 5d090d3c4..295ea739a 100644 --- a/uv/private/uv_hub/repository.bzl +++ b/uv/private/uv_hub/repository.bzl @@ -1,31 +1,35 @@ -""" - +"""Repository rule implementation for the uv hub. + +The hub exposes resolved Python dependencies to the build as Bazel aliases. +It produces: + +- `//:defs.bzl`: helper functions for dependency-group constraints and + `group_deps()` / `group_deps_for()` accessors. +- `//:requirements.bzl`: a rules_python compatibility shim. +- `//dep_group`: config_setting targets for each dependency group. +- `//`: per-package aliases for the library, wheel, dist-info and + `pkg` targets. +- `//project/`: project-scoped aliases that resolve ambiguity when + multiple projects share a package name. """ load("@bazel_features//:features.bzl", features = "bazel_features") load("//uv/private/pprint:defs.bzl", "indent", "pprint") def _hub_impl(repository_ctx): - """Generates the central hub repository that exposes resolved dependencies to the build. + """Generate the central hub repository. - - Defines a helper alias for configuring the active [dependency-group] - - Defines aliases for every package in any component project - - This "surface" hub is dead easy, as it just wraps up project hubs which are - responsible for all the heavy lifting. + The hub is a thin surface over per-project hubs. It writes BUILD files and + .bzl files that route dependency-group selections to the correct underlying + project targets. Args: - repository_ctx: The repository context. + repository_ctx: The repository rule context. """ - # {requirement: {cfg: target}} packages = json.decode(repository_ctx.attr.packages) package_names = sorted(packages.keys()) - ################################################################################ - # Lay down the //dep_group:BUILD.bazel file with config flags - # - # We do this first because everything else hangs off of these config_settings. content = [ """\ alias( @@ -36,7 +40,6 @@ alias( """, ] - # Lay down the dep_group config settings for name in repository_ctx.attr.configurations: content.append( """ @@ -57,8 +60,6 @@ exports_files( """) repository_ctx.file("dep_group/BUILD.bazel", content = "\n".join(content)) - ################################################################################ - # Lay down the //:BUILD.bazel file content = [] index_select_clauses = { @@ -82,8 +83,6 @@ exports_files( repository_ctx.file("BUILD.bazel", "\n".join(content)) - ################################################################################ - # Lay down the hub aliases for package_name, specs in packages.items(): content = [ """\ @@ -100,10 +99,8 @@ load("//:defs.bzl", "compatible_with") for cfg, l in specs.items() } - error = "Available only in dep_groups: " + ", ".join(specs.keys()) # Simplified error string + error = "Available only in dep_groups: " + ", ".join(specs.keys()) - # When the package itself is named "pkg", the `:{name}` alias below already - # exposes a `pkg` target — emitting a separate `:pkg` alias would collide. pkg_alias = "" if package_name == "pkg" else """\ alias( name = "pkg", @@ -112,12 +109,8 @@ alias( ) """.format(name = package_name) - # FIXME: Add support for entrypoints? - # FIXME: Create a narrower dist-info rule content.append( """ -# This target is for a "hard" dependency. -# Dependencies on this target will cause build failures if it's unavailable. alias( name = "lib", actual = "{name}", @@ -160,23 +153,59 @@ exports_files( repository_ctx.file(package_name + "/BUILD.bazel", content = "\n".join(content)) - ################################################################################ - # Lay down //:defs.bzl + per_project = {} + for pkg_name, cfgs in packages.items(): + for cfg, target in cfgs.items(): + proj_id = target[1:].split("//")[0] + per_project.setdefault(proj_id, {}).setdefault(pkg_name, {})[cfg] = target + + for proj_id, proj_pkgs in sorted(per_project.items()): + stamp = proj_id[len("project__"):] + proj_cfg_set = {} + for pkg_cfgs in proj_pkgs.values(): + for cfg in pkg_cfgs.keys(): + proj_cfg_set[cfg] = 1 + proj_cfg_names = sorted(proj_cfg_set.keys()) + + p_content = [ + """\ +load("@aspect_rules_py//py:defs.bzl", "py_library") +load("//:defs.bzl", "compatible_with") +""", + ] + + for pkg_name, pkg_cfgs in sorted(proj_pkgs.items()): + select_spec = { + "//dep_group:{}".format(cfg): tgt + for cfg, tgt in pkg_cfgs.items() + } + p_content.append( + """ +alias( + name = "{name}", + actual = select({select}), + target_compatible_with = select(compatible_with({compat})), + visibility = ["//visibility:public"], +) +""".format( + name = pkg_name, + select = indent(pprint(select_spec), " ").lstrip(), + compat = repr(proj_cfg_names), + ), + ) + + repository_ctx.file( + "project/{}/BUILD.bazel".format(stamp), + "\n".join(p_content), + ) - # Invert the sparse package->group membership (`packages` is keyed - # {name: {group: target}}) into group->[canonical :pkg labels] in a single - # pass, rather than rescanning every package once per group. Seed every - # known group so a group with no packages still gets an (empty) select arm. deps_by_group = {group: [] for group in sorted(repository_ctx.attr.configurations)} - for name in package_names: # sorted, so each group's label list stays sorted + for name in package_names: label = "@@{}//{}:pkg".format(repository_ctx.name, name) for group in packages[name]: if group in deps_by_group: deps_by_group[group].append(label) - # The emitted `_GROUP_DEPS` is a module-level select() built once at load. - # Its keys use `Label(...)` so they resolve relative to this hub repo rather - # than the consuming package that loads `group_deps()`. content = [ """\ VIRTUALENVS = {configurations} @@ -239,10 +268,6 @@ def group_deps(): repository_ctx.file("defs.bzl", content = "\n".join(content)) - ################################################################################ - # Lay down a requirements.bzl for compatibility with rules_python. Keep this - # file's surface aligned with rules_python's generated requirements.bzl; any - # rules_py-specific additions belong in defs.bzl instead. content = [] content.append(""" load("@rules_python//python:pip.bzl", "pip_utils") @@ -312,12 +337,18 @@ uv_hub = repository_rule( attrs = { "configurations": attr.string_dict( doc = """ - Mapping of configuration name to a project _containing_ that configuration. + Mapping from dependency-group name to the project id that owns the + group. Each key becomes a public `//dep_group:` config_setting + and a select arm in the generated aliases. """, ), "packages": attr.string( doc = """ - JSON blob mapping packages to configurations to projects. + JSON-encoded dictionary `{package: {group: target}}` describing every + resolved package. `package` is the PEP 503-normalized distribution + name, `group` is a key from `configurations`, and `target` is the + fully qualified label of the underlying project hub target that + provides that package under that group. """, ), }, diff --git a/uv/private/uv_hub/snapshots/pypi.cowsay.BUILD.bazel b/uv/private/uv_hub/snapshots/pypi.cowsay.BUILD.bazel index 7276b56a9..02ebe4e88 100644 --- a/uv/private/uv_hub/snapshots/pypi.cowsay.BUILD.bazel +++ b/uv/private/uv_hub/snapshots/pypi.cowsay.BUILD.bazel @@ -1,8 +1,6 @@ load("//:defs.bzl", "compatible_with") -# This target is for a "hard" dependency. -# Dependencies on this target will cause build failures if it's unavailable. alias( name = "lib", actual = "cowsay", diff --git a/uv/private/uv_hub/snapshots/pypi.pkg.BUILD.bazel b/uv/private/uv_hub/snapshots/pypi.pkg.BUILD.bazel index 7349b1e22..297233602 100644 --- a/uv/private/uv_hub/snapshots/pypi.pkg.BUILD.bazel +++ b/uv/private/uv_hub/snapshots/pypi.pkg.BUILD.bazel @@ -1,8 +1,6 @@ load("//:defs.bzl", "compatible_with") -# This target is for a "hard" dependency. -# Dependencies on this target will cause build failures if it's unavailable. alias( name = "lib", actual = "pkg",