Skip to content

Commit a6c756d

Browse files
Add support for requirement extras (#28)
1 parent f3c53fe commit a6c756d

File tree

7 files changed

+108
-29
lines changed

7 files changed

+108
-29
lines changed

extract_wheels/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import subprocess
1212
import sys
1313

14-
from extract_wheels.lib import bazel
14+
from extract_wheels.lib import bazel, requirements
1515

1616

1717
def configure_reproducible_wheels() -> None:
@@ -70,8 +70,10 @@ def main() -> None:
7070
[sys.executable, "-m", "pip", "wheel", "-r", args.requirements]
7171
)
7272

73+
extras = requirements.parse_extras(args.requirements)
74+
7375
targets = [
74-
'"%s%s"' % (args.repo, bazel.extract_wheel(whl, []))
76+
'"%s%s"' % (args.repo, bazel.extract_wheel(whl, extras))
7577
for whl in glob.glob("*.whl")
7678
]
7779

extract_wheels/lib/BUILD

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ py_library(
77
"bazel.py",
88
"namespace_pkgs.py",
99
"purelib.py",
10+
"requirements.py",
1011
"wheel.py",
1112
],
1213
deps = [
@@ -26,3 +27,15 @@ py_test(
2627
":lib",
2728
],
2829
)
30+
31+
py_test(
32+
name = "requirements_test",
33+
size = "small",
34+
srcs = [
35+
"requirements_test.py",
36+
],
37+
tags = ["unit"],
38+
deps = [
39+
":lib",
40+
],
41+
)

extract_wheels/lib/bazel.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Utility functions to manipulate Bazel files"""
22
import os
33
import textwrap
4-
from typing import Iterable, List
4+
from typing import Iterable, List, Dict, Set
55

66
from extract_wheels.lib import namespace_pkgs, wheel, purelib
77

@@ -113,7 +113,7 @@ def setup_namespace_pkg_compatibility(wheel_dir: str) -> None:
113113
namespace_pkgs.add_pkgutil_style_namespace_pkg_init(ns_pkg_dir)
114114

115115

116-
def extract_wheel(wheel_file: str, extras: List[str]) -> str:
116+
def extract_wheel(wheel_file: str, extras: Dict[str, Set[str]]) -> str:
117117
"""Extracts wheel into given directory and creates a py_library target.
118118
119119
Args:
@@ -134,16 +134,17 @@ def extract_wheel(wheel_file: str, extras: List[str]) -> str:
134134
purelib.spread_purelib_into_root(directory)
135135
setup_namespace_pkg_compatibility(directory)
136136

137+
extras_requested = extras[whl.name] if whl.name in extras else set()
138+
139+
sanitised_dependencies = [
140+
'"//%s"' % sanitise_name(d) for d in sorted(whl.dependencies(extras_requested))
141+
]
142+
137143
with open(os.path.join(directory, "BUILD"), "w") as build_file:
138-
build_file.write(
139-
generate_build_file_contents(
140-
sanitise_name(whl.name),
141-
[
142-
'"//%s"' % sanitise_name(d)
143-
for d in sorted(whl.dependencies(extras_requested=extras))
144-
],
145-
)
144+
contents = generate_build_file_contents(
145+
sanitise_name(whl.name), sanitised_dependencies,
146146
)
147+
build_file.write(contents)
147148

148149
os.remove(whl.path)
149150

extract_wheels/lib/namespace_pkgs_test.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import os
21
import pathlib
32
import shutil
4-
import sys
53
import tempfile
64
import unittest
75

@@ -133,17 +131,5 @@ def test_empty_case(self):
133131
self.assertEqual(actual, set())
134132

135133

136-
def main():
137-
loader = unittest.TestLoader()
138-
cur_dir = os.path.dirname(os.path.realpath(__file__))
139-
140-
suite = loader.discover(cur_dir)
141-
142-
runner = unittest.TextTestRunner()
143-
result = runner.run(suite)
144-
if result.errors or result.failures:
145-
sys.exit(1)
146-
147-
148134
if __name__ == "__main__":
149-
main()
135+
unittest.main()

extract_wheels/lib/requirements.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import re
2+
from typing import Dict, Set, Tuple, Optional
3+
4+
5+
def parse_extras(requirements_path: str) -> Dict[str, Set[str]]:
6+
"""Parse over the requirements.txt file to find extras requested.
7+
8+
Args:
9+
requirements_path: The filepath for the requirements.txt file to parse.
10+
11+
Returns:
12+
A dictionary mapping the requirement name to a set of extras requested.
13+
"""
14+
15+
extras_requested = {}
16+
with open(requirements_path, "r") as requirements:
17+
# Merge all backslash line continuations so we parse each requirement as a single line.
18+
for line in requirements.read().replace("\\\n", "").split("\n"):
19+
requirement, extras = _parse_requirement_for_extra(line)
20+
if requirement and extras:
21+
extras_requested[requirement] = extras
22+
23+
return extras_requested
24+
25+
26+
def _parse_requirement_for_extra(
27+
requirement: str,
28+
) -> Tuple[Optional[str], Optional[Set[str]]]:
29+
"""Given a requirement string, returns the requirement name and set of extras, if extras specified.
30+
Else, returns (None, None)
31+
"""
32+
33+
# https://www.python.org/dev/peps/pep-0508/#grammar
34+
extras_pattern = re.compile(
35+
r"^\s*([0-9A-Za-z][0-9A-Za-z_.\-]*)\s*\[\s*([0-9A-Za-z][0-9A-Za-z_.\-]*(?:\s*,\s*[0-9A-Za-z][0-9A-Za-z_.\-]*)*)\s*\]"
36+
)
37+
38+
matches = extras_pattern.match(requirement)
39+
if matches:
40+
return (
41+
matches.group(1),
42+
{extra.strip() for extra in matches.group(2).split(",")},
43+
)
44+
45+
return None, None
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import unittest
2+
3+
from extract_wheels.lib import requirements
4+
5+
6+
class TestRequirementExtrasParsing(unittest.TestCase):
7+
def test_parses_requirement_for_extra(self) -> None:
8+
cases = [
9+
("name[foo]", ("name", frozenset(["foo"]))),
10+
("name[ Foo123 ]", ("name", frozenset(["Foo123"]))),
11+
(" name1[ foo ] ", ("name1", frozenset(["foo"]))),
12+
(
13+
"name [fred,bar] @ http://foo.com ; python_version=='2.7'",
14+
("name", frozenset(["fred", "bar"])),
15+
),
16+
(
17+
"name[quux, strange];python_version<'2.7' and platform_version=='2'",
18+
("name", frozenset(["quux", "strange"])),
19+
),
20+
("name; (os_name=='a' or os_name=='b') and os_name=='c'", (None, None),),
21+
("name@http://foo.com", (None, None),),
22+
]
23+
24+
for case, expected in cases:
25+
with self.subTest():
26+
self.assertTupleEqual(
27+
requirements._parse_requirement_for_extra(case), expected
28+
)
29+
30+
31+
if __name__ == "__main__":
32+
unittest.main()

extract_wheels/lib/wheel.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import glob
33
import os
44
import zipfile
5-
from typing import Dict, Optional, List, Set
5+
from typing import Dict, Optional, Set
66

77
import pkg_resources
88
import pkginfo
@@ -26,7 +26,7 @@ def name(self) -> str:
2626
def metadata(self) -> pkginfo.Wheel:
2727
return pkginfo.get_metadata(self.path)
2828

29-
def dependencies(self, extras_requested: Optional[List[str]] = None) -> Set[str]:
29+
def dependencies(self, extras_requested: Optional[Set[str]] = None) -> Set[str]:
3030
dependency_set = set()
3131

3232
for wheel_req in self.metadata.requires_dist:

0 commit comments

Comments
 (0)