| """Repository rule py_workspace(), augmenting @rules_python, for relocating | |
| py_library() and py_package() files underneath a given Python namespace. | |
| The stock @rules_python py_library() -> py_package() -> py_wheel() BUILD file | |
| workflow packages files at Python package paths set to the source paths of the | |
| files relative to the workspace root. This has a several problems. Firstly, it | |
| implies that files must be located underneath a source directory with the same | |
| name as the desired Python namespace package. ( py_wheel.strip_path_prefixes | |
| can remove path components, but cannot add them.) This is not always feasible | |
| or desirable. | |
| Secondly, this path naming is incompatible with the PYTHONPATH set by | |
| @rules_python when executing Python programs in the source tree via | |
| py_binary(). PYTHONPATH is set such that imports should begin with the | |
| WORKSPACE name, followed by the path from the workspace root. py_wheel(), | |
| however, packages files such that imports use only the path from the workspace | |
| root. | |
| For example, the source file: | |
| example/hello.py | |
| is imported by a py_binary() running in the source tree as: | |
| `import workspace_name.example.hello` | |
| but must be imported from within the package created by py_wheel() as: | |
| `import example.hello` | |
| The end result is that code cannot be written to work both in the source tree | |
| and installed in a Python environment via a package. | |
| py_namespace() fixes these problems by providing the means to package files | |
| within a Python package namespace without adding a corresponding directory in | |
| the source tree. The BUILD workflow changes to py_libary() -> py_package() -> | |
| **py_namespace()** -> py_wheel(). For example: | |
| ``` | |
| # in example/BUILD | |
| py_library( | |
| name = "library", | |
| srcs = ["hello.py"], | |
| deps = ..., | |
| ) | |
| py_package( | |
| name = "package", | |
| deps = [":library"], | |
| ) | |
| py_namespace( | |
| name = "namespace", | |
| deps = [":package"], | |
| namespace = "foo", | |
| ) | |
| py_wheel( | |
| .... | |
| deps = [":namespace"], | |
| ) | |
| ``` | |
| In this case, the source file: | |
| example/hello.py | |
| which is imported by a py_binary() running in the source tree as: | |
| `import workspace_name.example.hello` | |
| is imported from the package created by py_wheel() as: | |
| `import foo.example.hello` | |
| If the namespace and the WORKSPACE name match, the import paths used when | |
| running in the source tree will match the import paths used when installed in | |
| the Python environment. | |
| Furthermore, the Python package can be given an __init__.py file via the | |
| attribute `init`. The given file is relocated directly under the namespace as | |
| __init__.py, regardless of its path in the source tree. This __init__.py can be | |
| used for, among other things, providing a user-friendly public API: providing | |
| aliases for modules otherwise deeply nested in subpackages due to their | |
| location in the source tree. | |
| """ | |
| def _relocate_init(ctx): | |
| # Copy the init file directly underneath the namespace directory. | |
| outfile = ctx.actions.declare_file(ctx.attr.namespace + "/__init__.py") | |
| ctx.actions.run_shell( | |
| inputs = [ctx.file.init], | |
| outputs = [outfile], | |
| arguments = [ctx.file.init.path, outfile.path], | |
| command = "cp $1 $2", | |
| ) | |
| return outfile | |
| def _relocate_deps(ctx): | |
| # Copy all transitive deps underneath the namespace directory. E.g., | |
| # example/hello.py | |
| # becomes: | |
| # namespace/example/hello.py | |
| outfiles = [] | |
| inputs = depset(transitive = [dep[DefaultInfo].files for dep in ctx.attr.deps]) | |
| for infile in sorted(inputs.to_list()): | |
| outfile = ctx.actions.declare_file(ctx.attr.namespace + "/" + infile.short_path) | |
| ctx.actions.run_shell( | |
| inputs = [infile], | |
| outputs = [outfile], | |
| arguments = [infile.path, outfile.path], | |
| command = "cp $1 $2", | |
| ) | |
| outfiles.append(outfile) | |
| return outfiles | |
| def _py_namespace(ctx): | |
| # Copy all input files underneath the namesapce directory and return a | |
| # Provider with the new file locations. | |
| outfiles = [] | |
| if ctx.file.init: | |
| outfiles.append(_relocate_init(ctx)) | |
| outfiles.extend(_relocate_deps(ctx)) | |
| return [ | |
| DefaultInfo(files = depset(outfiles)), | |
| ] | |
| py_namespace = rule( | |
| implementation = _py_namespace, | |
| attrs = { | |
| "init": attr.label( | |
| doc = "optional file for __init__.py", | |
| allow_single_file = [".py"], | |
| mandatory = False, | |
| ), | |
| "namespace": attr.string( | |
| doc = "name for Python namespace", | |
| mandatory = True, | |
| ), | |
| "deps": attr.label_list( | |
| doc = "list of py_library() and py_package()s to include", | |
| mandatory = True, | |
| ), | |
| }, | |
| ) |