@adhd/apigen-python-env 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/index.d.ts +2 -0
- package/index.js +8 -0
- package/index.mjs +155 -0
- package/lib/python-env.d.ts +44 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @adhd/apigen-python-env
|
|
2
|
+
|
|
3
|
+
Python host provisioning for apigen's `py-*` plugins.
|
|
4
|
+
|
|
5
|
+
## The problem it solves
|
|
6
|
+
|
|
7
|
+
`py-grpc` / `py-flask` spawn a Python process (`apigen_python.grpc_server` /
|
|
8
|
+
`flask_server`). Those servers need `grpcio` + `grpcio-reflection` — deps that
|
|
9
|
+
are **declared in `packages/apigen/python/pyproject.toml`** (the `grpc` extra),
|
|
10
|
+
but that used to be expected on whatever `python3` happened to be on PATH.
|
|
11
|
+
On any machine without them, `apigen run/serve --type py-grpc` died on import.
|
|
12
|
+
|
|
13
|
+
This package makes the Python packaging the single source of truth: it
|
|
14
|
+
provisions a **managed venv** by `pip install`-ing `apigen-python` (with the
|
|
15
|
+
requested extras) from its own `pyproject.toml`, and hands back that venv's
|
|
16
|
+
interpreter.
|
|
17
|
+
|
|
18
|
+
## API
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { ensurePythonEnv, resolvePythonPkgDir } from '@adhd/apigen-python-env'
|
|
22
|
+
|
|
23
|
+
const { python, venvDir, pythonPkgDir } = ensurePythonEnv({ extras: ['grpc'] })
|
|
24
|
+
// spawn(python, ['-m', 'apigen_python.grpc_server', ...], { env: { PYTHONPATH: pythonPkgDir } })
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **`ensurePythonEnv({ extras })`** — returns the interpreter to spawn.
|
|
28
|
+
Resolution order:
|
|
29
|
+
1. `APIGEN_PYTHON` env var (explicit override — CI images, power users).
|
|
30
|
+
2. Managed venv at `~/.adhd/apigen/pyvenv` — created on first use, refreshed
|
|
31
|
+
automatically when `pyproject.toml` (or the requested extras) change
|
|
32
|
+
(content-hash stamp file). Creation is cross-process locked, so
|
|
33
|
+
concurrent `serve` children don't double-provision.
|
|
34
|
+
- **`resolvePythonPkgDir()`** — locates the `apigen_python` sources (shared by
|
|
35
|
+
both py plugins; also exported for tools like the conformance gate).
|
|
36
|
+
|
|
37
|
+
Environment knobs:
|
|
38
|
+
|
|
39
|
+
| Var | Effect |
|
|
40
|
+
| --- | ------ |
|
|
41
|
+
| `APIGEN_PYTHON` | Skip the managed venv; use this interpreter as-is. |
|
|
42
|
+
| `APIGEN_PYENV_HOME` | Relocate the managed-venv root (tests point it at `tmp/`). |
|
|
43
|
+
|
|
44
|
+
## Failure mode
|
|
45
|
+
|
|
46
|
+
Loud, never silent: if no Python ≥ 3.11 is on PATH or `pip install` fails, the
|
|
47
|
+
error says exactly what to install. The plugins no longer fall back to a bare
|
|
48
|
+
`python3` that may be missing its deps.
|
|
49
|
+
|
|
50
|
+
## Consumers
|
|
51
|
+
|
|
52
|
+
- `@adhd/apigen-plugin-py-grpc` — `ensurePythonEnv({ extras: ['grpc'] })`
|
|
53
|
+
- `@adhd/apigen-plugin-py-flask` — `ensurePythonEnv()` (stdlib HTTP fallback needs no extras)
|
|
54
|
+
- `apigen serve` — pre-provisions before spawning Python hosts so child ready
|
|
55
|
+
timeouts don't absorb a first-time venv build, and pins children via
|
|
56
|
+
`APIGEN_PYTHON`.
|
package/index.d.ts
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const T=require("node:fs"),I=require("node:os"),q=require("node:path"),F=require("node:crypto"),h=require("node:child_process");function S(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t){for(const r in t)if(r!=="default"){const i=Object.getOwnPropertyDescriptor(t,r);Object.defineProperty(e,r,i.get?i:{enumerable:!0,get:()=>t[r]})}}return e.default=t,Object.freeze(e)}const n=S(T),E=S(I),o=S(q);function k(t){const e=t??__dirname,r=o.resolve(e,"..","..","..","python");if(n.existsSync(o.join(r,"apigen_python")))return r;let i=e;for(let p=0;p<20;p++){const s=o.join(i,"packages","apigen","python");if(n.existsSync(o.join(s,"apigen_python")))return s;const u=o.dirname(i);if(u===i)break;i=u}return r}const N=["python3.13","python3.12","python3.11","python3"],d=[3,11];function M(t){const e=h.spawnSync(t,["-c",'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")'],{encoding:"utf8",timeout:15e3});if(e.status!==0||!e.stdout)return;const[r,i]=e.stdout.trim().split(".").map(Number);if(!(!Number.isFinite(r)||!Number.isFinite(i)))return[r,i]}function Y(){for(const t of N){const e=M(t);if(e&&(e[0]>d[0]||e[0]===d[0]&&e[1]>=d[1]))return t}throw new Error(`apigen-python-env: no Python >= ${d.join(".")} found on PATH (tried: ${N.join(", ")}). Install Python ${d.join(".")}+ or set APIGEN_PYTHON to a provisioned interpreter.`)}function G(t){return process.platform==="win32"?o.join(t,"Scripts","python.exe"):o.join(t,"bin","python3")}function B(t){const e=n.readFileSync(o.join(t,"pyproject.toml"));return F.createHash("sha256").update(e).digest("hex")}function $(t){try{const e=JSON.parse(n.readFileSync(t,"utf8"));if(typeof e.pyprojectHash=="string"&&Array.isArray(e.extras))return e}catch{}}function V(t,e,r){const i=Date.now()+e;for(;;)try{n.mkdirSync(t,{recursive:!1});break}catch{if(Date.now()>i){try{n.rmdirSync(t)}catch{}continue}h.spawnSync(process.execPath,["-e","setTimeout(()=>{}, 250)"],{timeout:5e3})}try{return r()}finally{try{n.rmdirSync(t)}catch{}}}function J(t={}){const e=k(),r=process.env.APIGEN_PYTHON;if(r){if(!n.existsSync(r))throw new Error(`apigen-python-env: APIGEN_PYTHON points to a missing file: ${r}`);return{python:r,venvDir:"",pythonPkgDir:e}}const i=[...new Set(t.extras??[])].sort(),p=t.envHome??process.env.APIGEN_PYENV_HOME??o.join(E.homedir(),".adhd","apigen"),s=o.join(p,"pyvenv"),u=o.join(s,".apigen-stamp"),g=B(e),y=G(s),w=()=>{if(!n.existsSync(y))return!1;const c=$(u);return!c||c.pyprojectHash!==g?!1:i.every(f=>c.extras.includes(f))};if(w())return{python:y,venvDir:s,pythonPkgDir:e};n.mkdirSync(p,{recursive:!0});const H=o.join(p,"pyvenv.lock"),D=t.lockTimeoutMs??3e5;return V(H,D,()=>{if(w())return{python:y,venvDir:s,pythonPkgDir:e};const c=$(u),f=[...new Set([...(c==null?void 0:c.extras)??[],...i])].sort();n.rmSync(s,{recursive:!0,force:!0});const j=Y(),v=h.spawnSync(j,["-m","venv",s],{encoding:"utf8",timeout:12e4});if(v.status!==0)throw new Error(`apigen-python-env: "${j} -m venv" failed (exit ${v.status}):
|
|
2
|
+
${v.stderr}`);const P=o.join(e,"build");n.existsSync(P)&&n.rmSync(P,{recursive:!0,force:!0});for(const a of n.readdirSync(e))a.endsWith(".egg-info")&&n.rmSync(o.join(e,a),{recursive:!0,force:!0});const l=n.mkdtempSync(o.join(E.tmpdir(),"apigen-python-wheel-"));try{const a=h.spawnSync(y,["-m","pip","wheel","--no-deps","--wheel-dir",l,e],{encoding:"utf8",timeout:6e5});if(a.status!==0)throw new Error(`apigen-python-env: pip wheel failed (exit ${a.status}):
|
|
3
|
+
${a.stderr}
|
|
4
|
+
stdout:
|
|
5
|
+
${a.stdout}`);const _=n.readdirSync(l).filter(A=>A.endsWith(".whl"));if(_.length===0)throw new Error("apigen-python-env: pip wheel produced no .whl file");const b=o.join(l,_[0]),x=f.length?`${b}[${f.join(",")}]`:b,m=h.spawnSync(y,["-m","pip","install","--quiet","--disable-pip-version-check",x],{encoding:"utf8",timeout:6e5});if(m.status!==0)throw new Error(`apigen-python-env: pip install "${x}" failed (exit ${m.status}):
|
|
6
|
+
${m.stderr}
|
|
7
|
+
stdout:
|
|
8
|
+
${m.stdout}`)}finally{n.rmSync(l,{recursive:!0,force:!0})}const O={pyprojectHash:g,extras:f};return n.writeFileSync(u,JSON.stringify(O)),{python:y,venvDir:s,pythonPkgDir:e}})}exports.ensurePythonEnv=J;exports.resolvePythonPkgDir=k;
|
package/index.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as n from "node:fs";
|
|
2
|
+
import * as E from "node:os";
|
|
3
|
+
import * as r from "node:path";
|
|
4
|
+
import { createHash as D } from "node:crypto";
|
|
5
|
+
import { spawnSync as d } from "node:child_process";
|
|
6
|
+
function I(e) {
|
|
7
|
+
const t = e ?? __dirname, o = r.resolve(t, "..", "..", "..", "python");
|
|
8
|
+
if (n.existsSync(r.join(o, "apigen_python")))
|
|
9
|
+
return o;
|
|
10
|
+
let i = t;
|
|
11
|
+
for (let a = 0; a < 20; a++) {
|
|
12
|
+
const s = r.join(i, "packages", "apigen", "python");
|
|
13
|
+
if (n.existsSync(r.join(s, "apigen_python")))
|
|
14
|
+
return s;
|
|
15
|
+
const u = r.dirname(i);
|
|
16
|
+
if (u === i)
|
|
17
|
+
break;
|
|
18
|
+
i = u;
|
|
19
|
+
}
|
|
20
|
+
return o;
|
|
21
|
+
}
|
|
22
|
+
const N = ["python3.13", "python3.12", "python3.11", "python3"], h = [3, 11];
|
|
23
|
+
function T(e) {
|
|
24
|
+
const t = d(e, ["-c", 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")'], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
timeout: 15e3
|
|
27
|
+
});
|
|
28
|
+
if (t.status !== 0 || !t.stdout)
|
|
29
|
+
return;
|
|
30
|
+
const [o, i] = t.stdout.trim().split(".").map(Number);
|
|
31
|
+
if (!(!Number.isFinite(o) || !Number.isFinite(i)))
|
|
32
|
+
return [o, i];
|
|
33
|
+
}
|
|
34
|
+
function F() {
|
|
35
|
+
for (const e of N) {
|
|
36
|
+
const t = T(e);
|
|
37
|
+
if (t && (t[0] > h[0] || t[0] === h[0] && t[1] >= h[1]))
|
|
38
|
+
return e;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(
|
|
41
|
+
`apigen-python-env: no Python >= ${h.join(".")} found on PATH (tried: ${N.join(", ")}). Install Python ${h.join(".")}+ or set APIGEN_PYTHON to a provisioned interpreter.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
function O(e) {
|
|
45
|
+
return process.platform === "win32" ? r.join(e, "Scripts", "python.exe") : r.join(e, "bin", "python3");
|
|
46
|
+
}
|
|
47
|
+
function Y(e) {
|
|
48
|
+
const t = n.readFileSync(r.join(e, "pyproject.toml"));
|
|
49
|
+
return D("sha256").update(t).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
function k(e) {
|
|
52
|
+
try {
|
|
53
|
+
const t = JSON.parse(n.readFileSync(e, "utf8"));
|
|
54
|
+
if (typeof t.pyprojectHash == "string" && Array.isArray(t.extras))
|
|
55
|
+
return t;
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function G(e, t, o) {
|
|
60
|
+
const i = Date.now() + t;
|
|
61
|
+
for (; ; )
|
|
62
|
+
try {
|
|
63
|
+
n.mkdirSync(e, { recursive: !1 });
|
|
64
|
+
break;
|
|
65
|
+
} catch {
|
|
66
|
+
if (Date.now() > i) {
|
|
67
|
+
try {
|
|
68
|
+
n.rmdirSync(e);
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
d(process.execPath, ["-e", "setTimeout(()=>{}, 250)"], { timeout: 5e3 });
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return o();
|
|
77
|
+
} finally {
|
|
78
|
+
try {
|
|
79
|
+
n.rmdirSync(e);
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function V(e = {}) {
|
|
85
|
+
const t = I(), o = process.env.APIGEN_PYTHON;
|
|
86
|
+
if (o) {
|
|
87
|
+
if (!n.existsSync(o))
|
|
88
|
+
throw new Error(`apigen-python-env: APIGEN_PYTHON points to a missing file: ${o}`);
|
|
89
|
+
return { python: o, venvDir: "", pythonPkgDir: t };
|
|
90
|
+
}
|
|
91
|
+
const i = [...new Set(e.extras ?? [])].sort(), a = e.envHome ?? process.env.APIGEN_PYENV_HOME ?? r.join(E.homedir(), ".adhd", "apigen"), s = r.join(a, "pyvenv"), u = r.join(s, ".apigen-stamp"), w = Y(t), y = O(s), S = () => {
|
|
92
|
+
if (!n.existsSync(y))
|
|
93
|
+
return !1;
|
|
94
|
+
const c = k(u);
|
|
95
|
+
return !c || c.pyprojectHash !== w ? !1 : i.every((f) => c.extras.includes(f));
|
|
96
|
+
};
|
|
97
|
+
if (S())
|
|
98
|
+
return { python: y, venvDir: s, pythonPkgDir: t };
|
|
99
|
+
n.mkdirSync(a, { recursive: !0 });
|
|
100
|
+
const H = r.join(a, "pyvenv.lock"), _ = e.lockTimeoutMs ?? 3e5;
|
|
101
|
+
return G(H, _, () => {
|
|
102
|
+
if (S())
|
|
103
|
+
return { python: y, venvDir: s, pythonPkgDir: t };
|
|
104
|
+
const c = k(u), f = [.../* @__PURE__ */ new Set([...(c == null ? void 0 : c.extras) ?? [], ...i])].sort();
|
|
105
|
+
n.rmSync(s, { recursive: !0, force: !0 });
|
|
106
|
+
const g = F(), v = d(g, ["-m", "venv", s], { encoding: "utf8", timeout: 12e4 });
|
|
107
|
+
if (v.status !== 0)
|
|
108
|
+
throw new Error(
|
|
109
|
+
`apigen-python-env: "${g} -m venv" failed (exit ${v.status}):
|
|
110
|
+
${v.stderr}`
|
|
111
|
+
);
|
|
112
|
+
const j = r.join(t, "build");
|
|
113
|
+
n.existsSync(j) && n.rmSync(j, { recursive: !0, force: !0 });
|
|
114
|
+
for (const p of n.readdirSync(t))
|
|
115
|
+
p.endsWith(".egg-info") && n.rmSync(r.join(t, p), { recursive: !0, force: !0 });
|
|
116
|
+
const m = n.mkdtempSync(r.join(E.tmpdir(), "apigen-python-wheel-"));
|
|
117
|
+
try {
|
|
118
|
+
const p = d(
|
|
119
|
+
y,
|
|
120
|
+
["-m", "pip", "wheel", "--no-deps", "--wheel-dir", m, t],
|
|
121
|
+
{ encoding: "utf8", timeout: 6e5 }
|
|
122
|
+
);
|
|
123
|
+
if (p.status !== 0)
|
|
124
|
+
throw new Error(
|
|
125
|
+
`apigen-python-env: pip wheel failed (exit ${p.status}):
|
|
126
|
+
${p.stderr}
|
|
127
|
+
stdout:
|
|
128
|
+
${p.stdout}`
|
|
129
|
+
);
|
|
130
|
+
const P = n.readdirSync(m).filter((A) => A.endsWith(".whl"));
|
|
131
|
+
if (P.length === 0)
|
|
132
|
+
throw new Error("apigen-python-env: pip wheel produced no .whl file");
|
|
133
|
+
const x = r.join(m, P[0]), $ = f.length ? `${x}[${f.join(",")}]` : x, l = d(
|
|
134
|
+
y,
|
|
135
|
+
["-m", "pip", "install", "--quiet", "--disable-pip-version-check", $],
|
|
136
|
+
{ encoding: "utf8", timeout: 6e5 }
|
|
137
|
+
);
|
|
138
|
+
if (l.status !== 0)
|
|
139
|
+
throw new Error(
|
|
140
|
+
`apigen-python-env: pip install "${$}" failed (exit ${l.status}):
|
|
141
|
+
${l.stderr}
|
|
142
|
+
stdout:
|
|
143
|
+
${l.stdout}`
|
|
144
|
+
);
|
|
145
|
+
} finally {
|
|
146
|
+
n.rmSync(m, { recursive: !0, force: !0 });
|
|
147
|
+
}
|
|
148
|
+
const b = { pyprojectHash: w, extras: f };
|
|
149
|
+
return n.writeFileSync(u, JSON.stringify(b)), { python: y, venvDir: s, pythonPkgDir: t };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export {
|
|
153
|
+
V as ensurePythonEnv,
|
|
154
|
+
I as resolvePythonPkgDir
|
|
155
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Result of {@link ensurePythonEnv}. */
|
|
2
|
+
export interface IPythonEnv {
|
|
3
|
+
/** Absolute path to the interpreter the caller should spawn. */
|
|
4
|
+
python: string;
|
|
5
|
+
/** Absolute path to the managed venv dir ('' when APIGEN_PYTHON overrode it). */
|
|
6
|
+
venvDir: string;
|
|
7
|
+
/** Absolute path to the apigen-python package sources (PYTHONPATH target). */
|
|
8
|
+
pythonPkgDir: string;
|
|
9
|
+
}
|
|
10
|
+
export interface IEnsurePythonEnvOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Extras from apigen-python's pyproject `[project.optional-dependencies]`
|
|
13
|
+
* to provision (e.g. `['grpc']`). Sorted + deduped into the stamp so the
|
|
14
|
+
* venv is refreshed when the requested extras change.
|
|
15
|
+
*/
|
|
16
|
+
extras?: string[];
|
|
17
|
+
/**
|
|
18
|
+
* Override the managed-venv root (default `~/.adhd/apigen`). Tests point
|
|
19
|
+
* this at `tmp/` so they never touch the user's real environment.
|
|
20
|
+
* Honours the `APIGEN_PYENV_HOME` env var when the option is absent.
|
|
21
|
+
*/
|
|
22
|
+
envHome?: string;
|
|
23
|
+
/** Bound (ms) on waiting for another process's in-flight bootstrap. */
|
|
24
|
+
lockTimeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Locate the directory containing the `apigen_python` package sources.
|
|
28
|
+
*
|
|
29
|
+
* Primary: `<this file>/../../../python` (source layout:
|
|
30
|
+
* packages/apigen/python-env/src/lib → packages/apigen/python).
|
|
31
|
+
* Fallback: walk up from __dirname looking for `packages/apigen/python`
|
|
32
|
+
* (covers the vite-bundled CLI, where __dirname is the bundle output dir).
|
|
33
|
+
* (Shared by py-grpc / py-flask — previously duplicated in each plugin.)
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolvePythonPkgDir(fromDir?: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the interpreter for apigen's Python hosts, provisioning the managed
|
|
38
|
+
* venv from `apigen-python`'s own pyproject.toml when needed.
|
|
39
|
+
*
|
|
40
|
+
* Synchronous by design — this is a startup-time path and its consumers
|
|
41
|
+
* (plugin `run()` bodies, serve pre-flight) need a settled interpreter before
|
|
42
|
+
* spawning children.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ensurePythonEnv(opts?: IEnsurePythonEnvOptions): IPythonEnv;
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adhd/apigen-python-env",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "./index.js",
|
|
5
|
+
"module": "./index.mjs",
|
|
6
|
+
"types": "./index.d.ts",
|
|
7
|
+
"description": "apigen python host provisioning: resolves the interpreter for py-* plugins by bootstrapping a managed venv from apigen-python's own pyproject.toml extras",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"apigen",
|
|
10
|
+
"python",
|
|
11
|
+
"venv",
|
|
12
|
+
"provisioning"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
}
|
|
17
|
+
}
|