@qvac/harness 0.3.0 → 0.3.1
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/CHANGELOG.md +213 -0
- package/dist/harness.bundle +13 -11
- package/dist-lib/index.d.ts +1 -1
- package/dist-lib/lib/capabilities/completion.d.ts +3 -0
- package/dist-lib/lib/capabilities/completion.js +17 -5
- package/dist-lib/lib/helpers/tool-call-markup.js +15 -7
- package/dist-lib/lib/no-redirect-fetch-impl.d.ts +3 -2
- package/dist-lib/lib/no-redirect-fetch-impl.js +4 -1
- package/dist-lib/lib/tools/exec/adapters/adapter.d.ts +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/distributions.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/distributions.js +4 -0
- package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.d.ts +67 -0
- package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +196 -0
- package/dist-lib/lib/tools/exec/adapters/python/loader.js +2 -1
- package/dist-lib/lib/tools/exec/adapters/python/module-distributions.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +52 -0
- package/dist-lib/lib/tools/exec/adapters/python/net-shim.d.ts +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/net-shim.js +148 -0
- package/dist-lib/lib/tools/exec/adapters/python/output-policy.js +13 -1
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +9 -3
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +130 -20
- package/dist-lib/lib/tools/exec/adapters/python/run-session.d.ts +3 -0
- package/dist-lib/lib/tools/exec/adapters/python/run-session.js +25 -8
- package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +6 -0
- package/dist-lib/lib/tools/exec/adapters/python/wheels.js +17 -3
- package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +7 -1
- package/dist-lib/lib/tools/exec/shell-guidance.d.ts +10 -0
- package/dist-lib/lib/tools/exec/shell-guidance.js +123 -0
- package/dist-lib/lib/tools/exec/video-tools.d.ts +5 -0
- package/dist-lib/lib/tools/exec/video-tools.js +40 -0
- package/dist-lib/lib/tools/exec.d.ts +2 -1
- package/dist-lib/lib/tools/exec.js +59 -32
- package/dist-lib/lib/tools/http-request/truncate-response.d.ts +1 -0
- package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
- package/dist-lib/lib/tools/http-request.js +4 -1
- package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
- package/dist-lib/lib/tools/operations/builders.js +9 -0
- package/dist-lib/lib/tools/operations/compile.js +41 -18
- package/dist-lib/lib/tools/operations/decoders.d.ts +6 -0
- package/dist-lib/lib/tools/operations/decoders.js +82 -0
- package/dist-lib/lib/tools/operations/manifest.d.ts +16 -2
- package/dist-lib/lib/tools/operations/manifest.js +6 -1
- package/dist-lib/lib/tools/skill.d.ts +3 -0
- package/dist-lib/lib/tools/skill.js +38 -5
- package/dist-lib/lib/tools/skills/manifest-parse.js +43 -1
- package/dist-lib/lib/tools/skills/manifest.d.ts +13 -0
- package/dist-lib/schema/harness.js +20 -1
- package/dist-lib/spec/hrpc/messages.js +93 -5
- package/dist-lib/spec/hyperschema/index.js +93 -5
- package/dist-lib/spec/hyperschema/schema.json +66 -1
- package/dist-lib/spec/hyperschema/types.d.ts +18 -1
- package/package.json +30 -2
- package/wheels/manifest.json +15 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export function pythonReachesNetwork() {
|
|
2
|
+
return (typeof globalThis.Bare !== 'undefined' &&
|
|
3
|
+
typeof WebAssembly.Suspending === 'function');
|
|
4
|
+
}
|
|
5
|
+
export const NET_SHIM = `
|
|
6
|
+
def _qvac_install_net():
|
|
7
|
+
import errno, http.client, io
|
|
8
|
+
import js
|
|
9
|
+
from pyodide.ffi import can_run_sync, run_sync, to_js
|
|
10
|
+
|
|
11
|
+
if getattr(http.client, '_qvac_net', False):
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
class _Response(http.client.HTTPResponse):
|
|
15
|
+
# the body is read in full by the fetch, so there is no socket behind it
|
|
16
|
+
def __init__(self, status, headers, body, url):
|
|
17
|
+
self.status = self.code = status
|
|
18
|
+
self.reason = http.client.responses.get(status, '')
|
|
19
|
+
self.headers = self.msg = http.client.HTTPMessage()
|
|
20
|
+
for name, value in headers:
|
|
21
|
+
self.headers[name] = value
|
|
22
|
+
self.fp = io.BytesIO(body)
|
|
23
|
+
self.version = 11
|
|
24
|
+
self.will_close = True
|
|
25
|
+
self.chunked = False
|
|
26
|
+
self.length = len(body)
|
|
27
|
+
self.url = url
|
|
28
|
+
self.debuglevel = 0
|
|
29
|
+
|
|
30
|
+
def read(self, amt=None):
|
|
31
|
+
return self.fp.read() if amt is None or amt < 0 else self.fp.read(amt)
|
|
32
|
+
|
|
33
|
+
def read1(self, n=-1):
|
|
34
|
+
return self.read(n)
|
|
35
|
+
|
|
36
|
+
def readinto(self, b):
|
|
37
|
+
data = self.fp.read(len(b))
|
|
38
|
+
b[: len(data)] = data
|
|
39
|
+
return len(data)
|
|
40
|
+
|
|
41
|
+
def peek(self, n=0):
|
|
42
|
+
pos = self.fp.tell()
|
|
43
|
+
data = self.fp.read(max(n, 1))
|
|
44
|
+
self.fp.seek(pos)
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
def isclosed(self):
|
|
48
|
+
return self.fp.closed
|
|
49
|
+
|
|
50
|
+
def close(self):
|
|
51
|
+
self.fp.close()
|
|
52
|
+
|
|
53
|
+
def fileno(self):
|
|
54
|
+
raise OSError('this response has no socket')
|
|
55
|
+
|
|
56
|
+
class _Connection(http.client.HTTPConnection):
|
|
57
|
+
scheme = 'http'
|
|
58
|
+
|
|
59
|
+
def __init__(self, host, port=None, timeout=None, source_address=None, blocksize=8192, **kw):
|
|
60
|
+
self.host, self.port, self.timeout = host, port, timeout
|
|
61
|
+
self.sock = None
|
|
62
|
+
self.debuglevel = 0
|
|
63
|
+
self._tunnel_host = None
|
|
64
|
+
self._request = None
|
|
65
|
+
self._response = None
|
|
66
|
+
|
|
67
|
+
def set_debuglevel(self, level):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def set_tunnel(self, host, port=None, headers=None):
|
|
71
|
+
self._tunnel_host = host
|
|
72
|
+
|
|
73
|
+
def connect(self):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def close(self):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def putrequest(self, method, url, skip_host=False, skip_accept_encoding=False):
|
|
80
|
+
self._request = [method, url, {}, b'']
|
|
81
|
+
self._response = None
|
|
82
|
+
|
|
83
|
+
def putheader(self, header, *values):
|
|
84
|
+
self._request[2][header] = ', '.join(str(v) for v in values)
|
|
85
|
+
|
|
86
|
+
# A socket connects while the request is sent, so that is where a failure lands and
|
|
87
|
+
# where urllib turns it into URLError. A body sent after the headers waits for it.
|
|
88
|
+
def endheaders(self, message_body=None, *, encode_chunked=False):
|
|
89
|
+
if message_body is not None:
|
|
90
|
+
self.send(message_body)
|
|
91
|
+
if message_body is not None or self._request[0] in ('GET', 'HEAD', 'DELETE', 'OPTIONS'):
|
|
92
|
+
self._response = self._fetch()
|
|
93
|
+
|
|
94
|
+
def send(self, data):
|
|
95
|
+
if hasattr(data, 'read'):
|
|
96
|
+
data = data.read()
|
|
97
|
+
if isinstance(data, str):
|
|
98
|
+
data = data.encode()
|
|
99
|
+
self._request[3] += bytes(data)
|
|
100
|
+
|
|
101
|
+
def request(self, method, url, body=None, headers={}, *, encode_chunked=False):
|
|
102
|
+
self.putrequest(method, url)
|
|
103
|
+
for name, value in dict(headers).items():
|
|
104
|
+
self.putheader(name, value)
|
|
105
|
+
self.endheaders(body)
|
|
106
|
+
|
|
107
|
+
def getresponse(self):
|
|
108
|
+
response = self._response or self._fetch()
|
|
109
|
+
self._response = None
|
|
110
|
+
return response
|
|
111
|
+
|
|
112
|
+
def _fetch(self):
|
|
113
|
+
method, url, headers, body = self._request
|
|
114
|
+
if not url.startswith(('http://', 'https://')):
|
|
115
|
+
port = '' if self.port in (None, self.default_port) else ':%d' % self.port
|
|
116
|
+
url = '%s://%s%s%s' % (self.scheme, self.host, port, url)
|
|
117
|
+
if not can_run_sync():
|
|
118
|
+
raise OSError(errno.EHOSTUNREACH, 'Host is unreachable')
|
|
119
|
+
# the fetch hands back decoded bytes, so no encoding is asked for and none reported
|
|
120
|
+
skip = ('accept-encoding', 'connection', 'host', 'content-length')
|
|
121
|
+
headers = {k: v for k, v in headers.items() if k.lower() not in skip}
|
|
122
|
+
headers['Accept-Encoding'] = 'identity'
|
|
123
|
+
init = {'method': method, 'headers': headers, 'redirect': 'manual'}
|
|
124
|
+
if body:
|
|
125
|
+
init['body'] = to_js(body)
|
|
126
|
+
try:
|
|
127
|
+
response = run_sync(js.fetch(url, to_js(init, dict_converter=js.Object.fromEntries)))
|
|
128
|
+
data = bytes(run_sync(response.arrayBuffer()).to_py())
|
|
129
|
+
except Exception as error:
|
|
130
|
+
# the guard's own refusal, or the transport's: either says more than an errno
|
|
131
|
+
raise OSError(getattr(error, 'message', None) or str(error)) from None
|
|
132
|
+
entries = [(str(k), str(v)) for k, v in response.headers.entries()]
|
|
133
|
+
drop = ('content-encoding', 'transfer-encoding', 'content-length')
|
|
134
|
+
kept = [(k, v) for k, v in entries if k.lower() not in drop]
|
|
135
|
+
kept.append(('Content-Length', str(len(data))))
|
|
136
|
+
return _Response(int(response.status), kept, data, url)
|
|
137
|
+
|
|
138
|
+
class _HTTPSConnection(_Connection):
|
|
139
|
+
scheme = 'https'
|
|
140
|
+
default_port = 443
|
|
141
|
+
|
|
142
|
+
http.client.HTTPConnection = _Connection
|
|
143
|
+
http.client.HTTPSConnection = _HTTPSConnection
|
|
144
|
+
http.client._qvac_net = True
|
|
145
|
+
|
|
146
|
+
_qvac_install_net()
|
|
147
|
+
del _qvac_install_net
|
|
148
|
+
`;
|
|
@@ -19,7 +19,11 @@ const MIME_TYPES = {
|
|
|
19
19
|
json: 'application/json',
|
|
20
20
|
txt: 'text/plain',
|
|
21
21
|
md: 'text/markdown',
|
|
22
|
-
zip: 'application/zip'
|
|
22
|
+
zip: 'application/zip',
|
|
23
|
+
mp4: 'video/mp4',
|
|
24
|
+
webm: 'video/webm',
|
|
25
|
+
m4a: 'audio/mp4',
|
|
26
|
+
mp3: 'audio/mpeg'
|
|
23
27
|
};
|
|
24
28
|
const DENIED_EXTENSIONS = new Set([
|
|
25
29
|
'pptm',
|
|
@@ -48,6 +52,14 @@ function hasExpectedSignature(extension, bytes) {
|
|
|
48
52
|
if (extension === 'pptx' || extension === 'docx' || extension === 'xlsx' || extension === 'zip') {
|
|
49
53
|
return matches(bytes, [0x50, 0x4b, 0x03, 0x04]);
|
|
50
54
|
}
|
|
55
|
+
if (extension === 'mp4' || extension === 'm4a') {
|
|
56
|
+
return matches(bytes.slice(4), [0x66, 0x74, 0x79, 0x70]);
|
|
57
|
+
}
|
|
58
|
+
if (extension === 'webm')
|
|
59
|
+
return matches(bytes, [0x1a, 0x45, 0xdf, 0xa3]);
|
|
60
|
+
if (extension === 'mp3') {
|
|
61
|
+
return (matches(bytes, [0x49, 0x44, 0x33]) || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0));
|
|
62
|
+
}
|
|
51
63
|
if (extension === 'pdf')
|
|
52
64
|
return matches(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]);
|
|
53
65
|
if (extension === 'png')
|
|
@@ -15,5 +15,6 @@ export type RunPythonOptions = {
|
|
|
15
15
|
readonly outputs?: readonly string[];
|
|
16
16
|
readonly workdir?: string;
|
|
17
17
|
readonly loadPyodide?: LoadPyodideFn;
|
|
18
|
+
readonly network?: boolean;
|
|
18
19
|
};
|
|
19
20
|
export declare function runPython(code: string, options?: RunPythonOptions): Promise<PythonRunResult>;
|
|
@@ -88,6 +88,7 @@ export async function runPython(code, options = {}) {
|
|
|
88
88
|
outputs: options.outputs,
|
|
89
89
|
workdir: options.workdir,
|
|
90
90
|
maxOutputChars: options.maxOutputChars ?? MAX_OUTPUT_CHARS,
|
|
91
|
+
network: options.network === true,
|
|
91
92
|
installTimeoutMs: timeoutMs
|
|
92
93
|
};
|
|
93
94
|
if (options.loadPyodide) {
|
|
@@ -4,8 +4,11 @@ import { type LoadPyodideFn } from './pyodide.ts';
|
|
|
4
4
|
export declare function isPythonLanguage(language: unknown): boolean;
|
|
5
5
|
export declare function runsThroughPyodide(language: unknown, disablePyodide: boolean): boolean;
|
|
6
6
|
export declare function unwrapPythonSource(command: string): string | null;
|
|
7
|
+
export declare function withoutProgramDir(command: string): string;
|
|
7
8
|
export declare function pipInstallTargets(command: string): string[] | null;
|
|
8
|
-
export declare function
|
|
9
|
+
export declare function pinned(spec: string): string;
|
|
10
|
+
export declare function pipMessage(targets: readonly string[] | null, sandbox?: boolean): string;
|
|
11
|
+
export declare function pipFromSourceHint(stderr: string, source: string, packages: readonly string[] | undefined, sandbox?: boolean): string | null;
|
|
9
12
|
export declare function stringLiteralProgramHint(source: string): string | null;
|
|
10
13
|
export declare function checkCollapsedCompound(source: string): string | null;
|
|
11
14
|
export declare function mangledPptxApiHint(source: string): string | null;
|
|
@@ -44,7 +47,7 @@ export declare function parseOutputs(value: unknown): string[] | {
|
|
|
44
47
|
export declare function parseWorkdir(value: unknown): string | {
|
|
45
48
|
error: string;
|
|
46
49
|
} | undefined;
|
|
47
|
-
export declare function missingModuleHint(stderr: string, packages: readonly string[] | undefined): string | null;
|
|
50
|
+
export declare function missingModuleHint(stderr: string, packages: readonly string[] | undefined, sandbox?: boolean, network?: boolean): string | null;
|
|
48
51
|
export declare function nonePlaceholderHint(stderr: string): string | null;
|
|
49
52
|
export declare function slideShapesHint(stderr: string): string | null;
|
|
50
53
|
export declare function shapeFontHint(stderr: string): string | null;
|
|
@@ -53,6 +56,9 @@ export declare function paragraphFillHint(stderr: string): string | null;
|
|
|
53
56
|
export declare function readOnlyPropertyHint(stderr: string): string | null;
|
|
54
57
|
export declare function paragraphIndexHint(stderr: string, outputs: readonly string[]): string | null;
|
|
55
58
|
export declare function shellNameHint(stderr: string): string | null;
|
|
59
|
+
export declare function noProcessHint(stderr: string, sandbox?: boolean): string | null;
|
|
60
|
+
export declare function noNetworkHint(stderr: string, tools?: readonly string[], network?: boolean): string | null;
|
|
61
|
+
export declare function nativeWheelHint(stderr: string): string | null;
|
|
56
62
|
export declare function packageFetchFailedHint(stderr: string): string | null;
|
|
57
63
|
export declare const IMAGE_INPUT: RegExp;
|
|
58
64
|
export declare function pptxHasEmbeddedMedia(bytes: Uint8Array): boolean;
|
|
@@ -72,4 +78,4 @@ export declare function checkTurnImagesStaged(source: string, inputs: readonly E
|
|
|
72
78
|
export declare function deckDeliveredStop(attachments: readonly {
|
|
73
79
|
readonly fileName: string;
|
|
74
80
|
}[] | undefined, exitCode: number, source?: string): string | null;
|
|
75
|
-
export declare function createPythonAdapter(disabled: boolean, loadPyodide?: LoadPyodideFn): ExecutionAdapter;
|
|
81
|
+
export declare function createPythonAdapter(disabled: boolean, loadPyodide?: LoadPyodideFn, network?: boolean): ExecutionAdapter;
|
|
@@ -2,6 +2,9 @@ import b4a from 'b4a';
|
|
|
2
2
|
import { attachOutputs, stageInputs } from './attachments.js';
|
|
3
3
|
import { checkFileName, checkOutputName, MAX_INPUTS, MAX_OUTPUTS } from './output-policy.js';
|
|
4
4
|
import { runPython } from './pyodide.js';
|
|
5
|
+
import { isTranscoder, TRANSCODE_ERROR, VIDEO_TOOLS_ERROR, videoTool } from '../../video-tools.js';
|
|
6
|
+
import { moduleDistribution } from './distributions.js';
|
|
7
|
+
import { normalizeDistribution, planVendoredInstall, vendoredSpec } from './wheels.js';
|
|
5
8
|
export function isPythonLanguage(language) {
|
|
6
9
|
return typeof language === 'string' && language.trim().toLowerCase() === 'python';
|
|
7
10
|
}
|
|
@@ -114,7 +117,7 @@ function unwrapBareInterpreter(s) {
|
|
|
114
117
|
return body || null;
|
|
115
118
|
}
|
|
116
119
|
export function unwrapPythonSource(command) {
|
|
117
|
-
const trimmed = command.trim();
|
|
120
|
+
const trimmed = withoutProgramDir(command).trim();
|
|
118
121
|
const heredoc = unwrapHeredoc(trimmed);
|
|
119
122
|
if (heredoc !== null)
|
|
120
123
|
return heredoc;
|
|
@@ -123,8 +126,11 @@ export function unwrapPythonSource(command) {
|
|
|
123
126
|
return dashC;
|
|
124
127
|
return unwrapBareInterpreter(trimmed);
|
|
125
128
|
}
|
|
129
|
+
export function withoutProgramDir(command) {
|
|
130
|
+
return command.trimStart().replace(/^[^\s'"]*[/\\](?=[^\s/\\]+(\s|$))/, '');
|
|
131
|
+
}
|
|
126
132
|
function pipInstallRest(command) {
|
|
127
|
-
const s = command.trim();
|
|
133
|
+
const s = withoutProgramDir(command).trim();
|
|
128
134
|
let i = 0;
|
|
129
135
|
const pip = leadingToken(s, PIPS);
|
|
130
136
|
if (pip) {
|
|
@@ -178,17 +184,64 @@ export function pipInstallTargets(command) {
|
|
|
178
184
|
continue;
|
|
179
185
|
if (!REQUIREMENT.test(token))
|
|
180
186
|
return null;
|
|
187
|
+
if (/^pip3?$/i.test(token.split(/[=<>!~[]/)[0]))
|
|
188
|
+
continue;
|
|
181
189
|
targets.push(token);
|
|
182
190
|
}
|
|
183
191
|
return targets.length > 0 ? targets : null;
|
|
184
192
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
193
|
+
const SANDBOX_NO_INSTALL = 'and sandbox mode installs only the packages bundled with this app, which do not include it. Use only what is already installed, or tell the user it is unavailable while sandbox mode is on.';
|
|
194
|
+
const specName = (spec) => normalizeDistribution(spec.split(/[=<>!~[]/)[0]);
|
|
195
|
+
export function pinned(spec) {
|
|
196
|
+
return /[=<>!~]/.test(spec) ? spec : (vendoredSpec(spec) ?? spec);
|
|
197
|
+
}
|
|
198
|
+
export function pipMessage(targets, sandbox = false) {
|
|
199
|
+
const specs = targets?.map(pinned) ?? null;
|
|
200
|
+
if (sandbox && !(specs?.length && planVendoredInstall(specs))) {
|
|
201
|
+
return `pip is not available, ${SANDBOX_NO_INSTALL}`;
|
|
202
|
+
}
|
|
203
|
+
const declare = specs ? `packages: ${JSON.stringify(specs)}` : 'the PyPI names in packages';
|
|
204
|
+
return `pip is not available. Call exec with language "python", ${declare} and the Python source in command. Installing and running are the same one call.`;
|
|
205
|
+
}
|
|
206
|
+
const PIP_CALL = /\b(?:subprocess\.\w+|os\.(?:system|popen)|Popen|check_(?:output|call))\s*\(/;
|
|
207
|
+
const STRING_LITERAL = /"([^"\n]*)"|'([^'\n]*)'/g;
|
|
208
|
+
function pipLineTargets(line) {
|
|
209
|
+
const words = [...line.matchAll(STRING_LITERAL)].map((match) => (match[1] ?? match[2]));
|
|
210
|
+
const whole = words.find((word) => /\bpip3?\b[^]*\binstall\b/.test(word));
|
|
211
|
+
if (whole !== undefined)
|
|
212
|
+
return pipInstallTargets(whole.trim());
|
|
213
|
+
const at = words.findIndex((word) => /^pip3?$/.test(word));
|
|
214
|
+
if (at === -1)
|
|
215
|
+
return undefined;
|
|
216
|
+
return pipInstallTargets(words.slice(at).join(' '));
|
|
217
|
+
}
|
|
218
|
+
export function pipFromSourceHint(stderr, source, packages, sandbox = false) {
|
|
219
|
+
if (!/emscripten does not support processes/.test(stderr))
|
|
220
|
+
return null;
|
|
221
|
+
for (const line of source.split('\n')) {
|
|
222
|
+
if (!PIP_CALL.test(line))
|
|
223
|
+
continue;
|
|
224
|
+
const targets = pipLineTargets(line);
|
|
225
|
+
if (targets === undefined)
|
|
226
|
+
continue;
|
|
227
|
+
const declared = packages ?? [];
|
|
228
|
+
const has = new Set(declared.map(specName));
|
|
229
|
+
const missing = (targets ?? []).filter((t) => !has.has(specName(t))).map(pinned);
|
|
230
|
+
if (sandbox && (targets === null || !planVendoredInstall([...declared, ...missing]))) {
|
|
231
|
+
return `pip cannot run here: this Python cannot start processes, so that line failed, ${SANDBOX_NO_INSTALL} Delete the pip line.`;
|
|
232
|
+
}
|
|
233
|
+
if (targets === null) {
|
|
234
|
+
return 'pip cannot run here: this Python cannot start processes, so that line failed. Delete it and declare the PyPI names in packages on this same call.';
|
|
235
|
+
}
|
|
236
|
+
if (missing.length) {
|
|
237
|
+
return `pip cannot run here: this Python cannot start processes, so that line failed. Delete it and send the same source with packages: ${JSON.stringify([...declared, ...missing])} on this one call. Installing and running are the same call.`;
|
|
238
|
+
}
|
|
239
|
+
if (declared.length) {
|
|
240
|
+
return `pip cannot run here: this Python cannot start processes, so that line failed. packages: ${JSON.stringify(declared)} already installs before your code runs, so delete the pip line and send the rest.`;
|
|
241
|
+
}
|
|
242
|
+
return 'pip cannot run here: this Python cannot start processes, so that line failed. Delete it and declare the PyPI names in packages on this same call.';
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
192
245
|
}
|
|
193
246
|
export function stringLiteralProgramHint(source) {
|
|
194
247
|
const first = source.trimStart()[0];
|
|
@@ -771,16 +824,35 @@ function prepare(args, cwd) {
|
|
|
771
824
|
return workdir;
|
|
772
825
|
return { packages, inputs, outputs, workdir };
|
|
773
826
|
}
|
|
774
|
-
export function missingModuleHint(stderr, packages) {
|
|
827
|
+
export function missingModuleHint(stderr, packages, sandbox = false, network = false) {
|
|
775
828
|
const module = /ModuleNotFoundError: No module named '([^']+)'/.exec(stderr)?.[1];
|
|
776
829
|
if (!module)
|
|
777
830
|
return null;
|
|
778
831
|
const root = module.split('.')[0];
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
832
|
+
const tool = videoTool(root);
|
|
833
|
+
if (tool && (!network || isTranscoder(tool)))
|
|
834
|
+
return network ? TRANSCODE_ERROR : VIDEO_TOOLS_ERROR;
|
|
835
|
+
if (module !== root) {
|
|
836
|
+
const then = sandbox
|
|
837
|
+
? 'If the path is right it ships as its own distribution under a shared namespace, which sandbox mode cannot install: tell the user.'
|
|
838
|
+
: 'If the path is right it ships as its own distribution under a shared namespace, as google.* does: declare that distribution in packages.';
|
|
839
|
+
return `"${module}" failed to import although "${root}" loaded. Check the import path first. ${then}`;
|
|
840
|
+
}
|
|
841
|
+
const distribution = moduleDistribution(root);
|
|
842
|
+
const provider = distribution ?? root;
|
|
843
|
+
const vendored = vendoredSpec(provider);
|
|
844
|
+
if (sandbox && !vendored)
|
|
845
|
+
return `module "${root}" is not installed, ${SANDBOX_NO_INSTALL}`;
|
|
846
|
+
const spec = vendored ?? provider;
|
|
847
|
+
const renamed = distribution !== null && distribution.toLowerCase() !== root.toLowerCase();
|
|
848
|
+
const declared = (packages ?? []).map(specName);
|
|
849
|
+
if (declared.includes(root.toLowerCase()) && renamed) {
|
|
850
|
+
return `"${root}" was declared in packages but does not provide that module; the distribution that does is "${spec}": put that in packages instead. There is no pip.`;
|
|
851
|
+
}
|
|
852
|
+
if (declared.includes(root.toLowerCase()) || declared.includes(provider.toLowerCase())) {
|
|
853
|
+
return `"${provider}" is already declared in packages and still does not provide "${root}", so declaring it again cannot help. Import the module name the library really exports, or tell the user it is unavailable on this device.`;
|
|
854
|
+
}
|
|
855
|
+
return `module "${root}" is not installed: add "${spec}" to packages (packages: ["${spec}"]) and run the same source again. There is no pip.`;
|
|
784
856
|
}
|
|
785
857
|
export function nonePlaceholderHint(stderr) {
|
|
786
858
|
if (!/AttributeError: 'NoneType' object has no attribute '(text|text_frame)'/.test(stderr)) {
|
|
@@ -833,6 +905,33 @@ export function shellNameHint(stderr) {
|
|
|
833
905
|
return null;
|
|
834
906
|
return `"${name}" is not defined because command already runs as Python source — remove the ${name} invocation and pass only the Python code itself`;
|
|
835
907
|
}
|
|
908
|
+
export function noProcessHint(stderr, sandbox = false) {
|
|
909
|
+
if (!/emscripten does not support processes/.test(stderr))
|
|
910
|
+
return null;
|
|
911
|
+
const packages = sandbox
|
|
912
|
+
? ' In sandbox mode only the packages bundled with this app install;'
|
|
913
|
+
: ' Declare PyPI packages in packages;';
|
|
914
|
+
return `Python here cannot start processes: there is no shell and no pip from the source.${packages} run a shell command as its own exec call without language.`;
|
|
915
|
+
}
|
|
916
|
+
const FETCH_TOOLS = ['http_request', 'web_fetch'];
|
|
917
|
+
export function noNetworkHint(stderr, tools, network = false) {
|
|
918
|
+
if (network)
|
|
919
|
+
return null;
|
|
920
|
+
if (!/Host is unreachable|Network is unreachable|socket\.gaierror|URLError|Temporary failure in name resolution|NewConnectionError|XMLHttpRequest is not defined/.test(stderr)) {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
const failed = 'Python here cannot open network connections (requests, urllib and sockets fail), and retrying will not change that.';
|
|
924
|
+
const fetchers = FETCH_TOOLS.filter((name) => !tools || tools.includes(name));
|
|
925
|
+
return fetchers.length
|
|
926
|
+
? `${failed} Fetch the URL with the ${fetchers.join(' or ')} tool and pass the data into the source.`
|
|
927
|
+
: `${failed} No tool that fetches a URL is available to you, so tell the user.`;
|
|
928
|
+
}
|
|
929
|
+
export function nativeWheelHint(stderr) {
|
|
930
|
+
const name = /Can't find a pure Python 3 wheel for '([^']+)'/.exec(stderr)?.[1];
|
|
931
|
+
if (!name)
|
|
932
|
+
return null;
|
|
933
|
+
return `"${name}" is built from compiled code, and this device installs only pure-Python packages plus the set already built into its Python. Nothing about this call can install it: use a pure-Python library instead, or tell the user it is not available here.`;
|
|
934
|
+
}
|
|
836
935
|
export function packageFetchFailedHint(stderr) {
|
|
837
936
|
const name = /Can't fetch metadata for '([^']+)'/.exec(stderr)?.[1];
|
|
838
937
|
if (!name)
|
|
@@ -987,7 +1086,7 @@ async function collect(result, declared, attach, runBudget, source) {
|
|
|
987
1086
|
...(rejected.length > 0 && { rejectedOutputs: rejected })
|
|
988
1087
|
};
|
|
989
1088
|
}
|
|
990
|
-
export function createPythonAdapter(disabled, loadPyodide) {
|
|
1089
|
+
export function createPythonAdapter(disabled, loadPyodide, network = false) {
|
|
991
1090
|
return {
|
|
992
1091
|
resolve(args, ctx) {
|
|
993
1092
|
if (!runsThroughPyodide(args.language, disabled))
|
|
@@ -997,6 +1096,9 @@ export function createPythonAdapter(disabled, loadPyodide) {
|
|
|
997
1096
|
const fetchAttachment = ctx?.fetchAttachment;
|
|
998
1097
|
const turnImages = ctx?.turnImages;
|
|
999
1098
|
const runBudget = ctx?.runBudget;
|
|
1099
|
+
const sandbox = ctx?.sandboxMode === true;
|
|
1100
|
+
const toolNames = ctx?.toolNames;
|
|
1101
|
+
const reach = network && !sandbox;
|
|
1000
1102
|
return {
|
|
1001
1103
|
async run(options) {
|
|
1002
1104
|
if (!command)
|
|
@@ -1035,10 +1137,13 @@ export function createPythonAdapter(disabled, loadPyodide) {
|
|
|
1035
1137
|
});
|
|
1036
1138
|
if (staged && 'error' in staged)
|
|
1037
1139
|
return fail(staged.error);
|
|
1038
|
-
const install = pipInstallTargets(command);
|
|
1140
|
+
const install = pipInstallTargets(command)?.map(pinned) ?? null;
|
|
1039
1141
|
const packages = install
|
|
1040
1142
|
? [...new Set([...(prepared.packages ?? []), ...install])]
|
|
1041
1143
|
: prepared.packages;
|
|
1144
|
+
if (sandbox && install && !planVendoredInstall(packages ?? [])) {
|
|
1145
|
+
return fail(pipMessage(install, true));
|
|
1146
|
+
}
|
|
1042
1147
|
const result = await runPython(install ? 'pass' : source, {
|
|
1043
1148
|
timeoutMs: options.timeoutMs,
|
|
1044
1149
|
maxOutputChars: options.maxOutputChars,
|
|
@@ -1047,7 +1152,8 @@ export function createPythonAdapter(disabled, loadPyodide) {
|
|
|
1047
1152
|
inputs: staged?.files,
|
|
1048
1153
|
outputs: prepared.outputs,
|
|
1049
1154
|
workdir: prepared.workdir,
|
|
1050
|
-
loadPyodide
|
|
1155
|
+
loadPyodide,
|
|
1156
|
+
network: reach
|
|
1051
1157
|
});
|
|
1052
1158
|
const soft = softFailedDeckHint(result.stdout, result.exitCode, source);
|
|
1053
1159
|
if (soft)
|
|
@@ -1056,7 +1162,11 @@ export function createPythonAdapter(disabled, loadPyodide) {
|
|
|
1056
1162
|
if (missingPic)
|
|
1057
1163
|
return fail(missingPic);
|
|
1058
1164
|
const hint = packageFetchFailedHint(result.stderr) ??
|
|
1059
|
-
|
|
1165
|
+
nativeWheelHint(result.stderr) ??
|
|
1166
|
+
missingModuleHint(result.stderr, packages, sandbox, reach) ??
|
|
1167
|
+
pipFromSourceHint(result.stderr, source, packages, sandbox) ??
|
|
1168
|
+
noProcessHint(result.stderr, sandbox) ??
|
|
1169
|
+
noNetworkHint(result.stderr, toolNames, reach) ??
|
|
1060
1170
|
missingFileHint(result.stderr) ??
|
|
1061
1171
|
rgbColorImportHint(result.stderr) ??
|
|
1062
1172
|
nonePlaceholderHint(result.stderr) ??
|
|
@@ -1077,7 +1187,7 @@ export function createPythonAdapter(disabled, loadPyodide) {
|
|
|
1077
1187
|
const installed = install && result.exitCode === 0
|
|
1078
1188
|
? {
|
|
1079
1189
|
...hinted,
|
|
1080
|
-
stdout: `installed: ${packages?.join(', ')}\nnothing was run —
|
|
1190
|
+
stdout: `installed: ${packages?.join(', ')}\nnothing was run — call exec again with language "python", packages: ${JSON.stringify(packages ?? [])} and the Python source in command\n${hinted.stdout}`
|
|
1081
1191
|
}
|
|
1082
1192
|
: hinted;
|
|
1083
1193
|
const undelivered = result.undeclared?.length
|
|
@@ -4,6 +4,8 @@ export type PyodideLike = {
|
|
|
4
4
|
filename?: string;
|
|
5
5
|
}): unknown;
|
|
6
6
|
runPythonAsync(code: string): Promise<unknown>;
|
|
7
|
+
ffi?: unknown;
|
|
8
|
+
pyimport?(name: string): unknown;
|
|
7
9
|
loadPackage(name: string): Promise<unknown>;
|
|
8
10
|
setStdout(options: {
|
|
9
11
|
batched: (output: string) => void;
|
|
@@ -62,6 +64,7 @@ export type RunSpec = {
|
|
|
62
64
|
readonly workdir?: string | undefined;
|
|
63
65
|
readonly maxOutputChars: number;
|
|
64
66
|
readonly installTimeoutMs: number;
|
|
67
|
+
readonly network?: boolean | undefined;
|
|
65
68
|
};
|
|
66
69
|
export type RunOutcome = {
|
|
67
70
|
readonly result: PythonRunResult;
|
|
@@ -2,6 +2,8 @@ import { AbortedError } from '../../../../helpers/race-abort.js';
|
|
|
2
2
|
import { hlog } from '../../../../log.js';
|
|
3
3
|
import { TRUNCATED_OUTPUT_SUFFIX } from '../../runner.js';
|
|
4
4
|
import { collectOutputs, openScratch, stageInputFiles } from './memfs.js';
|
|
5
|
+
import { isGuardedFetch } from './guarded-fetch.js';
|
|
6
|
+
import { NET_SHIM, pythonReachesNetwork } from './net-shim.js';
|
|
5
7
|
import { planVendoredInstall, stageVendoredWheels } from './wheels.js';
|
|
6
8
|
export function cap(text, limit) {
|
|
7
9
|
if (text.length <= limit)
|
|
@@ -37,9 +39,24 @@ export function repairDoubleEscapedSource(code, message) {
|
|
|
37
39
|
const repaired = unescapeSource(code);
|
|
38
40
|
return repaired === code ? null : repaired;
|
|
39
41
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
const promisingEval = new WeakMap();
|
|
43
|
+
async function enter(py, code, network) {
|
|
44
|
+
const guarded = pythonReachesNetwork() && isGuardedFetch(globalThis.fetch);
|
|
45
|
+
if (!network || !guarded || py.ffi === undefined || py.pyimport === undefined) {
|
|
42
46
|
py.runPython(code);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let evalCode = promisingEval.get(py);
|
|
50
|
+
if (!evalCode) {
|
|
51
|
+
py.runPython(NET_SHIM);
|
|
52
|
+
evalCode = py.pyimport('pyodide.code').eval_code;
|
|
53
|
+
promisingEval.set(py, evalCode);
|
|
54
|
+
}
|
|
55
|
+
await evalCode.callPromising(code, py.globals);
|
|
56
|
+
}
|
|
57
|
+
async function attempt(py, code, output, maxOutputChars, network) {
|
|
58
|
+
try {
|
|
59
|
+
await enter(py, code, network);
|
|
43
60
|
return {
|
|
44
61
|
result: {
|
|
45
62
|
stdout: cap(output.stdout, maxOutputChars),
|
|
@@ -63,14 +80,14 @@ function attempt(py, code, output, maxOutputChars) {
|
|
|
63
80
|
};
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
|
-
function runOnce(py, code, output, maxOutputChars) {
|
|
67
|
-
const first = attempt(py, code, output, maxOutputChars);
|
|
83
|
+
async function runOnce(py, code, output, maxOutputChars, network) {
|
|
84
|
+
const first = await attempt(py, code, output, maxOutputChars, network);
|
|
68
85
|
if (first.error === null)
|
|
69
86
|
return first;
|
|
70
87
|
const repaired = repairDoubleEscapedSource(code, first.error);
|
|
71
88
|
if (repaired === null)
|
|
72
89
|
return first;
|
|
73
|
-
const retry = attempt(py, repaired, output, maxOutputChars);
|
|
90
|
+
const retry = await attempt(py, repaired, output, maxOutputChars, network);
|
|
74
91
|
if (retry.error !== null && retry.error.includes('SyntaxError'))
|
|
75
92
|
return first;
|
|
76
93
|
return retry;
|
|
@@ -168,7 +185,7 @@ async function installPackages(py, packages, timeoutMs, isCancelled) {
|
|
|
168
185
|
clearTimeout(timer);
|
|
169
186
|
}
|
|
170
187
|
}
|
|
171
|
-
const DELIVERABLE = /\.(pptx|docx|xlsx|pdf|png|jpe?g|gif|webp|csv|json|txt|md|zip)$/i;
|
|
188
|
+
const DELIVERABLE = /\.(pptx|docx|xlsx|pdf|png|jpe?g|gif|webp|csv|json|txt|md|zip|mp4|webm|m4a|mp3)$/i;
|
|
172
189
|
function listFiles(py, dir) {
|
|
173
190
|
try {
|
|
174
191
|
return new Set(py.FS.readdir(dir).filter((name) => name !== '.' && name !== '..'));
|
|
@@ -234,7 +251,7 @@ export async function performRun(py, spec, opts = {}) {
|
|
|
234
251
|
if (!spec.inputs?.length && !spec.outputs?.length && !spec.workdir) {
|
|
235
252
|
const cwd = py.FS.cwd();
|
|
236
253
|
const before = listFiles(py, cwd);
|
|
237
|
-
const { result, fatal } = runOnce(py, spec.code, output, spec.maxOutputChars);
|
|
254
|
+
const { result, fatal } = await runOnce(py, spec.code, output, spec.maxOutputChars, spec.network === true);
|
|
238
255
|
if (result.exitCode !== 0)
|
|
239
256
|
return { result, evict: fatal };
|
|
240
257
|
const undeclared = newDeliverables(before, listFiles(py, cwd));
|
|
@@ -245,7 +262,7 @@ export async function performRun(py, spec, opts = {}) {
|
|
|
245
262
|
py.FS.chdir(scratch.cwd);
|
|
246
263
|
stageInputFiles(py.FS, scratch.cwd, spec.inputs ?? []);
|
|
247
264
|
const before = listFiles(py, scratch.cwd);
|
|
248
|
-
const { result, fatal } = runOnce(py, spec.code, output, spec.maxOutputChars);
|
|
265
|
+
const { result, fatal } = await runOnce(py, spec.code, output, spec.maxOutputChars, spec.network === true);
|
|
249
266
|
let evict = fatal;
|
|
250
267
|
if (result.exitCode === 0)
|
|
251
268
|
evict = fitDecks(py, spec.outputs ?? []);
|
|
@@ -3,6 +3,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
3
3
|
pyodide: z.ZodString;
|
|
4
4
|
packages: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
5
5
|
version: z.ZodOptional<z.ZodString>;
|
|
6
|
+
imports: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
6
7
|
pypi: z.ZodArray<z.ZodString>;
|
|
7
8
|
pyodide: z.ZodArray<z.ZodString>;
|
|
8
9
|
}, z.core.$strip>>;
|
|
@@ -29,6 +30,11 @@ type WheelFs = {
|
|
|
29
30
|
};
|
|
30
31
|
export declare function wheelsDir(): string | undefined;
|
|
31
32
|
export declare function vendoredManifest(dir?: string | undefined): WheelManifest | null;
|
|
33
|
+
export declare function normalizeDistribution(name: string): string;
|
|
34
|
+
export declare function vendoredSpec(distribution: string, opts?: {
|
|
35
|
+
readonly dir?: string | undefined;
|
|
36
|
+
readonly manifest?: WheelManifest | null;
|
|
37
|
+
}): string | null;
|
|
32
38
|
export declare function planVendoredInstall(specs: readonly string[], opts?: {
|
|
33
39
|
readonly dir?: string | undefined;
|
|
34
40
|
readonly manifest?: WheelManifest | null;
|
|
@@ -7,6 +7,7 @@ const ManifestSchema = z.object({
|
|
|
7
7
|
pyodide: z.string(),
|
|
8
8
|
packages: z.record(z.string(), z.object({
|
|
9
9
|
version: z.string().optional(),
|
|
10
|
+
imports: z.array(z.string()).optional(),
|
|
10
11
|
pypi: z.array(z.string()),
|
|
11
12
|
pyodide: z.array(z.string())
|
|
12
13
|
})),
|
|
@@ -42,12 +43,25 @@ export function vendoredManifest(dir = wheelsDir()) {
|
|
|
42
43
|
return null;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
function
|
|
46
|
-
return name
|
|
46
|
+
export function normalizeDistribution(name) {
|
|
47
|
+
return name
|
|
48
|
+
.trim()
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[-_.]+/g, '-');
|
|
47
51
|
}
|
|
48
52
|
function parseSpec(spec) {
|
|
49
53
|
const [name, version] = spec.split('==');
|
|
50
|
-
return { name:
|
|
54
|
+
return { name: normalizeDistribution(name ?? ''), version: version?.trim() };
|
|
55
|
+
}
|
|
56
|
+
export function vendoredSpec(distribution, opts = {}) {
|
|
57
|
+
const name = normalizeDistribution(distribution);
|
|
58
|
+
const dir = opts.dir ?? wheelsDir();
|
|
59
|
+
const manifest = opts.manifest === undefined ? vendoredManifest(dir) : opts.manifest;
|
|
60
|
+
const entry = manifest?.packages[name];
|
|
61
|
+
if (!entry)
|
|
62
|
+
return null;
|
|
63
|
+
const spec = entry.version ? `${name}==${entry.version}` : name;
|
|
64
|
+
return planVendoredInstall([spec], { dir, manifest }) ? spec : null;
|
|
51
65
|
}
|
|
52
66
|
export function planVendoredInstall(specs, opts = {}) {
|
|
53
67
|
const dir = opts.dir ?? wheelsDir();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runCommand } from '../../runner.js';
|
|
2
|
+
import { shellDownloadNote } from '../../shell-guidance.js';
|
|
2
3
|
export function createShellAdapter(options) {
|
|
3
4
|
return {
|
|
4
5
|
resolve(args) {
|
|
@@ -8,11 +9,16 @@ export function createShellAdapter(options) {
|
|
|
8
9
|
const runtime = options.spawn
|
|
9
10
|
? { spawn: options.spawn, killProcess: options.killProcess }
|
|
10
11
|
: await options.loadRuntime();
|
|
11
|
-
|
|
12
|
+
const result = await runCommand(command, {
|
|
12
13
|
...execution,
|
|
13
14
|
spawn: runtime.spawn,
|
|
14
15
|
killProcess: runtime.killProcess
|
|
15
16
|
});
|
|
17
|
+
const note = result.exitCode === 0 ? shellDownloadNote(command) : null;
|
|
18
|
+
if (!note)
|
|
19
|
+
return result;
|
|
20
|
+
const stdout = `${result.stdout}${result.stdout && !result.stdout.endsWith('\n') ? '\n' : ''}${note}`;
|
|
21
|
+
return { ...result, stdout };
|
|
16
22
|
}
|
|
17
23
|
};
|
|
18
24
|
}
|