feat(pip_parse): support referencing dependencies to packages via hub (#1856)

With this change we can in theory have multi-platform libraries in the
dependency cycle and use the pip hub repo for the dependencies. With
this we can also make the contents of `whl_library` not depend on what
platform the actual dependencies are. This allows us to support the
following topologies:

* A platform-specific wheel depends on cross-platform wheel.
* A cross-platform wheel depends on cross-platform wheel.
* A whl_library can have `select` dependencies based on the interpreter
  version, e.g. pull in a `tomli` dependency only when the Python
  interpreter is less than 3.11.

Relates to #1663.
Work towards #735.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b481832..415b936 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -74,6 +74,13 @@
   the downloading of metadata is done in parallel can be done using
   `parallel_download` attribute.
 * (deps): `rules_python` depends now on `rules_cc` 0.0.9
+* (pip_parse): A new flag `use_hub_alias_dependencies` has been added that is going
+  to become default in the next release. This makes use of `dep_template` flag
+  in the `whl_library` rule. This also affects the
+  `experimental_requirement_cycles` feature where the dependencies that are in
+  a group would be only accessible via the hub repo aliases. If you still
+  depend on legacy labels instead of the hub repo aliases and you use the
+  `experimental_requirement_cycles`, now is a good time to migrate.
 
 [0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
 [python_default_visibility]: gazelle/README.md#directive-python_default_visibility
diff --git a/python/pip_install/pip_repository.bzl b/python/pip_install/pip_repository.bzl
index 55d61fc..db67368 100644
--- a/python/pip_install/pip_repository.bzl
+++ b/python/pip_install/pip_repository.bzl
@@ -365,9 +365,12 @@
         "python_interpreter": _get_python_interpreter_attr(rctx),
         "quiet": rctx.attr.quiet,
         "repo": rctx.attr.name,
-        "repo_prefix": "{}_".format(rctx.attr.name),
         "timeout": rctx.attr.timeout,
     }
+    if rctx.attr.use_hub_alias_dependencies:
+        config["dep_template"] = "@{}//{{name}}:{{target}}".format(rctx.attr.name)
+    else:
+        config["repo_prefix"] = "{}_".format(rctx.attr.name)
 
     if rctx.attr.python_interpreter_target:
         config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target)
@@ -387,6 +390,13 @@
 
     rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS)
     rctx.template("requirements.bzl", rctx.attr._template, substitutions = {
+        "    # %%GROUP_LIBRARY%%": """\
+    group_repo = "{name}__groups"
+    group_library(
+        name = group_repo,
+        repo_prefix = "{name}_",
+        groups = all_requirement_groups,
+    )""".format(name = rctx.attr.name) if not rctx.attr.use_hub_alias_dependencies else "",
         "%%ALL_DATA_REQUIREMENTS%%": _format_repr_list([
             macro_tmpl.format(p, "data")
             for p in bzl_packages
@@ -595,6 +605,8 @@
     "repo_prefix": attr.string(
         doc = """
 Prefix for the generated packages will be of the form `@<prefix><sanitized-package-name>//...`
+
+DEPRECATED. Only left for people who vendor requirements.bzl.
 """,
     ),
     # 600 is documented as default here: https://docs.bazel.build/versions/master/skylark/lib/repository_ctx.html#execute
@@ -637,6 +649,15 @@
         allow_single_file = True,
         doc = "Override the requirements_lock attribute when the host platform is Windows",
     ),
+    "use_hub_alias_dependencies": attr.bool(
+        default = False,
+        doc = """\
+Controls if the hub alias dependencies are used. If set to true, then the
+group_library will be included in the hub repo.
+
+True will become default in a subsequent release.
+""",
+    ),
     "_template": attr.label(
         default = ":pip_repository_requirements.bzl.tmpl",
     ),
@@ -886,7 +907,7 @@
         entry_points[entry_point_without_py] = entry_point_script_name
 
     build_file_contents = generate_whl_library_build_bazel(
-        repo_prefix = rctx.attr.repo_prefix,
+        dep_template = rctx.attr.dep_template or "@{}{{name}}//:{{target}}".format(rctx.attr.repo_prefix),
         whl_name = whl_path.basename,
         dependencies = metadata["deps"],
         dependencies_by_platform = metadata["deps_by_platform"],
@@ -941,6 +962,13 @@
         ),
         allow_files = True,
     ),
+    "dep_template": attr.string(
+        doc = """
+The dep template to use for referencing the dependencies. It should have `{name}`
+and `{target}` tokens that will be replaced with the normalized distribution name
+and the target that we need respectively.
+""",
+    ),
     "filename": attr.string(
         doc = "Download the whl file to this filename. Only used when the `urls` is passed. If not specified, will be auto-detected from the `urls`.",
     ),
diff --git a/python/pip_install/pip_repository_requirements.bzl.tmpl b/python/pip_install/pip_repository_requirements.bzl.tmpl
index 2b88f5c..8e17720 100644
--- a/python/pip_install/pip_repository_requirements.bzl.tmpl
+++ b/python/pip_install/pip_repository_requirements.bzl.tmpl
@@ -58,12 +58,7 @@
         for requirement in group_requirements
     }
 
-    group_repo = "%%NAME%%__groups"
-    group_library(
-        name = group_repo,
-        repo_prefix = "%%NAME%%_",
-        groups = all_requirement_groups,
-    )
+    # %%GROUP_LIBRARY%%
 
     # Install wheels which may be participants in a group
     whl_config = dict(_config)
diff --git a/python/pip_install/private/generate_group_library_build_bazel.bzl b/python/pip_install/private/generate_group_library_build_bazel.bzl
index c122b04..5fa93e2 100644
--- a/python/pip_install/private/generate_group_library_build_bazel.bzl
+++ b/python/pip_install/private/generate_group_library_build_bazel.bzl
@@ -22,9 +22,10 @@
     "WHEEL_FILE_PUBLIC_LABEL",
 )
 load("//python/private:normalize_name.bzl", "normalize_name")
+load("//python/private:text_util.bzl", "render")
 
 _PRELUDE = """\
-load("@rules_python//python:defs.bzl", "py_library", "py_binary")
+load("@rules_python//python:defs.bzl", "py_library")
 """
 
 _GROUP_TEMPLATE = """\
@@ -62,26 +63,39 @@
           which make up the group.
     """
 
-    lib_dependencies = [
-        "@%s%s//:%s" % (repo_prefix, normalize_name(d), PY_LIBRARY_IMPL_LABEL)
-        for d in group_members
-    ]
-    whl_file_deps = [
-        "@%s%s//:%s" % (repo_prefix, normalize_name(d), WHEEL_FILE_IMPL_LABEL)
-        for d in group_members
-    ]
-    visibility = [
-        "@%s%s//:__pkg__" % (repo_prefix, normalize_name(d))
-        for d in group_members
-    ]
+    group_members = sorted(group_members)
+
+    if repo_prefix:
+        lib_dependencies = [
+            "@%s%s//:%s" % (repo_prefix, normalize_name(d), PY_LIBRARY_IMPL_LABEL)
+            for d in group_members
+        ]
+        whl_file_deps = [
+            "@%s%s//:%s" % (repo_prefix, normalize_name(d), WHEEL_FILE_IMPL_LABEL)
+            for d in group_members
+        ]
+        visibility = [
+            "@%s%s//:__pkg__" % (repo_prefix, normalize_name(d))
+            for d in group_members
+        ]
+    else:
+        lib_dependencies = [
+            "//%s:%s" % (normalize_name(d), PY_LIBRARY_IMPL_LABEL)
+            for d in group_members
+        ]
+        whl_file_deps = [
+            "//%s:%s" % (normalize_name(d), WHEEL_FILE_IMPL_LABEL)
+            for d in group_members
+        ]
+        visibility = ["//:__subpackages__"]
 
     return _GROUP_TEMPLATE.format(
         name = normalize_name(group_name),
         whl_public_label = WHEEL_FILE_PUBLIC_LABEL,
-        whl_deps = repr(whl_file_deps),
+        whl_deps = render.indent(render.list(whl_file_deps)).lstrip(),
         lib_public_label = PY_LIBRARY_PUBLIC_LABEL,
-        lib_deps = repr(lib_dependencies),
-        visibility = repr(visibility),
+        lib_deps = render.indent(render.list(lib_dependencies)).lstrip(),
+        visibility = render.indent(render.list(visibility)).lstrip(),
     )
 
 def generate_group_library_build_bazel(
diff --git a/python/pip_install/private/generate_whl_library_build_bazel.bzl b/python/pip_install/private/generate_whl_library_build_bazel.bzl
index b121909..8010ccb 100644
--- a/python/pip_install/private/generate_whl_library_build_bazel.bzl
+++ b/python/pip_install/private/generate_whl_library_build_bazel.bzl
@@ -213,7 +213,7 @@
 
 def generate_whl_library_build_bazel(
         *,
-        repo_prefix,
+        dep_template,
         whl_name,
         dependencies,
         dependencies_by_platform,
@@ -226,7 +226,7 @@
     """Generate a BUILD file for an unzipped Wheel
 
     Args:
-        repo_prefix: the repo prefix that should be used for dependency lists.
+        dep_template: the dependency template that should be used for dependency lists.
         whl_name: the whl_name that this is generated for.
         dependencies: a list of PyPI packages that are dependencies to the py_library.
         dependencies_by_platform: a dict[str, list] of PyPI packages that may vary by platform.
@@ -328,38 +328,49 @@
     lib_dependencies = _render_list_and_select(
         deps = dependencies,
         deps_by_platform = dependencies_by_platform,
-        tmpl = "@{}{{}}//:{}".format(repo_prefix, PY_LIBRARY_PUBLIC_LABEL),
+        tmpl = dep_template.format(name = "{}", target = PY_LIBRARY_PUBLIC_LABEL),
     )
 
     whl_file_deps = _render_list_and_select(
         deps = dependencies,
         deps_by_platform = dependencies_by_platform,
-        tmpl = "@{}{{}}//:{}".format(repo_prefix, WHEEL_FILE_PUBLIC_LABEL),
+        tmpl = dep_template.format(name = "{}", target = WHEEL_FILE_PUBLIC_LABEL),
     )
 
     # If this library is a member of a group, its public label aliases need to
     # point to the group implementation rule not the implementation rules. We
     # also need to mark the implementation rules as visible to the group
     # implementation.
-    if group_name:
-        group_repo = repo_prefix + "_groups"
-        label_tmpl = "\"@{}//:{}_{{}}\"".format(group_repo, normalize_name(group_name))
-        impl_vis = ["@{}//:__pkg__".format(group_repo)]
+    if group_name and "//:" in dep_template:
+        # This is the legacy behaviour where the group library is outside the hub repo
+        label_tmpl = dep_template.format(
+            name = "_groups",
+            target = normalize_name(group_name) + "_{}",
+        )
+        impl_vis = [dep_template.format(
+            name = "_groups",
+            target = "__pkg__",
+        )]
         additional_content.extend([
             "",
             render.alias(
                 name = PY_LIBRARY_PUBLIC_LABEL,
-                actual = label_tmpl.format(PY_LIBRARY_PUBLIC_LABEL),
+                actual = repr(label_tmpl.format(PY_LIBRARY_PUBLIC_LABEL)),
             ),
             "",
             render.alias(
                 name = WHEEL_FILE_PUBLIC_LABEL,
-                actual = label_tmpl.format(WHEEL_FILE_PUBLIC_LABEL),
+                actual = repr(label_tmpl.format(WHEEL_FILE_PUBLIC_LABEL)),
             ),
         ])
         py_library_label = PY_LIBRARY_IMPL_LABEL
         whl_file_label = WHEEL_FILE_IMPL_LABEL
 
+    elif group_name:
+        py_library_label = PY_LIBRARY_PUBLIC_LABEL
+        whl_file_label = WHEEL_FILE_PUBLIC_LABEL
+        impl_vis = [dep_template.format(name = "", target = "__subpackages__")]
+
     else:
         py_library_label = PY_LIBRARY_PUBLIC_LABEL
         whl_file_label = WHEEL_FILE_PUBLIC_LABEL
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index f1928e2..fdbd20b 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -227,6 +227,7 @@
         ":normalize_name_bzl",
         ":text_util_bzl",
         ":version_label_bzl",
+        "//python/pip_install/private:generate_group_library_build_bazel_bzl",
     ],
 )
 
diff --git a/python/private/bzlmod/pip.bzl b/python/private/bzlmod/pip.bzl
index 3d5c0f5..ce68125 100644
--- a/python/private/bzlmod/pip.bzl
+++ b/python/private/bzlmod/pip.bzl
@@ -18,7 +18,6 @@
 load("@pythons_hub//:interpreters.bzl", "DEFAULT_PYTHON_VERSION", "INTERPRETER_LABELS")
 load(
     "//python/pip_install:pip_repository.bzl",
-    "group_library",
     "locked_requirements_label",
     "pip_repository_attrs",
     "use_isolated",
@@ -101,7 +100,7 @@
             whl_mods = whl_mods,
         )
 
-def _create_whl_repos(module_ctx, pip_attr, whl_map, whl_overrides, simpleapi_cache):
+def _create_whl_repos(module_ctx, pip_attr, whl_map, whl_overrides, group_map, simpleapi_cache):
     python_interpreter_target = pip_attr.python_interpreter_target
 
     # if we do not have the python_interpreter set in the attributes
@@ -129,6 +128,7 @@
         hub_name,
         version_label(pip_attr.python_version),
     )
+    major_minor = _major_minor_version(pip_attr.python_version)
 
     requirements_lock = locked_requirements_label(module_ctx, pip_attr)
 
@@ -171,12 +171,11 @@
             for whl_name in group_whls
         }
 
-        group_repo = "%s__groups" % (pip_name,)
-        group_library(
-            name = group_repo,
-            repo_prefix = pip_name + "_",
-            groups = pip_attr.experimental_requirement_cycles,
-        )
+        # TODO @aignas 2024-04-05: how do we support different requirement
+        # cycles for different abis/oses? For now we will need the users to
+        # assume the same groups across all versions/platforms until we start
+        # using an alternative cycle resolution strategy.
+        group_map[hub_name] = pip_attr.experimental_requirement_cycles
     else:
         whl_group_mapping = {}
         requirement_cycles = {}
@@ -202,8 +201,6 @@
             parallel_download = pip_attr.parallel_download,
         )
 
-    major_minor = _major_minor_version(pip_attr.python_version)
-
     # Create a new wheel library for each of the different whls
     for whl_name, requirement_line in requirements:
         # We are not using the "sanitized name" because the user
@@ -220,7 +217,7 @@
         repo_name = "{}_{}".format(pip_name, whl_name)
         whl_library_args = dict(
             repo = pip_name,
-            repo_prefix = pip_name + "_",
+            dep_template = "@{}//{{name}}:{{target}}".format(hub_name),
             requirement = requirement_line,
         )
         maybe_args = dict(
@@ -422,6 +419,7 @@
     # dict[hub, dict[whl, dict[version, str pip]]]
     # Where hub, whl, and pip are the repo names
     hub_whl_map = {}
+    hub_group_map = {}
 
     simpleapi_cache = {}
 
@@ -460,7 +458,7 @@
             else:
                 pip_hub_map[pip_attr.hub_name].python_versions.append(pip_attr.python_version)
 
-            _create_whl_repos(module_ctx, pip_attr, hub_whl_map, whl_overrides, simpleapi_cache)
+            _create_whl_repos(module_ctx, pip_attr, hub_whl_map, whl_overrides, hub_group_map, simpleapi_cache)
 
     for hub_name, whl_map in hub_whl_map.items():
         pip_repository(
@@ -471,6 +469,7 @@
                 for key, value in whl_map.items()
             },
             default_version = _major_minor_version(DEFAULT_PYTHON_VERSION),
+            groups = hub_group_map.get(hub_name),
         )
 
 def _pip_parse_ext_attrs():
diff --git a/python/private/bzlmod/pip_repository.bzl b/python/private/bzlmod/pip_repository.bzl
index d96131d..3a09766 100644
--- a/python/private/bzlmod/pip_repository.bzl
+++ b/python/private/bzlmod/pip_repository.bzl
@@ -32,6 +32,7 @@
             for key, values in rctx.attr.whl_map.items()
         },
         default_version = rctx.attr.default_version,
+        requirement_cycles = rctx.attr.groups,
     )
     for path, contents in aliases.items():
         rctx.file(path, contents)
@@ -68,6 +69,9 @@
 what is setup by the 'python' extension using the 'is_default = True'
 setting.""",
     ),
+    "groups": attr.string_list_dict(
+        mandatory = False,
+    ),
     "repo_name": attr.string(
         mandatory = True,
         doc = "The apparent name of the repo. This is needed because in bzlmod, the name attribute becomes the canonical name.",
diff --git a/python/private/render_pkg_aliases.bzl b/python/private/render_pkg_aliases.bzl
index 5851baf..bc1bab2 100644
--- a/python/private/render_pkg_aliases.bzl
+++ b/python/private/render_pkg_aliases.bzl
@@ -16,6 +16,19 @@
 
 This is used in bzlmod and non-bzlmod setups."""
 
+load(
+    "//python/pip_install/private:generate_group_library_build_bazel.bzl",
+    "generate_group_library_build_bazel",
+)  # buildifier: disable=bzl-visibility
+load(
+    ":labels.bzl",
+    "DATA_LABEL",
+    "DIST_INFO_LABEL",
+    "PY_LIBRARY_IMPL_LABEL",
+    "PY_LIBRARY_PUBLIC_LABEL",
+    "WHEEL_FILE_IMPL_LABEL",
+    "WHEEL_FILE_PUBLIC_LABEL",
+)
 load(":normalize_name.bzl", "normalize_name")
 load(":text_util.bzl", "render")
 
@@ -43,13 +56,18 @@
         name,
         default_version,
         aliases,
+        target_name,
         **kwargs):
     """Render an alias for common targets."""
     if len(aliases) == 1 and not aliases[0].version:
         alias = aliases[0]
         return render.alias(
             name = name,
-            actual = repr("@{repo}//:{name}".format(repo = alias.repo, name = name)),
+            actual = repr("@{repo}//:{name}".format(
+                repo = alias.repo,
+                name = target_name,
+            )),
+            **kwargs
         )
 
     # Create the alias repositories which contains different select
@@ -58,7 +76,7 @@
     selects = {}
     no_match_error = "_NO_MATCH_ERROR"
     for alias in sorted(aliases, key = lambda x: x.version):
-        actual = "@{repo}//:{name}".format(repo = alias.repo, name = name)
+        actual = "@{repo}//:{name}".format(repo = alias.repo, name = target_name)
         selects.setdefault(actual, []).append(alias.config_setting)
         if alias.version == default_version:
             selects[actual].append("//conditions:default")
@@ -84,7 +102,7 @@
         **kwargs
     )
 
-def _render_common_aliases(*, name, aliases, default_version = None):
+def _render_common_aliases(*, name, aliases, default_version = None, group_name = None):
     lines = [
         """load("@bazel_skylib//lib:selects.bzl", "selects")""",
         """package(default_visibility = ["//visibility:public"])""",
@@ -119,17 +137,37 @@
     lines.extend(
         [
             _render_whl_library_alias(
-                name = target,
+                name = name,
                 default_version = default_version,
                 aliases = aliases,
+                target_name = target_name,
+                visibility = ["//_groups:__subpackages__"] if name.startswith("_") else None,
             )
-            for target in ["pkg", "whl", "data", "dist_info"]
+            for target_name, name in {
+                PY_LIBRARY_PUBLIC_LABEL: PY_LIBRARY_IMPL_LABEL if group_name else PY_LIBRARY_PUBLIC_LABEL,
+                WHEEL_FILE_PUBLIC_LABEL: WHEEL_FILE_IMPL_LABEL if group_name else WHEEL_FILE_PUBLIC_LABEL,
+                DATA_LABEL: DATA_LABEL,
+                DIST_INFO_LABEL: DIST_INFO_LABEL,
+            }.items()
         ],
     )
+    if group_name:
+        lines.extend(
+            [
+                render.alias(
+                    name = "pkg",
+                    actual = repr("//_groups:{}_pkg".format(group_name)),
+                ),
+                render.alias(
+                    name = "whl",
+                    actual = repr("//_groups:{}_whl".format(group_name)),
+                ),
+            ],
+        )
 
     return "\n\n".join(lines)
 
-def render_pkg_aliases(*, aliases, default_version = None):
+def render_pkg_aliases(*, aliases, default_version = None, requirement_cycles = None):
     """Create alias declarations for each PyPI package.
 
     The aliases should be appended to the pip_repository BUILD.bazel file. These aliases
@@ -140,6 +178,7 @@
         aliases: dict, the keys are normalized distribution names and values are the
             whl_alias instances.
         default_version: the default version to be used for the aliases.
+        requirement_cycles: any package groups to also add.
 
     Returns:
         A dict of file paths and their contents.
@@ -150,16 +189,33 @@
     elif type(aliases) != type({}):
         fail("The aliases need to be provided as a dict, got: {}".format(type(aliases)))
 
-    return {
+    whl_group_mapping = {}
+    if requirement_cycles:
+        requirement_cycles = {
+            name: [normalize_name(whl_name) for whl_name in whls]
+            for name, whls in requirement_cycles.items()
+        }
+
+        whl_group_mapping = {
+            whl_name: group_name
+            for group_name, group_whls in requirement_cycles.items()
+            for whl_name in group_whls
+        }
+
+    files = {
         "{}/BUILD.bazel".format(normalize_name(name)): _render_common_aliases(
             name = normalize_name(name),
             aliases = pkg_aliases,
             default_version = default_version,
+            group_name = whl_group_mapping.get(normalize_name(name)),
         ).strip()
         for name, pkg_aliases in aliases.items()
     }
+    if requirement_cycles:
+        files["_groups/BUILD.bazel"] = generate_group_library_build_bazel("", requirement_cycles)
+    return files
 
-def whl_alias(*, repo, version = None, config_setting = None):
+def whl_alias(*, repo, version = None, config_setting = None, extra_targets = None):
     """The bzl_packages value used by by the render_pkg_aliases function.
 
     This contains the minimum amount of information required to generate correct
@@ -173,6 +229,8 @@
             is no match found during a select.
         config_setting: optional(Label or str), the config setting that we should use. Defaults
             to "@rules_python//python/config_settings:is_python_{version}".
+        extra_targets: optional(list[str]), the extra targets that we need to create
+            aliases for.
 
     Returns:
         a struct with the validated and parsed values.
@@ -188,4 +246,5 @@
         repo = repo,
         version = version,
         config_setting = config_setting,
+        extra_targets = extra_targets or [],
     )
diff --git a/tests/pip_hub_repository/render_pkg_aliases/render_pkg_aliases_test.bzl b/tests/pip_hub_repository/render_pkg_aliases/render_pkg_aliases_test.bzl
index ddc9da7..a38d657 100644
--- a/tests/pip_hub_repository/render_pkg_aliases/render_pkg_aliases_test.bzl
+++ b/tests/pip_hub_repository/render_pkg_aliases/render_pkg_aliases_test.bzl
@@ -388,6 +388,50 @@
 
 _tests.append(_test_aliases_are_created_for_all_wheels)
 
+def _test_aliases_with_groups(env):
+    actual = render_pkg_aliases(
+        default_version = "3.2",
+        aliases = {
+            "bar": [
+                whl_alias(version = "3.1", repo = "pypi_31_bar"),
+                whl_alias(version = "3.2", repo = "pypi_32_bar"),
+            ],
+            "baz": [
+                whl_alias(version = "3.1", repo = "pypi_31_baz"),
+                whl_alias(version = "3.2", repo = "pypi_32_baz"),
+            ],
+            "foo": [
+                whl_alias(version = "3.1", repo = "pypi_32_foo"),
+                whl_alias(version = "3.2", repo = "pypi_31_foo"),
+            ],
+        },
+        requirement_cycles = {
+            "group": ["bar", "baz"],
+        },
+    )
+
+    want_files = [
+        "bar/BUILD.bazel",
+        "foo/BUILD.bazel",
+        "baz/BUILD.bazel",
+        "_groups/BUILD.bazel",
+    ]
+    env.expect.that_dict(actual).keys().contains_exactly(want_files)
+
+    want_key = "_groups/BUILD.bazel"
+
+    # Just check that it contains a private whl
+    env.expect.that_str(actual[want_key]).contains("//bar:_whl")
+
+    want_key = "bar/BUILD.bazel"
+
+    # Just check that it contains a private whl
+    env.expect.that_str(actual[want_key]).contains("name = \"_whl\"")
+    env.expect.that_str(actual[want_key]).contains("name = \"whl\"")
+    env.expect.that_str(actual[want_key]).contains("\"//_groups:group_whl\"")
+
+_tests.append(_test_aliases_with_groups)
+
 def render_pkg_aliases_test_suite(name):
     """Create the test suite.
 
diff --git a/tests/pip_install/group_library/generate_build_bazel_tests.bzl b/tests/pip_install/group_library/generate_build_bazel_tests.bzl
index e7d6b44..cf082c2 100644
--- a/tests/pip_install/group_library/generate_build_bazel_tests.bzl
+++ b/tests/pip_install/group_library/generate_build_bazel_tests.bzl
@@ -21,7 +21,7 @@
 
 def _test_simple(env):
     want = """\
-load("@rules_python//python:defs.bzl", "py_library", "py_binary")
+load("@rules_python//python:defs.bzl", "py_library")
 
 
 ## Group vbap
@@ -29,25 +29,72 @@
 filegroup(
     name = "vbap_whl",
     srcs = [],
-    data = ["@pypi_oletools//:_whl", "@pypi_pcodedmp//:_whl"],
-    visibility = ["@pypi_oletools//:__pkg__", "@pypi_pcodedmp//:__pkg__"],
+    data = [
+        "@pypi_oletools//:_whl",
+        "@pypi_pcodedmp//:_whl",
+    ],
+    visibility = [
+        "@pypi_oletools//:__pkg__",
+        "@pypi_pcodedmp//:__pkg__",
+    ],
 )
 
 py_library(
     name = "vbap_pkg",
     srcs = [],
-    deps = ["@pypi_oletools//:_pkg", "@pypi_pcodedmp//:_pkg"],
-    visibility = ["@pypi_oletools//:__pkg__", "@pypi_pcodedmp//:__pkg__"],
+    deps = [
+        "@pypi_oletools//:_pkg",
+        "@pypi_pcodedmp//:_pkg",
+    ],
+    visibility = [
+        "@pypi_oletools//:__pkg__",
+        "@pypi_pcodedmp//:__pkg__",
+    ],
 )
 """
     actual = generate_group_library_build_bazel(
         repo_prefix = "pypi_",
-        groups = {"vbap": ["oletools", "pcodedmp"]},
+        groups = {"vbap": ["pcodedmp", "oletools"]},
     )
     env.expect.that_str(actual).equals(want)
 
 _tests.append(_test_simple)
 
+def _test_in_hub(env):
+    want = """\
+load("@rules_python//python:defs.bzl", "py_library")
+
+
+## Group vbap
+
+filegroup(
+    name = "vbap_whl",
+    srcs = [],
+    data = [
+        "//oletools:_whl",
+        "//pcodedmp:_whl",
+    ],
+    visibility = ["//:__subpackages__"],
+)
+
+py_library(
+    name = "vbap_pkg",
+    srcs = [],
+    deps = [
+        "//oletools:_pkg",
+        "//pcodedmp:_pkg",
+    ],
+    visibility = ["//:__subpackages__"],
+)
+"""
+    actual = generate_group_library_build_bazel(
+        repo_prefix = "",
+        groups = {"vbap": ["pcodedmp", "oletools"]},
+    )
+    env.expect.that_str(actual).equals(want)
+
+_tests.append(_test_in_hub)
+
 def generate_build_bazel_test_suite(name):
     """Create the test suite.
 
diff --git a/tests/pip_install/whl_library/generate_build_bazel_tests.bzl b/tests/pip_install/whl_library/generate_build_bazel_tests.bzl
index 11611b9..66126cf 100644
--- a/tests/pip_install/whl_library/generate_build_bazel_tests.bzl
+++ b/tests/pip_install/whl_library/generate_build_bazel_tests.bzl
@@ -71,7 +71,7 @@
 )
 """
     actual = generate_whl_library_build_bazel(
-        repo_prefix = "pypi_",
+        dep_template = "@pypi_{name}//:{target}",
         whl_name = "foo.whl",
         dependencies = ["foo", "bar-baz"],
         dependencies_by_platform = {},
@@ -216,7 +216,7 @@
 )
 """
     actual = generate_whl_library_build_bazel(
-        repo_prefix = "pypi_",
+        dep_template = "@pypi_{name}//:{target}",
         whl_name = "foo.whl",
         dependencies = ["foo", "bar-baz"],
         dependencies_by_platform = {
@@ -305,7 +305,7 @@
 # SOMETHING SPECIAL AT THE END
 """
     actual = generate_whl_library_build_bazel(
-        repo_prefix = "pypi_",
+        dep_template = "@pypi_{name}//:{target}",
         whl_name = "foo.whl",
         dependencies = ["foo", "bar-baz"],
         dependencies_by_platform = {},
@@ -386,7 +386,7 @@
 )
 """
     actual = generate_whl_library_build_bazel(
-        repo_prefix = "pypi_",
+        dep_template = "@pypi_{name}//:{target}",
         whl_name = "foo.whl",
         dependencies = ["foo", "bar-baz"],
         dependencies_by_platform = {},
@@ -482,7 +482,7 @@
 )
 """
     actual = generate_whl_library_build_bazel(
-        repo_prefix = "pypi_",
+        dep_template = "@pypi_{name}//:{target}",
         whl_name = "foo.whl",
         dependencies = ["foo", "bar-baz", "qux"],
         dependencies_by_platform = {
@@ -501,6 +501,98 @@
 
 _tests.append(_test_group_member)
 
+def _test_group_member_deps_to_hub(env):
+    want = """\
+load("@rules_python//python:defs.bzl", "py_library", "py_binary")
+load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
+
+package(default_visibility = ["//visibility:public"])
+
+filegroup(
+    name = "dist_info",
+    srcs = glob(["site-packages/*.dist-info/**"], allow_empty = True),
+)
+
+filegroup(
+    name = "data",
+    srcs = glob(["data/**"], allow_empty = True),
+)
+
+filegroup(
+    name = "whl",
+    srcs = ["foo.whl"],
+    data = ["@pypi//bar_baz:whl"] + select(
+        {
+            "@platforms//os:linux": ["@pypi//box:whl"],
+            ":is_linux_x86_64": [
+                "@pypi//box:whl",
+                "@pypi//box_amd64:whl",
+            ],
+            "//conditions:default": [],
+        },
+    ),
+    visibility = ["@pypi//:__subpackages__"],
+)
+
+py_library(
+    name = "pkg",
+    srcs = glob(
+        ["site-packages/**/*.py"],
+        exclude=[],
+        # Empty sources are allowed to support wheels that don't have any
+        # pure-Python code, e.g. pymssql, which is written in Cython.
+        allow_empty = True,
+    ),
+    data = [] + glob(
+        ["site-packages/**/*"],
+        exclude=["**/* *", "**/*.py", "**/*.pyc", "**/*.pyc.*", "**/*.dist-info/RECORD"],
+    ),
+    # This makes this directory a top-level in the python import
+    # search path for anything that depends on this.
+    imports = ["site-packages"],
+    deps = ["@pypi//bar_baz:pkg"] + select(
+        {
+            "@platforms//os:linux": ["@pypi//box:pkg"],
+            ":is_linux_x86_64": [
+                "@pypi//box:pkg",
+                "@pypi//box_amd64:pkg",
+            ],
+            "//conditions:default": [],
+        },
+    ),
+    tags = [],
+    visibility = ["@pypi//:__subpackages__"],
+)
+
+config_setting(
+    name = "is_linux_x86_64",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:linux",
+    ],
+    visibility = ["//visibility:private"],
+)
+"""
+    actual = generate_whl_library_build_bazel(
+        dep_template = "@pypi//{name}:{target}",
+        whl_name = "foo.whl",
+        dependencies = ["foo", "bar-baz", "qux"],
+        dependencies_by_platform = {
+            "linux_x86_64": ["box", "box-amd64"],
+            "windows_x86_64": ["fox"],
+            "@platforms//os:linux": ["box"],  # buildifier: disable=unsorted-dict-items to check that we sort inside the test
+        },
+        tags = [],
+        entry_points = {},
+        data_exclude = [],
+        annotation = None,
+        group_name = "qux",
+        group_deps = ["foo", "fox", "qux"],
+    )
+    env.expect.that_str(actual.replace("@@", "@")).equals(want)
+
+_tests.append(_test_group_member_deps_to_hub)
+
 def generate_build_bazel_test_suite(name):
     """Create the test suite.