sapling/build/fbcode_builder/CMake/make_fbpy_archive.py
Zsolt Dollenstein dbfe4a85f3 Opt in opensource/fbcode_builder to pyfmt
Reviewed By: zertosh

Differential Revision: D29612107

fbshipit-source-id: ac450058134e23a3831db35d2e49c80eb8cde36a
2021-07-09 06:24:16 -07:00

328 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Copyright (c) Facebook, Inc. and its affiliates.
#
import argparse
import collections
import errno
import os
import shutil
import sys
import tempfile
import zipapp
MANIFEST_SEPARATOR = " :: "
MANIFEST_HEADER_V1 = "FBPY_MANIFEST 1\n"
class UsageError(Exception):
def __init__(self, message):
self.message = message
def __str__(self):
return self.message
class BadManifestError(UsageError):
def __init__(self, path, line_num, message):
full_msg = "%s:%s: %s" % (path, line_num, message)
super().__init__(full_msg)
self.path = path
self.line_num = line_num
self.raw_message = message
PathInfo = collections.namedtuple(
"PathInfo", ("src", "dest", "manifest_path", "manifest_line")
)
def parse_manifest(manifest, path_map):
bad_prefix = ".." + os.path.sep
manifest_dir = os.path.dirname(manifest)
with open(manifest, "r") as f:
line_num = 1
line = f.readline()
if line != MANIFEST_HEADER_V1:
raise BadManifestError(
manifest, line_num, "Unexpected manifest file header"
)
for line in f:
line_num += 1
if line.startswith("#"):
continue
line = line.rstrip("\n")
parts = line.split(MANIFEST_SEPARATOR)
if len(parts) != 2:
msg = "line must be of the form SRC %s DEST" % MANIFEST_SEPARATOR
raise BadManifestError(manifest, line_num, msg)
src, dest = parts
dest = os.path.normpath(dest)
if dest.startswith(bad_prefix):
msg = "destination path starts with %s: %s" % (bad_prefix, dest)
raise BadManifestError(manifest, line_num, msg)
if not os.path.isabs(src):
src = os.path.normpath(os.path.join(manifest_dir, src))
if dest in path_map:
prev_info = path_map[dest]
msg = (
"multiple source paths specified for destination "
"path %s. Previous source was %s from %s:%s"
% (
dest,
prev_info.src,
prev_info.manifest_path,
prev_info.manifest_line,
)
)
raise BadManifestError(manifest, line_num, msg)
info = PathInfo(
src=src,
dest=dest,
manifest_path=manifest,
manifest_line=line_num,
)
path_map[dest] = info
def populate_install_tree(inst_dir, path_map):
os.mkdir(inst_dir)
dest_dirs = {"": False}
def make_dest_dir(path):
if path in dest_dirs:
return
parent = os.path.dirname(path)
make_dest_dir(parent)
abs_path = os.path.join(inst_dir, path)
os.mkdir(abs_path)
dest_dirs[path] = False
def install_file(info):
dir_name, base_name = os.path.split(info.dest)
make_dest_dir(dir_name)
if base_name == "__init__.py":
dest_dirs[dir_name] = True
abs_dest = os.path.join(inst_dir, info.dest)
shutil.copy2(info.src, abs_dest)
# Copy all of the destination files
for info in path_map.values():
install_file(info)
# Create __init__ files in any directories that don't have them.
for dir_path, has_init in dest_dirs.items():
if has_init:
continue
init_path = os.path.join(inst_dir, dir_path, "__init__.py")
with open(init_path, "w"):
pass
def build_zipapp(args, path_map):
"""Create a self executing python binary using Python 3's built-in
zipapp module.
This type of Python binary is relatively simple, as zipapp is part of the
standard library, but it does not support native language extensions
(.so/.dll files).
"""
dest_dir = os.path.dirname(args.output)
with tempfile.TemporaryDirectory(prefix="make_fbpy.", dir=dest_dir) as tmpdir:
inst_dir = os.path.join(tmpdir, "tree")
populate_install_tree(inst_dir, path_map)
tmp_output = os.path.join(tmpdir, "output.exe")
zipapp.create_archive(
inst_dir, target=tmp_output, interpreter=args.python, main=args.main
)
os.replace(tmp_output, args.output)
def create_main_module(args, inst_dir, path_map):
if not args.main:
assert "__main__.py" in path_map
return
dest_path = os.path.join(inst_dir, "__main__.py")
main_module, main_fn = args.main.split(":")
main_contents = """\
#!{python}
if __name__ == "__main__":
import {main_module}
{main_module}.{main_fn}()
""".format(
python=args.python, main_module=main_module, main_fn=main_fn
)
with open(dest_path, "w") as f:
f.write(main_contents)
os.chmod(dest_path, 0o755)
def build_install_dir(args, path_map):
"""Create a directory that contains all of the sources, with a __main__
module to run the program.
"""
# Populate a temporary directory first, then rename to the destination
# location. This ensures that we don't ever leave a halfway-built
# directory behind at the output path if something goes wrong.
dest_dir = os.path.dirname(args.output)
with tempfile.TemporaryDirectory(prefix="make_fbpy.", dir=dest_dir) as tmpdir:
inst_dir = os.path.join(tmpdir, "tree")
populate_install_tree(inst_dir, path_map)
create_main_module(args, inst_dir, path_map)
os.rename(inst_dir, args.output)
def ensure_directory(path):
try:
os.makedirs(path)
except OSError as ex:
if ex.errno != errno.EEXIST:
raise
def install_library(args, path_map):
"""Create an installation directory a python library."""
out_dir = args.output
out_manifest = args.output + ".manifest"
install_dir = args.install_dir
if not install_dir:
install_dir = out_dir
os.makedirs(out_dir)
with open(out_manifest, "w") as manifest:
manifest.write(MANIFEST_HEADER_V1)
for info in path_map.values():
abs_dest = os.path.join(out_dir, info.dest)
ensure_directory(os.path.dirname(abs_dest))
print("copy %r --> %r" % (info.src, abs_dest))
shutil.copy2(info.src, abs_dest)
installed_dest = os.path.join(install_dir, info.dest)
manifest.write("%s%s%s\n" % (installed_dest, MANIFEST_SEPARATOR, info.dest))
def parse_manifests(args):
# Process args.manifest_separator to help support older versions of CMake
if args.manifest_separator:
manifests = []
for manifest_arg in args.manifests:
split_arg = manifest_arg.split(args.manifest_separator)
manifests.extend(split_arg)
args.manifests = manifests
path_map = {}
for manifest in args.manifests:
parse_manifest(manifest, path_map)
return path_map
def check_main_module(args, path_map):
# Translate an empty string in the --main argument to None,
# just to allow the CMake logic to be slightly simpler and pass in an
# empty string when it really wants the default __main__.py module to be
# used.
if args.main == "":
args.main = None
if args.type == "lib-install":
if args.main is not None:
raise UsageError("cannot specify a --main argument with --type=lib-install")
return
main_info = path_map.get("__main__.py")
if args.main:
if main_info is not None:
msg = (
"specified an explicit main module with --main, "
"but the file listing already includes __main__.py"
)
raise BadManifestError(
main_info.manifest_path, main_info.manifest_line, msg
)
parts = args.main.split(":")
if len(parts) != 2:
raise UsageError(
"argument to --main must be of the form MODULE:CALLABLE "
"(received %s)" % (args.main,)
)
else:
if main_info is None:
raise UsageError(
"no main module specified with --main, "
"and no __main__.py module present"
)
BUILD_TYPES = {
"zipapp": build_zipapp,
"dir": build_install_dir,
"lib-install": install_library,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-o", "--output", required=True, help="The output file path")
ap.add_argument(
"--install-dir",
help="When used with --type=lib-install, this parameter specifies the "
"final location where the library where be installed. This can be "
"used to generate the library in one directory first, when you plan "
"to move or copy it to another final location later.",
)
ap.add_argument(
"--manifest-separator",
help="Split manifest arguments around this separator. This is used "
"to support older versions of CMake that cannot supply the manifests "
"as separate arguments.",
)
ap.add_argument(
"--main",
help="The main module to run, specified as <module>:<callable>. "
"This must be specified if and only if the archive does not contain "
"a __main__.py file.",
)
ap.add_argument(
"--python",
help="Explicitly specify the python interpreter to use for the " "executable.",
)
ap.add_argument(
"--type", choices=BUILD_TYPES.keys(), help="The type of output to build."
)
ap.add_argument(
"manifests",
nargs="+",
help="The manifest files specifying how to construct the archive",
)
args = ap.parse_args()
if args.python is None:
args.python = sys.executable
if args.type is None:
# In the future we might want different default output types
# for different platforms.
args.type = "zipapp"
build_fn = BUILD_TYPES[args.type]
try:
path_map = parse_manifests(args)
check_main_module(args, path_map)
except UsageError as ex:
print("error: %s" % (ex,), file=sys.stderr)
sys.exit(1)
build_fn(args, path_map)
if __name__ == "__main__":
main()