mirror of
https://github.com/yt-dlp/ejs.git
synced 2026-06-02 10:33:18 +00:00
426 lines
13 KiB
Python
Executable File
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)
|