diff --git a/example/BUILD b/example/BUILD index 4e11154..0a69732 100644 --- a/example/BUILD +++ b/example/BUILD @@ -1,5 +1,6 @@ load("@rules_zapp//zapp:zapp.bzl", "zapp_binary", + "zapp_test", ) load("@rules_python//python:defs.bzl", "py_library") @@ -8,13 +9,13 @@ load("@my_deps//:requirements.bzl", py_requirement="requirement", ) -zapp_binary( +zapp_test( name = "hello_script", main = "hello.py", # entry_point is inferred from main = ) -zapp_binary( +zapp_test( name = "hello_deps", main = "hello.py", # deps also get zapped via their underlying wheels @@ -31,10 +32,20 @@ py_library( ] ) -zapp_binary( +zapp_test( name = "hello_lib_deps", main = "hello.py", deps = [ ":lib_hello", ], ) + + +zapp_test( + name = "hello_unzipped", + zip_safe = False, + main = "hello.py", + deps = [ + ":lib_hello", + ], +) diff --git a/example/WORKSPACE b/example/WORKSPACE index 29465d3..164c6e3 100644 --- a/example/WORKSPACE +++ b/example/WORKSPACE @@ -20,6 +20,9 @@ git_repository( load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace") bazel_skylib_workspace() +#################################################################################################### +# rules_python +#################################################################################################### git_repository( name = "rules_python", remote = "https://github.com/bazelbuild/rules_python.git", @@ -41,6 +44,6 @@ local_repository( load("@rules_python//python:pip.bzl", "pip_install") pip_install( - name = "my_deps", - requirements = "//:requirements.txt", + name = "my_deps", + requirements = "//:requirements.txt", ) diff --git a/example/hello.py b/example/hello.py index 88f8b23..bb50773 100644 --- a/example/hello.py +++ b/example/hello.py @@ -5,7 +5,7 @@ def main(): for e in sys.path: print(" -", e) - print("hello, world!") + print(f"hello, world! This is {__file__}") try: import yaml @@ -14,6 +14,8 @@ def main(): except ImportError: print("Don't have YAML.") + exit(0) + if __name__ == "__main__": main() diff --git a/zapp/compiler/__main__.py b/zapp/compiler/__main__.py index 60dc081..3fd7cbc 100644 --- a/zapp/compiler/__main__.py +++ b/zapp/compiler/__main__.py @@ -104,7 +104,7 @@ def load_wheel(opts, manifest, 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 { # "record": record, @@ -114,7 +114,6 @@ def load_wheel(opts, manifest, path): } - def wheel_name(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: for dest, src in wheel["sources"].items(): - whl.write(src, dest) + whl.write(src["source"], dest) 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.""" wheels = [ - load_wheel(opts, manifest, os.path.dirname(p)) - for p in manifest["sources"].values() - if p.endswith("/WHEEL") + load_wheel(opts, manifest, os.path.dirname(s["source"])) + for s in manifest["sources"].values() + if s["source"].endswith("/WHEEL") ] # Zip up the wheels and insert wheel records to the manifest for w in wheels: # Try to cheat and hit in the local cache first rather than building wheels every time wn = wheel_name(w) + # Expunge sources available in the wheel + manifest["sources"] = dsub(manifest["sources"], w["sources"]) # We may have a double-path dependency. # If we DON'T, we have to zip @@ -184,13 +185,10 @@ def rezip_wheels(opts, manifest): # Insert a new wheel source manifest["wheels"][wn] = {"hashes": [], "source": wf} - # Expunge sources available in the wheel - manifest["sources"] = dsub(manifest["sources"], w["sources"]) - return manifest -def generate_dunder_inits(manifest): +def generate_dunder_inits(opts, manifest): """Hack the manifest to insert __init__ files as needed.""" sources = manifest["sources"] @@ -207,20 +205,46 @@ def generate_dunder_inits(manifest): def insert_manifest_json(opts, manifest): """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 -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.""" 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"]: - # enable a similar injection for unzipping + if not manifest["zip_safe"]: + manifest["prelude_points"].extend([ + "zapp.support.unpack:unpack_zapp", + ]) return manifest @@ -235,11 +259,13 @@ def main(): setattr(opts, "tmpdir", d) manifest = rezip_wheels(opts, manifest) - manifest = insert_manifest_json(opts, manifest) - manifest = enable_unzipping(manifest) + manifest = enable_unzipping(opts, manifest) # 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 - manifest = generate_dunder_inits(manifest) + manifest = insert_manifest_json(opts, manifest) if opts.debug: from pprint import pprint @@ -257,22 +283,14 @@ def main(): shebang = "#!" + manifest["shebang"] + "\n" 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 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 for dest, src in sorted(manifest["sources"].items(), key=lambda x: x[0]): if src is None: zapp.writestr(dest, "") else: - zapp.write(src, dest) + zapp.write(src["source"], dest) # Append user-specified libraries for whl, config in manifest["wheels"].items(): diff --git a/zapp/support/unpack.py b/zapp/support/unpack.py index 2a434ed..fd00efe 100644 --- a/zapp/support/unpack.py +++ b/zapp/support/unpack.py @@ -3,13 +3,26 @@ import os import sys 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: - 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(): @@ -31,24 +44,79 @@ def cache_zapp_path(fingerprint): def unpack_deps(): """Unpack deps, populating and updating the host's cache.""" - # Create the cache dir as needed - cache_wheel_root().mkdir(parents=True, exist_ok=True) + if is_zipfile(sys.argv[0]): + # 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. - with ZipFile(sys.argv[0], "r") as zf: - for whl, config in manifest()["wheels"].items(): - cached_whl = cache_wheel_path(whl) - if cached_whl.exists(): - cached_whl.touch() + # For each wheel, touch the existing cached wheel or unpack this one. + with ZipFile(sys.argv[0], "r") as zf: + for whl, config in manifest()["wheels"].items(): + cached_whl = cache_wheel_path(whl) + if cached_whl.exists(): + cached_whl.touch() - else: - with open(cached_whl, "wb") as of: - of.write(zf.read(".deps/" + whl)) + else: + with open(cached_whl, "wb") as of: + of.write(zf.read(".deps/" + whl)) - sys.path.insert(0, str(cached_whl)) + else: + pass -def main(): - """Inspect the manifest.""" +def install_deps(): + """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 diff --git a/zapp/zapp.bzl b/zapp/zapp.bzl index ddffb4b..913109f 100644 --- a/zapp/zapp.bzl +++ b/zapp/zapp.bzl @@ -125,7 +125,7 @@ def _zapp_impl(ctx): output = manifest_file, content = json.encode({ "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, "prelude_points": ctx.attr.prelude_points, "entry_point": main_py_ref, @@ -153,25 +153,32 @@ def _zapp_impl(ctx): ) # .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( - 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( + attrs = _zapp_attrs, executable = True, implementation = _zapp_impl, ) @@ -186,6 +193,7 @@ def zapp_binary(name, test=False, compiler=None, zip_safe=True, + rule=_zapp, **kwargs): """A self-contained, single-file Python program, with a .zapp file extension. @@ -232,7 +240,7 @@ def zapp_binary(name, **kwargs ) - zapp( + rule( name = name, src = name + ".lib", 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): """Same as zapp_binary, just sets the test=True bit.""" - kwargs.pop("test") - zapp_binary(name, test=True, **kwargs) + zapp_binary(name, rule=_zapp_test, **kwargs)