From b7e13284643fc986465847cc9cd05d2ca42b27ef Mon Sep 17 00:00:00 2001 From: Jeremy Volkman Date: Sat, 18 Jul 2026 18:17:58 +0000 Subject: [PATCH 1/5] Implement testonly dependency generation (#228) - Parse testonly and transitive entries from dependency_groups across all translation backends (uv, poetry, pdm, pylock). - Implement BFS reachability in lock_resolver.bzl to compute packages exclusively reachable from testonly groups. - Apply testonly = True to pycross_library_proxy targets in thin repos. - Replace create_transitive_aliases with dependency_groups = ["transitive"]. --- README.md | 70 ++++++-- docs/ext_pdm.md | 7 +- docs/ext_poetry.md | 7 +- docs/ext_pylock.md | 7 +- docs/ext_uv.md | 7 +- pycross/private/format_extension.bzl | 10 +- pycross/private/lock_attrs.bzl | 3 - pycross/private/lock_common.bzl | 114 ++++++++++-- pycross/private/lock_resolver.bzl | 26 ++- pycross/private/pdm_lock_model.bzl | 28 ++- pycross/private/poetry_lock_model.bzl | 69 ++++++-- pycross/private/pylock_lock_model.bzl | 37 +++- pycross/private/resolved_lock_repo.bzl | 4 +- pycross/private/thin_package_repo.bzl | 24 ++- pycross/private/translator_common.bzl | 6 +- pycross/private/uv_lock_model.bzl | 33 +++- tests/e2e/build_cmake/MODULE.bazel | 5 +- tests/e2e/build_maturin/MODULE.bazel | 5 +- tests/e2e/build_meson/MODULE.bazel | 5 +- tests/e2e/build_pure_python/MODULE.bazel | 5 +- tests/e2e/build_setuptools/MODULE.bazel | 5 +- tests/e2e/bzlmod_flags/MODULE.bazel | 5 +- tests/e2e/patches_and_hooks/MODULE.bazel | 5 +- tests/unit/BUILD.bazel | 3 + tests/unit/pdm/always_build/expected.json | 3 +- tests/unit/pdm/local_wheel/expected.json | 3 +- tests/unit/pdm/requirements/expected.json | 3 +- tests/unit/poetry/always_build/expected.json | 3 +- tests/unit/poetry/local_wheel/expected.json | 3 +- tests/unit/poetry/requirements/expected.json | 3 +- tests/unit/test_lock_resolver.bzl | 162 +++++++++++++++++- tests/unit/test_parse_dependency_groups.bzl | 156 +++++++++++++++++ tests/unit/test_uv_translator.bzl | 154 ++++++++++++++++- tests/unit/uv/lock_0_2_35/expected.json | 3 +- .../unit/uv/lock_0_4_0_virtual/expected.json | 3 +- .../expected.json | 3 +- .../unit/uv/lock_0_4_27_pep735/expected.json | 3 +- tests/unit/uv/lock_pre_0_2_35/expected.json | 3 +- tests/unit/uv/lock_workspace/expected.json | 3 +- 39 files changed, 882 insertions(+), 116 deletions(-) create mode 100644 tests/unit/test_parse_dependency_groups.bzl diff --git a/README.md b/README.md index dfbdf15b..b863404f 100644 --- a/README.md +++ b/README.md @@ -81,19 +81,6 @@ uv.workspace( These explicitly specified files are appended to the auto-discovered files. -#### Transitive Aliases - -By default, `rules_pycross` only generates top-level aliases for packages that are explicitly defined as dependencies in your project. If you want to be able to depend on transitive dependencies directly using `requirement("transitive-package")`, you can enable `create_transitive_aliases` on your `uv.repo()` tag: - -```python -uv.repo( - workspace = "pypi", - create_transitive_aliases = True, -) -``` - -If a transitive package has multiple versions in the lock file, `rules_pycross` will print a warning and alias to the highest version. - #### The Internal Build Tools Repository (`__build`) For every workspace, `rules_pycross` also auto-generates an internal companion repository named `__build` (e.g., `@pypi__build`). @@ -215,6 +202,63 @@ uv.repo( ) ``` +### Transitive Aliases + +By adding `"transitive"` to `dependency_groups`, `rules_pycross` will generate aliases for all transitively resolved packages — not just those directly pinned by your selected groups. This lets you reference any package in the lock file via `@pypi//package_name`, even if it's only an indirect dependency. + +```python +uv.repo( + dependency_groups = ["default", "transitive"], + workspace = "pypi", +) +``` + +If a transitive package has multiple versions in the lock file, `rules_pycross` will print a warning and alias to the highest version. + +### Testonly Dependencies + +Append `;testonly` to any group specifier to mark its packages as `testonly` in the generated Bazel targets. This is useful for test frameworks and other packages that should not be depended upon by production code: + +```python +uv.repo( + dependency_groups = ["default", "group:test;testonly"], + workspace = "pypi", +) +``` + +With `transitive;testonly`, `rules_pycross` performs reachability analysis to determine which transitive packages are **exclusively** reachable from testonly roots. Packages reachable from both testonly and non-testonly groups remain non-testonly: + +```python +uv.repo( + dependency_groups = ["default", "group:test;testonly", "transitive;testonly"], + workspace = "pypi", +) +``` + +### Wildcards and Precedence + +The `*` wildcard and `;testonly` modifier follow **last-wins** precedence. When `*` appears, it sets the default testonly status for all groups and resets any prior specific overrides. Specific entries after `*` override individual groups: + +```python +# Everything testonly except group:dev +uv.repo( + dependency_groups = ["*;testonly", "group:dev"], + workspace = "pypi", +) + +# Only group:test is testonly +uv.repo( + dependency_groups = ["*", "group:test;testonly"], + workspace = "pypi", +) + +# group:dev;testonly is overridden by the later * +uv.repo( + dependency_groups = ["group:dev;testonly", "*"], + workspace = "pypi", +) +``` + --- ## Extras diff --git a/docs/ext_pdm.md b/docs/ext_pdm.md index ec0a21b6..46e27ad7 100644 --- a/docs/ext_pdm.md +++ b/docs/ext_pdm.md @@ -8,8 +8,8 @@ The pdm extension.
 pdm = use_extension("@rules_pycross//pycross/extensions:pdm.bzl", "pdm")
-pdm.repo(name, constraint_values, create_transitive_aliases, dependency_groups, flags,
-         legacy_create_root_aliases, platform, projects, workspace)
+pdm.repo(name, constraint_values, dependency_groups, flags, legacy_create_root_aliases, platform,
+         projects, workspace)
 pdm.package(name, always_build, bin_paths, build_backend, build_target, build_tools_repo,
             data_paths, extra_build_tools, ignore_dependencies, include_paths, install_exclude_globs,
             post_install_patches, pre_build_patches, site_hooks, site_paths, wheel_library_tags,
@@ -32,8 +32,7 @@ Override a pdm workspace member's settings.
 | :------------- | :------------- | :------------- | :------------- | :------------- |
 | name |  Override the repo name.   | Name | optional |  `""`  |
 | constraint_values |  A list of constraint values to apply to the generated platform.   | List of labels | optional |  `[]`  |
-| create_transitive_aliases |  Generate aliases for transitive single-version packages in this repo.   | Boolean | optional |  `False`  |
-| dependency_groups |  A list of dependency groups to include. E.g. ['default', 'group:foo', '*']. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
+| dependency_groups |  A list of target groups to include. E.g. ['default', 'group:foo', '*']. Use 'transitive' to generate aliases for transitively-reachable packages. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
 | flags |  A list of flags to apply to the generated platform (e.g., '--@flag=value').   | List of strings | optional |  `[]`  |
 | legacy_create_root_aliases |  Create //:pkg aliases for bare packages in the generated repo. Useful for migrating from 1.x.   | Boolean | optional |  `False`  |
 | platform |  An existing platform target to use directly.   | Label | optional |  `None`  |
diff --git a/docs/ext_poetry.md b/docs/ext_poetry.md
index 8cb60102..8fa996e7 100644
--- a/docs/ext_poetry.md
+++ b/docs/ext_poetry.md
@@ -8,8 +8,8 @@ The poetry extension.
 
 
 poetry = use_extension("@rules_pycross//pycross/extensions:poetry.bzl", "poetry")
-poetry.repo(name, constraint_values, create_transitive_aliases, dependency_groups, flags,
-            legacy_create_root_aliases, platform, projects, workspace)
+poetry.repo(name, constraint_values, dependency_groups, flags, legacy_create_root_aliases, platform,
+            projects, workspace)
 poetry.package(name, always_build, bin_paths, build_backend, build_target, build_tools_repo,
                data_paths, extra_build_tools, ignore_dependencies, include_paths,
                install_exclude_globs, post_install_patches, pre_build_patches, site_hooks, site_paths,
@@ -32,8 +32,7 @@ Override a poetry workspace member's settings.
 | :------------- | :------------- | :------------- | :------------- | :------------- |
 | name |  Override the repo name.   | Name | optional |  `""`  |
 | constraint_values |  A list of constraint values to apply to the generated platform.   | List of labels | optional |  `[]`  |
-| create_transitive_aliases |  Generate aliases for transitive single-version packages in this repo.   | Boolean | optional |  `False`  |
-| dependency_groups |  A list of dependency groups to include. E.g. ['default', 'group:foo', '*']. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
+| dependency_groups |  A list of target groups to include. E.g. ['default', 'group:foo', '*']. Use 'transitive' to generate aliases for transitively-reachable packages. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
 | flags |  A list of flags to apply to the generated platform (e.g., '--@flag=value').   | List of strings | optional |  `[]`  |
 | legacy_create_root_aliases |  Create //:pkg aliases for bare packages in the generated repo. Useful for migrating from 1.x.   | Boolean | optional |  `False`  |
 | platform |  An existing platform target to use directly.   | Label | optional |  `None`  |
diff --git a/docs/ext_pylock.md b/docs/ext_pylock.md
index 00b4e160..2a3cc15b 100644
--- a/docs/ext_pylock.md
+++ b/docs/ext_pylock.md
@@ -8,8 +8,8 @@ The pylock extension.
 
 
 pylock = use_extension("@rules_pycross//pycross/extensions:pylock.bzl", "pylock")
-pylock.repo(name, constraint_values, create_transitive_aliases, dependency_groups, flags,
-            legacy_create_root_aliases, platform, projects, workspace)
+pylock.repo(name, constraint_values, dependency_groups, flags, legacy_create_root_aliases, platform,
+            projects, workspace)
 pylock.package(name, always_build, bin_paths, build_backend, build_target, build_tools_repo,
                data_paths, extra_build_tools, ignore_dependencies, include_paths,
                install_exclude_globs, post_install_patches, pre_build_patches, site_hooks, site_paths,
@@ -32,8 +32,7 @@ Override a pylock workspace member's settings.
 | :------------- | :------------- | :------------- | :------------- | :------------- |
 | name |  Override the repo name.   | Name | optional |  `""`  |
 | constraint_values |  A list of constraint values to apply to the generated platform.   | List of labels | optional |  `[]`  |
-| create_transitive_aliases |  Generate aliases for transitive single-version packages in this repo.   | Boolean | optional |  `False`  |
-| dependency_groups |  A list of dependency groups to include. E.g. ['default', 'group:foo', '*']. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
+| dependency_groups |  A list of target groups to include. E.g. ['default', 'group:foo', '*']. Use 'transitive' to generate aliases for transitively-reachable packages. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
 | flags |  A list of flags to apply to the generated platform (e.g., '--@flag=value').   | List of strings | optional |  `[]`  |
 | legacy_create_root_aliases |  Create //:pkg aliases for bare packages in the generated repo. Useful for migrating from 1.x.   | Boolean | optional |  `False`  |
 | platform |  An existing platform target to use directly.   | Label | optional |  `None`  |
diff --git a/docs/ext_uv.md b/docs/ext_uv.md
index bdbef649..1151d4d3 100644
--- a/docs/ext_uv.md
+++ b/docs/ext_uv.md
@@ -8,8 +8,8 @@ The uv extension.
 
 
 uv = use_extension("@rules_pycross//pycross/extensions:uv.bzl", "uv")
-uv.repo(name, constraint_values, create_transitive_aliases, dependency_groups, flags,
-        legacy_create_root_aliases, platform, projects, workspace)
+uv.repo(name, constraint_values, dependency_groups, flags, legacy_create_root_aliases, platform,
+        projects, workspace)
 uv.package(name, always_build, bin_paths, build_backend, build_target, build_tools_repo, data_paths,
            extra_build_tools, ignore_dependencies, include_paths, install_exclude_globs,
            post_install_patches, pre_build_patches, site_hooks, site_paths, wheel_library_tags,
@@ -33,8 +33,7 @@ Override a uv workspace member's settings.
 | :------------- | :------------- | :------------- | :------------- | :------------- |
 | name |  Override the repo name.   | Name | optional |  `""`  |
 | constraint_values |  A list of constraint values to apply to the generated platform.   | List of labels | optional |  `[]`  |
-| create_transitive_aliases |  Generate aliases for transitive single-version packages in this repo.   | Boolean | optional |  `False`  |
-| dependency_groups |  A list of dependency groups to include. E.g. ['default', 'group:foo', '*']. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
+| dependency_groups |  A list of target groups to include. E.g. ['default', 'group:foo', '*']. Use 'transitive' to generate aliases for transitively-reachable packages. Defaults to ['default'].   | List of strings | optional |  `["default"]`  |
 | flags |  A list of flags to apply to the generated platform (e.g., '--@flag=value').   | List of strings | optional |  `[]`  |
 | legacy_create_root_aliases |  Create //:pkg aliases for bare packages in the generated repo. Useful for migrating from 1.x.   | Boolean | optional |  `False`  |
 | platform |  An existing platform target to use directly.   | Label | optional |  `None`  |
diff --git a/pycross/private/format_extension.bzl b/pycross/private/format_extension.bzl
index 3d049355..832513f4 100644
--- a/pycross/private/format_extension.bzl
+++ b/pycross/private/format_extension.bzl
@@ -60,11 +60,8 @@ REPO_ATTRS = dict(
     name = attr.string(
         doc = "Override the repo name.",
     ),
-    create_transitive_aliases = attr.bool(
-        doc = "Generate aliases for transitive single-version packages in this repo.",
-    ),
     dependency_groups = attr.string_list(
-        doc = "A list of dependency groups to include. E.g. ['default', 'group:foo', '*']. Defaults to ['default'].",
+        doc = "A list of target groups to include. E.g. ['default', 'group:foo', '*']. Use 'transitive' to generate aliases for transitively-reachable packages. Defaults to ['default'].",
         default = ["default"],
     ),
     legacy_create_root_aliases = attr.bool(
@@ -226,7 +223,8 @@ def _resolve_lock_inline(module_ctx, lock_info, serialized_lock_model, workspace
         always_include_sdist = False,
         annotations_data = annotations_data,
         default_extra_build_tools_args = wildcard_pkg.extra_build_tools if wildcard_pkg else [],
-        create_transitive_aliases = lock_info.create_transitive_aliases,
+        include_transitive = getattr(lock_model, "include_transitive", False),
+        transitive_testonly = getattr(lock_model, "transitive_testonly", False),
     )
 
     return {
@@ -237,6 +235,7 @@ def _resolve_lock_inline(module_ctx, lock_info, serialized_lock_model, workspace
         "variants": resolved_lock.variants,
         "resolution_marker_exprs": resolved_lock.resolution_marker_exprs,
         "legacy_create_root_aliases": getattr(lock_model, "legacy_create_root_aliases", False),
+        "testonly_pins": resolved_lock.testonly_pins,
     }
 
 def make_format_extension(
@@ -296,7 +295,6 @@ def make_format_extension(
                     flags = getattr(tag, "flags", []),
                     constraint_values = getattr(tag, "constraint_values", []),
                     platform = getattr(tag, "platform", None),
-                    create_transitive_aliases = getattr(tag, "create_transitive_aliases", False),
                 )
                 member_tags.append(struct(tag = member_tag, module = module))
 
diff --git a/pycross/private/lock_attrs.bzl b/pycross/private/lock_attrs.bzl
index 8837443a..12387714 100644
--- a/pycross/private/lock_attrs.bzl
+++ b/pycross/private/lock_attrs.bzl
@@ -77,9 +77,6 @@ RESOLVE_ATTRS = dict(
     remote_wheels = attr.string_dict(
         doc = "A mapping of remote wheels to their sha256 hashes.",
     ),
-    create_transitive_aliases = attr.bool(
-        doc = "Generate aliases for all packages that have a single version in the lock file.",
-    ),
     annotations = attr.string_dict(
         doc = "Optional annotations to apply to packages.",
     ),
diff --git a/pycross/private/lock_common.bzl b/pycross/private/lock_common.bzl
index b9ef5796..52ba4dea 100644
--- a/pycross/private/lock_common.bzl
+++ b/pycross/private/lock_common.bzl
@@ -97,7 +97,6 @@ def workspace_lock_struct(ws_tag, repo_name, workspace_name, transition_attrs):
     return struct(
         repo_name = repo_name,
         workspace = workspace_name,
-        create_transitive_aliases = transition_attrs.get("create_transitive_aliases", False),
         local_wheels = ws_tag.local_wheels,
         disallow_builds = ws_tag.disallow_builds,
         packages = {},
@@ -243,26 +242,17 @@ def get_member_transition_attrs(members_tag, override_tag):
     has_explicit_constraints = override_tag and getattr(override_tag, "constraint_values", [])
     has_explicit_platform = override_tag and getattr(override_tag, "platform", None)
 
-    # create_transitive_aliases: override wins if set, otherwise inherit from members_tag
-    create_transitive_aliases = False
-    if override_tag and getattr(override_tag, "create_transitive_aliases", False):
-        create_transitive_aliases = True
-    elif members_tag and getattr(members_tag, "create_transitive_aliases", False):
-        create_transitive_aliases = True
-
     if override_tag and (has_explicit_flags or has_explicit_constraints or has_explicit_platform):
         return dict(
             flags = getattr(override_tag, "flags", []),
             constraint_values = [str(c) for c in getattr(override_tag, "constraint_values", [])],
             platform = str(override_tag.platform) if override_tag.platform else None,
-            create_transitive_aliases = create_transitive_aliases,
         )
 
     return dict(
         flags = getattr(members_tag, "flags", []) if members_tag else [],
         constraint_values = [str(c) for c in getattr(members_tag, "constraint_values", [])] if members_tag else [],
         platform = str(members_tag.platform) if members_tag and getattr(members_tag, "platform", None) else None,
-        create_transitive_aliases = create_transitive_aliases,
     )
 
 def register_workspace_repo(
@@ -276,6 +266,11 @@ def register_workspace_repo(
         repo_name,
         projects,
         dependency_groups,
+        testonly_groups,
+        non_testonly_groups,
+        wildcard_testonly,
+        include_transitive,
+        transitive_testonly,
         legacy_create_root_aliases,
         transition_attrs,
         lock_module,
@@ -292,7 +287,12 @@ def register_workspace_repo(
         model_type: The lock model type.
         repo_name: The repo name for this member.
         projects: List of projects included in this repo.
-        dependency_groups: List of dependency groups.
+        dependency_groups: List of parsed dependency groups.
+        testonly_groups: List of groups explicitly marked testonly.
+        non_testonly_groups: List of groups explicitly marked non-testonly (overrides wildcard).
+        wildcard_testonly: Whether the wildcard (*) is marked testonly.
+        include_transitive: Whether to include transitive dependencies.
+        transitive_testonly: Whether transitive dependencies are testonly.
         legacy_create_root_aliases: Boolean to create root aliases.
         transition_attrs: Transition attributes dict.
         lock_module: The module owning this lock.
@@ -309,6 +309,11 @@ def register_workspace_repo(
         lock_file = str(ws_tag.lock_file),
         projects = projects,
         dependency_groups = dependency_groups,
+        testonly_groups = testonly_groups,
+        non_testonly_groups = non_testonly_groups,
+        wildcard_testonly = wildcard_testonly,
+        include_transitive = include_transitive,
+        transitive_testonly = transitive_testonly,
         legacy_create_root_aliases = legacy_create_root_aliases,
     )
 
@@ -318,6 +323,62 @@ def register_workspace_repo(
             model[attr_name] = getattr(ws_tag, attr_name)
     lock_model_structs[repo_name] = json.encode(model)
 
+def parse_dependency_group_entries(raw_groups):
+    """Parse dependency group entries with last-wins testonly semantics.
+
+    Processes a list of dependency group entries (e.g., ["default", "group:dev;testonly",
+    "*;testonly", "transitive"]) and resolves testonly status with last-wins precedence:
+    - When * appears, it sets the default testonly status and clears prior specific overrides
+    - Specific entries after * override individual groups
+    - The last entry matching a group wins
+
+    Args:
+        raw_groups: List of raw dependency group strings from the user's config.
+
+    Returns:
+        A struct with:
+            dependency_groups: Parsed group specs (without ;testonly suffixes).
+            testonly_groups: Groups explicitly marked testonly (after last *).
+            non_testonly_groups: Groups explicitly marked non-testonly (after last *).
+            wildcard_testonly: Whether * was marked testonly.
+            include_transitive: Whether "transitive" was present.
+            transitive_testonly: Whether "transitive;testonly" was present.
+    """
+    parsed_groups = []
+    include_transitive = False
+    transitive_testonly = False
+
+    wildcard_testonly = False
+    testonly_overrides = {}  # group -> is_testonly (only entries after last *)
+
+    for entry in raw_groups:
+        parts = entry.split(";")
+        spec = parts[0]
+        is_testonly = "testonly" in parts[1:]
+
+        if spec == "transitive":
+            include_transitive = True
+            transitive_testonly = is_testonly
+        elif spec == "*":
+            wildcard_testonly = is_testonly
+            testonly_overrides = {}  # Reset: * supersedes earlier entries
+            parsed_groups.append(spec)
+        else:
+            testonly_overrides[spec] = is_testonly
+            parsed_groups.append(spec)
+
+    testonly_groups = sorted([g for g, t in testonly_overrides.items() if t])
+    non_testonly_groups = sorted([g for g, t in testonly_overrides.items() if not t])
+
+    return struct(
+        dependency_groups = parsed_groups,
+        testonly_groups = testonly_groups,
+        non_testonly_groups = non_testonly_groups,
+        wildcard_testonly = wildcard_testonly,
+        include_transitive = include_transitive,
+        transitive_testonly = transitive_testonly,
+    )
+
 def process_repo(
         lock_owners,
         lock_repos,
@@ -343,17 +404,25 @@ def process_repo(
     """
     tag = tag_info.tag
 
-    dependency_groups = tag.dependency_groups
-    has_wildcard = "*" in dependency_groups
+    raw_groups = tag.dependency_groups
+    parsed = parse_dependency_group_entries(raw_groups)
+    parsed_groups = parsed.dependency_groups
+    testonly_groups = parsed.testonly_groups
+    non_testonly_groups = parsed.non_testonly_groups
+    wildcard_testonly = parsed.wildcard_testonly
+    include_transitive = parsed.include_transitive
+    transitive_testonly = parsed.transitive_testonly
+
+    has_wildcard = "*" in parsed_groups
     has_specific = False
-    for group in dependency_groups:
+    for group in parsed_groups:
         if group not in ("*", "default"):
             has_specific = True
             break
 
     if has_wildcard and has_specific:
         # buildifier: disable=print
-        print("WARNING: repo '{}' in workspace '{}' specifies both wildcard ('*') and specific dependency groups ({}). The specific groups are redundant.".format(tag.repo, ws_name, dependency_groups))
+        print("WARNING: repo '{}' in workspace '{}' specifies both wildcard ('*') and specific dependency groups ({}). The specific groups are redundant.".format(tag.repo, ws_name, parsed_groups))
 
     # Get transition attrs
     transition_attrs = get_member_transition_attrs(None, tag)
@@ -368,7 +437,12 @@ def process_repo(
         model_type,
         tag.repo,
         tag.projects,
-        tag.dependency_groups,
+        parsed_groups,
+        testonly_groups,
+        non_testonly_groups,
+        wildcard_testonly,
+        include_transitive,
+        transitive_testonly,
         tag.legacy_create_root_aliases,
         transition_attrs,
         tag_info.module,
@@ -459,7 +533,6 @@ def process_workspaces(
                     flags = [],
                     constraint_values = [],
                     platform = None,
-                    create_transitive_aliases = False,
                 )
                 member_tags.append(struct(tag = implicit_tag, module = ws_info.module))
                 workspace_repo_count[ws_name] += 1
@@ -498,7 +571,6 @@ def process_workspaces(
             flags = getattr(tag, "flags", []),
             constraint_values = getattr(tag, "constraint_values", []),
             platform = getattr(tag, "platform", None),
-            create_transitive_aliases = getattr(tag, "create_transitive_aliases", False),
         )
         new_tag_info = struct(tag = new_tag, module = tag_info.module)
 
@@ -532,12 +604,16 @@ def process_workspaces(
             repo_name = build_repo_name,
             projects = ["*"],
             dependency_groups = ["*"],
+            testonly_groups = [],
+            non_testonly_groups = [],
+            wildcard_testonly = False,
+            include_transitive = False,
+            transitive_testonly = False,
             legacy_create_root_aliases = False,
             transition_attrs = dict(
                 flags = [],
                 constraint_values = [],
                 platform = None,
-                create_transitive_aliases = True,
             ),
             lock_module = ws_info.module,
             extra_project_files = workspace_extra_project_files[ws_name],
diff --git a/pycross/private/lock_resolver.bzl b/pycross/private/lock_resolver.bzl
index 2e474828..85b89742 100644
--- a/pycross/private/lock_resolver.bzl
+++ b/pycross/private/lock_resolver.bzl
@@ -481,7 +481,8 @@ def resolve(
         always_include_sdist = False,
         annotations_data = None,
         default_extra_build_tools_args = None,
-        create_transitive_aliases = False):
+        include_transitive = False,
+        transitive_testonly = False):
     """Resolves dependencies from lock model data.
 
     Args:
@@ -491,7 +492,8 @@ def resolve(
         always_include_sdist: Whether to always include sdist.
         annotations_data: Annotations data.
         default_extra_build_tools_args: Default extra build tools args.
-        create_transitive_aliases: Whether to alias transitive single-version packages.
+        include_transitive: Whether to include transitive dependencies.
+        transitive_testonly: Whether to perform reachability analysis to assign testonly status.
 
     Returns:
         Dictionary of resolved packages.
@@ -623,7 +625,24 @@ def resolve(
     sorted_repo_keys = sorted(repos.keys())
     repos = {k: repos[k] for k in sorted_repo_keys}
 
-    if create_transitive_aliases:
+    testonly_pin_names = lock_model_data.get("testonly_pins", [])
+    if transitive_testonly:
+        # All reachable from actual pins
+        all_reachable = _compute_reachable_keys(pins, packages_by_package_key)
+
+        # All reachable from pins explicitly NOT testonly
+        non_testonly_pins = {p: v for p, v in pins.items() if p not in testonly_pin_names}
+        non_testonly_reachable = _compute_reachable_keys(non_testonly_pins, packages_by_package_key)
+
+        testonly_keys = [k for k in all_reachable if k not in non_testonly_reachable]
+        testonly_names_dict = {}
+        for k in testonly_keys:
+            entry = packages_by_package_key.get(k)
+            if entry:
+                testonly_names_dict[entry.resolved_package["name"]] = True
+        testonly_pin_names = sorted(testonly_names_dict.keys())
+
+    if include_transitive:
         reachable_keys = _compute_reachable_keys(pins, packages_by_package_key)
         resolved_versions_by_name = {}
         for entry in resolved_packages:
@@ -668,4 +687,5 @@ def resolve(
         cycle_groups = cycle_groups,
         variants = lock_model_data.get("variants", []),
         resolution_marker_exprs = lock_model_data.get("resolution_marker_exprs", {}),
+        testonly_pins = testonly_pin_names,
     )
diff --git a/pycross/private/pdm_lock_model.bzl b/pycross/private/pdm_lock_model.bzl
index 80379dac..f0bf3f35 100644
--- a/pycross/private/pdm_lock_model.bzl
+++ b/pycross/private/pdm_lock_model.bzl
@@ -110,10 +110,13 @@ def translate_pdm(project_dict, lock_dict, lock_model):
     dependency_groups = getattr(lock_model, "dependency_groups", ["default"])
     include_all = "*" in dependency_groups
     include_default = "default" in dependency_groups or include_all
+    testonly_groups = getattr(lock_model, "testonly_groups", [])
+    non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
+    wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
 
     if include_default:
         for dep_str in default_deps:
-            requirements.append(parse_pep508_requirement(dep_str))
+            requirements.append((parse_pep508_requirement(dep_str), False))
 
     effective_groups = ["optional:*", "group:*"] if include_all else dependency_groups
     for group in effective_groups:
@@ -133,6 +136,14 @@ def translate_pdm(project_dict, lock_dict, lock_model):
         else:
             target_names = [name]
 
+        # Last-wins testonly: specific overrides beat wildcard default
+        if group in testonly_groups:
+            is_testonly = True
+        elif group in non_testonly_groups:
+            is_testonly = False
+        else:
+            is_testonly = wildcard_testonly
+
         for target_name in target_names:
             if target_name in groups_dict:
                 entries = groups_dict[target_name]
@@ -142,7 +153,7 @@ def translate_pdm(project_dict, lock_dict, lock_model):
                         stripped = dep_str.strip()
                         if stripped.startswith("-e "):
                             stripped = stripped[3:].strip()
-                        requirements.append(parse_pep508_requirement(stripped))
+                        requirements.append((parse_pep508_requirement(stripped), is_testonly))
                     elif type(dep_str) == "dict" and "include-group" in dep_str:
                         inc_group = dep_str["include-group"]
                         if inc_group in dev_deps:
@@ -151,14 +162,22 @@ def translate_pdm(project_dict, lock_dict, lock_model):
                                     stripped = inc_dep.strip()
                                     if stripped.startswith("-e "):
                                         stripped = stripped[3:].strip()
-                                    requirements.append(parse_pep508_requirement(stripped))
+                                    requirements.append((parse_pep508_requirement(stripped), is_testonly))
             else:
                 fail("Non-existent {} dependency group: {}".format(kind, target_name))
 
     # Build pinned specs from requirements
     pinned_package_specs = {}
-    for req in requirements:
+    testonly_reqs = {}
+    non_testonly_reqs = {}
+    for req, is_testonly in requirements:
         pinned_package_specs[req.name] = {"": req.specifier}
+        if is_testonly:
+            testonly_reqs[req.name] = True
+        else:
+            non_testonly_reqs[req.name] = True
+
+    testonly_pin_names = [name for name in testonly_reqs if name not in non_testonly_reqs]
 
     # Parse lock packages
     packages = []
@@ -235,6 +254,7 @@ def translate_pdm(project_dict, lock_dict, lock_model):
         requires_python = requires_python,
         strict_dependencies = False,
         resolution_marker_exprs = resolution_marker_exprs,
+        testonly_pins = testonly_pin_names,
     )
 
 def repo_create_pdm_model(rctx, extra_project_files, lock_file, lock_model, output):
diff --git a/pycross/private/poetry_lock_model.bzl b/pycross/private/poetry_lock_model.bzl
index 15ffd8b0..8f2a51b5 100644
--- a/pycross/private/poetry_lock_model.bzl
+++ b/pycross/private/poetry_lock_model.bzl
@@ -257,7 +257,7 @@ def _get_files_for_package(files, package_name, package_version):
 
     return result
 
-def _parse_poetry_pin(pin, pin_info, pinned_package_specs, enrich_only = False):
+def _parse_poetry_pin(pin, pin_info, pinned_package_specs, track_pin, enrich_only = False):
     """Parse a Poetry dependency pin into pinned_package_specs.
 
     Handles three formats:
@@ -268,27 +268,29 @@ def _parse_poetry_pin(pin, pin_info, pinned_package_specs, enrich_only = False):
     Args:
         pin: Canonicalized package name.
         pin_info: The dependency value from pyproject.toml (string, dict, or list).
-        pinned_package_specs: Dict to update with {name: {"": specifier} or {name: {"": specifier}}}.
+        pinned_package_specs: Dict to check for existing specs.
+        track_pin: Callback to track pin.
         enrich_only: If True, only replace existing pin specifiers if the new one is not empty/wildcard.
     """
     existing_spec = pinned_package_specs.get(pin, {}).get("")
 
-    def set_spec(spec):
+    # We don't propagate is_testonly here directly because pinned_package_specs
+    # is now managed by the track_pin callback passed to us.
+
+    def track_spec(spec):
         # Do not overwrite a specific existing constraint with a wildcard if enrich_only is set.
         if enrich_only and existing_spec and not spec:
             return
-        pinned_package_specs[pin] = {"": spec}
+        track_pin(pin, spec)
 
     if type(pin_info) == "string":
-        set_spec(_poetry_constraint_to_pep440(pin_info))
+        track_spec(_poetry_constraint_to_pep440(pin_info))
     elif type(pin_info) == "dict":
         if "path" in pin_info or pin_info.get("optional"):
             return
-        set_spec(_poetry_constraint_to_pep440(pin_info.get("version", "*")))
+        track_spec(_poetry_constraint_to_pep440(pin_info.get("version", "*")))
     elif type(pin_info) == "list":
         # List-of-dicts: each entry may have version, markers, url, python, etc.
-        # We record the version spec for each entry; fork detection happens
-        # later when we scan the lock file for per-package markers.
         for entry in pin_info:
             if type(entry) != "dict":
                 continue
@@ -297,11 +299,9 @@ def _parse_poetry_pin(pin, pin_info, pinned_package_specs, enrich_only = False):
             version = entry.get("version", "*")
             spec = _poetry_constraint_to_pep440(version)
 
-            # If we are enriching and have an existing spec, maintain it if this entry is wildcard.
             if enrich_only and existing_spec and not spec:
                 continue
-            pinned_package_specs.setdefault(pin, {})
-            pinned_package_specs[pin][""] = spec
+            track_pin(pin, spec)
 
 def translate_poetry(project_dict, lock_dict, lock_model):
     """Translates Poetry project and lock data to raw_lock_data dict.
@@ -347,6 +347,21 @@ def translate_poetry(project_dict, lock_dict, lock_model):
     include_all = "*" in dependency_groups
     include_default = "default" in dependency_groups or include_all
 
+    testonly_groups = getattr(lock_model, "testonly_groups", [])
+    non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
+    wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
+
+    testonly_reqs = {}
+    non_testonly_reqs = {}
+
+    def track_pin(pin_name, specifier, is_testonly):
+        pinned_package_specs.setdefault(pin_name, {})
+        pinned_package_specs[pin_name][""] = specifier
+        if is_testonly:
+            testonly_reqs[pin_name] = True
+        else:
+            non_testonly_reqs[pin_name] = True
+
     if include_default:
         if has_project_deps:
             # PEP 508 format from [project.dependencies]
@@ -354,7 +369,7 @@ def translate_poetry(project_dict, lock_dict, lock_model):
                 req = parse_pep508_requirement(dep_str)
                 if req.name == "python":
                     continue
-                pinned_package_specs[req.name] = {"": req.specifier}
+                track_pin(req.name, req.specifier, False)
         if poetry_deps:
             # Also merge [tool.poetry.dependencies] if present
             for pin, pin_info in poetry_deps.items():
@@ -365,7 +380,13 @@ def translate_poetry(project_dict, lock_dict, lock_model):
                 # If project.dependencies is present, tool.poetry.dependencies can only enrich them.
                 if has_project_deps and pin not in pinned_package_specs:
                     continue
-                _parse_poetry_pin(pin, pin_info, pinned_package_specs, enrich_only = has_project_deps)
+                _parse_poetry_pin(
+                    pin,
+                    pin_info,
+                    pinned_package_specs,
+                    track_pin = lambda p, spec: track_pin(p, spec, False),
+                    enrich_only = has_project_deps,
+                )
 
     project_optional_deps = project_dict.get("project", {}).get("optional-dependencies", {})
     pep735_groups = project_dict.get("dependency-groups", {})
@@ -375,6 +396,14 @@ def translate_poetry(project_dict, lock_dict, lock_model):
         if group == "default" or group == "*":
             continue
 
+        # Last-wins testonly: specific overrides beat wildcard default
+        if group in testonly_groups:
+            is_testonly = True
+        elif group in non_testonly_groups:
+            is_testonly = False
+        else:
+            is_testonly = wildcard_testonly
+
         kind, _, name = group.partition(":")
 
         if name == "*":
@@ -392,24 +421,31 @@ def translate_poetry(project_dict, lock_dict, lock_model):
                     req = parse_pep508_requirement(dep_str)
                     if req.name == "python":
                         continue
-                    pinned_package_specs[canonicalize_name(req.name)] = {"": req.specifier}
+                    track_pin(canonicalize_name(req.name), req.specifier, is_testonly)
             if group_name in poetry_groups:
                 g = poetry_groups[group_name]
                 for pin, pin_info in g.get("dependencies", {}).items():
                     pin = canonicalize_name(pin)
                     if pin == "python":
                         continue
-                    _parse_poetry_pin(pin, pin_info, pinned_package_specs)
+                    _parse_poetry_pin(
+                        pin,
+                        pin_info,
+                        pinned_package_specs,
+                        track_pin = lambda p, spec: track_pin(p, spec, is_testonly),
+                    )
             if group_name in project_optional_deps:
                 for dep_str in project_optional_deps[group_name]:
                     req = parse_pep508_requirement(dep_str)
                     if req.name == "python":
                         continue
-                    pinned_package_specs[req.name] = {"": req.specifier}
+                    track_pin(req.name, req.specifier, is_testonly)
             elif name != "*":
                 # buildifier: disable=print
                 print("WARNING: Dependency group '{}:{}' not found in project file.".format(kind, group_name))
 
+    testonly_pin_names = [name for name in testonly_reqs if name not in non_testonly_reqs]
+
     # Parse lock file metadata
     lock_python_versions = _parse_python_versions(
         lock_dict.get("metadata", {}).get("python-versions", ""),
@@ -567,6 +603,7 @@ def translate_poetry(project_dict, lock_dict, lock_model):
         requires_python = lock_python_versions,
         strict_dependencies = True,
         resolution_marker_exprs = resolution_marker_exprs,
+        testonly_pins = testonly_pin_names,
     )
 
 def repo_create_poetry_model(rctx, extra_project_files, lock_file, lock_model, output):
diff --git a/pycross/private/pylock_lock_model.bzl b/pycross/private/pylock_lock_model.bzl
index 27919353..82a5813f 100644
--- a/pycross/private/pylock_lock_model.bzl
+++ b/pycross/private/pylock_lock_model.bzl
@@ -202,6 +202,10 @@ def translate_pylock(lock_dict, project_dict, lock_model):
 
     if project_dict and has_filter:
         root_req_names = []
+        testonly_root_req_names = []
+        testonly_groups = getattr(lock_model, "testonly_groups", [])
+        non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
+        wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
 
         project_section = project_dict.get("project", {})
         if include_default:
@@ -216,6 +220,14 @@ def translate_pylock(lock_dict, project_dict, lock_model):
             if group == "default" or group == "*":
                 continue
 
+            # Last-wins testonly: specific overrides beat wildcard default
+            if group in testonly_groups:
+                is_testonly = True
+            elif group in non_testonly_groups:
+                is_testonly = False
+            else:
+                is_testonly = wildcard_testonly
+
             kind, _, name = group.partition(":")
             if kind == "optional":
                 groups_dict = optional_deps
@@ -234,23 +246,34 @@ def translate_pylock(lock_dict, project_dict, lock_model):
                     entries = groups_dict[target_name]
                     for entry in entries:
                         if type(entry) == "string":
-                            root_req_names.append(extract_pep508_name(entry))
+                            n = extract_pep508_name(entry)
+                            if is_testonly:
+                                testonly_root_req_names.append(n)
+                            else:
+                                root_req_names.append(n)
                         elif type(entry) == "dict" and "include-group" in entry:
                             inc_group = entry["include-group"]
                             if inc_group in dev_deps:
                                 for inc_dep in dev_deps[inc_group]:
                                     if type(inc_dep) == "string":
-                                        root_req_names.append(extract_pep508_name(inc_dep))
+                                        n = extract_pep508_name(inc_dep)
+                                        if is_testonly:
+                                            testonly_root_req_names.append(n)
+                                        else:
+                                            root_req_names.append(n)
                 else:
                     # buildifier: disable=print
                     print("WARNING: Dependency group '{}:{}' not found in project file.".format(kind, target_name))
 
         # Deduplicate
         root_package_names = {n: True for n in root_req_names}
+        testonly_package_names = {n: True for n in testonly_root_req_names if n not in root_package_names}
 
-        # BFS from root_package_names
+        # BFS from all root names to find reachable packages
         visited_names = {}
-        queue = sorted(root_package_names.keys())
+        all_roots = dict(root_package_names)
+        all_roots.update(testonly_package_names)
+        queue = sorted(all_roots.keys())
 
         # Starlark has no while loop, simulate with for+range.
         # Upper bound: each edge can add one item to the queue, plus the initial queue size.
@@ -284,10 +307,12 @@ def translate_pylock(lock_dict, project_dict, lock_model):
         lock_packages = filtered_packages
 
         # Pins from roots
-        for root_name in sorted(root_package_names.keys()):
+        for root_name in sorted(all_roots.keys()):
             keys = deps_by_name.get(root_name, [])
             if keys:
                 pins[root_name] = keys[0]
+
+        testonly_pins = sorted(testonly_package_names.keys())
     else:
         # Default: include all (use first key per name; fork detection below may override)
         seen_names = {}
@@ -296,6 +321,7 @@ def translate_pylock(lock_dict, project_dict, lock_model):
             if pname not in seen_names:
                 pins[pname] = pkg_key
                 seen_names[pname] = True
+        testonly_pins = []
 
     # Detect resolution-marker forks: same package name with multiple versions.
     resolution_marker_exprs = {}
@@ -322,6 +348,7 @@ def translate_pylock(lock_dict, project_dict, lock_model):
         "packages": lock_packages,
         "pins": pins,
         "python_versions": requires_python,
+        "testonly_pins": testonly_pins,
     }
     if resolution_marker_exprs:
         result["resolution_marker_exprs"] = resolution_marker_exprs
diff --git a/pycross/private/resolved_lock_repo.bzl b/pycross/private/resolved_lock_repo.bzl
index 2eb32978..508fe400 100644
--- a/pycross/private/resolved_lock_repo.bzl
+++ b/pycross/private/resolved_lock_repo.bzl
@@ -61,7 +61,8 @@ def _generate_lock_file(rctx):
         always_include_sdist = rctx.attr.always_include_sdist,
         annotations_data = annotations_data,
         default_extra_build_tools_args = rctx.attr.default_build_dependencies,
-        create_transitive_aliases = rctx.attr.create_transitive_aliases,
+        include_transitive = getattr(lock_model, "include_transitive", False),
+        transitive_testonly = getattr(lock_model, "transitive_testonly", False),
     )
 
     resolved_lock_dict = {
@@ -72,6 +73,7 @@ def _generate_lock_file(rctx):
         "variants": resolved_lock.variants,
         "resolution_marker_exprs": resolved_lock.resolution_marker_exprs,
         "legacy_create_root_aliases": raw_lock_data.get("legacy_create_root_aliases", False),
+        "testonly_pins": resolved_lock.testonly_pins,
     }
 
     rctx.file("lock.json", json.encode(resolved_lock_dict))
diff --git a/pycross/private/thin_package_repo.bzl b/pycross/private/thin_package_repo.bzl
index 05ba7924..14bd88a6 100644
--- a/pycross/private/thin_package_repo.bzl
+++ b/pycross/private/thin_package_repo.bzl
@@ -117,7 +117,7 @@ def _proxy_actual(actual_lines, target_dict, prefix, suffix, workspace_repo, ali
         return '"{}:{}",'.format(actual_pkg_ref, alias_name)
     return "{},".format(actual)
 
-def _pin_build(target_name, pin_target_dict, package, workspace_repo, workspace_lock_target_dict = None, has_aggregated_variant = False, extras_dict = None, default_variants = {}, target_platform = None, transition_bzl = None, maybe_available_key = None):
+def _pin_build(target_name, pin_target_dict, package, workspace_repo, workspace_lock_target_dict = None, has_aggregated_variant = False, extras_dict = None, default_variants = {}, target_platform = None, transition_bzl = None, maybe_available_key = None, testonly = False):
     """Generates the BUILD file for a pin directory, pointing to the workspace."""
     lock_target_dict = workspace_lock_target_dict if workspace_lock_target_dict else pin_target_dict
     lock_ref = "@{}//_lock:".format(workspace_repo)
@@ -167,6 +167,8 @@ def _pin_build(target_name, pin_target_dict, package, workspace_repo, workspace_
             '    name = "{}",'.format(_safe_name(target_name, "pkg")),
             "    actual = {}".format(actual_pkg),
         ])
+        if testonly:
+            lines.append("    testonly = True,")
         if emit_platform:
             lines.append('    platform = "{}",'.format(target_platform))
         lines.extend([
@@ -218,6 +220,8 @@ def _pin_build(target_name, pin_target_dict, package, workspace_repo, workspace_
                     '    name = "[]",',
                     "    actual = {}".format(actual_all),
                 ])
+                if testonly:
+                    lines.append("    testonly = True,")
                 if emit_platform:
                     lines.append('    platform = "{}",'.format(target_platform))
                 lines.extend([
@@ -248,6 +252,8 @@ def _pin_build(target_name, pin_target_dict, package, workspace_repo, workspace_
             '    name = "[{}]",'.format(extra_name),
             "    actual = {}".format(actual_extra),
         ])
+        if testonly:
+            lines.append("    testonly = True,")
         if emit_platform:
             lines.append('    platform = "{}",'.format(target_platform))
         lines.extend([
@@ -286,6 +292,7 @@ def _thin_package_repo_impl(rctx):
     lock = json.decode(rctx.read(lock_json_path))
     packages = lock["packages"]
     pins = lock["pins"]
+    testonly_pins_set = {p: True for p in lock.get("testonly_pins", [])}
 
     # Normalize pin values: bare strings (unconditional) become {"": value}
     for pin_name in pins.keys():
@@ -618,7 +625,20 @@ pycross_transitioning_file_proxy = rule(
                     maybe_available_key = pkg_key
                     break
 
-        result = _pin_build(us_name, base_target_dict, package, workspace_repo, workspace_lock_target_dict, has_aggregated_variant, extras_dict, default_variants = default_variants, target_platform = target_platform, transition_bzl = "//:_transition.bzl" if has_flags else None, maybe_available_key = maybe_available_key)
+        result = _pin_build(
+            us_name,
+            base_target_dict,
+            package,
+            workspace_repo,
+            workspace_lock_target_dict,
+            has_aggregated_variant,
+            extras_dict,
+            default_variants = default_variants,
+            target_platform = target_platform,
+            transition_bzl = "//:_transition.bzl" if has_flags else None,
+            maybe_available_key = maybe_available_key,
+            testonly = (base_pin_name in testonly_pins_set),
+        )
         rctx.file(
             "{}/BUILD.bazel".format(us_name),
             result.build,
diff --git a/pycross/private/translator_common.bzl b/pycross/private/translator_common.bzl
index 5ca4ffa0..320c0d93 100644
--- a/pycross/private/translator_common.bzl
+++ b/pycross/private/translator_common.bzl
@@ -188,7 +188,7 @@ def _version_key(version_str):
     """Parse a version string into a comparable key tuple."""
     return pypackaging.version.parse(version_str).key
 
-def resolve_lock_graph(packages, pinned_package_specs, requires_python, strict_dependencies = True, variants = None, resolution_marker_exprs = None):
+def resolve_lock_graph(packages, pinned_package_specs, requires_python, strict_dependencies = True, variants = None, resolution_marker_exprs = None, testonly_pins = None):
     """Resolves a dependency graph of packages.
 
     Ports translator_utils.py resolve_lock_graph() to Starlark.
@@ -213,6 +213,7 @@ def resolve_lock_graph(packages, pinned_package_specs, requires_python, strict_d
         variants: List of variant set dicts (optional).
         resolution_marker_exprs: Dict mapping constraint names to PEP 508
             marker expressions (optional). Used for resolution-marker forks.
+        testonly_pins: List of pin names that are exclusively reachable from testonly groups.
 
     Returns:
         A dict in raw_lock.json format.
@@ -221,6 +222,8 @@ def resolve_lock_graph(packages, pinned_package_specs, requires_python, strict_d
         variants = []
     if resolution_marker_exprs == None:
         resolution_marker_exprs = {}
+    if testonly_pins == None:
+        testonly_pins = []
 
     # Deduplicate: merge packages with same key
     distinct_packages = {}
@@ -402,6 +405,7 @@ def resolve_lock_graph(packages, pinned_package_specs, requires_python, strict_d
         "packages": lock_packages,
         "pins": simplified_pins,
         "python_versions": requires_python,
+        "testonly_pins": testonly_pins,
     }
 
     if variants:
diff --git a/pycross/private/uv_lock_model.bzl b/pycross/private/uv_lock_model.bzl
index dfbe7384..a734a1c3 100644
--- a/pycross/private/uv_lock_model.bzl
+++ b/pycross/private/uv_lock_model.bzl
@@ -203,6 +203,9 @@ def translate_uv(project_dict, lock_dict, lock_model):
     # Identify projects
     projects_list = getattr(lock_model, "projects", [])
     dependency_groups = getattr(lock_model, "dependency_groups", ["default"])
+    testonly_groups = getattr(lock_model, "testonly_groups", [])
+    non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
+    wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
 
     workspace_members = {}
     for pkg in packages_list:
@@ -267,7 +270,7 @@ def translate_uv(project_dict, lock_dict, lock_model):
             resolution_marker_exprs[cname] = combined
 
     # Collect requirements
-    requirements = []  # list of (req_name, specifier, constraint)
+    requirements = []  # list of (req_name, specifier, constraint, is_testonly)
 
     include_all = "*" in dependency_groups
     include_default = "default" in dependency_groups or include_all
@@ -296,9 +299,9 @@ def translate_uv(project_dict, lock_dict, lock_model):
                 if dep_extras:
                     for extra in dep_extras:
                         pin_name = "{}[{}]".format(dep_name, canonicalize_name(extra))
-                        requirements.append((pin_name, specifier, fork_constraint))
+                        requirements.append((pin_name, specifier, fork_constraint, False))
                 else:
-                    requirements.append((dep_name, specifier, fork_constraint))
+                    requirements.append((dep_name, specifier, fork_constraint, False))
 
         # Parse groups
         effective_groups = ["optional:*", "group:*"] if include_all else dependency_groups
@@ -306,6 +309,14 @@ def translate_uv(project_dict, lock_dict, lock_model):
             if group == "default" or group == "*":
                 continue
 
+            # Last-wins testonly: specific overrides beat wildcard default
+            if group in testonly_groups:
+                is_testonly = True
+            elif group in non_testonly_groups:
+                is_testonly = False
+            else:
+                is_testonly = wildcard_testonly
+
             kind, _, name = group.partition(":")
             if kind == "optional":
                 groups_dict = optional_dependencies
@@ -356,19 +367,28 @@ def translate_uv(project_dict, lock_dict, lock_model):
                     if dep_extras:
                         for extra in dep_extras:
                             pin_name = "{}[{}]".format(dep_name, canonicalize_name(extra))
-                            requirements.append((pin_name, specifier, effective_constraint))
+                            requirements.append((pin_name, specifier, effective_constraint, is_testonly))
                     else:
-                        requirements.append((dep_name, specifier, effective_constraint))
+                        requirements.append((dep_name, specifier, effective_constraint, is_testonly))
 
     # End collect requirements
 
     # Build pinned specs
     pinned_package_specs = {}
-    for pin_name, specifier, constraint in requirements:
+    testonly_reqs = {}
+    non_testonly_reqs = {}
+    for pin_name, specifier, constraint, is_testonly in requirements:
         if pin_name not in pinned_package_specs:
             pinned_package_specs[pin_name] = {}
         pinned_package_specs[pin_name][constraint] = specifier
 
+        if is_testonly:
+            testonly_reqs[pin_name] = True
+        else:
+            non_testonly_reqs[pin_name] = True
+
+    testonly_pin_names = [name for name in testonly_reqs if name not in non_testonly_reqs]
+
     # Process all packages from lock
     packages = []
     for lock_pkg in packages_list:
@@ -497,6 +517,7 @@ def translate_uv(project_dict, lock_dict, lock_model):
         strict_dependencies = True,
         variants = variant_sets,
         resolution_marker_exprs = resolution_marker_exprs,
+        testonly_pins = testonly_pin_names,
     )
 
 def repo_create_uv_model(rctx, extra_project_files, lock_file, lock_model, output):
diff --git a/tests/e2e/build_cmake/MODULE.bazel b/tests/e2e/build_cmake/MODULE.bazel
index ecb62936..4da44a8b 100644
--- a/tests/e2e/build_cmake/MODULE.bazel
+++ b/tests/e2e/build_cmake/MODULE.bazel
@@ -38,7 +38,10 @@ uv.workspace(
     lock_file = "//:uv.lock",
 )
 uv.repo(
-    create_transitive_aliases = True,
+    dependency_groups = [
+        "default",
+        "transitive",
+    ],
     workspace = "uv",
 )
 uv.package(
diff --git a/tests/e2e/build_maturin/MODULE.bazel b/tests/e2e/build_maturin/MODULE.bazel
index f90689f4..03005a62 100644
--- a/tests/e2e/build_maturin/MODULE.bazel
+++ b/tests/e2e/build_maturin/MODULE.bazel
@@ -53,7 +53,10 @@ uv.workspace(
     lock_file = "//:uv.lock",
 )
 uv.repo(
-    create_transitive_aliases = True,
+    dependency_groups = [
+        "default",
+        "transitive",
+    ],
     workspace = "uv",
 )
 uv.package(
diff --git a/tests/e2e/build_meson/MODULE.bazel b/tests/e2e/build_meson/MODULE.bazel
index 6af2db3d..a190426b 100644
--- a/tests/e2e/build_meson/MODULE.bazel
+++ b/tests/e2e/build_meson/MODULE.bazel
@@ -44,7 +44,10 @@ uv.workspace(
     lock_file = "//:uv.lock",
 )
 uv.repo(
-    create_transitive_aliases = True,
+    dependency_groups = [
+        "default",
+        "transitive",
+    ],
     workspace = "uv",
 )
 uv.package(
diff --git a/tests/e2e/build_pure_python/MODULE.bazel b/tests/e2e/build_pure_python/MODULE.bazel
index 1d3f819b..ec8f2471 100644
--- a/tests/e2e/build_pure_python/MODULE.bazel
+++ b/tests/e2e/build_pure_python/MODULE.bazel
@@ -42,7 +42,10 @@ uv.workspace(
     lock_file = "//:uv.lock",
 )
 uv.repo(
-    create_transitive_aliases = True,
+    dependency_groups = [
+        "default",
+        "transitive",
+    ],
     workspace = "uv",
 )
 use_repo(uv, "uv")
diff --git a/tests/e2e/build_setuptools/MODULE.bazel b/tests/e2e/build_setuptools/MODULE.bazel
index a643f730..1e1c6a7a 100644
--- a/tests/e2e/build_setuptools/MODULE.bazel
+++ b/tests/e2e/build_setuptools/MODULE.bazel
@@ -45,7 +45,10 @@ uv.workspace(
     lock_file = "//:uv.lock",
 )
 uv.repo(
-    create_transitive_aliases = True,
+    dependency_groups = [
+        "default",
+        "transitive",
+    ],
     workspace = "uv",
 )
 uv.package(
diff --git a/tests/e2e/bzlmod_flags/MODULE.bazel b/tests/e2e/bzlmod_flags/MODULE.bazel
index 9f5f9ee4..70348f74 100644
--- a/tests/e2e/bzlmod_flags/MODULE.bazel
+++ b/tests/e2e/bzlmod_flags/MODULE.bazel
@@ -35,7 +35,10 @@ uv.workspace(
     lock_file = "//:uv.lock",
 )
 uv.repo(
-    create_transitive_aliases = True,
+    dependency_groups = [
+        "default",
+        "transitive",
+    ],
     workspace = "uv",
 )
 uv.package(
diff --git a/tests/e2e/patches_and_hooks/MODULE.bazel b/tests/e2e/patches_and_hooks/MODULE.bazel
index f4e05c27..49ac2071 100644
--- a/tests/e2e/patches_and_hooks/MODULE.bazel
+++ b/tests/e2e/patches_and_hooks/MODULE.bazel
@@ -36,7 +36,10 @@ uv.workspace(
     lock_file = "//:uv.lock",
 )
 uv.repo(
-    create_transitive_aliases = True,
+    dependency_groups = [
+        "default",
+        "transitive",
+    ],
     workspace = "uv",
 )
 uv.package(
diff --git a/tests/unit/BUILD.bazel b/tests/unit/BUILD.bazel
index 4444ccfc..3fdc5d6f 100644
--- a/tests/unit/BUILD.bazel
+++ b/tests/unit/BUILD.bazel
@@ -3,6 +3,7 @@ load(":test_common_attrs.bzl", "common_attrs_test_suite")
 load(":test_cycle_member_marker_deps.bzl", "cycle_member_marker_deps_test_suite")
 load(":test_lock_resolver.bzl", "lock_resolver_test_suite")
 load(":test_override_helpers.bzl", "override_helpers_test_suite")
+load(":test_parse_dependency_groups.bzl", "parse_dependency_groups_test_suite")
 load(":test_pdm_translator.bzl", "pdm_translator_test_suite")
 load(":test_pep508.bzl", "pep508_test_suite")
 load(":test_poetry_translator.bzl", "poetry_translator_test_suite")
@@ -134,3 +135,5 @@ poetry_translator_test_suite(name = "test_poetry_translator")
 pylock_translator_test_suite(name = "test_pylock_translator")
 
 uv_translator_test_suite(name = "test_uv_translator")
+
+parse_dependency_groups_test_suite(name = "test_parse_dependency_groups")
diff --git a/tests/unit/pdm/always_build/expected.json b/tests/unit/pdm/always_build/expected.json
index d80d55a8..3a754db6 100644
--- a/tests/unit/pdm/always_build/expected.json
+++ b/tests/unit/pdm/always_build/expected.json
@@ -633,5 +633,6 @@
     "setuptools": "setuptools@75.6.0",
     "wheel": "wheel@0.45.1"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/pdm/local_wheel/expected.json b/tests/unit/pdm/local_wheel/expected.json
index 7e34e59c..159f5785 100644
--- a/tests/unit/pdm/local_wheel/expected.json
+++ b/tests/unit/pdm/local_wheel/expected.json
@@ -21,5 +21,6 @@
   "pins": {
     "cowsay": "cowsay@6.1"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/pdm/requirements/expected.json b/tests/unit/pdm/requirements/expected.json
index ed31c3c0..50945364 100644
--- a/tests/unit/pdm/requirements/expected.json
+++ b/tests/unit/pdm/requirements/expected.json
@@ -1144,5 +1144,6 @@
     "ipython": "ipython@8.18.1",
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/poetry/always_build/expected.json b/tests/unit/poetry/always_build/expected.json
index 645f8ef6..8b0d3e5f 100644
--- a/tests/unit/poetry/always_build/expected.json
+++ b/tests/unit/poetry/always_build/expected.json
@@ -618,5 +618,6 @@
     "setuptools": "setuptools@75.6.0",
     "wheel": "wheel@0.45.1"
   },
-  "python_versions": ">=3.9,<3.13"
+  "python_versions": ">=3.9,<3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/poetry/local_wheel/expected.json b/tests/unit/poetry/local_wheel/expected.json
index bc22010f..b9ed04e4 100644
--- a/tests/unit/poetry/local_wheel/expected.json
+++ b/tests/unit/poetry/local_wheel/expected.json
@@ -18,5 +18,6 @@
   "pins": {
     "cowsay": "cowsay@6.1"
   },
-  "python_versions": ">=3.9,<3.13"
+  "python_versions": ">=3.9,<3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/poetry/requirements/expected.json b/tests/unit/poetry/requirements/expected.json
index 6e2c3aa6..615f8862 100644
--- a/tests/unit/poetry/requirements/expected.json
+++ b/tests/unit/poetry/requirements/expected.json
@@ -1033,5 +1033,6 @@
     "ipython": "ipython@8.18.1",
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9,<3.13"
+  "python_versions": ">=3.9,<3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/test_lock_resolver.bzl b/tests/unit/test_lock_resolver.bzl
index 1f9efea4..58facdf4 100644
--- a/tests/unit/test_lock_resolver.bzl
+++ b/tests/unit/test_lock_resolver.bzl
@@ -103,7 +103,7 @@ def _test_create_transitive_aliases_with_extras_impl(env, target):
         },
     }
 
-    res = resolve(lock_model_data, create_transitive_aliases = True)
+    res = resolve(lock_model_data, include_transitive = True)
 
     # Should have alias for urllib3 because it is resolved (transitively) and has only one version.
     env.expect.that_collection(res.pins.keys()).contains_exactly(["selenium", "urllib3"])
@@ -1885,6 +1885,161 @@ def _test_wheel_library_tags(name):
     util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
     analysis_test(name = name, target = name + "_subject", impl = _test_wheel_library_tags_impl)
 
+# --- testonly tests ---
+
+# buildifier: disable=unused-variable
+def _test_testonly_passthrough_without_transitive_impl(env, target):
+    """When transitive_testonly is False, testonly_pins from lock_model_data are passed through as-is."""
+    lock_model_data = {
+        "packages": {
+            "foo@1.0": _make_pkg("foo", "1.0", [_make_file("foo-1.0.tar.gz")]),
+            "pytest@7.0": _make_pkg("pytest", "7.0", [_make_file("pytest-7.0.tar.gz")]),
+        },
+        "pins": {
+            "foo": "foo@1.0",
+            "pytest": "pytest@7.0",
+        },
+        "testonly_pins": ["pytest"],
+    }
+
+    res = resolve(lock_model_data, transitive_testonly = False)
+    env.expect.that_collection(res.testonly_pins).contains_exactly(["pytest"])
+
+def _test_testonly_passthrough_without_transitive(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_testonly_passthrough_without_transitive_impl)
+
+# buildifier: disable=unused-variable
+def _test_testonly_exclusive_transitive_impl(env, target):
+    """Packages exclusively reachable from testonly pins are marked testonly.
+
+    Graph:
+        foo (normal) -> shared-lib
+        pytest (testonly) -> test-utils -> test-helper
+    Expected: pytest, test-utils, test-helper are testonly; foo, shared-lib are not.
+    """
+    lock_model_data = {
+        "packages": {
+            "foo@1.0": _make_pkg("foo", "1.0", [_make_file("foo-1.0.tar.gz")], deps = [_make_dep("shared-lib", "1.0")]),
+            "shared-lib@1.0": _make_pkg("shared-lib", "1.0", [_make_file("shared_lib-1.0.tar.gz")]),
+            "pytest@7.0": _make_pkg("pytest", "7.0", [_make_file("pytest-7.0.tar.gz")], deps = [_make_dep("test-utils", "1.0")]),
+            "test-utils@1.0": _make_pkg("test-utils", "1.0", [_make_file("test_utils-1.0.tar.gz")], deps = [_make_dep("test-helper", "1.0")]),
+            "test-helper@1.0": _make_pkg("test-helper", "1.0", [_make_file("test_helper-1.0.tar.gz")]),
+        },
+        "pins": {
+            "foo": "foo@1.0",
+            "pytest": "pytest@7.0",
+        },
+        "testonly_pins": ["pytest"],
+    }
+
+    res = resolve(lock_model_data, transitive_testonly = True)
+
+    # pytest and its exclusive transitive deps should be testonly
+    env.expect.that_collection(res.testonly_pins).contains("pytest")
+    env.expect.that_collection(res.testonly_pins).contains("test-utils")
+    env.expect.that_collection(res.testonly_pins).contains("test-helper")
+
+    # foo and shared-lib should NOT be testonly
+    env.expect.that_collection(res.testonly_pins).contains_none_of(["foo", "shared-lib"])
+
+def _test_testonly_exclusive_transitive(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_testonly_exclusive_transitive_impl)
+
+# buildifier: disable=unused-variable
+def _test_testonly_shared_dep_not_testonly_impl(env, target):
+    """A package reachable from both testonly and non-testonly roots is NOT testonly.
+
+    Graph:
+        foo (normal) -> common
+        pytest (testonly) -> common
+    Expected: common is NOT testonly because it's reachable from foo.
+    """
+    lock_model_data = {
+        "packages": {
+            "foo@1.0": _make_pkg("foo", "1.0", [_make_file("foo-1.0.tar.gz")], deps = [_make_dep("common", "1.0")]),
+            "common@1.0": _make_pkg("common", "1.0", [_make_file("common-1.0.tar.gz")]),
+            "pytest@7.0": _make_pkg("pytest", "7.0", [_make_file("pytest-7.0.tar.gz")], deps = [_make_dep("common", "1.0")]),
+        },
+        "pins": {
+            "foo": "foo@1.0",
+            "pytest": "pytest@7.0",
+        },
+        "testonly_pins": ["pytest"],
+    }
+
+    res = resolve(lock_model_data, transitive_testonly = True)
+
+    # pytest is testonly (it's a direct testonly pin)
+    env.expect.that_collection(res.testonly_pins).contains("pytest")
+
+    # common is reachable from foo (non-testonly), so it should NOT be testonly
+    env.expect.that_collection(res.testonly_pins).contains_none_of(["foo", "common"])
+
+def _test_testonly_shared_dep_not_testonly(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_testonly_shared_dep_not_testonly_impl)
+
+# buildifier: disable=unused-variable
+def _test_testonly_no_testonly_pins_impl(env, target):
+    """When testonly_pins is empty, transitive_testonly produces no testonly pins."""
+    lock_model_data = {
+        "packages": {
+            "foo@1.0": _make_pkg("foo", "1.0", [_make_file("foo-1.0.tar.gz")], deps = [_make_dep("bar", "1.0")]),
+            "bar@1.0": _make_pkg("bar", "1.0", [_make_file("bar-1.0.tar.gz")]),
+        },
+        "pins": {
+            "foo": "foo@1.0",
+            "bar": "bar@1.0",
+        },
+        "testonly_pins": [],
+    }
+
+    res = resolve(lock_model_data, transitive_testonly = True)
+    env.expect.that_collection(res.testonly_pins).has_size(0)
+
+def _test_testonly_no_testonly_pins(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_testonly_no_testonly_pins_impl)
+
+# buildifier: disable=unused-variable
+def _test_testonly_diamond_with_testonly_branch_impl(env, target):
+    """Diamond dependency where one branch is testonly.
+
+    Graph:
+        foo (normal) -> mid-a -> leaf
+        bar (testonly) -> mid-b -> leaf
+    Expected: bar, mid-b are testonly; leaf is NOT (reachable from foo via mid-a).
+    """
+    lock_model_data = {
+        "packages": {
+            "foo@1.0": _make_pkg("foo", "1.0", [_make_file("foo-1.0.tar.gz")], deps = [_make_dep("mid-a", "1.0")]),
+            "mid-a@1.0": _make_pkg("mid-a", "1.0", [_make_file("mid_a-1.0.tar.gz")], deps = [_make_dep("leaf", "1.0")]),
+            "bar@1.0": _make_pkg("bar", "1.0", [_make_file("bar-1.0.tar.gz")], deps = [_make_dep("mid-b", "1.0")]),
+            "mid-b@1.0": _make_pkg("mid-b", "1.0", [_make_file("mid_b-1.0.tar.gz")], deps = [_make_dep("leaf", "1.0")]),
+            "leaf@1.0": _make_pkg("leaf", "1.0", [_make_file("leaf-1.0.tar.gz")]),
+        },
+        "pins": {
+            "foo": "foo@1.0",
+            "bar": "bar@1.0",
+        },
+        "testonly_pins": ["bar"],
+    }
+
+    res = resolve(lock_model_data, transitive_testonly = True)
+
+    # bar and mid-b are exclusively testonly
+    env.expect.that_collection(res.testonly_pins).contains("bar")
+    env.expect.that_collection(res.testonly_pins).contains("mid-b")
+
+    # leaf is reachable from foo -> mid-a -> leaf, so NOT testonly
+    env.expect.that_collection(res.testonly_pins).contains_none_of(["foo", "mid-a", "leaf"])
+
+def _test_testonly_diamond_with_testonly_branch(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_testonly_diamond_with_testonly_branch_impl)
+
 def lock_resolver_test_suite(name):
     test_suite(
         name = name,
@@ -1949,5 +2104,10 @@ def lock_resolver_test_suite(name):
             _test_wildcard_install_exclude_globs_end_to_end,
             _test_wildcard_replace_semantics_exclude_globs_end_to_end,
             _test_wheel_library_tags,
+            _test_testonly_passthrough_without_transitive,
+            _test_testonly_exclusive_transitive,
+            _test_testonly_shared_dep_not_testonly,
+            _test_testonly_no_testonly_pins,
+            _test_testonly_diamond_with_testonly_branch,
         ],
     )
diff --git a/tests/unit/test_parse_dependency_groups.bzl b/tests/unit/test_parse_dependency_groups.bzl
new file mode 100644
index 00000000..12af5108
--- /dev/null
+++ b/tests/unit/test_parse_dependency_groups.bzl
@@ -0,0 +1,156 @@
+"""Tests for parse_dependency_group_entries in lock_common.bzl."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
+load("@rules_testing//lib:util.bzl", "util")
+
+# buildifier: disable=bzl-visibility
+load("//pycross/private:lock_common.bzl", "parse_dependency_group_entries")
+
+# --- test: basic testonly group ---
+
+# buildifier: disable=unused-variable
+def _test_parse_basic_testonly_impl(env, target):
+    """['default', 'group:dev;testonly'] -> group:dev is testonly."""
+    result = parse_dependency_group_entries(["default", "group:dev;testonly"])
+
+    env.expect.that_collection(result.dependency_groups).contains_exactly(["default", "group:dev"])
+    env.expect.that_collection(result.testonly_groups).contains_exactly(["group:dev"])
+    env.expect.that_collection(result.non_testonly_groups).contains_exactly(["default"])
+    env.expect.that_bool(result.wildcard_testonly).equals(False)
+
+def _test_parse_basic_testonly(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_basic_testonly_impl)
+
+# --- test: wildcard then specific testonly ---
+
+# buildifier: disable=unused-variable
+def _test_parse_wildcard_then_specific_testonly_impl(env, target):
+    """['*', 'group:dev;testonly'] -> wildcard not testonly, group:dev overrides to testonly."""
+    result = parse_dependency_group_entries(["*", "group:dev;testonly"])
+
+    env.expect.that_bool(result.wildcard_testonly).equals(False)
+    env.expect.that_collection(result.testonly_groups).contains_exactly(["group:dev"])
+    env.expect.that_collection(result.non_testonly_groups).has_size(0)
+
+def _test_parse_wildcard_then_specific_testonly(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_wildcard_then_specific_testonly_impl)
+
+# --- test: specific testonly then wildcard resets ---
+
+# buildifier: disable=unused-variable
+def _test_parse_specific_testonly_then_wildcard_impl(env, target):
+    """['group:dev;testonly', '*'] -> * comes last, resets group:dev's testonly."""
+    result = parse_dependency_group_entries(["group:dev;testonly", "*"])
+
+    env.expect.that_bool(result.wildcard_testonly).equals(False)
+    env.expect.that_collection(result.testonly_groups).has_size(0)
+    env.expect.that_collection(result.non_testonly_groups).has_size(0)
+
+def _test_parse_specific_testonly_then_wildcard(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_specific_testonly_then_wildcard_impl)
+
+# --- test: wildcard testonly with specific override ---
+
+# buildifier: disable=unused-variable
+def _test_parse_wildcard_testonly_with_override_impl(env, target):
+    """['*;testonly', 'group:dev'] -> everything testonly except group:dev."""
+    result = parse_dependency_group_entries(["*;testonly", "group:dev"])
+
+    env.expect.that_bool(result.wildcard_testonly).equals(True)
+    env.expect.that_collection(result.testonly_groups).has_size(0)
+    env.expect.that_collection(result.non_testonly_groups).contains_exactly(["group:dev"])
+
+def _test_parse_wildcard_testonly_with_override(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_wildcard_testonly_with_override_impl)
+
+# --- test: wildcard testonly alone ---
+
+# buildifier: disable=unused-variable
+def _test_parse_wildcard_testonly_alone_impl(env, target):
+    """['*;testonly'] -> everything testonly, no overrides."""
+    result = parse_dependency_group_entries(["*;testonly"])
+
+    env.expect.that_bool(result.wildcard_testonly).equals(True)
+    env.expect.that_collection(result.testonly_groups).has_size(0)
+    env.expect.that_collection(result.non_testonly_groups).has_size(0)
+
+def _test_parse_wildcard_testonly_alone(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_wildcard_testonly_alone_impl)
+
+# --- test: no testonly at all ---
+
+# buildifier: disable=unused-variable
+def _test_parse_no_testonly_impl(env, target):
+    """['default', 'group:dev'] -> nothing testonly."""
+    result = parse_dependency_group_entries(["default", "group:dev"])
+
+    env.expect.that_bool(result.wildcard_testonly).equals(False)
+    env.expect.that_collection(result.testonly_groups).has_size(0)
+    env.expect.that_collection(result.non_testonly_groups).contains_exactly(["default", "group:dev"])
+
+def _test_parse_no_testonly(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_no_testonly_impl)
+
+# --- test: transitive with testonly ---
+
+# buildifier: disable=unused-variable
+def _test_parse_transitive_testonly_impl(env, target):
+    """['default', 'group:dev;testonly', 'transitive;testonly'] -> transitive_testonly is True."""
+    result = parse_dependency_group_entries(["default", "group:dev;testonly", "transitive;testonly"])
+
+    env.expect.that_bool(result.include_transitive).equals(True)
+    env.expect.that_bool(result.transitive_testonly).equals(True)
+    env.expect.that_collection(result.testonly_groups).contains_exactly(["group:dev"])
+
+    # "transitive" should NOT appear in dependency_groups
+    env.expect.that_collection(result.dependency_groups).contains_exactly(["default", "group:dev"])
+
+def _test_parse_transitive_testonly(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_transitive_testonly_impl)
+
+# --- test: double wildcard, last wins ---
+
+# buildifier: disable=unused-variable
+def _test_parse_double_wildcard_impl(env, target):
+    """['*;testonly', 'group:dev;testonly', '*', 'group:test;testonly'] -> second * resets.
+
+    First *;testonly sets wildcard_testonly=True.
+    group:dev;testonly is a specific override.
+    Second * (not testonly) resets wildcard_testonly=False and clears group:dev override.
+    group:test;testonly is a new specific override after the second *.
+    """
+    result = parse_dependency_group_entries(["*;testonly", "group:dev;testonly", "*", "group:test;testonly"])
+
+    env.expect.that_bool(result.wildcard_testonly).equals(False)
+
+    # group:dev;testonly was before the second *, so it's reset
+    env.expect.that_collection(result.testonly_groups).contains_exactly(["group:test"])
+    env.expect.that_collection(result.non_testonly_groups).has_size(0)
+
+def _test_parse_double_wildcard(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_parse_double_wildcard_impl)
+
+# --- Test suite ---
+
+def parse_dependency_groups_test_suite(name):
+    test_suite(
+        name = name,
+        tests = [
+            _test_parse_basic_testonly,
+            _test_parse_wildcard_then_specific_testonly,
+            _test_parse_specific_testonly_then_wildcard,
+            _test_parse_wildcard_testonly_with_override,
+            _test_parse_wildcard_testonly_alone,
+            _test_parse_no_testonly,
+            _test_parse_transitive_testonly,
+            _test_parse_double_wildcard,
+        ],
+    )
diff --git a/tests/unit/test_uv_translator.bzl b/tests/unit/test_uv_translator.bzl
index 79e915b2..392b5af6 100644
--- a/tests/unit/test_uv_translator.bzl
+++ b/tests/unit/test_uv_translator.bzl
@@ -8,10 +8,16 @@ load("//pycross/private:uv_lock_model.bzl", "translate_uv")
 
 def _lock_model(
         projects = ["*"],
-        dependency_groups = ["default"]):
+        dependency_groups = ["default"],
+        testonly_groups = [],
+        non_testonly_groups = [],
+        wildcard_testonly = False):
     return struct(
         projects = projects,
         dependency_groups = dependency_groups,
+        testonly_groups = testonly_groups,
+        non_testonly_groups = non_testonly_groups,
+        wildcard_testonly = wildcard_testonly,
     )
 
 def _project(name = "my-app", version = "0.1.0", deps = None, opt_deps = None, dep_groups = None, uv_conflicts = None, uv_default_groups = None):
@@ -567,6 +573,148 @@ def _test_uv_resolution_marker_fork_optional_group(name):
     util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
     analysis_test(name = name, target = name + "_subject", impl = _test_uv_resolution_marker_fork_optional_group_impl)
 
+# --- test_testonly_groups ---
+
+# buildifier: disable=unused-variable
+def _test_uv_testonly_groups_impl(env, target):
+    """Dev group marked as testonly produces testonly_pins in the raw lock data."""
+    project = _project(
+        deps = ["requests==2.31.0"],
+        dep_groups = {"test": ["pytest==7.0"]},
+    )
+    lock = _lock([
+        _vpkg("my-app", deps = [_dep("requests", "2.31.0")], dev_deps = {"test": [_dep("pytest")]}),
+        _pkg("requests", "2.31.0", wheels = [_whl("requests-2.31.0-py3-none-any.whl", "abc")]),
+        _pkg("pytest", "7.0", wheels = [_whl("pytest-7.0-py3-none-any.whl", "def")]),
+    ])
+    result = translate_uv(
+        project,
+        lock,
+        _lock_model(dependency_groups = ["default", "group:test"], testonly_groups = ["group:test"]),
+    )
+
+    # Both packages should be resolved
+    env.expect.that_collection(result["packages"].keys()).contains("requests@2.31.0")
+    env.expect.that_collection(result["packages"].keys()).contains("pytest@7.0")
+
+    # pytest should be in testonly_pins because its group is testonly
+    env.expect.that_collection(result["testonly_pins"]).contains("pytest")
+
+    # requests should NOT be in testonly_pins
+    env.expect.that_collection(result["testonly_pins"]).contains_none_of(["requests"])
+
+def _test_uv_testonly_groups(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_uv_testonly_groups_impl)
+
+# --- test_testonly_shared_dep_not_testonly ---
+
+# buildifier: disable=unused-variable
+def _test_uv_testonly_shared_dep_not_testonly_impl(env, target):
+    """A package required by both default and testonly groups is NOT testonly."""
+    project = _project(
+        deps = ["common==1.0"],
+        dep_groups = {"test": ["common==1.0", "pytest==7.0"]},
+    )
+    lock = _lock([
+        _vpkg("my-app", deps = [_dep("common", "1.0")], dev_deps = {"test": [_dep("common"), _dep("pytest")]}),
+        _pkg("common", "1.0", wheels = [_whl("common-1.0-py3-none-any.whl", "aaa")]),
+        _pkg("pytest", "7.0", wheels = [_whl("pytest-7.0-py3-none-any.whl", "bbb")]),
+    ])
+    result = translate_uv(
+        project,
+        lock,
+        _lock_model(dependency_groups = ["default", "group:test"], testonly_groups = ["group:test"]),
+    )
+
+    # pytest is exclusively testonly
+    env.expect.that_collection(result["testonly_pins"]).contains("pytest")
+
+    # common appears in both default and test groups, so NOT testonly
+    env.expect.that_collection(result["testonly_pins"]).contains_none_of(["common"])
+
+def _test_uv_testonly_shared_dep_not_testonly(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_uv_testonly_shared_dep_not_testonly_impl)
+
+# --- test_testonly_wildcard_with_override ---
+
+# buildifier: disable=unused-variable
+def _test_uv_testonly_wildcard_with_override_impl(env, target):
+    """['*;testonly', 'group:dev'] -> everything testonly except dev.
+
+    Simulates: dependency_groups = ['*;testonly', 'group:dev']
+    Parsed by process_repo as: wildcard_testonly=True, non_testonly_groups=['group:dev']
+    """
+    project = _project(
+        deps = ["requests==2.31.0"],
+        dep_groups = {"dev": ["flask==2.0"], "test": ["pytest==7.0"]},
+    )
+    lock = _lock([
+        _vpkg("my-app", deps = [_dep("requests", "2.31.0")], dev_deps = {"dev": [_dep("flask")], "test": [_dep("pytest")]}),
+        _pkg("requests", "2.31.0", wheels = [_whl("requests-2.31.0-py3-none-any.whl", "abc")]),
+        _pkg("flask", "2.0", wheels = [_whl("flask-2.0-py3-none-any.whl", "def")]),
+        _pkg("pytest", "7.0", wheels = [_whl("pytest-7.0-py3-none-any.whl", "ghi")]),
+    ])
+    result = translate_uv(
+        project,
+        lock,
+        _lock_model(
+            dependency_groups = ["default", "group:dev", "group:test"],
+            # *;testonly -> wildcard_testonly=True, group:dev overrides to non-testonly
+            wildcard_testonly = True,
+            non_testonly_groups = ["group:dev"],
+        ),
+    )
+
+    # pytest is testonly (from wildcard, no override)
+    env.expect.that_collection(result["testonly_pins"]).contains("pytest")
+
+    # flask is NOT testonly (group:dev explicitly overridden)
+    env.expect.that_collection(result["testonly_pins"]).contains_none_of(["flask"])
+
+def _test_uv_testonly_wildcard_with_override(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_uv_testonly_wildcard_with_override_impl)
+
+# --- test_testonly_wildcard_overrides_earlier ---
+
+# buildifier: disable=unused-variable
+def _test_uv_testonly_wildcard_overrides_earlier_impl(env, target):
+    """['group:dev;testonly', '*'] -> wildcard last, overrides dev to non-testonly.
+
+    Simulates: dependency_groups = ['group:dev;testonly', '*']
+    Parsed by process_repo as: wildcard_testonly=False, testonly_groups=[], non_testonly_groups=[]
+    (The * resets overrides, so group:dev;testonly is forgotten)
+    """
+    project = _project(
+        deps = ["requests==2.31.0"],
+        dep_groups = {"dev": ["pytest==7.0"]},
+    )
+    lock = _lock([
+        _vpkg("my-app", deps = [_dep("requests", "2.31.0")], dev_deps = {"dev": [_dep("pytest")]}),
+        _pkg("requests", "2.31.0", wheels = [_whl("requests-2.31.0-py3-none-any.whl", "abc")]),
+        _pkg("pytest", "7.0", wheels = [_whl("pytest-7.0-py3-none-any.whl", "def")]),
+    ])
+    result = translate_uv(
+        project,
+        lock,
+        _lock_model(
+            dependency_groups = ["default", "group:dev"],
+            # group:dev;testonly then * -> wildcard_testonly=False, no specific overrides
+            wildcard_testonly = False,
+            testonly_groups = [],
+            non_testonly_groups = [],
+        ),
+    )
+
+    # Nothing is testonly: * came last and reset everything
+    env.expect.that_collection(result["testonly_pins"]).has_size(0)
+
+def _test_uv_testonly_wildcard_overrides_earlier(name):
+    util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
+    analysis_test(name = name, target = name + "_subject", impl = _test_uv_testonly_wildcard_overrides_earlier_impl)
+
 # --- Test suite ---
 
 def uv_translator_test_suite(name):
@@ -595,5 +743,9 @@ def uv_translator_test_suite(name):
             _test_uv_resolution_marker_single_version_no_fork,
             _test_uv_resolution_marker_multi_marker_or,
             _test_uv_resolution_marker_fork_optional_group,
+            _test_uv_testonly_groups,
+            _test_uv_testonly_shared_dep_not_testonly,
+            _test_uv_testonly_wildcard_with_override,
+            _test_uv_testonly_wildcard_overrides_earlier,
         ],
     )
diff --git a/tests/unit/uv/lock_0_2_35/expected.json b/tests/unit/uv/lock_0_2_35/expected.json
index 42ae71d5..214d1bdb 100644
--- a/tests/unit/uv/lock_0_2_35/expected.json
+++ b/tests/unit/uv/lock_0_2_35/expected.json
@@ -792,5 +792,6 @@
   "pins": {
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/uv/lock_0_4_0_virtual/expected.json b/tests/unit/uv/lock_0_4_0_virtual/expected.json
index 42ae71d5..214d1bdb 100644
--- a/tests/unit/uv/lock_0_4_0_virtual/expected.json
+++ b/tests/unit/uv/lock_0_4_0_virtual/expected.json
@@ -792,5 +792,6 @@
   "pins": {
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/uv/lock_0_4_27_legacy_dev_dependencies/expected.json b/tests/unit/uv/lock_0_4_27_legacy_dev_dependencies/expected.json
index c3b3c967..ed12f87c 100644
--- a/tests/unit/uv/lock_0_4_27_legacy_dev_dependencies/expected.json
+++ b/tests/unit/uv/lock_0_4_27_legacy_dev_dependencies/expected.json
@@ -642,5 +642,6 @@
   "pins": {
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/uv/lock_0_4_27_pep735/expected.json b/tests/unit/uv/lock_0_4_27_pep735/expected.json
index c3b3c967..ed12f87c 100644
--- a/tests/unit/uv/lock_0_4_27_pep735/expected.json
+++ b/tests/unit/uv/lock_0_4_27_pep735/expected.json
@@ -642,5 +642,6 @@
   "pins": {
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/uv/lock_pre_0_2_35/expected.json b/tests/unit/uv/lock_pre_0_2_35/expected.json
index 42ae71d5..214d1bdb 100644
--- a/tests/unit/uv/lock_pre_0_2_35/expected.json
+++ b/tests/unit/uv/lock_pre_0_2_35/expected.json
@@ -792,5 +792,6 @@
   "pins": {
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }
diff --git a/tests/unit/uv/lock_workspace/expected.json b/tests/unit/uv/lock_workspace/expected.json
index c3b3c967..ed12f87c 100644
--- a/tests/unit/uv/lock_workspace/expected.json
+++ b/tests/unit/uv/lock_workspace/expected.json
@@ -642,5 +642,6 @@
   "pins": {
     "regex": "regex@2024.11.6"
   },
-  "python_versions": ">=3.9, <3.13"
+  "python_versions": ">=3.9, <3.13",
+  "testonly_pins": []
 }

From 161e47996cf2e92a42e4cda921077903addbbee9 Mon Sep 17 00:00:00 2001
From: Jeremy Volkman 
Date: Sun, 19 Jul 2026 00:18:42 +0000
Subject: [PATCH 2/5] feat: centralize testonly dependency group parsing logic
 in translators

- Extract compute_requested_dependency_groups to translator_common.bzl
- Accept a single flat list of prefixed available groups (e.g. 'optional:foo',
  'group:dev') instead of separate bucket lists, letting translators decide
  the prefix taxonomy
- Extract project_name once per translator instead of inline
- Use shared canonicalize_name in pylock instead of private copy
- Add create_transitive_aliases removal to CHANGELOG breaking changes
---
 CHANGELOG.md                          |   2 +
 README.md                             |   2 +
 pycross/private/lock_common.bzl       |  64 +++++---------
 pycross/private/pdm_lock_model.bzl    | 105 +++++++++++------------
 pycross/private/poetry_lock_model.bzl |  81 +++++++++---------
 pycross/private/pylock_lock_model.bzl | 119 ++++++++++++--------------
 pycross/private/translator_common.bzl |  79 +++++++++++++++++
 pycross/private/uv_lock_model.bzl     |  71 +++++++--------
 8 files changed, 281 insertions(+), 242 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 040c311a..39518a9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file.
   dependencies — they are general-purpose named groups. Update your `repo()`
   declarations: e.g. `"development:dev"` → `"group:dev"`,
   `"development:*"` → `"group:*"`.
+- **`create_transitive_aliases` removed.** Use `"transitive"` in
+  `dependency_groups` instead. E.g. `dependency_groups = ["default", "transitive"]`.
 
 ### Fixed
 
diff --git a/README.md b/README.md
index b863404f..43511ec3 100644
--- a/README.md
+++ b/README.md
@@ -215,6 +215,8 @@ uv.repo(
 
 If a transitive package has multiple versions in the lock file, `rules_pycross` will print a warning and alias to the highest version.
 
+> **Note:** `"transitive"` is a modifier, not a dependency group — it is _not_ included by the `*` wildcard. You must list it explicitly.
+
 ### Testonly Dependencies
 
 Append `;testonly` to any group specifier to mark its packages as `testonly` in the generated Bazel targets. This is useful for test frameworks and other packages that should not be depended upon by production code:
diff --git a/pycross/private/lock_common.bzl b/pycross/private/lock_common.bzl
index 52ba4dea..666af404 100644
--- a/pycross/private/lock_common.bzl
+++ b/pycross/private/lock_common.bzl
@@ -265,12 +265,7 @@ def register_workspace_repo(
         model_type,
         repo_name,
         projects,
-        dependency_groups,
-        testonly_groups,
-        non_testonly_groups,
-        wildcard_testonly,
-        include_transitive,
-        transitive_testonly,
+        raw_dependency_groups,
         legacy_create_root_aliases,
         transition_attrs,
         lock_module,
@@ -287,12 +282,8 @@ def register_workspace_repo(
         model_type: The lock model type.
         repo_name: The repo name for this member.
         projects: List of projects included in this repo.
-        dependency_groups: List of parsed dependency groups.
-        testonly_groups: List of groups explicitly marked testonly.
-        non_testonly_groups: List of groups explicitly marked non-testonly (overrides wildcard).
-        wildcard_testonly: Whether the wildcard (*) is marked testonly.
-        include_transitive: Whether to include transitive dependencies.
-        transitive_testonly: Whether transitive dependencies are testonly.
+        raw_dependency_groups: Raw dependency group strings from user config
+            (e.g. ["default", "group:dev;testonly", "transitive"]).
         legacy_create_root_aliases: Boolean to create root aliases.
         transition_attrs: Transition attributes dict.
         lock_module: The module owning this lock.
@@ -303,17 +294,19 @@ def register_workspace_repo(
     if lock_module.is_root:
         root_direct_deps.append(repo_name)
 
+    parsed = parse_dependency_group_entries(raw_dependency_groups)
+
     model = dict(
         model_type = model_type,
         extra_project_files = [str(f) for f in extra_project_files],
         lock_file = str(ws_tag.lock_file),
         projects = projects,
-        dependency_groups = dependency_groups,
-        testonly_groups = testonly_groups,
-        non_testonly_groups = non_testonly_groups,
-        wildcard_testonly = wildcard_testonly,
-        include_transitive = include_transitive,
-        transitive_testonly = transitive_testonly,
+        dependency_groups = parsed.dependency_groups,
+        testonly_groups = parsed.testonly_groups,
+        non_testonly_groups = parsed.non_testonly_groups,
+        wildcard_testonly = parsed.wildcard_testonly,
+        include_transitive = parsed.include_transitive,
+        transitive_testonly = parsed.transitive_testonly,
         legacy_create_root_aliases = legacy_create_root_aliases,
     )
 
@@ -405,24 +398,19 @@ def process_repo(
     tag = tag_info.tag
 
     raw_groups = tag.dependency_groups
-    parsed = parse_dependency_group_entries(raw_groups)
-    parsed_groups = parsed.dependency_groups
-    testonly_groups = parsed.testonly_groups
-    non_testonly_groups = parsed.non_testonly_groups
-    wildcard_testonly = parsed.wildcard_testonly
-    include_transitive = parsed.include_transitive
-    transitive_testonly = parsed.transitive_testonly
-
-    has_wildcard = "*" in parsed_groups
+
+    has_wildcard = False
     has_specific = False
-    for group in parsed_groups:
-        if group not in ("*", "default"):
+    for entry in raw_groups:
+        spec = entry.split(";")[0]
+        if spec == "*":
+            has_wildcard = True
+        elif spec not in ("default", "transitive"):
             has_specific = True
-            break
 
     if has_wildcard and has_specific:
         # buildifier: disable=print
-        print("WARNING: repo '{}' in workspace '{}' specifies both wildcard ('*') and specific dependency groups ({}). The specific groups are redundant.".format(tag.repo, ws_name, parsed_groups))
+        print("WARNING: repo '{}' in workspace '{}' specifies both wildcard ('*') and specific dependency groups ({}). The specific groups are redundant.".format(tag.repo, ws_name, raw_groups))
 
     # Get transition attrs
     transition_attrs = get_member_transition_attrs(None, tag)
@@ -437,12 +425,7 @@ def process_repo(
         model_type,
         tag.repo,
         tag.projects,
-        parsed_groups,
-        testonly_groups,
-        non_testonly_groups,
-        wildcard_testonly,
-        include_transitive,
-        transitive_testonly,
+        raw_groups,
         tag.legacy_create_root_aliases,
         transition_attrs,
         tag_info.module,
@@ -603,12 +586,7 @@ def process_workspaces(
             model_type = model_type,
             repo_name = build_repo_name,
             projects = ["*"],
-            dependency_groups = ["*"],
-            testonly_groups = [],
-            non_testonly_groups = [],
-            wildcard_testonly = False,
-            include_transitive = False,
-            transitive_testonly = False,
+            raw_dependency_groups = ["*", "transitive"],
             legacy_create_root_aliases = False,
             transition_attrs = dict(
                 flags = [],
diff --git a/pycross/private/pdm_lock_model.bzl b/pycross/private/pdm_lock_model.bzl
index f0bf3f35..44af88d0 100644
--- a/pycross/private/pdm_lock_model.bzl
+++ b/pycross/private/pdm_lock_model.bzl
@@ -9,6 +9,7 @@ load("@toml.bzl//toml:toml.bzl", "decode")
 load(
     ":translator_common.bzl",
     "canonicalize_name",
+    "compute_requested_dependency_groups",
     "parse_pep508_requirement",
     "resolution_marker_constraint_name",
     "resolve_lock_graph",
@@ -84,9 +85,12 @@ def translate_pdm(project_dict, lock_dict, lock_model):
     if len(v_parts) < 1 or v_parts[0] != "4":
         fail("PDM lock file version {} not in supported range ~=4.0".format(lock_version))
 
+    project_section = project_dict.get("project", {})
+    project_name = project_section.get("name")
+
     # Parse project dependencies
-    default_deps = project_dict.get("project", {}).get("dependencies", [])
-    optional_deps = project_dict.get("project", {}).get("optional-dependencies", {})
+    default_deps = project_section.get("dependencies", [])
+    optional_deps = project_section.get("optional-dependencies", {})
 
     # Development dependencies: dependency-groups + legacy tool.pdm.dev-dependencies
     dev_deps = dict(project_dict.get("dependency-groups", {}))
@@ -108,63 +112,56 @@ def translate_pdm(project_dict, lock_dict, lock_model):
     requirements = []
 
     dependency_groups = getattr(lock_model, "dependency_groups", ["default"])
-    include_all = "*" in dependency_groups
-    include_default = "default" in dependency_groups or include_all
+
     testonly_groups = getattr(lock_model, "testonly_groups", [])
     non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
     wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
 
-    if include_default:
-        for dep_str in default_deps:
-            requirements.append((parse_pep508_requirement(dep_str), False))
-
-    effective_groups = ["optional:*", "group:*"] if include_all else dependency_groups
-    for group in effective_groups:
-        if group == "default" or group == "*":
-            continue
-
-        kind, _, name = group.partition(":")
-        if kind == "optional":
-            groups_dict = optional_deps
-        elif kind == "group":
-            groups_dict = dev_deps
-        else:
-            fail("Invalid dependency group format '{}'. Must be 'optional:name' or 'group:name'.".format(group))
-
-        if name == "*":
-            target_names = list(groups_dict.keys())
-        else:
-            target_names = [name]
+    requested_groups_dict = compute_requested_dependency_groups(
+        dependency_groups = dependency_groups,
+        testonly_groups = testonly_groups,
+        non_testonly_groups = non_testonly_groups,
+        wildcard_testonly = wildcard_testonly,
+        available_groups = (
+            ["default"] +
+            ["optional:" + g for g in optional_deps.keys()] +
+            ["group:" + g for g in dev_deps.keys()]
+        ),
+        project_name = project_name,
+        fail_on_missing = True,
+    )
 
-        # Last-wins testonly: specific overrides beat wildcard default
-        if group in testonly_groups:
-            is_testonly = True
-        elif group in non_testonly_groups:
-            is_testonly = False
-        else:
-            is_testonly = wildcard_testonly
-
-        for target_name in target_names:
-            if target_name in groups_dict:
-                entries = groups_dict[target_name]
-                for dep_str in entries:
-                    if type(dep_str) == "string":
-                        # Strip editable markers
-                        stripped = dep_str.strip()
-                        if stripped.startswith("-e "):
-                            stripped = stripped[3:].strip()
-                        requirements.append((parse_pep508_requirement(stripped), is_testonly))
-                    elif type(dep_str) == "dict" and "include-group" in dep_str:
-                        inc_group = dep_str["include-group"]
-                        if inc_group in dev_deps:
-                            for inc_dep in dev_deps[inc_group]:
-                                if type(inc_dep) == "string":
-                                    stripped = inc_dep.strip()
-                                    if stripped.startswith("-e "):
-                                        stripped = stripped[3:].strip()
-                                    requirements.append((parse_pep508_requirement(stripped), is_testonly))
-            else:
-                fail("Non-existent {} dependency group: {}".format(kind, target_name))
+    if "default" in requested_groups_dict:
+        default_is_testonly = requested_groups_dict["default"]
+        for dep_str in default_deps:
+            requirements.append((parse_pep508_requirement(dep_str), default_is_testonly))
+
+
+
+    for kind, groups_dict in [("optional", optional_deps), ("group", dev_deps)]:
+        for target_name in groups_dict.keys():
+            key = "{}:{}".format(kind, target_name)
+            if key not in requested_groups_dict:
+                continue
+            is_testonly = requested_groups_dict[key]
+
+            entries = groups_dict[target_name]
+            for dep_str in entries:
+                if type(dep_str) == "string":
+                    # Strip editable markers
+                    stripped = dep_str.strip()
+                    if stripped.startswith("-e "):
+                        stripped = stripped[3:].strip()
+                    requirements.append((parse_pep508_requirement(stripped), is_testonly))
+                elif type(dep_str) == "dict" and "include-group" in dep_str:
+                    inc_group = dep_str["include-group"]
+                    if inc_group in dev_deps:
+                        for inc_dep in dev_deps[inc_group]:
+                            if type(inc_dep) == "string":
+                                stripped = inc_dep.strip()
+                                if stripped.startswith("-e "):
+                                    stripped = stripped[3:].strip()
+                                requirements.append((parse_pep508_requirement(stripped), is_testonly))
 
     # Build pinned specs from requirements
     pinned_package_specs = {}
diff --git a/pycross/private/poetry_lock_model.bzl b/pycross/private/poetry_lock_model.bzl
index 8f2a51b5..9e788598 100644
--- a/pycross/private/poetry_lock_model.bzl
+++ b/pycross/private/poetry_lock_model.bzl
@@ -15,6 +15,7 @@ load("@toml.bzl//toml:toml.bzl", "decode")
 load(
     ":translator_common.bzl",
     "canonicalize_name",
+    "compute_requested_dependency_groups",
     "parse_pep508_requirement",
     "resolution_marker_constraint_name",
     "resolve_lock_graph",
@@ -336,7 +337,9 @@ def translate_poetry(project_dict, lock_dict, lock_model):
     pinned_package_specs = {}
 
     # First, check for [project.dependencies] (PEP 508, preferred)
-    project_deps = project_dict.get("project", {}).get("dependencies", [])
+    project_section = project_dict.get("project", {})
+    project_name = project_section.get("name")
+    project_deps = project_section.get("dependencies", [])
     has_project_deps = len(project_deps) > 0
 
     # Then, check for [tool.poetry.dependencies] (Poetry format)
@@ -344,8 +347,7 @@ def translate_poetry(project_dict, lock_dict, lock_model):
     poetry_groups = project_dict.get("tool", {}).get("poetry", {}).get("group", {})
 
     dependency_groups = getattr(lock_model, "dependency_groups", ["default"])
-    include_all = "*" in dependency_groups
-    include_default = "default" in dependency_groups or include_all
+
 
     testonly_groups = getattr(lock_model, "testonly_groups", [])
     non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
@@ -362,14 +364,34 @@ def translate_poetry(project_dict, lock_dict, lock_model):
         else:
             non_testonly_reqs[pin_name] = True
 
-    if include_default:
+    project_optional_deps = project_dict.get("project", {}).get("optional-dependencies", {})
+    pep735_groups = project_dict.get("dependency-groups", {})
+
+    available_dev_groups = list({k: True for k in list(poetry_groups.keys()) + list(pep735_groups.keys())}.keys())
+
+    requested_groups_dict = compute_requested_dependency_groups(
+        dependency_groups = dependency_groups,
+        testonly_groups = testonly_groups,
+        non_testonly_groups = non_testonly_groups,
+        wildcard_testonly = wildcard_testonly,
+        available_groups = (
+            ["default"] +
+            ["optional:" + g for g in project_optional_deps.keys()] +
+            ["group:" + g for g in available_dev_groups]
+        ),
+        project_name = project_name,
+        fail_on_missing = False,
+    )
+
+    if "default" in requested_groups_dict:
+        default_is_testonly = requested_groups_dict["default"]
         if has_project_deps:
             # PEP 508 format from [project.dependencies]
             for dep_str in project_deps:
                 req = parse_pep508_requirement(dep_str)
                 if req.name == "python":
                     continue
-                track_pin(req.name, req.specifier, False)
+                track_pin(req.name, req.specifier, default_is_testonly)
         if poetry_deps:
             # Also merge [tool.poetry.dependencies] if present
             for pin, pin_info in poetry_deps.items():
@@ -384,37 +406,26 @@ def translate_poetry(project_dict, lock_dict, lock_model):
                     pin,
                     pin_info,
                     pinned_package_specs,
-                    track_pin = lambda p, spec: track_pin(p, spec, False),
+                    track_pin = lambda p, spec: track_pin(p, spec, default_is_testonly),
                     enrich_only = has_project_deps,
                 )
 
-    project_optional_deps = project_dict.get("project", {}).get("optional-dependencies", {})
-    pep735_groups = project_dict.get("dependency-groups", {})
 
-    effective_groups = ["optional:*", "group:*"] if include_all else dependency_groups
-    for group in effective_groups:
-        if group == "default" or group == "*":
-            continue
-
-        # Last-wins testonly: specific overrides beat wildcard default
-        if group in testonly_groups:
-            is_testonly = True
-        elif group in non_testonly_groups:
-            is_testonly = False
-        else:
-            is_testonly = wildcard_testonly
-
-        kind, _, name = group.partition(":")
-
-        if name == "*":
-            target_names = list(poetry_groups.keys()) + list(pep735_groups.keys()) + list(project_optional_deps.keys())
+    for group_name in project_optional_deps.keys():
+        key = "optional:{}".format(group_name)
+        if key in requested_groups_dict:
+            is_testonly = requested_groups_dict[key]
+            for dep_str in project_optional_deps[group_name]:
+                req = parse_pep508_requirement(dep_str)
+                if req.name == "python":
+                    continue
+                track_pin(req.name, req.specifier, is_testonly)
 
-            # Deduplicate
-            target_names = {k: True for k in target_names}.keys()
-        else:
-            target_names = [name]
+    for group_name in available_dev_groups:
+        key = "group:{}".format(group_name)
+        if key in requested_groups_dict:
+            is_testonly = requested_groups_dict[key]
 
-        for group_name in target_names:
             # Poetry merges PEP 735 and legacy groups (union, not fallback).
             if group_name in pep735_groups:
                 for dep_str in pep735_groups[group_name]:
@@ -422,6 +433,7 @@ def translate_poetry(project_dict, lock_dict, lock_model):
                     if req.name == "python":
                         continue
                     track_pin(canonicalize_name(req.name), req.specifier, is_testonly)
+
             if group_name in poetry_groups:
                 g = poetry_groups[group_name]
                 for pin, pin_info in g.get("dependencies", {}).items():
@@ -434,15 +446,6 @@ def translate_poetry(project_dict, lock_dict, lock_model):
                         pinned_package_specs,
                         track_pin = lambda p, spec: track_pin(p, spec, is_testonly),
                     )
-            if group_name in project_optional_deps:
-                for dep_str in project_optional_deps[group_name]:
-                    req = parse_pep508_requirement(dep_str)
-                    if req.name == "python":
-                        continue
-                    track_pin(req.name, req.specifier, is_testonly)
-            elif name != "*":
-                # buildifier: disable=print
-                print("WARNING: Dependency group '{}:{}' not found in project file.".format(kind, group_name))
 
     testonly_pin_names = [name for name in testonly_reqs if name not in non_testonly_reqs]
 
diff --git a/pycross/private/pylock_lock_model.bzl b/pycross/private/pylock_lock_model.bzl
index 82a5813f..f2babcf7 100644
--- a/pycross/private/pylock_lock_model.bzl
+++ b/pycross/private/pylock_lock_model.bzl
@@ -5,14 +5,10 @@ Parses pylock.toml files and produces the raw_lock.json structure consumed by
 lock_resolver.bzl.
 """
 
-load("@pypackaging.bzl", "pypackaging")
 load("@toml.bzl//toml:toml.bzl", "decode")
-load(":translator_common.bzl", "resolution_marker_constraint_name", "select_project_file")
+load(":translator_common.bzl", "canonicalize_name", "compute_requested_dependency_groups", "resolution_marker_constraint_name", "select_project_file")
 load(":util.bzl", "extract_pep508_name", "parse_package_key")
 
-def _canonicalize_name(name):
-    return pypackaging.utils.canonicalize_name(name)
-
 def _strip_selection_markers(marker):
     """Strip PDM selection markers (dependency_groups, extras) from a marker string.
 
@@ -67,7 +63,7 @@ def translate_pylock(lock_dict, project_dict, lock_model):
     versions = {}  # {name: version} - first version seen
     versions_all = {}  # {name: {version: marker}} - all versions with markers
     for pkg in packages_list:
-        name = _canonicalize_name(pkg["name"])
+        name = canonicalize_name(pkg["name"])
         version = pkg["version"]
         if name not in versions:
             versions[name] = version
@@ -81,25 +77,25 @@ def translate_pylock(lock_dict, project_dict, lock_model):
     lock_packages = {}
 
     for pkg in packages_list:
-        name = _canonicalize_name(pkg["name"])
+        name = canonicalize_name(pkg["name"])
         version = pkg["version"]
         pkg_key = "{}@{}".format(name, version)
 
         dependencies = []
         for dep in pkg.get("dependencies", []):
             dep_name_raw = dep["name"]
-            dep_name = _canonicalize_name(dep_name_raw)
+            dep_name = canonicalize_name(dep_name_raw)
 
             # Handle extras in dependency name
             dep_extra = ""
             if "[" in dep_name_raw:
                 parts = dep_name_raw.split("[", 1)
-                dep_name = _canonicalize_name(parts[0])
+                dep_name = canonicalize_name(parts[0])
                 dep_extra = parts[1].rstrip("]").strip()
 
             dep_display = dep_name
             if dep_extra:
-                dep_display = "{}[{}]".format(dep_name, _canonicalize_name(dep_extra))
+                dep_display = "{}[{}]".format(dep_name, canonicalize_name(dep_extra))
 
             dep_version = versions.get(dep_name)
             if not dep_version:
@@ -196,8 +192,7 @@ def translate_pylock(lock_dict, project_dict, lock_model):
     pins = {}
 
     dependency_groups = getattr(lock_model, "dependency_groups", ["default"])
-    include_all = "*" in dependency_groups
-    include_default = "default" in dependency_groups or include_all
+    include_default = "default" in dependency_groups or "*" in dependency_groups
     has_filter = not include_default or len([g for g in dependency_groups if g != "default"]) > 0
 
     if project_dict and has_filter:
@@ -207,63 +202,59 @@ def translate_pylock(lock_dict, project_dict, lock_model):
         non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
         wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
 
-        project_section = project_dict.get("project", {})
-        if include_default:
-            for dep_str in project_section.get("dependencies", []):
-                root_req_names.append(extract_pep508_name(dep_str))
 
+        project_section = project_dict.get("project", {})
         optional_deps = project_section.get("optional-dependencies", {})
         dev_deps = project_dict.get("dependency-groups", {})
 
-        effective_groups = ["optional:*", "group:*"] if include_all else dependency_groups
-        for group in effective_groups:
-            if group == "default" or group == "*":
-                continue
-
-            # Last-wins testonly: specific overrides beat wildcard default
-            if group in testonly_groups:
-                is_testonly = True
-            elif group in non_testonly_groups:
-                is_testonly = False
-            else:
-                is_testonly = wildcard_testonly
-
-            kind, _, name = group.partition(":")
-            if kind == "optional":
-                groups_dict = optional_deps
-            elif kind == "group":
-                groups_dict = dev_deps
-            else:
-                fail("Invalid dependency group format '{}'. Must be 'optional:name' or 'group:name'.".format(group))
-
-            if name == "*":
-                target_names = list(groups_dict.keys())
-            else:
-                target_names = [name]
-
-            for target_name in target_names:
-                if target_name in groups_dict:
-                    entries = groups_dict[target_name]
-                    for entry in entries:
-                        if type(entry) == "string":
-                            n = extract_pep508_name(entry)
-                            if is_testonly:
-                                testonly_root_req_names.append(n)
-                            else:
-                                root_req_names.append(n)
-                        elif type(entry) == "dict" and "include-group" in entry:
-                            inc_group = entry["include-group"]
-                            if inc_group in dev_deps:
-                                for inc_dep in dev_deps[inc_group]:
-                                    if type(inc_dep) == "string":
-                                        n = extract_pep508_name(inc_dep)
-                                        if is_testonly:
-                                            testonly_root_req_names.append(n)
-                                        else:
-                                            root_req_names.append(n)
+        project_name = project_dict.get("project", {}).get("name")
+
+        requested_groups_dict = compute_requested_dependency_groups(
+            dependency_groups = dependency_groups,
+            testonly_groups = testonly_groups,
+            non_testonly_groups = non_testonly_groups,
+            wildcard_testonly = wildcard_testonly,
+            available_groups = (
+                ["default"] +
+                ["optional:" + g for g in optional_deps.keys()] +
+                ["group:" + g for g in dev_deps.keys()]
+            ),
+            project_name = project_name,
+            fail_on_missing = False,  # Following precedent set by original print warning
+        )
+
+        if "default" in requested_groups_dict:
+            is_testonly = requested_groups_dict["default"]
+            for dep_str in project_section.get("dependencies", []):
+                if is_testonly:
+                    testonly_root_req_names.append(extract_pep508_name(dep_str))
                 else:
-                    # buildifier: disable=print
-                    print("WARNING: Dependency group '{}:{}' not found in project file.".format(kind, target_name))
+                    root_req_names.append(extract_pep508_name(dep_str))
+
+        for kind, groups_dict in [("optional", optional_deps), ("group", dev_deps)]:
+            for target_name in groups_dict.keys():
+                key = "{}:{}".format(kind, target_name)
+                if key not in requested_groups_dict:
+                    continue
+                is_testonly = requested_groups_dict[key]
+                entries = groups_dict[target_name]
+                for entry in entries:
+                    if type(entry) == "string":
+                        n = extract_pep508_name(entry)
+                        if is_testonly:
+                            testonly_root_req_names.append(n)
+                        else:
+                            root_req_names.append(n)
+                    elif type(entry) == "dict" and "include-group" in entry:
+                        inc_group = entry["include-group"]
+                        if inc_group in dev_deps:
+                            for inc_dep in dev_deps[inc_group]:
+                                if type(inc_dep) == "string":
+                                    n = extract_pep508_name(inc_dep)
+                                    if is_testonly:
+                                        testonly_root_req_names.append(n)
+                                    else:
+                                        root_req_names.append(n)
 
         # Deduplicate
         root_package_names = {n: True for n in root_req_names}
diff --git a/pycross/private/translator_common.bzl b/pycross/private/translator_common.bzl
index 320c0d93..9a025962 100644
--- a/pycross/private/translator_common.bzl
+++ b/pycross/private/translator_common.bzl
@@ -415,3 +415,82 @@ def resolve_lock_graph(packages, pinned_package_specs, requires_python, strict_d
         result["resolution_marker_exprs"] = resolution_marker_exprs
 
     return result
+
+def compute_requested_dependency_groups(
+        dependency_groups,
+        testonly_groups,
+        non_testonly_groups,
+        wildcard_testonly,
+        available_groups,
+        project_name = None,
+        fail_on_missing = True):
+    """Compute the definitive list of requested dependency groups and their testonly status.
+
+    Processes wildcard group expansions and applies last-wins testonly resolution
+    to construct the final dictionary of requested groups.
+
+    Wildcard rules:
+    - '*' expands to all entries in available_groups
+    - 'kind:*' (e.g. 'optional:*', 'group:*') expands to all entries with that prefix
+
+    Args:
+        dependency_groups: List of requested dependency group specs (from lock_model).
+        testonly_groups: List of dependency groups explicitly marked testonly.
+        non_testonly_groups: List of dependency groups explicitly marked non-testonly.
+        wildcard_testonly: Boolean indicating if the wildcard default is testonly.
+        available_groups: List of fully-prefixed available group names
+            (e.g. ["optional:extras1", "group:dev", "group:test"]).
+        project_name: Optional project name for error messages.
+        fail_on_missing: If True, fail when an explicitly requested group is missing.
+                         If False, print a warning instead.
+
+    Returns:
+        A dictionary mapping the group identifier (e.g. "optional:foo", "group:dev")
+        to a boolean indicating whether it is testonly.
+    """
+    available_set = {g: True for g in available_groups}
+
+    include_all = "*" in dependency_groups
+    effective_groups = list(available_set.keys()) if include_all else dependency_groups
+
+    requested = {}
+
+    for group in effective_groups:
+        if group in testonly_groups:
+            is_testonly = True
+        elif group in non_testonly_groups:
+            is_testonly = False
+        else:
+            is_testonly = wildcard_testonly
+
+        kind, sep, name = group.partition(":")
+        if not sep:
+            if group in available_set:
+                requested[group] = is_testonly
+            continue
+
+        if name == "*":
+            # Expand kind:* to all available groups with this prefix.
+            prefix = kind + ":"
+            targets = [g for g in available_set if g.startswith(prefix)]
+        else:
+            targets = [group]
+
+        for target in targets:
+            if target not in available_set:
+                if name != "*":
+                    if project_name:
+                        msg = "Project '{}' does not have group '{}'.".format(project_name, target)
+                    else:
+                        msg = "Dependency group '{}' not found.".format(target)
+
+                    if fail_on_missing:
+                        fail(msg)
+                    else:
+                        # buildifier: disable=print
+                        print("WARNING: " + msg)
+                continue
+
+            requested[target] = is_testonly
+
+    return requested
diff --git a/pycross/private/uv_lock_model.bzl b/pycross/private/uv_lock_model.bzl
index a734a1c3..89ceda9b 100644
--- a/pycross/private/uv_lock_model.bzl
+++ b/pycross/private/uv_lock_model.bzl
@@ -12,6 +12,7 @@ load("@toml.bzl//toml:toml.bzl", "decode")
 load(
     ":translator_common.bzl",
     "canonicalize_name",
+    "compute_requested_dependency_groups",
     "resolution_marker_constraint_name",
     "resolve_lock_graph",
     "select_project_file",
@@ -272,8 +273,7 @@ def translate_uv(project_dict, lock_dict, lock_model):
     # Collect requirements
     requirements = []  # list of (req_name, specifier, constraint, is_testonly)
 
-    include_all = "*" in dependency_groups
-    include_default = "default" in dependency_groups or include_all
+
 
     for project_name in target_projects:
         project_info = workspace_members[project_name]
@@ -282,7 +282,23 @@ def translate_uv(project_dict, lock_dict, lock_model):
         optional_dependencies = project_info.get("optional-dependencies", {})
         development_dependencies = project_info.get("dev-dependencies", {})
 
-        if include_default:
+        # Parse groups
+        requested_groups_dict = compute_requested_dependency_groups(
+            dependency_groups = dependency_groups,
+            testonly_groups = testonly_groups,
+            non_testonly_groups = non_testonly_groups,
+            wildcard_testonly = wildcard_testonly,
+            available_groups = (
+                ["default"] +
+                ["optional:" + g for g in optional_dependencies.keys()] +
+                ["group:" + g for g in development_dependencies.keys()]
+            ),
+            project_name = project_name,
+            fail_on_missing = True,
+        )
+
+        if "default" in requested_groups_dict:
+            default_is_testonly = requested_groups_dict["default"]
             for dep in default_dependencies:
                 dep_name = canonicalize_name(dep["name"])
                 dep_version = dep.get("version", "")
@@ -299,49 +315,20 @@ def translate_uv(project_dict, lock_dict, lock_model):
                 if dep_extras:
                     for extra in dep_extras:
                         pin_name = "{}[{}]".format(dep_name, canonicalize_name(extra))
-                        requirements.append((pin_name, specifier, fork_constraint, False))
+                        requirements.append((pin_name, specifier, fork_constraint, default_is_testonly))
                 else:
-                    requirements.append((dep_name, specifier, fork_constraint, False))
+                    requirements.append((dep_name, specifier, fork_constraint, default_is_testonly))
 
-        # Parse groups
-        effective_groups = ["optional:*", "group:*"] if include_all else dependency_groups
-        for group in effective_groups:
-            if group == "default" or group == "*":
-                continue
-
-            # Last-wins testonly: specific overrides beat wildcard default
-            if group in testonly_groups:
-                is_testonly = True
-            elif group in non_testonly_groups:
-                is_testonly = False
-            else:
-                is_testonly = wildcard_testonly
-
-            kind, _, name = group.partition(":")
-            if kind == "optional":
-                groups_dict = optional_dependencies
-                constraint_dict = extra_variant_values
-            elif kind == "group":
-                groups_dict = development_dependencies
-                constraint_dict = group_variant_values
-            else:
-                fail("Invalid dependency group format '{}'. Must be 'optional:name' or 'group:name'.".format(group))
 
-            if name == "*":
-                target_names = list(groups_dict.keys())
-            else:
-                target_names = [name]
-
-            for t_name in target_names:
-                if t_name not in groups_dict:
-                    # It's a warning if the user explicitly requested a wildcard that matches nothing,
-                    # but if they explicitly ask for a specific group and it's missing, it should be an error.
-                    if name != "*":
-                        fail("Project '{}' does not have {} group '{}'.".format(project_name, kind, t_name))
-                    continue
 
-                constraint = constraint_dict.get(t_name, "")
-                for dep in groups_dict[t_name]:
+        for kind, groups_dict, constraint_dict in [("optional", optional_dependencies, extra_variant_values), ("group", development_dependencies, group_variant_values)]:
+            for group_name in groups_dict.keys():
+                key = "{}:{}".format(kind, group_name)
+                if key not in requested_groups_dict:
+                    continue
+                is_testonly = requested_groups_dict[key]
+                constraint = constraint_dict.get(group_name, "")
+                for dep in groups_dict[group_name]:
                     dep_name = canonicalize_name(dep["name"])
                     dep_version = dep.get("version", "")
                     dep_extras = dep.get("extra") or dep.get("extras", [])

From 931d1ca8a8a681f53053d2835afc39920688d6d3 Mon Sep 17 00:00:00 2001
From: Jeremy Volkman 
Date: Sun, 19 Jul 2026 05:26:40 +0000
Subject: [PATCH 3/5] fix(uv): strip extras from pin names when tracking
 testonly status

---
 pycross/private/uv_lock_model.bzl | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/pycross/private/uv_lock_model.bzl b/pycross/private/uv_lock_model.bzl
index 89ceda9b..cd1f360d 100644
--- a/pycross/private/uv_lock_model.bzl
+++ b/pycross/private/uv_lock_model.bzl
@@ -369,10 +369,11 @@ def translate_uv(project_dict, lock_dict, lock_model):
             pinned_package_specs[pin_name] = {}
         pinned_package_specs[pin_name][constraint] = specifier
 
+        base_pin_name = pin_name.split("[")[0]
         if is_testonly:
-            testonly_reqs[pin_name] = True
+            testonly_reqs[base_pin_name] = True
         else:
-            non_testonly_reqs[pin_name] = True
+            non_testonly_reqs[base_pin_name] = True
 
     testonly_pin_names = [name for name in testonly_reqs if name not in non_testonly_reqs]
 

From d8aeed43a22f01bc0113d9cf407844fa9e3701eb Mon Sep 17 00:00:00 2001
From: Jeremy Volkman 
Date: Sun, 19 Jul 2026 15:00:29 +0000
Subject: [PATCH 4/5] fix: remove testonly reachability analysis from resolver

testonly is only applied to proxy aliases (@pypi//...:pkg), not to
the underlying package targets in __pkgs. Since a testonly proxy can
freely point at a non-testonly actual target, there is no need for
reachability analysis. When transitive;testonly is set, simply mark
all transitive pins as testonly.
---
 pycross/private/lock_resolver.bzl     | 22 +++++++---------------
 pycross/private/pdm_lock_model.bzl    |  2 --
 pycross/private/poetry_lock_model.bzl |  2 --
 pycross/private/pylock_lock_model.bzl |  1 -
 pycross/private/uv_lock_model.bzl     |  4 ----
 5 files changed, 7 insertions(+), 24 deletions(-)

diff --git a/pycross/private/lock_resolver.bzl b/pycross/private/lock_resolver.bzl
index 85b89742..4340facd 100644
--- a/pycross/private/lock_resolver.bzl
+++ b/pycross/private/lock_resolver.bzl
@@ -626,21 +626,7 @@ def resolve(
     repos = {k: repos[k] for k in sorted_repo_keys}
 
     testonly_pin_names = lock_model_data.get("testonly_pins", [])
-    if transitive_testonly:
-        # All reachable from actual pins
-        all_reachable = _compute_reachable_keys(pins, packages_by_package_key)
-
-        # All reachable from pins explicitly NOT testonly
-        non_testonly_pins = {p: v for p, v in pins.items() if p not in testonly_pin_names}
-        non_testonly_reachable = _compute_reachable_keys(non_testonly_pins, packages_by_package_key)
-
-        testonly_keys = [k for k in all_reachable if k not in non_testonly_reachable]
-        testonly_names_dict = {}
-        for k in testonly_keys:
-            entry = packages_by_package_key.get(k)
-            if entry:
-                testonly_names_dict[entry.resolved_package["name"]] = True
-        testonly_pin_names = sorted(testonly_names_dict.keys())
+    testonly_pins_set = {p: True for p in testonly_pin_names}
 
     if include_transitive:
         reachable_keys = _compute_reachable_keys(pins, packages_by_package_key)
@@ -666,11 +652,17 @@ def resolve(
                 base_key = "{}@{}".format(package_pin_name, latest_version)
                 if base_key in packages_by_package_key:
                     pins[package_pin_name] = {"": base_key}
+                    if transitive_testonly:
+                        testonly_pins_set[package_pin_name] = True
                 continue
             version = versions.keys()[0]
             base_key = "{}@{}".format(package_pin_name, version)
             if base_key in packages_by_package_key:
                 pins[package_pin_name] = {"": base_key}
+                if transitive_testonly:
+                    testonly_pins_set[package_pin_name] = True
+
+    testonly_pin_names = sorted(testonly_pins_set.keys())
 
     cycle_groups = _compute_cycle_groups(lock_model_packages)
 
diff --git a/pycross/private/pdm_lock_model.bzl b/pycross/private/pdm_lock_model.bzl
index 44af88d0..b9d0a577 100644
--- a/pycross/private/pdm_lock_model.bzl
+++ b/pycross/private/pdm_lock_model.bzl
@@ -136,8 +136,6 @@ def translate_pdm(project_dict, lock_dict, lock_model):
         for dep_str in default_deps:
             requirements.append((parse_pep508_requirement(dep_str), default_is_testonly))
 
-
-
     for kind, groups_dict in [("optional", optional_deps), ("group", dev_deps)]:
         for target_name in groups_dict.keys():
             key = "{}:{}".format(kind, target_name)
diff --git a/pycross/private/poetry_lock_model.bzl b/pycross/private/poetry_lock_model.bzl
index 9e788598..93b0b6cd 100644
--- a/pycross/private/poetry_lock_model.bzl
+++ b/pycross/private/poetry_lock_model.bzl
@@ -348,7 +348,6 @@ def translate_poetry(project_dict, lock_dict, lock_model):
 
     dependency_groups = getattr(lock_model, "dependency_groups", ["default"])
 
-
     testonly_groups = getattr(lock_model, "testonly_groups", [])
     non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
     wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
@@ -410,7 +409,6 @@ def translate_poetry(project_dict, lock_dict, lock_model):
                     enrich_only = has_project_deps,
                 )
 
-
     for group_name in project_optional_deps.keys():
         key = "optional:{}".format(group_name)
         if key in requested_groups_dict:
diff --git a/pycross/private/pylock_lock_model.bzl b/pycross/private/pylock_lock_model.bzl
index f2babcf7..a1f4a67c 100644
--- a/pycross/private/pylock_lock_model.bzl
+++ b/pycross/private/pylock_lock_model.bzl
@@ -202,7 +202,6 @@ def translate_pylock(lock_dict, project_dict, lock_model):
         non_testonly_groups = getattr(lock_model, "non_testonly_groups", [])
         wildcard_testonly = getattr(lock_model, "wildcard_testonly", False)
 
-
         project_section = project_dict.get("project", {})
         optional_deps = project_section.get("optional-dependencies", {})
         dev_deps = project_dict.get("dependency-groups", {})
diff --git a/pycross/private/uv_lock_model.bzl b/pycross/private/uv_lock_model.bzl
index cd1f360d..fccc81c1 100644
--- a/pycross/private/uv_lock_model.bzl
+++ b/pycross/private/uv_lock_model.bzl
@@ -273,8 +273,6 @@ def translate_uv(project_dict, lock_dict, lock_model):
     # Collect requirements
     requirements = []  # list of (req_name, specifier, constraint, is_testonly)
 
-
-
     for project_name in target_projects:
         project_info = workspace_members[project_name]
 
@@ -319,8 +317,6 @@ def translate_uv(project_dict, lock_dict, lock_model):
                 else:
                     requirements.append((dep_name, specifier, fork_constraint, default_is_testonly))
 
-
-
         for kind, groups_dict, constraint_dict in [("optional", optional_dependencies, extra_variant_values), ("group", development_dependencies, group_variant_values)]:
             for group_name in groups_dict.keys():
                 key = "{}:{}".format(kind, group_name)

From e7eb1096bd19896257de3d01410c4b13fd437f6f Mon Sep 17 00:00:00 2001
From: Jeremy Volkman 
Date: Sun, 19 Jul 2026 15:35:41 +0000
Subject: [PATCH 5/5] test: update testonly tests for proxy-only semantics

Tests now use include_transitive=True and verify that all transitive
pins are marked testonly when transitive_testonly is set, without
reachability analysis.

TAG=agy
---
 tests/unit/test_lock_resolver.bzl | 43 ++++++++++++++++++++++---------
 1 file changed, 31 insertions(+), 12 deletions(-)

diff --git a/tests/unit/test_lock_resolver.bzl b/tests/unit/test_lock_resolver.bzl
index 58facdf4..2d9c5f5c 100644
--- a/tests/unit/test_lock_resolver.bzl
+++ b/tests/unit/test_lock_resolver.bzl
@@ -1911,12 +1911,19 @@ def _test_testonly_passthrough_without_transitive(name):
 
 # buildifier: disable=unused-variable
 def _test_testonly_exclusive_transitive_impl(env, target):
-    """Packages exclusively reachable from testonly pins are marked testonly.
+    """transitive_testonly marks newly-added transitive pins as testonly.
+
+    testonly is only applied to proxy aliases, not propagated through the
+    dependency graph. transitive_testonly marks pins added by include_transitive
+    as testonly; non-pinned transitive deps are unaffected (they have no proxy).
 
     Graph:
         foo (normal) -> shared-lib
         pytest (testonly) -> test-utils -> test-helper
-    Expected: pytest, test-utils, test-helper are testonly; foo, shared-lib are not.
+    include_transitive discovers: shared-lib, test-utils, test-helper as new pins.
+    Expected: pytest is testonly (direct pin); shared-lib, test-utils, test-helper
+    are all testonly (added by include_transitive with transitive_testonly=True).
+    foo is NOT testonly (direct non-testonly pin).
     """
     lock_model_data = {
         "packages": {
@@ -1933,15 +1940,18 @@ def _test_testonly_exclusive_transitive_impl(env, target):
         "testonly_pins": ["pytest"],
     }
 
-    res = resolve(lock_model_data, transitive_testonly = True)
+    res = resolve(lock_model_data, include_transitive = True, transitive_testonly = True)
 
-    # pytest and its exclusive transitive deps should be testonly
+    # pytest is testonly (direct testonly pin)
     env.expect.that_collection(res.testonly_pins).contains("pytest")
+
+    # All transitive pins are testonly when transitive_testonly is set
     env.expect.that_collection(res.testonly_pins).contains("test-utils")
     env.expect.that_collection(res.testonly_pins).contains("test-helper")
+    env.expect.that_collection(res.testonly_pins).contains("shared-lib")
 
-    # foo and shared-lib should NOT be testonly
-    env.expect.that_collection(res.testonly_pins).contains_none_of(["foo", "shared-lib"])
+    # foo is NOT testonly (direct non-testonly pin)
+    env.expect.that_collection(res.testonly_pins).contains_none_of(["foo"])
 
 def _test_testonly_exclusive_transitive(name):
     util.helper_target(native.filegroup, name = name + "_subject", srcs = [])
@@ -2005,12 +2015,17 @@ def _test_testonly_no_testonly_pins(name):
 
 # buildifier: disable=unused-variable
 def _test_testonly_diamond_with_testonly_branch_impl(env, target):
-    """Diamond dependency where one branch is testonly.
+    """Diamond dependency with testonly branch: transitive pins are all testonly.
+
+    testonly is only applied to proxy aliases. transitive_testonly marks all
+    newly-discovered transitive pins as testonly regardless of reachability.
 
     Graph:
         foo (normal) -> mid-a -> leaf
         bar (testonly) -> mid-b -> leaf
-    Expected: bar, mid-b are testonly; leaf is NOT (reachable from foo via mid-a).
+    include_transitive discovers: mid-a, mid-b, leaf as new pins.
+    Expected: bar is testonly (direct pin); mid-a, mid-b, leaf are testonly
+    (transitive pins with transitive_testonly); foo is NOT testonly.
     """
     lock_model_data = {
         "packages": {
@@ -2027,14 +2042,18 @@ def _test_testonly_diamond_with_testonly_branch_impl(env, target):
         "testonly_pins": ["bar"],
     }
 
-    res = resolve(lock_model_data, transitive_testonly = True)
+    res = resolve(lock_model_data, include_transitive = True, transitive_testonly = True)
 
-    # bar and mid-b are exclusively testonly
+    # bar is testonly (direct testonly pin)
     env.expect.that_collection(res.testonly_pins).contains("bar")
+
+    # All transitive pins are testonly when transitive_testonly is set
+    env.expect.that_collection(res.testonly_pins).contains("mid-a")
     env.expect.that_collection(res.testonly_pins).contains("mid-b")
+    env.expect.that_collection(res.testonly_pins).contains("leaf")
 
-    # leaf is reachable from foo -> mid-a -> leaf, so NOT testonly
-    env.expect.that_collection(res.testonly_pins).contains_none_of(["foo", "mid-a", "leaf"])
+    # foo is NOT testonly (direct non-testonly pin)
+    env.expect.that_collection(res.testonly_pins).contains_none_of(["foo"])
 
 def _test_testonly_diamond_with_testonly_branch(name):
     util.helper_target(native.filegroup, name = name + "_subject", srcs = [])