Skip to content

Commit 5b164a2

Browse files
authored
feat(py_wheel): Added requires_file and extra_requires_files attrs (bazel-contrib#1710)
The `compile_pip_requirements` rule promotes having `requirements.in` files describe python dependencies. This change aims to allow these files to be the source of truth for constraints by allowing the `py_wheel` rule to use them for adding requirements to a wheel. This reduces overhead in needing to maintain two lists of equal information (one as he `.in` and the other as starlark data).
1 parent 1fd2d7d commit 5b164a2

5 files changed

Lines changed: 163 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ A brief description of the categories of changes:
2121

2222
[0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
2323

24+
### Changed
25+
26+
### Fixed
27+
28+
### Added
29+
30+
* (py_wheel) Added `requires_file` and `extra_requires_files` attributes.
31+
2432
## 0.29.0 - 2024-01-22
2533

2634
[0.29.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.29.0

examples/wheel/BUILD.bazel

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
load("@bazel_skylib//rules:build_test.bzl", "build_test")
16+
load("@bazel_skylib//rules:write_file.bzl", "write_file")
1617
load("//examples/wheel/private:wheel_utils.bzl", "directory_writer", "make_variable_tags")
1718
load("//python:defs.bzl", "py_library", "py_test")
1819
load("//python:packaging.bzl", "py_package", "py_wheel")
@@ -269,6 +270,47 @@ py_wheel(
269270
deps = [":example_pkg"],
270271
)
271272

273+
write_file(
274+
name = "requires_file",
275+
out = "requires.txt",
276+
content = """\
277+
# Requirements file
278+
--index-url https://pypi.com
279+
280+
tomli>=2.0.0
281+
""".splitlines(),
282+
)
283+
284+
write_file(
285+
name = "extra_requires_file",
286+
out = "extra_requires.txt",
287+
content = """\
288+
# Extras Requirements file
289+
--index-url https://pypi.com
290+
291+
pyyaml>=6.0.0,!=6.0.1
292+
toml; (python_version == "3.11" or python_version == "3.12") and python_version != "3.8"
293+
wheel; python_version == "3.11" or python_version == "3.12"
294+
""".splitlines(),
295+
)
296+
297+
# py_wheel can use text files to specify their requirements. This
298+
# can be convenient for users of `compile_pip_requirements` who have
299+
# granular `requirements.in` files per package. This target shows
300+
# how to provide this file.
301+
py_wheel(
302+
name = "requires_files",
303+
distribution = "requires_files",
304+
extra_requires_files = {":extra_requires.txt": "example"},
305+
python_tag = "py3",
306+
# py_wheel can use text files to specify their requirements. This
307+
# can be convenient for users of `compile_pip_requirements` who have
308+
# granular `requirements.in` files per package.
309+
requires_file = ":requires.txt",
310+
version = "0.0.1",
311+
deps = [":example_pkg"],
312+
)
313+
272314
py_test(
273315
name = "wheel_test",
274316
srcs = ["wheel_test.py"],
@@ -283,6 +325,7 @@ py_test(
283325
":minimal_with_py_package",
284326
":python_abi3_binary_wheel",
285327
":python_requires_in_a_package",
328+
":requires_files",
286329
":use_rule_with_dir_in_outs",
287330
],
288331
deps = [

examples/wheel/wheel_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,34 @@ def test_rule_expands_workspace_status_keys_in_wheel_metadata(self):
438438
self.assertNotIn("{BUILD_TIMESTAMP}", version)
439439
self.assertNotIn("{BUILD_USER}", name)
440440

441+
def test_requires_file_and_extra_requires_files(self):
442+
filename = self._get_path("requires_files-0.0.1-py3-none-any.whl")
443+
444+
with zipfile.ZipFile(filename) as zf:
445+
self.assertAllEntriesHasReproducibleMetadata(zf)
446+
metadata_file = None
447+
for f in zf.namelist():
448+
if os.path.basename(f) == "METADATA":
449+
metadata_file = f
450+
self.assertIsNotNone(metadata_file)
451+
452+
requires = []
453+
with zf.open(metadata_file) as fp:
454+
for line in fp:
455+
if line.startswith(b"Requires-Dist:"):
456+
requires.append(line.decode("utf-8").strip())
457+
458+
print(requires)
459+
self.assertEqual(
460+
[
461+
"Requires-Dist: tomli>=2.0.0;",
462+
"Requires-Dist: pyyaml!=6.0.1,>=6.0.0; extra == 'example'",
463+
'Requires-Dist: toml; ((python_version == "3.11" or python_version == "3.12") and python_version != "3.8") and extra == \'example\'',
464+
'Requires-Dist: wheel; (python_version == "3.11" or python_version == "3.12") and extra == \'example\'',
465+
],
466+
requires,
467+
)
468+
441469

442470
if __name__ == "__main__":
443471
unittest.main()

python/private/py_wheel.bzl

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,26 @@ _feature_flags = {}
122122

123123
_requirement_attrs = {
124124
"extra_requires": attr.string_list_dict(
125-
doc = "List of optional requirements for this package",
125+
doc = ("A mapping of [extras](https://peps.python.org/pep-0508/#extras) options to lists of requirements (similar to `requires`). This attribute " +
126+
"is mutually exclusive with `extra_requires_file`."),
127+
),
128+
"extra_requires_files": attr.label_keyed_string_dict(
129+
doc = ("A mapping of requirements files (similar to `requires_file`) to the name of an [extras](https://peps.python.org/pep-0508/#extras) option " +
130+
"This attribute is mutually exclusive with `extra_requires`."),
131+
allow_files = True,
126132
),
127133
"requires": attr.string_list(
128134
doc = ("List of requirements for this package. See the section on " +
129135
"[Declaring required dependency](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#declaring-dependencies) " +
130-
"for details and examples of the format of this argument."),
136+
"for details and examples of the format of this argument. This " +
137+
"attribute is mutually exclusive with `requires_file`."),
138+
),
139+
"requires_file": attr.label(
140+
doc = ("A file containing a list of requirements for this package. See the section on " +
141+
"[Declaring required dependency](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#declaring-dependencies) " +
142+
"for details and examples of the format of this argument. This " +
143+
"attribute is mutually exclusive with `requires`."),
144+
allow_single_file = True,
131145
),
132146
}
133147

@@ -365,15 +379,50 @@ def _py_wheel_impl(ctx):
365379

366380
if ctx.attr.python_requires:
367381
metadata_contents.append("Requires-Python: %s" % ctx.attr.python_requires)
368-
for requirement in ctx.attr.requires:
369-
metadata_contents.append("Requires-Dist: %s" % requirement)
370382

383+
if ctx.attr.requires and ctx.attr.requires_file:
384+
fail("`requires` and `requires_file` are mutually exclusive. Please update {}".format(ctx.label))
385+
386+
for requires in ctx.attr.requires:
387+
metadata_contents.append("Requires-Dist: %s" % requires)
388+
if ctx.attr.requires_file:
389+
# The @ prefixed paths will be resolved by the PyWheel action.
390+
# Expanding each line containing a constraint in place of this
391+
# directive.
392+
metadata_contents.append("Requires-Dist: @%s" % ctx.file.requires_file.path)
393+
other_inputs.append(ctx.file.requires_file)
394+
395+
if ctx.attr.extra_requires and ctx.attr.extra_requires_files:
396+
fail("`extra_requires` and `extra_requires_files` are mutually exclusive. Please update {}".format(ctx.label))
371397
for option, option_requirements in sorted(ctx.attr.extra_requires.items()):
372398
metadata_contents.append("Provides-Extra: %s" % option)
373399
for requirement in option_requirements:
374400
metadata_contents.append(
375401
"Requires-Dist: %s; extra == '%s'" % (requirement, option),
376402
)
403+
extra_requires_files = {}
404+
for option_requires_target, option in ctx.attr.extra_requires_files.items():
405+
if option in extra_requires_files:
406+
fail("Duplicate `extra_requires_files` option '{}' found on target {}".format(option, ctx.label))
407+
option_requires_files = option_requires_target[DefaultInfo].files.to_list()
408+
if len(option_requires_files) != 1:
409+
fail("Labels in `extra_requires_files` must result in a single file, but {label} provides {files} from {owner}".format(
410+
label = ctx.label,
411+
files = option_requires_files,
412+
owner = option_requires_target.label,
413+
))
414+
extra_requires_files.update({option: option_requires_files[0]})
415+
416+
for option, option_requires_file in sorted(extra_requires_files.items()):
417+
metadata_contents.append("Provides-Extra: %s" % option)
418+
metadata_contents.append(
419+
# The @ prefixed paths will be resolved by the PyWheel action.
420+
# Expanding each line containing a constraint in place of this
421+
# directive and appending the extra option.
422+
"Requires-Dist: @%s; extra == '%s'" % (option_requires_file.path, option),
423+
)
424+
other_inputs.append(option_requires_file)
425+
377426
ctx.actions.write(
378427
output = metadata_file,
379428
content = "\n".join(metadata_contents) + "\n",
@@ -425,6 +474,7 @@ def _py_wheel_impl(ctx):
425474
)
426475

427476
ctx.actions.run(
477+
mnemonic = "PyWheel",
428478
inputs = depset(direct = other_inputs, transitive = [inputs_to_package]),
429479
outputs = [outfile, name_file],
430480
arguments = [args],

tools/wheelmaker.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,9 +510,36 @@ def main() -> None:
510510
) as description_file:
511511
description = description_file.read()
512512

513-
metadata = None
514-
with open(arguments.metadata_file, "rt", encoding="utf-8") as metadata_file:
515-
metadata = metadata_file.read()
513+
metadata = arguments.metadata_file.read_text(encoding="utf-8")
514+
515+
# This is not imported at the top of the file due to the reliance
516+
# on this file in the `whl_library` repository rule which does not
517+
# provide `packaging` but does import symbols defined here.
518+
from packaging.requirements import Requirement
519+
520+
# Search for any `Requires-Dist` entries that refer to other files and
521+
# expand them.
522+
for meta_line in metadata.splitlines():
523+
if not meta_line.startswith("Requires-Dist: @"):
524+
continue
525+
file, _, extra = meta_line[len("Requires-Dist: @") :].partition(";")
526+
extra = extra.strip()
527+
528+
reqs = []
529+
for reqs_line in Path(file).read_text(encoding="utf-8").splitlines():
530+
reqs_text = reqs_line.strip()
531+
if not reqs_text or reqs_text.startswith(("#", "-")):
532+
continue
533+
534+
req = Requirement(reqs_text)
535+
if req.marker:
536+
reqs.append(
537+
f"Requires-Dist: {req.name}{req.specifier}; ({req.marker}) and {extra}"
538+
)
539+
else:
540+
reqs.append(f"Requires-Dist: {req.name}{req.specifier}; {extra}")
541+
542+
metadata = metadata.replace(meta_line, "\n".join(reqs))
516543

517544
maker.add_metadata(
518545
metadata=metadata,

0 commit comments

Comments
 (0)