Skip to content

Commit 5c14a47

Browse files
committed
Add python-standalone utility for dlx Python management
1 parent 3031751 commit 5c14a47

File tree

1 file changed

+280
-0
lines changed

1 file changed

+280
-0
lines changed

src/utils/python-standalone.mts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/** @fileoverview Python runtime management using python-build-standalone for Socket CLI. */
2+
3+
import { createWriteStream, existsSync, promises as fs } from 'node:fs'
4+
import os from 'node:os'
5+
import path from 'node:path'
6+
import { pipeline } from 'node:stream/promises'
7+
8+
import semver from 'semver'
9+
10+
import { whichBin } from '@socketsecurity/registry/lib/bin'
11+
import { spawn } from '@socketsecurity/registry/lib/spawn'
12+
13+
import constants from '../constants.mts'
14+
import { getDlxCachePath } from './dlx-binary.mts'
15+
import { InputError, getErrorCause } from './errors.mts'
16+
17+
import type { CResult } from '../types.mts'
18+
19+
const { ENV, PYTHON_MIN_VERSION, WIN32 } = constants
20+
21+
/**
22+
* Get the download URL for python-build-standalone based on platform and architecture.
23+
*/
24+
function getPythonStandaloneUrl(
25+
version: string = ENV['INLINED_SOCKET_CLI_PYTHON_VERSION'],
26+
tag: string = ENV['INLINED_SOCKET_CLI_PYTHON_BUILD_TAG'],
27+
): string {
28+
const platform = os.platform()
29+
const arch = os.arch()
30+
31+
let platformTriple: string
32+
33+
if (platform === 'darwin') {
34+
platformTriple =
35+
arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'
36+
} else if (platform === 'linux') {
37+
platformTriple =
38+
arch === 'arm64'
39+
? 'aarch64-unknown-linux-gnu'
40+
: 'x86_64-unknown-linux-gnu'
41+
} else if (platform === 'win32') {
42+
platformTriple = 'x86_64-pc-windows-msvc'
43+
} else {
44+
throw new InputError(`Unsupported platform: ${platform}`)
45+
}
46+
47+
// URL encoding for the '+' in version string
48+
const encodedVersion = `${version}%2B${tag}`
49+
return `https://github.com/astral-sh/python-build-standalone/releases/download/${tag}/cpython-${encodedVersion}-${platformTriple}-install_only.tar.gz`
50+
}
51+
52+
/**
53+
* Get the path to the cached Python installation directory.
54+
*/
55+
function getPythonCachePath(): string {
56+
const version = ENV['INLINED_SOCKET_CLI_PYTHON_VERSION']
57+
const tag = ENV['INLINED_SOCKET_CLI_PYTHON_BUILD_TAG']
58+
const platform = os.platform()
59+
const arch = os.arch()
60+
61+
return path.join(
62+
getDlxCachePath(),
63+
'python',
64+
`${version}-${tag}-${platform}-${arch}`,
65+
)
66+
}
67+
68+
/**
69+
* Get the path to the Python executable within the installation.
70+
*/
71+
function getPythonBinPath(pythonDir: string): string {
72+
if (WIN32) {
73+
// Windows: python/python.exe
74+
return path.join(pythonDir, 'python', 'python.exe')
75+
}
76+
// POSIX: python/bin/python3
77+
return path.join(pythonDir, 'python', 'bin', 'python3')
78+
}
79+
80+
/**
81+
* Check if system Python meets minimum version requirement.
82+
* Returns the path to Python executable if it meets requirements, null otherwise.
83+
*/
84+
export async function checkSystemPython(): Promise<string | null> {
85+
try {
86+
// Try python3 first, then python
87+
let pythonPath: string | string[] | null | undefined = await whichBin(
88+
'python3',
89+
{ nothrow: true },
90+
)
91+
92+
if (!pythonPath) {
93+
pythonPath = await whichBin('python', { nothrow: true })
94+
}
95+
96+
if (!pythonPath) {
97+
return null
98+
}
99+
100+
const pythonBin = Array.isArray(pythonPath) ? pythonPath[0]! : pythonPath
101+
102+
// Get version
103+
const result = await spawn(pythonBin, ['--version'], {
104+
shell: WIN32,
105+
})
106+
107+
const stdout =
108+
typeof result.stdout === 'string'
109+
? result.stdout
110+
: result.stdout.toString('utf8')
111+
112+
// Parse "Python 3.10.5" -> "3.10.5"
113+
const version = semver.coerce(stdout)
114+
115+
if (!version) {
116+
return null
117+
}
118+
119+
// Check if it meets minimum version
120+
if (semver.satisfies(version, `>=${PYTHON_MIN_VERSION}`)) {
121+
return pythonBin
122+
}
123+
124+
return null
125+
} catch {
126+
return null
127+
}
128+
}
129+
130+
/**
131+
* Download and extract Python from python-build-standalone.
132+
*/
133+
async function downloadPython(pythonDir: string): Promise<void> {
134+
const url = getPythonStandaloneUrl()
135+
const tarballPath = path.join(pythonDir, 'python.tar.gz')
136+
137+
// Ensure directory exists
138+
await fs.mkdir(pythonDir, { recursive: true })
139+
140+
// Download
141+
const response = await fetch(url)
142+
if (!response.ok) {
143+
throw new InputError(
144+
`Failed to download Python: ${response.status} ${response.statusText}`,
145+
)
146+
}
147+
148+
// Save tarball
149+
const fileStream = createWriteStream(tarballPath)
150+
// @ts-expect-error - ReadableStream from fetch is compatible with pipeline
151+
await pipeline(response.body, fileStream)
152+
153+
// Extract using system tar command
154+
await spawn('tar', ['-xzf', tarballPath, '-C', pythonDir], {
155+
shell: WIN32,
156+
})
157+
158+
// Clean up tarball
159+
await fs.unlink(tarballPath)
160+
}
161+
162+
/**
163+
* Ensure Python is available, either from system or by downloading.
164+
* Returns the path to the Python executable.
165+
*/
166+
export async function ensurePython(): Promise<string> {
167+
// Check system Python first
168+
const systemPython = await checkSystemPython()
169+
if (systemPython) {
170+
return systemPython
171+
}
172+
173+
// Use cached Python or download it
174+
const pythonDir = getPythonCachePath()
175+
const pythonBin = getPythonBinPath(pythonDir)
176+
177+
if (!existsSync(pythonBin)) {
178+
// Download and extract
179+
await downloadPython(pythonDir)
180+
181+
// Verify it was extracted correctly
182+
if (!existsSync(pythonBin)) {
183+
throw new InputError(
184+
`Python binary not found after extraction: ${pythonBin}`,
185+
)
186+
}
187+
188+
// Make executable on POSIX
189+
if (!WIN32) {
190+
await fs.chmod(pythonBin, 0o755)
191+
}
192+
}
193+
194+
return pythonBin
195+
}
196+
197+
/**
198+
* Check if socketcli is installed in the Python environment.
199+
*/
200+
async function isSocketCliInstalled(pythonBin: string): Promise<boolean> {
201+
try {
202+
const result = await spawn(
203+
pythonBin,
204+
['-c', 'import socketsecurity.socketcli'],
205+
{ shell: WIN32 },
206+
)
207+
return result.code === 0
208+
} catch {
209+
return false
210+
}
211+
}
212+
213+
/**
214+
* Install socketsecurity package into the Python environment.
215+
*/
216+
export async function ensureSocketCli(pythonBin: string): Promise<void> {
217+
// Check if already installed
218+
if (await isSocketCliInstalled(pythonBin)) {
219+
return
220+
}
221+
222+
// Install socketsecurity
223+
await spawn(
224+
pythonBin,
225+
['-m', 'pip', 'install', '--quiet', 'socketsecurity'],
226+
{
227+
shell: WIN32,
228+
stdio: 'inherit',
229+
},
230+
)
231+
}
232+
233+
/**
234+
* Run socketcli with arguments using managed or system Python.
235+
*/
236+
export async function spawnSocketPython(
237+
args: string[] | readonly string[],
238+
options?: {
239+
cwd?: string
240+
env?: Record<string, string>
241+
stdio?: 'inherit' | 'pipe'
242+
},
243+
): Promise<CResult<string>> {
244+
try {
245+
// Ensure Python is available
246+
const pythonBin = await ensurePython()
247+
248+
// Ensure socketcli is installed
249+
await ensureSocketCli(pythonBin)
250+
251+
const finalEnv = {
252+
...process.env,
253+
...constants.processEnv,
254+
...options?.env,
255+
}
256+
257+
// Run socketcli via python -m
258+
const spawnResult = await spawn(
259+
pythonBin,
260+
['-m', 'socketsecurity.socketcli', ...args],
261+
{
262+
cwd: options?.cwd,
263+
env: finalEnv,
264+
shell: WIN32,
265+
stdio: options?.stdio || 'inherit',
266+
},
267+
)
268+
269+
return {
270+
ok: true,
271+
data: spawnResult.stdout ? spawnResult.stdout.toString() : '',
272+
}
273+
} catch (e) {
274+
return {
275+
ok: false,
276+
data: e,
277+
message: getErrorCause(e),
278+
}
279+
}
280+
}

0 commit comments

Comments
 (0)