Skip to content

Commit 20af9d4

Browse files
authored
Merge pull request #1 from python-project-templates/tkp/init
Initial functionality
2 parents 125dee2 + df1862b commit 20af9d4

File tree

19 files changed

+2500
-11
lines changed

19 files changed

+2500
-11
lines changed

.github/workflows/build.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ jobs:
3939
with:
4040
version: ${{ matrix.python-version }}
4141

42+
- uses: actions-ext/node/setup@main
43+
with:
44+
version: 20.x
45+
js_folder: hatch_js/tests/test_project_basic/js
46+
4247
- name: Install dependencies
4348
run: make develop
4449

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,8 @@ hatch_js/labextension
151151

152152
# Rust
153153
target
154+
155+
# Test parts
156+
hatch_js/tests/test_project_basic/js/dist
157+
hatch_js/tests/test_project_basic/js/node_modules
158+
hatch_js/tests/test_project_basic/project/extension

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,36 @@ Hatch plugin for JavaScript builds
99

1010
## Overview
1111

12+
A simple, extensible JS build plugin for [hatch](https://hatch.pypa.io/latest/).
13+
14+
```toml
15+
[tool.hatch.build.hooks.hatch-js]
16+
path = "js"
17+
install_cmd = "install"
18+
build_cmd = "build"
19+
tool = "pnpm"
20+
targets = ["myproject/extension/cdn/index.js"]
21+
```
22+
23+
See the [test cases](./hatch_js/tests/) for more concrete examples.
24+
25+
`hatch-js` is driven by [pydantic](https://docs.pydantic.dev/latest/) models for configuration and execution of the build.
26+
These models can themselves be overridden by setting `build-config-class` / `build-plan-class`.
27+
28+
## Configuration
29+
30+
```toml
31+
verbose = "false"
32+
33+
path = "path/to/js/root"
34+
tool = "npm" # or pnpm, yarn, jlpm
35+
36+
install_cmd = "" # install command, defaults to `npm install`/`pnpm install`/`yarn`/`jlpm`
37+
build_cmd = "build" # build command, defaults to `npm run build`/`pnpm run build`/`yarn build`/`jlpm build`
38+
targets = [ # outputs to validate after build
39+
"some/output.js"
40+
]
41+
```
42+
1243
> [!NOTE]
1344
> 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).

hatch_js/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
__version__ = "0.1.0"
2+
3+
from .hooks import hatch_register_build_hook
4+
from .plugin import HatchJsBuildHook
5+
from .structs import *

hatch_js/hooks.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Type
2+
3+
from hatchling.plugin import hookimpl
4+
5+
from .plugin import HatchJsBuildHook
6+
7+
8+
@hookimpl
9+
def hatch_register_build_hook() -> Type[HatchJsBuildHook]:
10+
return HatchJsBuildHook

hatch_js/plugin.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
from logging import getLogger
4+
from os import getenv
5+
from typing import Any
6+
7+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
8+
9+
from .structs import HatchJsBuildConfig, HatchJsBuildPlan
10+
from .utils import import_string
11+
12+
__all__ = ("HatchJsBuildHook",)
13+
14+
15+
class HatchJsBuildHook(BuildHookInterface[HatchJsBuildConfig]):
16+
"""The hatch-js build hook."""
17+
18+
PLUGIN_NAME = "hatch-js"
19+
_logger = getLogger(__name__)
20+
21+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
22+
"""Initialize the plugin."""
23+
# Log some basic information
24+
project_name = self.metadata.config["project"]["name"]
25+
self._logger.info("Initializing hatch-js plugin version %s", version)
26+
self._logger.info(f"Running hatch-js: {project_name}")
27+
28+
# Only run if creating wheel
29+
# TODO: Add support for specify sdist-plan
30+
if self.target_name != "wheel":
31+
self._logger.info("ignoring target name %s", self.target_name)
32+
return
33+
34+
# Skip if SKIP_HATCH_JS is set
35+
# TODO: Support CLI once https://github.com/pypa/hatch/pull/1743
36+
if getenv("SKIP_HATCH_JS"):
37+
self._logger.info("Skipping the build hook since SKIP_HATCH_JS was set")
38+
return
39+
40+
# Get build config class or use default
41+
build_config_class = import_string(self.config["build-config-class"]) if "build-config-class" in self.config else HatchJsBuildConfig
42+
43+
# Instantiate build config
44+
config = build_config_class(name=project_name, **self.config)
45+
46+
# Get build plan class or use default
47+
build_plan_class = import_string(self.config["build-plan-class"]) if "build-plan-class" in self.config else HatchJsBuildPlan
48+
49+
# Instantiate builder
50+
build_plan = build_plan_class(**config.model_dump())
51+
52+
# Generate commands
53+
build_plan.generate()
54+
55+
# Log commands if in verbose mode
56+
if config.verbose:
57+
for command in build_plan.commands:
58+
self._logger.warning(command)
59+
60+
# Execute build plan
61+
build_plan.execute()
62+
63+
# Perform any cleanup actions
64+
build_plan.cleanup()
65+
66+
# if build_plan.libraries:
67+
# # force include libraries
68+
# for library in build_plan.libraries:
69+
# name = library.get_qualified_name(build_plan.platform.platform)
70+
# build_data["force_include"][name] = name
71+
72+
# build_data["pure_python"] = False
73+
# machine = platform_machine()
74+
# version_major = version_info.major
75+
# version_minor = version_info.minor
76+
# if "darwin" in sys_platform:
77+
# os_name = "macosx_11_0"
78+
# elif "linux" in sys_platform:
79+
# os_name = "linux"
80+
# else:
81+
# os_name = "win"
82+
# if all([lib.py_limited_api for lib in build_plan.libraries]):
83+
# build_data["tag"] = f"cp{version_major}{version_minor}-abi3-{os_name}_{machine}"
84+
# else:
85+
# build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"
86+
# else:
87+
# build_data["pure_python"] = False
88+
# machine = platform_machine()
89+
# version_major = version_info.major
90+
# version_minor = version_info.minor
91+
# # TODO abi3
92+
# if "darwin" in sys_platform:
93+
# os_name = "macosx_11_0"
94+
# elif "linux" in sys_platform:
95+
# os_name = "linux"
96+
# else:
97+
# os_name = "win"
98+
# build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"
99+
100+
# # force include libraries
101+
# for path in Path(".").rglob("*"):
102+
# if path.is_dir():
103+
# continue
104+
# if str(path).startswith(str(build_plan.cmake.build)) or str(path).startswith("dist"):
105+
# continue
106+
# if path.suffix in (".pyd", ".dll", ".so", ".dylib"):
107+
# build_data["force_include"][str(path)] = str(path)
108+
109+
# for path in build_data["force_include"]:
110+
# self._logger.warning(f"Force include: {path}")

hatch_js/structs.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from __future__ import annotations
2+
3+
from os import chdir, curdir, system as system_call
4+
from pathlib import Path
5+
from shutil import which
6+
from typing import List, Literal, Optional
7+
8+
from pydantic import BaseModel, Field, field_validator
9+
10+
__all__ = (
11+
"HatchJsBuildConfig",
12+
"HatchJsBuildPlan",
13+
)
14+
15+
Toolchain = Literal["npm", "yarn", "pnpm", "jlpm"]
16+
17+
18+
class HatchJsBuildConfig(BaseModel):
19+
"""Build config values for Hatch Js Builder."""
20+
21+
name: Optional[str] = Field(default=None)
22+
verbose: Optional[bool] = Field(default=False)
23+
24+
path: Optional[Path] = Field(default=None, description="Path to the JavaScript project. Defaults to the current directory.")
25+
tool: Optional[Toolchain] = Field(default="npm", description="Command to run for building the project, e.g., 'npm', 'yarn', 'pnpm'")
26+
27+
install_cmd: Optional[str] = Field(
28+
default=None, description="Custom command to run for installing dependencies. If specified, overrides the default install command."
29+
)
30+
build_cmd: Optional[str] = Field(
31+
default="build", description="Custom command to run for building the project. If specified, overrides the default build command."
32+
)
33+
34+
targets: Optional[List[str]] = Field(default_factory=list, description="List of ensured targets to build")
35+
36+
# Check that tool exists
37+
@field_validator("tool", mode="before")
38+
@classmethod
39+
def _check_tool_exists(cls, tool: Toolchain) -> Toolchain:
40+
if not which(tool):
41+
raise ValueError(f"Tool '{tool}' not found in PATH. Please install it or specify a different tool.")
42+
return tool
43+
44+
# Validate path
45+
@field_validator("path", mode="before")
46+
@classmethod
47+
def validate_path(cls, path: Optional[Path]) -> Path:
48+
if path is None:
49+
return Path.cwd()
50+
if not isinstance(path, Path):
51+
path = Path(path)
52+
if not path.is_dir():
53+
raise ValueError(f"Path '{path}' is not a valid directory.")
54+
return path
55+
56+
57+
class HatchJsBuildPlan(HatchJsBuildConfig):
58+
commands: List[str] = Field(default_factory=list)
59+
60+
def generate(self):
61+
self.commands = []
62+
63+
# Run installation
64+
if self.tool in ("npm", "pnpm"):
65+
if self.install_cmd:
66+
self.commands.append(f"{self.tool} {self.install_cmd}")
67+
else:
68+
self.commands.append(f"{self.tool} install")
69+
elif self.tool in ("yarn", "jlpm"):
70+
if self.install_cmd:
71+
self.commands.append(f"{self.tool} {self.install_cmd}")
72+
else:
73+
self.commands.append(f"{self.tool}")
74+
75+
# Run build command
76+
if self.tool in ("npm", "pnpm"):
77+
self.commands.append(f"{self.tool} run {self.build_cmd}")
78+
elif self.tool in ("yarn", "jlpm"):
79+
self.commands.append(f"{self.tool} {self.build_cmd}")
80+
81+
return self.commands
82+
83+
def execute(self):
84+
"""Execute the build commands."""
85+
86+
# First navigate to the project path
87+
cwd = Path(curdir).resolve()
88+
chdir(self.path)
89+
90+
for command in self.commands:
91+
system_call(command)
92+
93+
# Check that all targets exist
94+
# Go back to original path
95+
chdir(str(cwd))
96+
for target in self.targets:
97+
if not Path(target).resolve().exists():
98+
raise FileNotFoundError(f"Target '{target}' does not exist after build. Please check your build configuration.")
99+
return self.commands
100+
101+
def cleanup(self):
102+
# No-op
103+
...

hatch_js/tests/test_all.py

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { NodeModulesExternal } from "@finos/perspective-esbuild-plugin/external.js";
2+
import { build } from "@finos/perspective-esbuild-plugin/build.js";
3+
import { BuildCss } from "@prospective.co/procss/target/cjs/procss.js";
4+
import fs from "fs";
5+
import cpy from "cpy";
6+
import path_mod from "path";
7+
8+
const COMMON_DEFINE = {
9+
global: "window",
10+
};
11+
12+
const BUILD = [
13+
{
14+
define: COMMON_DEFINE,
15+
entryPoints: ["src/js/index.js"],
16+
plugins: [NodeModulesExternal()],
17+
format: "esm",
18+
loader: {
19+
".css": "text",
20+
".html": "text",
21+
},
22+
outfile: "dist/esm/index.js",
23+
},
24+
{
25+
define: COMMON_DEFINE,
26+
entryPoints: ["src/js/index.js"],
27+
plugins: [],
28+
format: "esm",
29+
loader: {
30+
".css": "text",
31+
".html": "text",
32+
},
33+
outfile: "dist/cdn/index.js",
34+
},
35+
];
36+
37+
async function compile_css() {
38+
const process_path = (path) => {
39+
const outpath = path.replace("src/less", "dist/css");
40+
fs.mkdirSync(outpath, { recursive: true });
41+
42+
fs.readdirSync(path).forEach((file_or_folder) => {
43+
if (file_or_folder.endsWith(".less")) {
44+
const outfile = file_or_folder.replace(".less", ".css");
45+
const builder = new BuildCss("");
46+
builder.add(
47+
`${path}/${file_or_folder}`,
48+
fs
49+
.readFileSync(path_mod.join(`${path}/${file_or_folder}`))
50+
.toString(),
51+
);
52+
fs.writeFileSync(
53+
`${path.replace("src/less", "dist/css")}/${outfile}`,
54+
builder.compile().get(outfile),
55+
);
56+
} else {
57+
process_path(`${path}/${file_or_folder}`);
58+
}
59+
});
60+
};
61+
// recursively process all less files in src/less
62+
process_path("src/less");
63+
cpy("src/css/*", "dist/css/");
64+
}
65+
66+
async function copy_html() {
67+
fs.mkdirSync("dist/html", { recursive: true });
68+
cpy("src/html/*", "dist/html");
69+
// also copy to top level
70+
cpy("src/html/*", "dist/");
71+
}
72+
73+
async function copy_img() {
74+
fs.mkdirSync("dist/img", { recursive: true });
75+
cpy("src/img/*", "dist/img");
76+
}
77+
78+
async function copy_to_python() {
79+
fs.mkdirSync("../project/extension", { recursive: true });
80+
cpy("dist/**/*", "../project/extension");
81+
}
82+
83+
async function build_all() {
84+
await compile_css();
85+
await copy_html();
86+
await copy_img();
87+
await Promise.all(BUILD.map(build)).catch(() => process.exit(1));
88+
await copy_to_python();
89+
}
90+
91+
build_all();

0 commit comments

Comments
 (0)