Skip to content

Commit 30a0583

Browse files
authored
feat(pypi): add pip.dep to declare abstract pypi dependencies (bazel-contrib#3850)
Introduce the pip.dep tag class to allow modules to declare abstract PyPI dependencies. These declarations ensure the target structure for a PyPI package is created, but provide a no-op implementation that passes analysis time, but fails at execution time. Along the way, create an agent rule for `*.bzl` files to help guide it in creating better bzl files.
1 parent dc0d977 commit 30a0583

10 files changed

Lines changed: 236 additions & 4 deletions

File tree

.agents/rules/bzl.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
trigger: glob
3+
description: Starlark / Bazel .bzl file coding style rules
4+
globs: *.bzl
5+
---
6+
7+
# Starlark Rules
8+
9+
* Use triple-quoted strings for multi-line rule doc args.
10+
* Don't use backslash line continuation in rule doc args.

docs/pypi/download.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,41 @@ Shared library targets can simply depend on the unified hub (e.g.,
121121
`@pypi//numpy`), and the dependency will automatically resolve to the correct
122122
wheel version from the active hub during the build.
123123

124+
### Declaring Abstract Dependencies (pip.dep)
125+
126+
:::{versionadded} VERSION_NEXT_FEATURE
127+
Declaring abstract PyPI dependencies via `pip.dep` tags.
128+
:::
129+
130+
Sometimes a shared library target or a ruleset needs to depend on a PyPI
131+
package (e.g., `@pypi//numpy`), but does not want to force a specific package
132+
version or a concrete `requirements.txt` lock file on its consumers.
133+
134+
Instead of calling `pip.parse()`, the module can declare its dependency using
135+
the `pip.dep` tag:
136+
137+
```starlark
138+
pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
139+
140+
# Declare an abstract dependency on 'numpy' and specify extra targets that
141+
# are expected to be available in the package.
142+
pip.dep(
143+
name = "numpy",
144+
extra_targets = ["extra-alias"],
145+
)
146+
```
147+
148+
This ensures that the target structure `@pypi//numpy` (and
149+
`@pypi//numpy:extra-alias`) exists in the unified `@pypi` hub repository, so the
150+
declaring module can compile and analyze successfully without needing any local
151+
requirements file.
152+
153+
The actual concrete implementation and version of the package must be provided
154+
by a downstream module calling `pip.parse`.
155+
156+
If a downstream module attempts to build a target that depends on an abstract
157+
dependency, but has not provided a concrete implementation for it via any
158+
`pip.parse` call, the build will fail at execution time.
124159

125160

126161
As with any repository rule or extension, if you would like to ensure that `pip_parse` is

news/pip-dep-tag-class.added.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
(pypi) Added a `dep` tag class to the `pip` bzlmod extension. This allows
2+
modules to declare abstract PyPI dependencies, ensuring target structures
3+
exist in the unified hub, while allowing other modules to provide the
4+
concrete implementation via `pip.parse`.

python/private/pypi/extension.bzl

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,15 @@ You cannot use both the additive_build_content and additive_build_content_file a
424424
pip_attr = pip_attr,
425425
)
426426

427+
# dict[str package, dict[str, None] extra_targets]
428+
declared_deps = {}
429+
for mod in module_ctx.modules:
430+
for dep_attr in mod.tags.dep:
431+
name = normalize_name(dep_attr.name)
432+
targets = declared_deps.setdefault(name, {})
433+
for target in dep_attr.extra_targets:
434+
targets[target] = None
435+
427436
# Keeps track of all the hub's whl repos across the different versions.
428437
# dict[hub, dict[whl, dict[version, str pip]]]
429438
# Where hub, whl, and pip are the repo names
@@ -448,6 +457,7 @@ You cannot use both the additive_build_content and additive_build_content_file a
448457

449458
return struct(
450459
config = config,
460+
declared_deps = declared_deps,
451461
default_hub = config.default_hub or renamed_default_hub,
452462
exposed_packages = exposed_packages,
453463
extra_aliases = extra_aliases,
@@ -492,6 +502,15 @@ def _create_unified_hub_repo(mods):
492502
if hub_name not in extra_aliases[qual_alias]:
493503
extra_aliases[qual_alias].append(hub_name)
494504

505+
for norm_pkg, extra_targets in mods.declared_deps.items():
506+
if norm_pkg not in packages:
507+
packages[norm_pkg] = []
508+
509+
for target_name in extra_targets:
510+
qual_alias = "%s:%s" % (norm_pkg, target_name)
511+
if qual_alias not in extra_aliases:
512+
extra_aliases[qual_alias] = []
513+
495514
unified_hub_repo(
496515
name = "pypi",
497516
default_hub = mods.default_hub or (hubs[0] if hubs else ""),
@@ -1015,6 +1034,31 @@ Apply any overrides (e.g. patches) to a given Python distribution defined by
10151034
other tags in this extension.""",
10161035
)
10171036

1037+
_dep_tag = tag_class(
1038+
attrs = {
1039+
"extra_targets": attr.string_list(
1040+
doc = """\
1041+
A list of extra target names in the package that are expected to be available.
1042+
See {obj}`pip.parse.extra_hub_aliases`.
1043+
""",
1044+
default = [],
1045+
),
1046+
"name": attr.string(
1047+
doc = "The name of a pypi package. Note that the name is normalized.",
1048+
mandatory = True,
1049+
),
1050+
},
1051+
doc = """\
1052+
Declare an abstract PyPI dependency to ensure its target structure exists in the unified hub.
1053+
1054+
This is useful for targets or rules that need to depend on a package (e.g., `@pypi//numpy`)
1055+
but do not want to force a specific version or concrete requirements lock file on their
1056+
consumers. The concrete version and implementation must be provided by downstreams calling
1057+
`pip.parse`. If they are not, the target will still be defined, but it will result in an
1058+
execution-phase error when built.
1059+
""",
1060+
)
1061+
10181062
pypi = module_extension(
10191063
environ = ["RULES_PYTHON_PYPI_HUB_RESERVED"],
10201064
doc = """\
@@ -1065,6 +1109,7 @@ terms used in this extension.
10651109
:::
10661110
""",
10671111
),
1112+
"dep": _dep_tag,
10681113
"override": _override_tag,
10691114
"parse": tag_class(
10701115
attrs = _pip_parse_ext_attrs(),

python/private/pypi/missing_package.bzl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ load("//python/private:reexports.bzl", "BuiltinPyInfo")
66
def _missing_package_error_impl(ctx):
77
out = ctx.actions.declare_file(ctx.label.name + ".error")
88

9+
if ctx.attr.hub_name:
10+
hub_clause = ' when building under PyPI hub "{hub}". Try adding it to the requirements of this hub (e.g. requirements_lock or requirements_by_platform in pip.parse)'.format(
11+
hub = ctx.attr.hub_name,
12+
)
13+
else:
14+
hub_clause = ' because no default PyPI hub was configured. Try designating a default hub via pip.default(default_hub = "...") or select a hub using --@rules_python//python/config_settings:venv'
15+
916
# Register an action that fails when Bazel attempts to stage/build this file
1017
ctx.actions.run_shell(
1118
outputs = [out],
1219
command = "echo 'ERROR: PyPI package \"{pkg}\" is not available{hub_clause}.' >&2 && exit 1".format(
1320
pkg = ctx.attr.package_name,
14-
hub_clause = (' when building under PyPI hub "%s"' % ctx.attr.hub_name) if ctx.attr.hub_name else " because no PyPI hub or default hub is requested",
21+
hub_clause = hub_clause,
1522
),
1623
)
1724

tests/integration/unified_pypi/BUILD.bazel

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,16 @@ py_binary(
4646
},
4747
deps = ["@pypi//six"],
4848
)
49+
50+
py_binary(
51+
name = "bin_declared_only",
52+
srcs = ["bin_declared_only.py"],
53+
deps = ["@pypi//declared_only_pkg"],
54+
)
55+
56+
py_binary(
57+
name = "bin_declared_only_alias",
58+
srcs = ["bin_declared_only.py"],
59+
main = "bin_declared_only.py",
60+
deps = ["@pypi//declared_only_pkg:declared-only-alias"],
61+
)

tests/integration/unified_pypi/MODULE.bazel

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,8 @@ pip.parse(
4545
use_repo(pip, "pypi_b")
4646

4747
pip.default(default_hub = "pypi_b")
48+
pip.dep(
49+
name = "declared-only-pkg",
50+
extra_targets = ["declared-only-alias"],
51+
)
4852
use_repo(pip, "pypi")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Dummy file for integration test
2+
print("declared_only")

tests/integration/unified_pypi_test.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def test_disjoint_package_cquery_succeeds_but_build_fails(self):
3030
)
3131
self.assert_result_matches(
3232
result,
33-
'ERROR: PyPI package "six" is not available when building under PyPI hub "pypi_a".',
33+
'ERROR: PyPI package "six" is not available when building under PyPI hub "pypi_a"\\. Try adding it to the requirements of this hub',
3434
)
3535

3636
def test_sibling_extra_alias_cquery_succeeds_but_build_fails(self):
@@ -43,7 +43,7 @@ def test_sibling_extra_alias_cquery_succeeds_but_build_fails(self):
4343
)
4444
self.assert_result_matches(
4545
result,
46-
'ERROR: PyPI package "colorama:my_colorama" is not available when building under PyPI hub "pypi_b".',
46+
'ERROR: PyPI package "colorama:my_colorama" is not available when building under PyPI hub "pypi_b"\\. Try adding it to the requirements of this hub',
4747
)
4848

4949
@contextlib.contextmanager
@@ -74,6 +74,27 @@ def test_invalid_default_hub_fails_evaluation(self):
7474
"default_hub 'invalid_hub' is not a defined PyPI hub",
7575
)
7676

77+
def test_unimplemented_declared_dep_fails_build(self):
78+
# Even though cquery succeeds:
79+
self.run_bazel("cquery", "//:bin_declared_only")
80+
81+
# Build must fail because the package is not implemented by any concrete hub
82+
result = self.run_bazel("build", "//:bin_declared_only", check=False)
83+
self.assertNotEqual(result.exit_code, 0)
84+
self.assert_result_matches(
85+
result,
86+
'ERROR: PyPI package "declared_only_pkg" is not available when building under PyPI hub "pypi_b"\\. Try adding it to the requirements of this hub',
87+
)
88+
89+
def test_unimplemented_declared_dep_alias_fails_build(self):
90+
# Build must fail for alias too
91+
result = self.run_bazel("build", "//:bin_declared_only_alias", check=False)
92+
self.assertNotEqual(result.exit_code, 0)
93+
self.assert_result_matches(
94+
result,
95+
'ERROR: PyPI package "declared_only_pkg:declared-only-alias" is not available when building under PyPI hub "pypi_b"\\. Try adding it to the requirements of this hub',
96+
)
97+
7798

7899
if __name__ == "__main__":
79100
unittest.main()

tests/pypi/extension/extension_tests.bzl

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,21 @@ _default_tags_default = [
9090
}.items()
9191
]
9292

93-
def _mod(*, name, default = _default_tags_default, parse = [], override = [], whl_mods = [], is_root = True):
93+
def _dep(*, name, extra_targets = []):
94+
return struct(
95+
name = name,
96+
extra_targets = extra_targets,
97+
)
98+
99+
def _mod(*, name, default = _default_tags_default, parse = [], override = [], whl_mods = [], dep = [], is_root = True):
94100
return struct(
95101
name = name,
96102
tags = struct(
97103
parse = parse,
98104
override = override,
99105
whl_mods = whl_mods,
100106
default = default,
107+
dep = dep,
101108
),
102109
is_root = is_root,
103110
)
@@ -106,6 +113,7 @@ def _parse_modules(env, **kwargs):
106113
return env.expect.that_struct(
107114
parse_modules(**kwargs),
108115
attrs = dict(
116+
declared_deps = subjects.dict,
109117
default_hub = subjects.str,
110118
exposed_packages = subjects.dict,
111119
hub_group_map = subjects.dict,
@@ -435,6 +443,89 @@ def _test_default_hub_precedence(env):
435443

436444
_tests.append(_test_default_hub_precedence)
437445

446+
def _test_extension_dep(env):
447+
pypi = _parse_modules(
448+
env,
449+
module_ctx = _pypi_mock_mctx(
450+
_mod(
451+
name = "my_module",
452+
dep = [
453+
_dep(
454+
name = "declared-pkg",
455+
extra_targets = ["declared-alias"],
456+
),
457+
],
458+
),
459+
os_name = "linux",
460+
arch_name = "x86_64",
461+
),
462+
available_interpreters = {},
463+
minor_mapping = {},
464+
)
465+
466+
pypi.declared_deps().contains_exactly({"declared_pkg": {"declared-alias": None}})
467+
pypi.exposed_packages().contains_exactly({})
468+
pypi.hub_group_map().contains_exactly({})
469+
pypi.hub_whl_map().contains_exactly({})
470+
pypi.whl_libraries().contains_exactly({})
471+
pypi.whl_mods().contains_exactly({})
472+
473+
_tests.append(_test_extension_dep)
474+
475+
def _test_extension_dep_coexists_with_concrete_hub(env):
476+
pypi = _parse_modules(
477+
env,
478+
module_ctx = _pypi_mock_mctx(
479+
_mod(
480+
name = "my_module",
481+
parse = [
482+
_parse(
483+
hub_name = "pypi_a",
484+
python_version = "3.15",
485+
simpleapi_skip = ["simple"],
486+
requirements_lock = "requirements.txt",
487+
),
488+
],
489+
dep = [
490+
_dep(
491+
name = "simple",
492+
extra_targets = ["extra-target"],
493+
),
494+
],
495+
),
496+
os_name = "linux",
497+
arch_name = "x86_64",
498+
),
499+
available_interpreters = {
500+
"python_3_15_host": "unit_test_interpreter_target",
501+
},
502+
minor_mapping = {"3.15": "3.15.19"},
503+
)
504+
505+
pypi.declared_deps().contains_exactly({"simple": {"extra-target": None}})
506+
pypi.exposed_packages().contains_exactly({"pypi_a": ["simple"]})
507+
pypi.hub_group_map().contains_exactly({"pypi_a": {}})
508+
pypi.hub_whl_map().contains_exactly({"pypi_a": {
509+
"simple": {
510+
"pypi_a_315_simple": [
511+
whl_config_setting(
512+
version = "3.15",
513+
),
514+
],
515+
},
516+
}})
517+
pypi.whl_libraries().contains_exactly({
518+
"pypi_a_315_simple": {
519+
"config_load": "@pypi_a//:config.bzl",
520+
"dep_template": "@pypi_a//{name}:{target}",
521+
"python_interpreter_target": "unit_test_interpreter_target",
522+
"requirement": "simple==0.0.1 --hash=sha256:deadbeef --hash=sha256:deadbaaf",
523+
},
524+
})
525+
pypi.whl_mods().contains_exactly({})
526+
527+
_tests.append(_test_extension_dep_coexists_with_concrete_hub)
528+
438529
def extension_test_suite(name):
439530
"""Create the test suite.
440531

0 commit comments

Comments
 (0)