Skip to content

Commit 571c268

Browse files
author
Jonathon Belotti
authored
Merge pull request #5 from dillon-giacoppo/jonathon/namespace-pkgs-support
Namespace pkgs support
2 parents 944b158 + 3b86004 commit 571c268

File tree

7 files changed

+419
-1
lines changed

7 files changed

+419
-1
lines changed

.gitignore

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Intellij
2+
.ijwb/
3+
.idea/
4+
5+
# Bazel
6+
bazel-*
7+
8+
# Byte-compiled / optimized / DLL files
9+
__pycache__/
10+
*.py[cod]
11+
*$py.class
12+
13+
# C extensions
14+
*.so
15+
16+
# Distribution / packaging
17+
.Python
18+
build/
19+
develop-eggs/
20+
dist/
21+
downloads/
22+
eggs/
23+
.eggs/
24+
lib/
25+
lib64/
26+
parts/
27+
sdist/
28+
var/
29+
wheels/
30+
pip-wheel-metadata/
31+
share/python-wheels/
32+
*.egg-info/
33+
.installed.cfg
34+
*.egg
35+
MANIFEST
36+
37+
# PyInstaller
38+
# Usually these files are written by a python script from a template
39+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
40+
*.manifest
41+
*.spec
42+
43+
# Installer logs
44+
pip-log.txt
45+
pip-delete-this-directory.txt
46+
47+
# Unit test / coverage reports
48+
htmlcov/
49+
.tox/
50+
.nox/
51+
.coverage
52+
.coverage.*
53+
.cache
54+
nosetests.xml
55+
coverage.xml
56+
*.cover
57+
*.py,cover
58+
.hypothesis/
59+
.pytest_cache/
60+
61+
# Translations
62+
*.mo
63+
*.pot
64+
65+
# Django stuff:
66+
*.log
67+
local_settings.py
68+
db.sqlite3
69+
db.sqlite3-journal
70+
71+
# Flask stuff:
72+
instance/
73+
.webassets-cache
74+
75+
# Scrapy stuff:
76+
.scrapy
77+
78+
# Sphinx documentation
79+
docs/_build/
80+
81+
# PyBuilder
82+
target/
83+
84+
# Jupyter Notebook
85+
.ipynb_checkpoints
86+
87+
# IPython
88+
profile_default/
89+
ipython_config.py
90+
91+
# pyenv
92+
.python-version
93+
94+
# pipenv
95+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
97+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
98+
# install all needed dependencies.
99+
#Pipfile.lock
100+
101+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
102+
__pypackages__/
103+
104+
# Celery stuff
105+
celerybeat-schedule
106+
celerybeat.pid
107+
108+
# SageMath parsed files
109+
*.sage.py
110+
111+
# Environments
112+
.env
113+
.venv
114+
env/
115+
venv/
116+
ENV/
117+
env.bak/
118+
venv.bak/
119+
120+
# Spyder project settings
121+
.spyderproject
122+
.spyproject
123+
124+
# Rope project settings
125+
.ropeproject
126+
127+
# mkdocs documentation
128+
/site
129+
130+
# mypy
131+
.mypy_cache/
132+
.dmypy.json
133+
dmypy.json
134+
135+
# Pyre type checker
136+
.pyre/

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,9 @@ py_binary(
4242
],
4343
)
4444
```
45+
46+
## Development
47+
48+
### Testing
49+
50+
`bazel test //...`

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/extract_wheels.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import subprocess
55
import sys
66

7+
8+
from . import namespace_pkgs
79
from .wheel import Wheel
810

911
BUILD_TEMPLATE = """\
@@ -37,9 +39,37 @@ def sanitise_name(name):
3739
return "pypi__" + name.replace("-", "_").replace(".", "_").lower()
3840

3941

42+
def _setup_namespace_pkg_compatibility(extracted_whl_directory):
43+
"""
44+
Namespace packages can be created in one of three ways. They are detailed here:
45+
https://packaging.python.org/guides/packaging-namespace-packages/#creating-a-namespace-package
46+
47+
'pkgutil-style namespace packages' (2) works in Bazel, but 'native namespace packages' (1) and
48+
'pkg_resources-style namespace packages' (3) do not.
49+
50+
We ensure compatibility with Bazel of methods 1 and 3 by converting them into method 2.
51+
"""
52+
namespace_pkg_dirs = namespace_pkgs.pkg_resources_style_namespace_packages(
53+
extracted_whl_directory
54+
)
55+
if (
56+
not namespace_pkg_dirs and
57+
namespace_pkgs.native_namespace_packages_supported()
58+
):
59+
namespace_pkg_dirs = namespace_pkgs.implicit_namespace_packages(
60+
extracted_whl_directory,
61+
ignored_dirnames=[
62+
f"{extracted_whl_directory}/bin",
63+
]
64+
)
65+
66+
for ns_pkg_dir in namespace_pkg_dirs:
67+
namespace_pkgs.add_pkgutil_style_namespace_pkg_init(ns_pkg_dir)
68+
69+
4070
def extract_wheel(whl, directory, extras):
4171
"""
42-
Unzips a wheel into the Bazel repository and creates the BUILD file
72+
Unzips a wheel into the Bazel repository and prepares it for use by Python rules.
4373
4474
:param whl: the Wheel object we are unpacking
4575
:param directory: the subdirectory of the external repo to unzip to
@@ -48,6 +78,8 @@ def extract_wheel(whl, directory, extras):
4878

4979
whl.unzip(directory)
5080

81+
_setup_namespace_pkg_compatibility(directory)
82+
5183
with open(os.path.join(directory, "BUILD"), "w") as f:
5284
f.write(
5385
BUILD_TEMPLATE.format(

src/namespace_pkgs.py

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

0 commit comments

Comments
 (0)