Skip to content

Commit a82cafa

Browse files
feat(ext/pip): pip install and import (#43)
1 parent d90d96d commit a82cafa

File tree

5 files changed

+228
-2
lines changed

5 files changed

+228
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.vscode/
22
/*.bat
33
deno.lock
4+
plug/

README.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@
55
[![checks](https://github.com/denosaurs/deno_python/actions/workflows/checks.yml/badge.svg)](https://github.com/denosaurs/deno_python/actions/workflows/checks.yml)
66
[![License](https://img.shields.io/github/license/denosaurs/deno_python)](https://github.com/denosaurs/deno_python/blob/master/LICENSE)
77

8-
Python interpreter bindings for Deno.
8+
This module provides a seamless integration between deno and python by
9+
integrating with the [Python/C API](https://docs.python.org/3/c-api/index.html).
10+
It acts as a bridge between the two languages, enabling you to pass data and
11+
execute python code from within your deno applications. This enables access to
12+
the large and wonderful [python ecosystem](https://pypi.org/) while remaining
13+
native (unlike a runtime like the wondeful
14+
[pyodide](https://github.com/pyodide/pyodide) which is compiled to wasm,
15+
sandboxed and may not work with all python packages) and simply using the
16+
existing python installation.
917

1018
## Example
1119

@@ -32,6 +40,43 @@ permissions since enabling FFI effectively escapes the permissions sandbox.
3240
deno run -A --unstable <file>
3341
```
3442

43+
### Dependencies
44+
45+
Normally deno_python follows the default python way of resolving imports, going
46+
through `sys.path` resolving them globally, locally or scoped to a virtual
47+
environment. This is ~~great~~ and allows you to manage your python dependencies
48+
for `deno_python` projects in the same way you would any other python project
49+
using your favorite package manager, be it
50+
[`pip`](https://pip.pypa.io/en/stable/),
51+
[`conda`](https://docs.conda.io/en/latest/) or
52+
[`poetry`](https://python-poetry.org/).
53+
54+
This may not be a good thing though, especially for something like a deno module
55+
which may depend on a python package. That is why the [`ext/pip`](./ext/pip.ts)
56+
utility exists for this project. It allows you to install python dependencies
57+
using pip, scoped to either the global deno installation or if defined the
58+
`--location` passed to deno without leaking to the global python scope. It uses
59+
the same caching location and algorithm as
60+
[plug](https://github.com/denosaurs/deno) and
61+
[deno cache](https://github.com/denoland/deno_cache).
62+
63+
To use [`ext/pip`](./ext/pip.ts) for python package management you simply use
64+
the provided `import` or `install` methods. The rest is handled automatically
65+
for you! Just take a look!
66+
67+
```ts
68+
import { pip } from "https://deno.land/x/python/ext/pip.ts";
69+
70+
const np = await pip.import("numpy");
71+
const plt = await pip.import("matplotlib", "matplotlib.pyplot");
72+
73+
const xpoints = np.array([1, 8]);
74+
const ypoints = np.array([3, 10]);
75+
76+
plt.plot(xpoints, ypoints);
77+
plt.show();
78+
```
79+
3580
## Documentation
3681

3782
Check out the docs

examples/pip_import.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { pip } from "../ext/pip.ts";
2+
3+
const np = await pip.import("numpy");
4+
const plt = await pip.import("matplotlib", "matplotlib.pyplot");
5+
6+
const xpoints = np.array([1, 8]);
7+
const ypoints = np.array([3, 10]);
8+
9+
plt.plot(xpoints, ypoints);
10+
plt.show();

ext/pip.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { kw, python, PythonError } from "../mod.ts";
2+
3+
import { join } from "https://deno.land/std@0.198.0/path/mod.ts";
4+
import { ensureDir } from "https://deno.land/std@0.198.0/fs/mod.ts";
5+
import { green, yellow } from "https://deno.land/std@0.198.0/fmt/colors.ts";
6+
7+
import type { CacheLocation } from "https://deno.land/x/plug@1.0.2/types.ts";
8+
import { ensureCacheLocation } from "https://deno.land/x/plug@1.0.2/download.ts";
9+
import { hash } from "https://deno.land/x/plug@1.0.2/util.ts";
10+
11+
const sys = python.import("sys");
12+
const runpy = python.import("runpy");
13+
const importlib = python.import("importlib");
14+
15+
// https://packaging.python.org/en/latest/specifications/name-normalization/
16+
const MODULE_REGEX =
17+
/^([a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9])([^a-z0-9._-].*)?$/i;
18+
19+
function normalizeModuleName(name: string) {
20+
return name.replaceAll(/[-_.]+/g, "-").toLowerCase();
21+
}
22+
23+
function getModuleNameAndVersion(module: string): {
24+
name: string;
25+
version?: string;
26+
} {
27+
const match = module.match(MODULE_REGEX);
28+
const name = match?.[1];
29+
const version = match?.[2];
30+
31+
if (name == null) {
32+
throw new TypeError("Could not match any valid pip module name");
33+
}
34+
35+
return {
36+
name: normalizeModuleName(name),
37+
version,
38+
};
39+
}
40+
41+
export class Pip {
42+
#cacheLocation: Promise<string>;
43+
44+
constructor(location: CacheLocation) {
45+
this.#cacheLocation = Promise.all([
46+
ensureCacheLocation(location),
47+
globalThis.location !== undefined
48+
? hash(globalThis.location.href)
49+
: Promise.resolve("pip"),
50+
]).then(async (parts) => {
51+
const cacheLocation = join(...parts);
52+
await ensureDir(cacheLocation);
53+
54+
if (!(cacheLocation in sys.path)) {
55+
sys.path.insert(0, cacheLocation);
56+
}
57+
58+
return cacheLocation;
59+
});
60+
}
61+
62+
/**
63+
* Install a Python module using the `pip` package manager.
64+
*
65+
* @param module The Python module which you wish to install
66+
*
67+
* @example
68+
* ```ts
69+
* import { python } from "https://deno.land/x/python/mod.ts";
70+
* import { install } from "https://deno.land/x/python/ext/pip.ts";
71+
*
72+
* await install("numpy");
73+
* const numpy = python.import("numpy");
74+
*
75+
* ```
76+
*/
77+
async install(module: string) {
78+
const argv = sys.argv;
79+
sys.argv = [
80+
"pip",
81+
"install",
82+
"-q",
83+
"-t",
84+
await this.#cacheLocation,
85+
module,
86+
];
87+
88+
console.log(`${green("Installing")} ${module}`);
89+
90+
try {
91+
runpy.run_module("pip", kw`run_name=${"__main__"}`);
92+
} catch (error) {
93+
if (
94+
!(
95+
error instanceof PythonError &&
96+
error.type.isInstance(python.builtins.SystemExit()) &&
97+
error.value.asLong() === 0
98+
)
99+
) {
100+
throw error;
101+
}
102+
} finally {
103+
sys.argv = argv;
104+
}
105+
}
106+
107+
/**
108+
* Install and import a Python module using the `pip` package manager.
109+
*
110+
* @param module The Python module which you wish to install
111+
*
112+
* @example
113+
* ```ts
114+
* import { python } from "https://deno.land/x/python/mod.ts";
115+
* import { pip } from "https://deno.land/x/python/ext/pip.ts";
116+
*
117+
* const numpy = await pip.import("numpy==1.25.2");
118+
*
119+
* ```
120+
*/
121+
async import(module: string, entrypoint?: string) {
122+
const { name } = getModuleNameAndVersion(module);
123+
124+
await this.install(module);
125+
126+
if (entrypoint) {
127+
return python.import(entrypoint);
128+
}
129+
130+
const packages = importlib.metadata.packages_distributions();
131+
const entrypoints = [];
132+
133+
for (const entry of packages) {
134+
if (packages[entry].valueOf().includes(name)) {
135+
entrypoints.push(entry.valueOf());
136+
}
137+
}
138+
139+
if (entrypoints.length === 0) {
140+
throw new TypeError(
141+
`Failed to import module ${module}, could not find import name ${name}`,
142+
);
143+
}
144+
145+
entrypoint = entrypoints[0];
146+
147+
if (entrypoints.length > 1) {
148+
if (entrypoints.includes(name)) {
149+
entrypoint = entrypoints[entrypoints.indexOf(name)];
150+
} else {
151+
console.warn(
152+
`${
153+
yellow(
154+
"Warning",
155+
)
156+
} could not determine a single entrypoint for module ${module}, please specify one of: ${
157+
entrypoints.join(
158+
", ",
159+
)
160+
}. Importing ${entrypoint}`,
161+
);
162+
}
163+
}
164+
165+
return python.import(entrypoint!);
166+
}
167+
}
168+
169+
export const pip = new Pip();
170+
export default pip;

test/deps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from "https://deno.land/std@0.178.0/testing/asserts.ts";
1+
export * from "https://deno.land/std@0.198.0/testing/asserts.ts";

0 commit comments

Comments
 (0)