Implement zip_safe=False support

This commit is contained in:
Reid 'arrdem' McKenzie 2021-08-29 18:44:43 -06:00
parent 3bcb3fc8ea
commit e5ff423318
6 changed files with 189 additions and 73 deletions

View file

@ -1,5 +1,6 @@
load("@rules_zapp//zapp:zapp.bzl", load("@rules_zapp//zapp:zapp.bzl",
"zapp_binary", "zapp_binary",
"zapp_test",
) )
load("@rules_python//python:defs.bzl", "py_library") load("@rules_python//python:defs.bzl", "py_library")
@ -8,13 +9,13 @@ load("@my_deps//:requirements.bzl",
py_requirement="requirement", py_requirement="requirement",
) )
zapp_binary( zapp_test(
name = "hello_script", name = "hello_script",
main = "hello.py", main = "hello.py",
# entry_point is inferred from main = # entry_point is inferred from main =
) )
zapp_binary( zapp_test(
name = "hello_deps", name = "hello_deps",
main = "hello.py", main = "hello.py",
# deps also get zapped via their underlying wheels # deps also get zapped via their underlying wheels
@ -31,10 +32,20 @@ py_library(
] ]
) )
zapp_binary( zapp_test(
name = "hello_lib_deps", name = "hello_lib_deps",
main = "hello.py", main = "hello.py",
deps = [ deps = [
":lib_hello", ":lib_hello",
], ],
) )
zapp_test(
name = "hello_unzipped",
zip_safe = False,
main = "hello.py",
deps = [
":lib_hello",
],
)

View file

@ -20,6 +20,9 @@ git_repository(
load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace") load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace")
bazel_skylib_workspace() bazel_skylib_workspace()
####################################################################################################
# rules_python
####################################################################################################
git_repository( git_repository(
name = "rules_python", name = "rules_python",
remote = "https://github.com/bazelbuild/rules_python.git", remote = "https://github.com/bazelbuild/rules_python.git",
@ -41,6 +44,6 @@ local_repository(
load("@rules_python//python:pip.bzl", "pip_install") load("@rules_python//python:pip.bzl", "pip_install")
pip_install( pip_install(
name = "my_deps", name = "my_deps",
requirements = "//:requirements.txt", requirements = "//:requirements.txt",
) )

View file

@ -5,7 +5,7 @@ def main():
for e in sys.path: for e in sys.path:
print(" -", e) print(" -", e)
print("hello, world!") print(f"hello, world! This is {__file__}")
try: try:
import yaml import yaml
@ -14,6 +14,8 @@ def main():
except ImportError: except ImportError:
print("Don't have YAML.") print("Don't have YAML.")
exit(0)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -104,7 +104,7 @@ def load_wheel(opts, manifest, path):
prefix = os.path.dirname(path) prefix = os.path.dirname(path)
sources = {k: v for k, v in manifest["sources"].items() if v.startswith(prefix)} sources = {k: v for k, v in manifest["sources"].items() if v["source"].startswith(prefix)}
return { return {
# "record": record, # "record": record,
@ -114,7 +114,6 @@ def load_wheel(opts, manifest, path):
} }
def wheel_name(wheel): def wheel_name(wheel):
"""Construct the "canonical" filename of the wheel.""" """Construct the "canonical" filename of the wheel."""
@ -145,7 +144,7 @@ def zip_wheel(tmpdir, wheel):
with zipfile.ZipFile(wheel_file, "w") as whl: with zipfile.ZipFile(wheel_file, "w") as whl:
for dest, src in wheel["sources"].items(): for dest, src in wheel["sources"].items():
whl.write(src, dest) whl.write(src["source"], dest)
return wheel_file return wheel_file
@ -158,15 +157,17 @@ def rezip_wheels(opts, manifest):
Files sourced from unzipped wheels should be removed, and a single wheel reference inserted.""" Files sourced from unzipped wheels should be removed, and a single wheel reference inserted."""
wheels = [ wheels = [
load_wheel(opts, manifest, os.path.dirname(p)) load_wheel(opts, manifest, os.path.dirname(s["source"]))
for p in manifest["sources"].values() for s in manifest["sources"].values()
if p.endswith("/WHEEL") if s["source"].endswith("/WHEEL")
] ]
# Zip up the wheels and insert wheel records to the manifest # Zip up the wheels and insert wheel records to the manifest
for w in wheels: for w in wheels:
# Try to cheat and hit in the local cache first rather than building wheels every time # Try to cheat and hit in the local cache first rather than building wheels every time
wn = wheel_name(w) wn = wheel_name(w)
# Expunge sources available in the wheel
manifest["sources"] = dsub(manifest["sources"], w["sources"])
# We may have a double-path dependency. # We may have a double-path dependency.
# If we DON'T, we have to zip # If we DON'T, we have to zip
@ -184,13 +185,10 @@ def rezip_wheels(opts, manifest):
# Insert a new wheel source # Insert a new wheel source
manifest["wheels"][wn] = {"hashes": [], "source": wf} manifest["wheels"][wn] = {"hashes": [], "source": wf}
# Expunge sources available in the wheel
manifest["sources"] = dsub(manifest["sources"], w["sources"])
return manifest return manifest
def generate_dunder_inits(manifest): def generate_dunder_inits(opts, manifest):
"""Hack the manifest to insert __init__ files as needed.""" """Hack the manifest to insert __init__ files as needed."""
sources = manifest["sources"] sources = manifest["sources"]
@ -207,20 +205,46 @@ def generate_dunder_inits(manifest):
def insert_manifest_json(opts, manifest): def insert_manifest_json(opts, manifest):
"""Insert the manifest.json file.""" """Insert the manifest.json file."""
manifest["sources"]["zapp/manifest.json"] = opts.manifest tempf = os.path.join(opts.tmpdir, "manifest.json")
# Note ordering to enable somewhat self-referential manifest
manifest["sources"]["zapp/manifest.json"] = {"source": tempf, "hashes": []}
with open(tempf, "w") as fp:
fp.write(json.dumps(manifest))
return manifest return manifest
def enable_unzipping(manifest): def generate_dunder_main(opts, manifest):
"""Insert the __main__.py to the manifest."""
if "__main__.py" in manifest["sources"]:
print("Error: __main__.py conflict.", file=sys.stderr)
exit(1)
tempf = os.path.join(opts.tmpdir, "__main__.py")
# Note ordering to enable somewhat self-referential manifest
manifest["sources"]["__main__.py"] = {"source": tempf, "hashes": []}
with open(tempf, "w") as fp:
fp.write(make_dunder_main(manifest))
return manifest
def enable_unzipping(opts, manifest):
"""Inject unzipping behavior as needed.""" """Inject unzipping behavior as needed."""
if manifest["wheels"]: if manifest["wheels"]:
manifest["prelude_points"].append("zapp.support.unpack:unpack_deps") manifest["prelude_points"].extend([
"zapp.support.unpack:unpack_deps",
"zapp.support.unpack:install_deps",
])
# FIXME: if not manifest["zip_safe"]:
# if not manifest["zip_safe"]: manifest["prelude_points"].extend([
# enable a similar injection for unzipping "zapp.support.unpack:unpack_zapp",
])
return manifest return manifest
@ -235,11 +259,13 @@ def main():
setattr(opts, "tmpdir", d) setattr(opts, "tmpdir", d)
manifest = rezip_wheels(opts, manifest) manifest = rezip_wheels(opts, manifest)
manifest = insert_manifest_json(opts, manifest) manifest = enable_unzipping(opts, manifest)
manifest = enable_unzipping(manifest)
# Patch the manifest to insert needed __init__ files # Patch the manifest to insert needed __init__ files
manifest = generate_dunder_inits(opts, manifest)
manifest = generate_dunder_main(opts, manifest)
# Generate and insert the manifest
# NOTE: This has to be the LAST thing we do # NOTE: This has to be the LAST thing we do
manifest = generate_dunder_inits(manifest) manifest = insert_manifest_json(opts, manifest)
if opts.debug: if opts.debug:
from pprint import pprint from pprint import pprint
@ -257,22 +283,14 @@ def main():
shebang = "#!" + manifest["shebang"] + "\n" shebang = "#!" + manifest["shebang"] + "\n"
zapp.write(shebang) zapp.write(shebang)
if "__main__.py" in manifest["sources"]:
print("Error: __main__.py conflict.", file=sys.stderr)
exit(1)
# Now we're gonna build the zapp from the manifest # Now we're gonna build the zapp from the manifest
with zipfile.ZipFile(opts.output, "a") as zapp: with zipfile.ZipFile(opts.output, "a") as zapp:
# Append the __main__.py generated record
zapp.writestr("__main__.py", make_dunder_main(manifest))
# Append user-specified sources # Append user-specified sources
for dest, src in sorted(manifest["sources"].items(), key=lambda x: x[0]): for dest, src in sorted(manifest["sources"].items(), key=lambda x: x[0]):
if src is None: if src is None:
zapp.writestr(dest, "") zapp.writestr(dest, "")
else: else:
zapp.write(src, dest) zapp.write(src["source"], dest)
# Append user-specified libraries # Append user-specified libraries
for whl, config in manifest["wheels"].items(): for whl, config in manifest["wheels"].items():

View file

@ -3,13 +3,26 @@
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from zipfile import ZipFile from tempfile import mkdtemp
from zipfile import ZipFile, is_zipfile
from zapp.support.manifest import manifest from zapp.support.manifest import manifest, once
@once
def cache_root() -> Path: def cache_root() -> Path:
return Path(os.path.join(os.path.expanduser("~"))) / ".cache" / "zapp" """Find a root directory for cached behaviors."""
shared_cache = Path(os.path.join(os.path.expanduser("~"))) / ".cache" / "zapp"
# Happy path, read+write filesystem
if os.access(shared_cache, os.X_OK | os.W_OK):
return shared_cache
# Unhappy path, we need a tempdir.
# At least make one that's stable for the program's lifetime.
else:
return Path(os.getenv("ZAPP_TMPDIR") or mkdtemp(), "deps")
def cache_wheel_root(): def cache_wheel_root():
@ -31,24 +44,79 @@ def cache_zapp_path(fingerprint):
def unpack_deps(): def unpack_deps():
"""Unpack deps, populating and updating the host's cache.""" """Unpack deps, populating and updating the host's cache."""
# Create the cache dir as needed if is_zipfile(sys.argv[0]):
cache_wheel_root().mkdir(parents=True, exist_ok=True) # Create the cache dir as needed
cache_wheel_root().mkdir(parents=True, exist_ok=True)
# For each wheel, touch the existing cached wheel or unpack this one. # For each wheel, touch the existing cached wheel or unpack this one.
with ZipFile(sys.argv[0], "r") as zf: with ZipFile(sys.argv[0], "r") as zf:
for whl, config in manifest()["wheels"].items(): for whl, config in manifest()["wheels"].items():
cached_whl = cache_wheel_path(whl) cached_whl = cache_wheel_path(whl)
if cached_whl.exists(): if cached_whl.exists():
cached_whl.touch() cached_whl.touch()
else: else:
with open(cached_whl, "wb") as of: with open(cached_whl, "wb") as of:
of.write(zf.read(".deps/" + whl)) of.write(zf.read(".deps/" + whl))
sys.path.insert(0, str(cached_whl)) else:
pass
def main(): def install_deps():
"""Inspect the manifest.""" """Validate that the PYTHONPATH has been configured correctly."""
unpack_deps() # FIXME: Can we reference the requirements, not specific PATH entries?
for whl, config in manifest()["wheels"].items():
cached_whl = cache_wheel_path(whl)
if cached_whl.exists():
cached_whl.touch()
# Remove any references to the dep and shift the cached whl to the front
p = str(cached_whl.resolve())
try:
sys.path.remove(p)
except ValueError:
pass
sys.path.insert(0, p)
def canonicalize_path():
"""Fixup sys.path entries to use absolute paths. De-dupe in the same pass."""
# Note that this HAS to be mutative/in-place
shift = 0
for i in range(len(sys.path)):
idx = i - shift
el = str(Path.resolve(sys.path[idx]))
if el in sys.path:
shift += 1
sys.path.pop(idx)
else:
sys.path[idx] = el
def unpack_zapp():
"""Unzip a zapp (excluding the .deps/* tree) into a working directory.
Note that unlike PEX, these directories are per-run which prevents local mutable state somewhat.
"""
# Short circuit
if is_zipfile(sys.argv[0]):
# Extract
tmpdir = mkdtemp()
with ZipFile(sys.argv[0], "r") as zf:
for src in manifest()["sources"].keys():
ofp = Path(tmpdir, "usr", src)
ofp.parent.mkdir(parents=True, exist_ok=True)
with open(ofp, "wb") as of:
of.write(zf.read(src))
# Re-exec the current interpreter
args = [sys.executable, "--", os.path.join(tmpdir, "usr", "__main__.py")] + sys.argv[1:]
os.execvpe(args[0], args[1:], {"PYTHONPATH": "", "ZAPP_TMPDIR": tmpdir})
else:
pass

View file

@ -125,7 +125,7 @@ def _zapp_impl(ctx):
output = manifest_file, output = manifest_file,
content = json.encode({ content = json.encode({
"shebang": ctx.attr.shebang, "shebang": ctx.attr.shebang,
"sources": sources_map, "sources": {d: {"hashes": [], "source": s} for d, s in sources_map.items()},
"zip_safe": ctx.attr.zip_safe, "zip_safe": ctx.attr.zip_safe,
"prelude_points": ctx.attr.prelude_points, "prelude_points": ctx.attr.prelude_points,
"entry_point": main_py_ref, "entry_point": main_py_ref,
@ -153,25 +153,32 @@ def _zapp_impl(ctx):
) )
# .zapp file itself has no runfiles and no providers # .zapp file itself has no runfiles and no providers
return [] return struct(
runfiles = ctx.runfiles(
files = [ctx.outputs.executable],
transitive_files = None,
collect_default = True,
)
)
_zapp_attrs = {
"src": attr.label(mandatory = True),
"main": attr.label(allow_single_file = True),
"wheels": attr.label_list(),
"entry_point": attr.string(),
"prelude_points": attr.string_list(),
"compiler": attr.label(
default = Label(DEFAULT_COMPILER),
executable = True,
cfg = "host",
),
"shebang": attr.string(default = "/usr/bin/env python3"),
"zip_safe": attr.bool(default = True),
"root_import": attr.bool(default = False),
}
zapp = rule( _zapp = rule(
attrs = { attrs = _zapp_attrs,
"src": attr.label(mandatory = True),
"main": attr.label(allow_single_file = True),
"wheels": attr.label_list(),
"entry_point": attr.string(),
"prelude_points": attr.string_list(),
"compiler": attr.label(
default = Label(DEFAULT_COMPILER),
executable = True,
cfg = "host",
),
"shebang": attr.string(default = "/usr/bin/env python3"),
"zip_safe": attr.bool(default = True),
"root_import": attr.bool(default = False),
},
executable = True, executable = True,
implementation = _zapp_impl, implementation = _zapp_impl,
) )
@ -186,6 +193,7 @@ def zapp_binary(name,
test=False, test=False,
compiler=None, compiler=None,
zip_safe=True, zip_safe=True,
rule=_zapp,
**kwargs): **kwargs):
"""A self-contained, single-file Python program, with a .zapp file extension. """A self-contained, single-file Python program, with a .zapp file extension.
@ -232,7 +240,7 @@ def zapp_binary(name,
**kwargs **kwargs
) )
zapp( rule(
name = name, name = name,
src = name + ".lib", src = name + ".lib",
compiler = compiler, compiler = compiler,
@ -244,8 +252,14 @@ def zapp_binary(name,
) )
_zapp_test = rule(
attrs = _zapp_attrs,
test = True,
implementation = _zapp_impl,
)
def zapp_test(name, **kwargs): def zapp_test(name, **kwargs):
"""Same as zapp_binary, just sets the test=True bit.""" """Same as zapp_binary, just sets the test=True bit."""
kwargs.pop("test") zapp_binary(name, rule=_zapp_test, **kwargs)
zapp_binary(name, test=True, **kwargs)