Files
ejs/hatch_build.py
2026-04-07 21:20:36 +00:00

426 lines
13 KiB
Python
Executable File

#!/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)