Skip to content

Commit 714f2d0

Browse files
authored
feat: add native bindings for python / nodejs (#82)
* feat: add native bindings for python / nodejs Signed-off-by: cpendery <cpendery@vt.edu> * fix: release & move reduce release.yml complexity Signed-off-by: cpendery <cpendery@vt.edu> * fix: tarball Signed-off-by: cpendery <cpendery@vt.edu> --------- Signed-off-by: cpendery <cpendery@vt.edu>
1 parent 77da84c commit 714f2d0

70 files changed

Lines changed: 3952 additions & 1295 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
4+
import { runNpm } from "./utils.mjs";
5+
6+
const bindingsDirectory = path.resolve("bindings/js");
7+
const nativePackagesDirectory = path.join(bindingsDirectory, "npm");
8+
const outputDirectory = path.resolve("package-artifacts/npm");
9+
const packagePath = path.join(bindingsDirectory, "package.json");
10+
const readmePath = path.join(bindingsDirectory, "README.md");
11+
12+
const nativePackages = fs
13+
.readdirSync(nativePackagesDirectory, { withFileTypes: true })
14+
.filter((entry) => entry.isDirectory())
15+
.map((entry) => path.join(nativePackagesDirectory, entry.name))
16+
.sort();
17+
18+
if (nativePackages.length === 0) {
19+
throw new Error(`No native packages found in ${nativePackagesDirectory}`);
20+
}
21+
22+
fs.mkdirSync(outputDirectory, { recursive: true });
23+
24+
for (const nativePackage of nativePackages) {
25+
const hasNativeAddon = fs
26+
.readdirSync(nativePackage, { withFileTypes: true })
27+
.some((entry) => entry.isFile() && entry.name.endsWith(".node"));
28+
if (!hasNativeAddon) {
29+
throw new Error(`Missing native addon in ${nativePackage}`);
30+
}
31+
32+
runNpm([
33+
"pack",
34+
nativePackage,
35+
"--pack-destination",
36+
outputDirectory,
37+
]);
38+
}
39+
40+
const originalPackage = fs.readFileSync(packagePath, "utf8");
41+
const originalReadme = fs.readFileSync(readmePath, "utf8");
42+
const rootPackage = JSON.parse(originalPackage);
43+
rootPackage.optionalDependencies = {};
44+
45+
for (const nativePackage of nativePackages) {
46+
const manifest = JSON.parse(
47+
fs.readFileSync(path.join(nativePackage, "package.json"), "utf8"),
48+
);
49+
rootPackage.optionalDependencies[manifest.name] = manifest.version;
50+
}
51+
52+
try {
53+
fs.writeFileSync(
54+
packagePath,
55+
`${JSON.stringify(rootPackage, null, 2)}\n`,
56+
);
57+
runNpm([
58+
"pack",
59+
bindingsDirectory,
60+
"--ignore-scripts",
61+
"--pack-destination",
62+
outputDirectory,
63+
]);
64+
65+
rootPackage.name = "shell-use";
66+
fs.writeFileSync(
67+
packagePath,
68+
`${JSON.stringify(rootPackage, null, 2)}\n`,
69+
);
70+
fs.writeFileSync(
71+
readmePath,
72+
originalReadme.replaceAll("@microsoft/shell-use", "shell-use"),
73+
);
74+
runNpm([
75+
"pack",
76+
bindingsDirectory,
77+
"--ignore-scripts",
78+
"--pack-destination",
79+
outputDirectory,
80+
]);
81+
} finally {
82+
fs.writeFileSync(packagePath, originalPackage);
83+
fs.writeFileSync(readmePath, originalReadme);
84+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
listPackageTarballs,
3+
readPackageManifest,
4+
runNpm,
5+
spawnNpm,
6+
} from "./utils.mjs";
7+
8+
const packages = listPackageTarballs("npm-packages").map((tarball) => ({
9+
tarball,
10+
manifest: readPackageManifest(tarball),
11+
}));
12+
13+
if (packages.length < 10) {
14+
throw new Error("Expected eight native packages and two root packages");
15+
}
16+
17+
function publishIfMissing({ tarball, manifest }) {
18+
const packageVersion = `${manifest.name}@${manifest.version}`;
19+
const result = spawnNpm(["view", packageVersion, "version"], {
20+
stdio: "ignore",
21+
});
22+
23+
if (result.error) {
24+
throw result.error;
25+
}
26+
if (result.status === 0) {
27+
console.log(`${packageVersion} is already published`);
28+
return;
29+
}
30+
if (result.signal) {
31+
throw new Error(
32+
`npm view ${packageVersion} terminated with ${result.signal}`,
33+
);
34+
}
35+
36+
runNpm([
37+
"publish",
38+
tarball,
39+
"--access",
40+
"public",
41+
"--provenance",
42+
"--tag",
43+
"latest",
44+
]);
45+
}
46+
47+
const nativePackages = packages.filter(({ manifest }) =>
48+
manifest.name.startsWith("@microsoft/shell-use-"),
49+
);
50+
const rootPackages = packages.filter(
51+
({ manifest }) =>
52+
manifest.name === "@microsoft/shell-use" || manifest.name === "shell-use",
53+
);
54+
55+
for (const packageArtifact of [...nativePackages, ...rootPackages]) {
56+
publishIfMissing(packageArtifact);
57+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { createRequire } from "node:module";
4+
import { pathToFileURL } from "node:url";
5+
6+
import {
7+
listPackageTarballs,
8+
readPackageManifest,
9+
runNpm,
10+
} from "./utils.mjs";
11+
12+
const packages = listPackageTarballs("npm-packages").map((tarball) => ({
13+
tarball,
14+
manifest: readPackageManifest(tarball),
15+
}));
16+
17+
function findPackage(name) {
18+
const matches = packages.filter(({ manifest }) => manifest.name === name);
19+
if (matches.length !== 1) {
20+
throw new Error(`Expected one ${name} package, found ${matches.length}`);
21+
}
22+
return matches[0].tarball;
23+
}
24+
25+
const rootPackage = findPackage("@microsoft/shell-use");
26+
const platformPackage = findPackage("@microsoft/shell-use-linux-x64-gnu");
27+
const smokeDirectory = path.resolve("smoke");
28+
29+
fs.mkdirSync(smokeDirectory);
30+
runNpm(["init", "-y"], {
31+
cwd: smokeDirectory,
32+
stdio: ["ignore", "ignore", "inherit"],
33+
});
34+
runNpm(["install", "--ignore-scripts", platformPackage], {
35+
cwd: smokeDirectory,
36+
});
37+
runNpm(
38+
["install", "--ignore-scripts", "--omit=optional", rootPackage],
39+
{ cwd: smokeDirectory },
40+
);
41+
42+
const requireFromSmoke = createRequire(path.join(smokeDirectory, "package.json"));
43+
const packageEntry = requireFromSmoke.resolve("@microsoft/shell-use");
44+
const { ShellUse } = await import(pathToFileURL(packageEntry).href);
45+
46+
const session = ShellUse.ephemeral("release-smoke");
47+
try {
48+
await session.open();
49+
await session.submit("echo release-smoke");
50+
await session.waitCommand();
51+
await session.expectText("release-smoke", { strict: false });
52+
} finally {
53+
await session.closeQuiet();
54+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import asyncio
2+
import os
3+
import subprocess
4+
import sys
5+
import venv
6+
from pathlib import Path
7+
8+
9+
async def smoke_test():
10+
from shell_use import ShellUse
11+
12+
async with ShellUse.ephemeral("release-smoke") as session:
13+
await session.open()
14+
await session.submit("echo release-smoke")
15+
await session.wait_command()
16+
await session.expect_text("release-smoke", strict=False)
17+
18+
19+
def main():
20+
if "--run-smoke" in sys.argv:
21+
asyncio.run(smoke_test())
22+
return
23+
24+
wheels = sorted(Path("dist").glob("*.whl"))
25+
if not wheels:
26+
raise RuntimeError("No Python wheels found in dist")
27+
28+
smoke_directory = Path("smoke")
29+
venv.create(smoke_directory, with_pip=True)
30+
python = smoke_directory / (
31+
"Scripts/python.exe" if os.name == "nt" else "bin/python"
32+
)
33+
34+
subprocess.run(
35+
[
36+
python,
37+
"-m",
38+
"pip",
39+
"install",
40+
"--disable-pip-version-check",
41+
*wheels,
42+
],
43+
check=True,
44+
)
45+
subprocess.run(
46+
[python, Path(__file__).resolve(), "--run-smoke"],
47+
check=True,
48+
)
49+
50+
51+
if __name__ == "__main__":
52+
main()

‎.github/scripts/release/utils.mjs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { execFileSync, spawnSync } from "node:child_process";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
5+
export function run(command, args, options = {}) {
6+
const { stdio = "inherit", ...rest } = options;
7+
execFileSync(command, args, { ...rest, stdio });
8+
}
9+
10+
function npmInvocation(args) {
11+
if (process.platform === "win32") {
12+
return {
13+
command: process.env.ComSpec ?? "cmd.exe",
14+
args: ["/d", "/s", "/c", "npm", ...args],
15+
};
16+
}
17+
return { command: "npm", args };
18+
}
19+
20+
export function runNpm(args, options = {}) {
21+
const invocation = npmInvocation(args);
22+
run(invocation.command, invocation.args, options);
23+
}
24+
25+
export function spawnNpm(args, options = {}) {
26+
const invocation = npmInvocation(args);
27+
return spawnSync(invocation.command, invocation.args, options);
28+
}
29+
30+
export function listPackageTarballs(directory) {
31+
return fs
32+
.readdirSync(directory, { withFileTypes: true })
33+
.filter((entry) => entry.isFile() && entry.name.endsWith(".tgz"))
34+
.map((entry) => path.resolve(directory, entry.name))
35+
.sort();
36+
}
37+
38+
export function readPackageManifest(tarball) {
39+
const absoluteTarball = path.resolve(tarball);
40+
const contents = execFileSync(
41+
"tar",
42+
["-xOf", path.basename(absoluteTarball), "package/package.json"],
43+
{
44+
cwd: path.dirname(absoluteTarball),
45+
encoding: "utf8",
46+
},
47+
);
48+
return JSON.parse(contents);
49+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import fs from "node:fs";
2+
3+
const releaseTag = process.env.RELEASE_TAG;
4+
if (!releaseTag) {
5+
throw new Error("RELEASE_TAG is required");
6+
}
7+
8+
const expected = releaseTag.replace(/^v/, "");
9+
const jsPackage = JSON.parse(
10+
fs.readFileSync("bindings/js/package.json", "utf8"),
11+
);
12+
const versions = {
13+
"Cargo.toml": fs.readFileSync("Cargo.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1],
14+
"bindings/js/package.json": jsPackage.version,
15+
"bindings/js/src/version.ts": fs.readFileSync("bindings/js/src/version.ts", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1],
16+
"bindings/python/pyproject.toml": fs.readFileSync("bindings/python/pyproject.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1],
17+
"bindings/python/src/shell_use/_config.py": fs.readFileSync("bindings/python/src/shell_use/_config.py", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1],
18+
};
19+
20+
for (const [file, version] of Object.entries(versions)) {
21+
if (version !== expected) {
22+
throw new Error(`${file} has version ${version}; expected ${expected}`);
23+
}
24+
}
25+
26+
const nativeLoader = fs.readFileSync("bindings/js/native/index.js", "utf8");
27+
if (!nativeLoader.includes(`'${expected}'`)) {
28+
throw new Error(
29+
`bindings/js/native/index.js was not regenerated for ${expected}`,
30+
);
31+
}

0 commit comments

Comments
 (0)