Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ jobs:
with:
version: ${{ matrix.python-version }}

- uses: actions-ext/node/setup@main
with:
version: 20.x
js_folder: hatch_js/tests/test_project_basic/js

- name: Install dependencies
run: make develop

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,8 @@ hatch_js/labextension

# Rust
target

# Test parts
hatch_js/tests/test_project_basic/js/dist
hatch_js/tests/test_project_basic/js/node_modules
hatch_js/tests/test_project_basic/project/extension
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,36 @@ Hatch plugin for JavaScript builds

## Overview

A simple, extensible JS build plugin for [hatch](https://hatch.pypa.io/latest/).

```toml
[tool.hatch.build.hooks.hatch-js]
path = "js"
install_cmd = "install"
build_cmd = "build"
tool = "pnpm"
targets = ["myproject/extension/cdn/index.js"]
```

See the [test cases](./hatch_js/tests/) for more concrete examples.

`hatch-js` is driven by [pydantic](https://docs.pydantic.dev/latest/) models for configuration and execution of the build.
These models can themselves be overridden by setting `build-config-class` / `build-plan-class`.

## Configuration

```toml
verbose = "false"

path = "path/to/js/root"
tool = "npm" # or pnpm, yarn, jlpm

install_cmd = "" # install command, defaults to `npm install`/`pnpm install`/`yarn`/`jlpm`
build_cmd = "build" # build command, defaults to `npm run build`/`pnpm run build`/`yarn build`/`jlpm build`
targets = [ # outputs to validate after build
"some/output.js"
]
```

> [!NOTE]
> This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).
4 changes: 4 additions & 0 deletions hatch_js/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__version__ = "0.1.0"

from .hooks import hatch_register_build_hook
from .plugin import HatchJsBuildHook
from .structs import *
10 changes: 10 additions & 0 deletions hatch_js/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from typing import Type

from hatchling.plugin import hookimpl

from .plugin import HatchJsBuildHook


@hookimpl
def hatch_register_build_hook() -> Type[HatchJsBuildHook]:
return HatchJsBuildHook
110 changes: 110 additions & 0 deletions hatch_js/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

from logging import getLogger
from os import getenv
from typing import Any

from hatchling.builders.hooks.plugin.interface import BuildHookInterface

from .structs import HatchJsBuildConfig, HatchJsBuildPlan
from .utils import import_string

__all__ = ("HatchJsBuildHook",)


class HatchJsBuildHook(BuildHookInterface[HatchJsBuildConfig]):
"""The hatch-js build hook."""

PLUGIN_NAME = "hatch-js"
_logger = getLogger(__name__)

def initialize(self, version: str, build_data: dict[str, Any]) -> None:
"""Initialize the plugin."""
# Log some basic information
project_name = self.metadata.config["project"]["name"]
self._logger.info("Initializing hatch-js plugin version %s", version)
self._logger.info(f"Running hatch-js: {project_name}")

# Only run if creating wheel
# TODO: Add support for specify sdist-plan
if self.target_name != "wheel":
self._logger.info("ignoring target name %s", self.target_name)
return

# Skip if SKIP_HATCH_JS is set
# TODO: Support CLI once https://github.com/pypa/hatch/pull/1743
if getenv("SKIP_HATCH_JS"):
self._logger.info("Skipping the build hook since SKIP_HATCH_JS was set")
return

# Get build config class or use default
build_config_class = import_string(self.config["build-config-class"]) if "build-config-class" in self.config else HatchJsBuildConfig

# Instantiate build config
config = build_config_class(name=project_name, **self.config)

# Get build plan class or use default
build_plan_class = import_string(self.config["build-plan-class"]) if "build-plan-class" in self.config else HatchJsBuildPlan

# Instantiate builder
build_plan = build_plan_class(**config.model_dump())

# Generate commands
build_plan.generate()

# Log commands if in verbose mode
if config.verbose:
for command in build_plan.commands:
self._logger.warning(command)

# Execute build plan
build_plan.execute()

# Perform any cleanup actions
build_plan.cleanup()

# if build_plan.libraries:
# # force include libraries
# for library in build_plan.libraries:
# name = library.get_qualified_name(build_plan.platform.platform)
# build_data["force_include"][name] = name

# build_data["pure_python"] = False
# machine = platform_machine()
# version_major = version_info.major
# version_minor = version_info.minor
# if "darwin" in sys_platform:
# os_name = "macosx_11_0"
# elif "linux" in sys_platform:
# os_name = "linux"
# else:
# os_name = "win"
# if all([lib.py_limited_api for lib in build_plan.libraries]):
# build_data["tag"] = f"cp{version_major}{version_minor}-abi3-{os_name}_{machine}"
# else:
# build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"
# else:
# build_data["pure_python"] = False
# machine = platform_machine()
# version_major = version_info.major
# version_minor = version_info.minor
# # TODO abi3
# if "darwin" in sys_platform:
# os_name = "macosx_11_0"
# elif "linux" in sys_platform:
# os_name = "linux"
# else:
# os_name = "win"
# build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"

# # force include libraries
# for path in Path(".").rglob("*"):
# if path.is_dir():
# continue
# if str(path).startswith(str(build_plan.cmake.build)) or str(path).startswith("dist"):
# continue
# if path.suffix in (".pyd", ".dll", ".so", ".dylib"):
# build_data["force_include"][str(path)] = str(path)

# for path in build_data["force_include"]:
# self._logger.warning(f"Force include: {path}")
103 changes: 103 additions & 0 deletions hatch_js/structs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from __future__ import annotations

from os import chdir, curdir, system as system_call
from pathlib import Path
from shutil import which
from typing import List, Literal, Optional

from pydantic import BaseModel, Field, field_validator

__all__ = (
"HatchJsBuildConfig",
"HatchJsBuildPlan",
)

Toolchain = Literal["npm", "yarn", "pnpm", "jlpm"]


class HatchJsBuildConfig(BaseModel):
"""Build config values for Hatch Js Builder."""

name: Optional[str] = Field(default=None)
verbose: Optional[bool] = Field(default=False)

path: Optional[Path] = Field(default=None, description="Path to the JavaScript project. Defaults to the current directory.")
tool: Optional[Toolchain] = Field(default="npm", description="Command to run for building the project, e.g., 'npm', 'yarn', 'pnpm'")

install_cmd: Optional[str] = Field(
default=None, description="Custom command to run for installing dependencies. If specified, overrides the default install command."
)
build_cmd: Optional[str] = Field(
default="build", description="Custom command to run for building the project. If specified, overrides the default build command."
)

targets: Optional[List[str]] = Field(default_factory=list, description="List of ensured targets to build")

# Check that tool exists
@field_validator("tool", mode="before")
@classmethod
def _check_tool_exists(cls, tool: Toolchain) -> Toolchain:
if not which(tool):
raise ValueError(f"Tool '{tool}' not found in PATH. Please install it or specify a different tool.")
return tool

# Validate path
@field_validator("path", mode="before")
@classmethod
def validate_path(cls, path: Optional[Path]) -> Path:
if path is None:
return Path.cwd()
if not isinstance(path, Path):
path = Path(path)
if not path.is_dir():
raise ValueError(f"Path '{path}' is not a valid directory.")
return path


class HatchJsBuildPlan(HatchJsBuildConfig):
commands: List[str] = Field(default_factory=list)

def generate(self):
self.commands = []

# Run installation
if self.tool in ("npm", "pnpm"):
if self.install_cmd:
self.commands.append(f"{self.tool} {self.install_cmd}")
else:
self.commands.append(f"{self.tool} install")
elif self.tool in ("yarn", "jlpm"):
if self.install_cmd:
self.commands.append(f"{self.tool} {self.install_cmd}")
else:
self.commands.append(f"{self.tool}")

# Run build command
if self.tool in ("npm", "pnpm"):
self.commands.append(f"{self.tool} run {self.build_cmd}")
elif self.tool in ("yarn", "jlpm"):
self.commands.append(f"{self.tool} {self.build_cmd}")

return self.commands

def execute(self):
"""Execute the build commands."""

# First navigate to the project path
cwd = Path(curdir).resolve()
chdir(self.path)

for command in self.commands:
system_call(command)

# Check that all targets exist
# Go back to original path
chdir(str(cwd))
for target in self.targets:
if not Path(target).resolve().exists():
raise FileNotFoundError(f"Target '{target}' does not exist after build. Please check your build configuration.")
return self.commands

def cleanup(self):
# No-op
...
5 changes: 0 additions & 5 deletions hatch_js/tests/test_all.py

This file was deleted.

91 changes: 91 additions & 0 deletions hatch_js/tests/test_project_basic/js/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { NodeModulesExternal } from "@finos/perspective-esbuild-plugin/external.js";
import { build } from "@finos/perspective-esbuild-plugin/build.js";
import { BuildCss } from "@prospective.co/procss/target/cjs/procss.js";
import fs from "fs";
import cpy from "cpy";
import path_mod from "path";

const COMMON_DEFINE = {
global: "window",
};

const BUILD = [
{
define: COMMON_DEFINE,
entryPoints: ["src/js/index.js"],
plugins: [NodeModulesExternal()],
format: "esm",
loader: {
".css": "text",
".html": "text",
},
outfile: "dist/esm/index.js",
},
{
define: COMMON_DEFINE,
entryPoints: ["src/js/index.js"],
plugins: [],
format: "esm",
loader: {
".css": "text",
".html": "text",
},
outfile: "dist/cdn/index.js",
},
];

async function compile_css() {
const process_path = (path) => {
const outpath = path.replace("src/less", "dist/css");
fs.mkdirSync(outpath, { recursive: true });

fs.readdirSync(path).forEach((file_or_folder) => {
if (file_or_folder.endsWith(".less")) {
const outfile = file_or_folder.replace(".less", ".css");
const builder = new BuildCss("");
builder.add(
`${path}/${file_or_folder}`,
fs
.readFileSync(path_mod.join(`${path}/${file_or_folder}`))
.toString(),
);
fs.writeFileSync(
`${path.replace("src/less", "dist/css")}/${outfile}`,
builder.compile().get(outfile),
);
} else {
process_path(`${path}/${file_or_folder}`);
}
});
};
// recursively process all less files in src/less
process_path("src/less");
cpy("src/css/*", "dist/css/");
}

async function copy_html() {
fs.mkdirSync("dist/html", { recursive: true });
cpy("src/html/*", "dist/html");
// also copy to top level
cpy("src/html/*", "dist/");
}

async function copy_img() {
fs.mkdirSync("dist/img", { recursive: true });
cpy("src/img/*", "dist/img");
}

async function copy_to_python() {
fs.mkdirSync("../project/extension", { recursive: true });
cpy("dist/**/*", "../project/extension");
}

async function build_all() {
await compile_css();
await copy_html();
await copy_img();
await Promise.all(BUILD.map(build)).catch(() => process.exit(1));
await copy_to_python();
}

build_all();
Loading
Loading