#!/usr/bin/env python from __future__ import annotations import hashlib import json import os import os.path import pathlib import re import shlex import shutil import subprocess import sys import tempfile import typing try: from hatchling.builders.hooks.plugin.interface import BuildHookInterface except ImportError: BuildHookInterface = object BASE_PATH = pathlib.Path(__file__).parent.resolve() DEFAULT_BUNDLER = ["esbuild", "pnpm", "deno", "bun", "node"] DEFAULT_INSTALLER = ["pnpm", "deno", "bun", "npm"] def run(): if not os.environ.get("EJS_BUILD_SKIP_INSTALL"): name, path = find_executable("EJS_BUILD_INSTALLER", DEFAULT_INSTALLER) args, env = build_install_args(name) cmd = [path, *args] print(f"Install command: {shlex.join(cmd)}") subprocess.run(cmd, env=env, check=False) esbuild = ESBuild(*find_executable("EJS_BUILD_BUNDLER", DEFAULT_BUNDLER)) print(f"Bundle command: {shlex.join(esbuild.cmd)}", file=sys.stderr) externals = list(get_external_packages(esbuild)) builds = create_builds(externals) print("SHA3-512 checksums:", file=sys.stderr) for build in builds: outfile = build.get("outfile") assert outfile esbuild.run(build) path = BASE_PATH / outfile # Workaround for https://github.com/evanw/esbuild/issues/3717#issuecomment-3765731197 data = path.read_bytes() if not build.get("minify"): data = re.sub( rb"^\s+// node_modules[^\n]+$", b"", data, flags=re.ASCII | re.MULTILINE, ) path.write_bytes(data) digest = hashlib.sha3_512(data).hexdigest() print(f"{digest} {path.name}") def create_builds(externals: list[Package]) -> list[ESBuildOptions]: with (BASE_PATH / "package.json").open("rb") as file: pkg = json.load(file) LICENSE_PREAMBLE = ( "SPDX-License-Identifier: Unlicense\n" + f"This file was automatically generated by {pkg['homepage']}" ) BANNER_WITHOUT_DEPENDENCIES = {"js": license_comment(LICENSE_PREAMBLE)} BANNER_WITH_DEPENDENCIES = { "js": license_comment( LICENSE_PREAMBLE + "\n\nBundled dependencies:\n\n" + "------\n\n".join(map(Package.license_comment, externals)) ) } EXTERNALS = [pkg.name for pkg in externals] ALIASES_DENO = {pkg.name: f"npm:{pkg.name}@{pkg.version}" for pkg in externals} ALIASES_BUN = {pkg.name: f"{pkg.name}@{pkg.version}" for pkg in externals} return [ { "entryPoints": ["src/yt/solver/main.ts"], "outfile": "dist/yt.solver.core.js", "format": "iife", "globalName": "jsc", "banner": BANNER_WITHOUT_DEPENDENCIES, "external": EXTERNALS, }, { "entryPoints": ["src/yt/solver/main.ts"], "outfile": "dist/yt.solver.core.min.js", "minify": True, "format": "iife", "globalName": "jsc", "banner": BANNER_WITHOUT_DEPENDENCIES, "external": EXTERNALS, }, { "entryPoints": ["src/yt/solver/lib.ts"], "outfile": "dist/yt.solver.lib.js", "format": "iife", "globalName": "lib", "banner": BANNER_WITH_DEPENDENCIES, }, { "entryPoints": ["src/yt/solver/lib.ts"], "outfile": "dist/yt.solver.lib.min.js", "minify": True, "format": "iife", "globalName": "lib", "banner": BANNER_WITH_DEPENDENCIES, }, { "entryPoints": ["src/yt/solver/lib.ts"], "outfile": "dist/yt.solver.bun.lib.js", "minifySyntax": True, "format": "esm", "globalName": "lib", "banner": BANNER_WITHOUT_DEPENDENCIES, "alias": ALIASES_BUN, "external": list(ALIASES_BUN.values()), }, { "entryPoints": ["src/yt/solver/lib.ts"], "outfile": "dist/yt.solver.deno.lib.js", "minifySyntax": True, "format": "esm", "globalName": "lib", "banner": BANNER_WITHOUT_DEPENDENCIES, "alias": ALIASES_DENO, "external": list(ALIASES_DENO.values()), }, ] class CustomBuildHook(BuildHookInterface): def initialize(self, version, build_data): run() build_data["force_include"].update( { "dist/yt.solver.core.min.js": "yt_dlp_ejs/yt/solver/core.min.js", "dist/yt.solver.lib.min.js": "yt_dlp_ejs/yt/solver/lib.min.js", } ) def clean(self, versions): shutil.rmtree("node_modules", ignore_errors=True) def find_executable(env: str, defaults: list[str]): values = defaults if value := os.getenv(env): values = value.split(os.path.pathsep) for value in values: if path := shutil.which(value): name = pathlib.Path(path).name.partition(".")[0] return name, path return None, "" def build_install_args(name: str | None): if name == "pnpm": return ["install", "--frozen-lockfile"], None if name == "deno": env = os.environ.copy() env["DENO_NO_UPDATE_CHECK"] = "1" return ["install", "--frozen"], env if name == "bun": return ["install", "--frozen-lockfile"], None if name == "npm": return ["ci"], None raise RuntimeError( "Only 'pnpm', 'deno', 'bun', or 'npm' are supported for installing dependencies. " "Please install one of them or pass EJS_BUILD_SKIP_INSTALL=1 to skip install step." ) class ESBuild: def __init__(self, name: str | None, path: str, /): self._stdin = True self._env = None if name == "esbuild": self._stdin = False self.cmd = [path] elif name == "pnpm": self._stdin = False self.cmd = [path, "run", "esbuild"] elif name == "deno": self._env = os.environ.copy() self._env["DENO_NO_UPDATE_CHECK"] = "1" self.cmd = [ path, "run", "--allow-read", "--allow-env", "--allow-run", "build.mjs", ] elif name == "bun": self.cmd = [path, "--bun", "run", "build.mjs"] elif name == "node": self.cmd = [path, "build.mjs"] else: raise RuntimeError( "One of 'esbuild', 'pnpm', 'deno', 'bun', or 'node' could not be found. " "Please install one of them to be able to run esbuild." ) def run(self, options: ESBuildOptions, /): options = options.copy() if entrypoints := options.get("entryPoints"): options["entryPoints"] = [str(BASE_PATH / path) for path in entrypoints] if outfile := options.get("outfile"): options["outfile"] = str(BASE_PATH / outfile) if self._stdin: process = self._run(self.cmd, json.dumps({"bundle": True, **options})) return json.loads(process.stdout) cmd = [*self.cmd, "--bundle", *self._convert_args(options)] fd = name = None if options.pop("metafile", None): fd, name = tempfile.mkstemp(prefix="ejs-build-metadata-", suffix=".json") cmd.append(f"--metafile={name}") try: self._run(cmd) if fd and name: with open(fd, "rb", closefd=True) as file: return json.load(file) return None finally: if name: os.unlink(name) def _run(self, cmd: list[str], stdin: str | None = None, /): process = subprocess.run( cmd, env=self._env, input=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) if process.returncode: raise RuntimeError( f"failed to run esbuild:\n{process.stdout}{process.stderr}" ) return process @staticmethod def _convert_args(options: ESBuildOptions): for entrypoint in options.pop("entryPoints", ()): yield entrypoint if not options.pop("write", True): options["outfile"] = "NUL" if os.name == "nt" else "/dev/null" for name, value in options.items(): parameter = "--" + re.sub(r"[A-Z]", lambda x: "-" + x[0].lower(), name) if isinstance(value, bool): yield f"{parameter}={str(value).lower()}" elif isinstance(value, str): yield f"{parameter}={value}" elif isinstance(value, list): if name == "absPaths": v = ",".join(value) yield f"{parameter}={v}" else: for v in value: yield f"{parameter}:{v}" elif isinstance(value, dict): for k, v in value.items(): yield f"{parameter}:{k}={v}" class ESBuildOptions(typing.TypedDict, total=False): entryPoints: list[str] write: bool absPaths: list[str] metafile: bool outfile: str minify: bool minifySyntax: bool format: str globalName: str banner: dict[str, str] alias: dict[str, str] external: list[str] def get_external_packages(esbuild: ESBuild): metafile = esbuild.run( { "entryPoints": ["src/yt/solver/lib.ts"], "absPaths": ["metafile"], "metafile": True, "write": False, } ) if not metafile: raise RuntimeError("failed to gather build metadata") _externals = {} for input_file, meta in metafile["inputs"].items(): try: pathlib.Path(input_file).relative_to(BASE_PATH) except ValueError: continue for import_meta in meta["imports"]: if "." in (import_meta.get("original") or ""): continue path = pathlib.Path(import_meta["path"]) _externals[path] = None for path in _externals: current = path.parent while current != BASE_PATH: package_path = current / "package.json" if package_path.is_file(): break current = current.parent else: msg = f"Failed to find package dir for {path}" raise ValueError(msg) yield Package.parse(current / "package.json") class Package: def __init__( self, /, name: str, description: str, version: str, author: str, repository: str, license: str, license_text: str, ): self.name = name self.description = description self.version = version self.author = author self.repository = repository self.license = license self.license_text = license_text @staticmethod def _parse_author(author): if isinstance(author, str): return author result = [author["name"]] if email := author.get("email"): result.append(f"<{email}>") if url := author.get("url"): result.append(f"({url})") return " ".join(result) @classmethod def parse(cls, path: pathlib.Path, /): with path.open("rb") as file: data = json.load(file) licenses = list(path.parent.glob("LICENSE*")) if len(licenses) != 1: msg = "could not find appropriate license" raise ValueError(msg) return cls( name=data["name"], version=data["version"], author=cls._parse_author(data["author"]), description=data["description"], repository=data["repository"]["url"], license=data["license"], license_text=licenses[0].read_text(encoding="utf-8"), ) def _license_comment(self, /): for name in ( "name", "description", "version", "author", "repository", "license", "license_text", ): if name == "license_text": yield "\n" else: yield name.capitalize() yield ": " yield getattr(self, name) yield "\n" def license_comment(self, /): return "".join(self._license_comment()) def license_comment(data: str): return "/*!\n * " + "\n * ".join(data.splitlines()) + "\n */" if __name__ == "__main__": try: run() except RuntimeError as error: print("ERROR:", error.args[0], file=sys.stderr) sys.exit(128)