Skip to content

Commit ffc83a2

Browse files
author
Jonathon Belotti
committed
add code and tests to handle namespace pkgs
1 parent 88ce18d commit ffc83a2

File tree

4 files changed

+209
-0
lines changed

4 files changed

+209
-0
lines changed

src/BUILD

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
py_library(
2+
name = "src",
3+
srcs = glob(["*.py"]),
4+
srcs_version = "PY3",
5+
deps = [],
6+
)
7+
8+
py_test(
9+
name = "test",
10+
srcs = glob(["tests/*.py"]),
11+
main = "tests/__main__.py",
12+
deps = [":src"],
13+
)

src/namespace_pkgs.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import os
2+
import glob
3+
4+
from typing import Set
5+
6+
7+
# See https://packaging.python.org/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages
8+
PKGUTIL_STYLE_NS_PKG_INIT_CONTENTS = (
9+
"# __path__ manipulation added by rules_python_external to support namespace pkgs.\n"
10+
"__path__ = __import__('pkgutil').extend_path(__path__, __name__)\n"
11+
)
12+
13+
14+
def pkg_resources_style_namespace_packages(extracted_whl_directory) -> Set[str]:
15+
"""
16+
"While this approach is no longer recommended, it is widely present in most existing namespace packages." - PyPA
17+
See https://packaging.python.org/guides/packaging-namespace-packages/#pkg-resources-style-namespace-packages
18+
"""
19+
namespace_pkg_dirs = set()
20+
21+
dist_info_dirs = glob.glob(os.path.join(extracted_whl_directory, "*.dist-info"))
22+
if not dist_info_dirs:
23+
raise ValueError(f"No *.dist-info directory found. {extracted_whl_directory} is not a valid Wheel.")
24+
elif len(dist_info_dirs) > 1:
25+
raise ValueError(f"Found more than 1 *.dist-info directory. {extracted_whl_directory} is not a valid Wheel.")
26+
else:
27+
dist_info = dist_info_dirs[0]
28+
namespace_packages_record_file = os.path.join(dist_info, "namespace_packages.txt")
29+
if os.path.exists(namespace_packages_record_file):
30+
with open(namespace_packages_record_file) as nspkg:
31+
for line in nspkg.readlines():
32+
namespace = line.strip().replace(".", os.sep)
33+
if namespace:
34+
namespace_pkg_dirs.add(
35+
os.path.join(extracted_whl_directory, namespace)
36+
)
37+
return namespace_pkg_dirs
38+
39+
40+
def implicit_namespace_packages(directory, ignored_dirnames=None) -> Set[str]:
41+
namespace_pkg_dirs = set()
42+
for dirpath, dirnames, filenames in os.walk(directory, topdown=True):
43+
# We are only interested in dirs with no init file
44+
if "__init__.py" in filenames:
45+
dirnames[:] = [] # Remove dirnames from search
46+
continue
47+
48+
for ignored_dir in (ignored_dirnames or []):
49+
if ignored_dir in dirnames:
50+
dirnames.remove(ignored_dir)
51+
52+
non_empty_directory = (dirnames or filenames)
53+
if (
54+
non_empty_directory and
55+
# The root of the directory should never be an implicit namespace
56+
dirpath != directory
57+
):
58+
namespace_pkg_dirs.add(dirpath)
59+
return namespace_pkg_dirs
60+
61+
62+
def add_pkgutil_style_namespace_pkg_init(dirpath) -> None:
63+
"""TODO"""
64+
ns_pkg_init_filepath = os.path.join(dirpath, "__init__.py")
65+
66+
if os.path.isfile(ns_pkg_init_filepath):
67+
raise ValueError(f"{dirpath} already contains an __init__.py file.")
68+
with open(ns_pkg_init_filepath, "w") as ns_pkg_init_f:
69+
ns_pkg_init_f.write(PKGUTIL_STYLE_NS_PKG_INIT_CONTENTS)

src/tests/__main__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import unittest
2+
3+
4+
if __name__ == '__main__':
5+
loader = unittest.TestLoader()
6+
start_dir = '.'
7+
suite = loader.discover(start_dir)
8+
9+
runner = unittest.TextTestRunner()
10+
result = runner.run(suite)
11+
if result.errors or result.failures:
12+
exit(1)

src/tests/test_namespace_pkgs.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import os
2+
import pathlib
3+
import unittest
4+
import tempfile
5+
import shutil
6+
7+
import src.namespace_pkgs
8+
9+
10+
class TempDir:
11+
def __init__(self):
12+
self.dir = tempfile.mkdtemp()
13+
14+
def root(self):
15+
return self.dir
16+
17+
def add_dir(self, rel_path):
18+
d = pathlib.Path(self.dir, rel_path)
19+
d.mkdir(parents=True)
20+
21+
def add_file(self, rel_path, contents=None):
22+
f = pathlib.Path(self.dir, rel_path)
23+
f.parent.mkdir(parents=True, exist_ok=True)
24+
if contents:
25+
with open(f, "w") as writeable_f:
26+
writeable_f.write(contents)
27+
else:
28+
f.touch()
29+
30+
def remove(self):
31+
shutil.rmtree(self.dir)
32+
33+
34+
class TestPkgResourcesStyleNamespacePackages(unittest.TestCase):
35+
def test_finds_correct_namespace_packages(self):
36+
directory = TempDir()
37+
directory.add_file("google/auth/__init__.py")
38+
directory.add_file("google/auth/foo.py")
39+
directory.add_file(
40+
"google_auth-1.8.2.dist-info/namespace_packages.txt",
41+
contents="google\n"
42+
)
43+
44+
expected = {
45+
f"{directory.root()}/google",
46+
}
47+
actual = src.namespace_pkgs.pkg_resources_style_namespace_packages(directory.root())
48+
self.assertEqual(actual, expected)
49+
50+
def test_empty_case(self):
51+
# Even though this directory contains directories with no __init__.py
52+
# it has an empty namespace_packages.txt file so no namespace packages
53+
# should be returned.
54+
directory = TempDir()
55+
directory.add_file("foo/bar/biz.py")
56+
directory.add_file("foo/bee/boo.py")
57+
directory.add_file("foo/buu/__init__.py")
58+
directory.add_file("foo/buu/bii.py")
59+
directory.add_file("foo-1.0.0.dist-info/namespace_packages.txt")
60+
61+
actual = src.namespace_pkgs.pkg_resources_style_namespace_packages(directory.root())
62+
self.assertEqual(actual, set())
63+
64+
def test_missing_namespace_pkgs_record_file(self):
65+
# Even though this directory contains directories with no __init__.py
66+
# it has no namespace_packages.txt file, so no namespace packages should
67+
# be found and returned.
68+
directory = TempDir()
69+
directory.add_file("foo/bar/biz.py")
70+
directory.add_file("foo/bee/boo.py")
71+
directory.add_file("foo/buu/__init__.py")
72+
directory.add_file("foo/buu/bii.py")
73+
directory.add_file("foo-1.0.0.dist-info/METADATA")
74+
directory.add_file("foo-1.0.0.dist-info/RECORD")
75+
76+
actual = src.namespace_pkgs.pkg_resources_style_namespace_packages(directory.root())
77+
self.assertEqual(actual, set())
78+
79+
80+
class TestImplicitNamespacePackages(unittest.TestCase):
81+
def test_finds_correct_namespace_packages(self):
82+
directory = TempDir()
83+
directory.add_file("foo/bar/biz.py")
84+
directory.add_file("foo/bee/boo.py")
85+
directory.add_file("foo/buu/__init__.py")
86+
directory.add_file("foo/buu/bii.py")
87+
88+
expected = {
89+
f"{directory.root()}/foo",
90+
f"{directory.root()}/foo/bar",
91+
f"{directory.root()}/foo/bee",
92+
}
93+
actual = src.namespace_pkgs.implicit_namespace_packages(directory.root())
94+
self.assertEqual(actual, expected)
95+
96+
def test_ignores_empty_directories(self):
97+
directory = TempDir()
98+
directory.add_file("foo/bar/biz.py")
99+
directory.add_dir("foo/cat")
100+
101+
expected = {
102+
f"{directory.root()}/foo",
103+
f"{directory.root()}/foo/bar",
104+
}
105+
actual = src.namespace_pkgs.implicit_namespace_packages(directory.root())
106+
self.assertEqual(actual, expected)
107+
108+
def test_empty_case(self):
109+
directory = TempDir()
110+
directory.add_file("foo/__init__.py")
111+
directory.add_file("foo/bar/__init__.py")
112+
directory.add_file("foo/bar/biz.py")
113+
114+
actual = src.namespace_pkgs.implicit_namespace_packages(directory.root())
115+
self.assertEqual(actual, set())

0 commit comments

Comments
 (0)