@qvac/harness 0.2.12 → 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/dist/harness.bundle +16 -13
  3. package/dist-lib/index.d.ts +12 -2
  4. package/dist-lib/index.js +66 -5
  5. package/dist-lib/lib/capabilities/completion.d.ts +5 -0
  6. package/dist-lib/lib/capabilities/completion.js +17 -5
  7. package/dist-lib/lib/capabilities/enhance.d.ts +10 -0
  8. package/dist-lib/lib/capabilities/enhance.js +29 -0
  9. package/dist-lib/lib/capabilities/invoke.d.ts +11 -2
  10. package/dist-lib/lib/capabilities/invoke.js +32 -8
  11. package/dist-lib/lib/connect.js +1 -0
  12. package/dist-lib/lib/contract-types.d.ts +1 -0
  13. package/dist-lib/lib/helpers/clean-answer.d.ts +1 -0
  14. package/dist-lib/lib/helpers/clean-answer.js +16 -0
  15. package/dist-lib/lib/helpers/load-stall.d.ts +6 -0
  16. package/dist-lib/lib/helpers/load-stall.js +24 -0
  17. package/dist-lib/lib/helpers/tool-call-markup.js +15 -7
  18. package/dist-lib/lib/models/model-pool.d.ts +6 -0
  19. package/dist-lib/lib/models/model-pool.js +12 -0
  20. package/dist-lib/lib/models/nmt-catalog.d.ts +34 -0
  21. package/dist-lib/lib/models/nmt-catalog.js +166 -0
  22. package/dist-lib/lib/no-redirect-fetch-impl.d.ts +3 -2
  23. package/dist-lib/lib/no-redirect-fetch-impl.js +4 -1
  24. package/dist-lib/lib/serve.js +1 -0
  25. package/dist-lib/lib/tools/exec/adapters/adapter.d.ts +2 -0
  26. package/dist-lib/lib/tools/exec/adapters/python/distributions.d.ts +1 -0
  27. package/dist-lib/lib/tools/exec/adapters/python/distributions.js +4 -0
  28. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.d.ts +67 -0
  29. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +196 -0
  30. package/dist-lib/lib/tools/exec/adapters/python/loader.js +2 -1
  31. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.d.ts +1 -0
  32. package/dist-lib/lib/tools/exec/adapters/python/module-distributions.js +52 -0
  33. package/dist-lib/lib/tools/exec/adapters/python/net-shim.d.ts +2 -0
  34. package/dist-lib/lib/tools/exec/adapters/python/net-shim.js +148 -0
  35. package/dist-lib/lib/tools/exec/adapters/python/output-policy.js +13 -1
  36. package/dist-lib/lib/tools/exec/adapters/python/pyodide.d.ts +1 -0
  37. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +1 -0
  38. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +9 -3
  39. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +130 -20
  40. package/dist-lib/lib/tools/exec/adapters/python/run-session.d.ts +3 -0
  41. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +25 -8
  42. package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +6 -0
  43. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +17 -3
  44. package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +7 -1
  45. package/dist-lib/lib/tools/exec/shell-guidance.d.ts +10 -0
  46. package/dist-lib/lib/tools/exec/shell-guidance.js +123 -0
  47. package/dist-lib/lib/tools/exec/video-tools.d.ts +5 -0
  48. package/dist-lib/lib/tools/exec/video-tools.js +40 -0
  49. package/dist-lib/lib/tools/exec.d.ts +2 -1
  50. package/dist-lib/lib/tools/exec.js +59 -32
  51. package/dist-lib/lib/tools/generate-image.js +2 -25
  52. package/dist-lib/lib/tools/http-request/truncate-response.d.ts +1 -0
  53. package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
  54. package/dist-lib/lib/tools/http-request.js +4 -1
  55. package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
  56. package/dist-lib/lib/tools/operations/builders.js +9 -0
  57. package/dist-lib/lib/tools/operations/compile.js +41 -18
  58. package/dist-lib/lib/tools/operations/decoders.d.ts +6 -0
  59. package/dist-lib/lib/tools/operations/decoders.js +82 -0
  60. package/dist-lib/lib/tools/operations/manifest.d.ts +16 -2
  61. package/dist-lib/lib/tools/operations/manifest.js +6 -1
  62. package/dist-lib/lib/tools/skill.d.ts +3 -0
  63. package/dist-lib/lib/tools/skill.js +38 -5
  64. package/dist-lib/lib/tools/skills/manifest-parse.js +43 -1
  65. package/dist-lib/lib/tools/skills/manifest.d.ts +13 -0
  66. package/dist-lib/lib/tools/translate.d.ts +78 -0
  67. package/dist-lib/lib/tools/translate.js +228 -0
  68. package/dist-lib/schema/harness.js +22 -2
  69. package/dist-lib/spec/hrpc/messages.js +101 -8
  70. package/dist-lib/spec/hyperschema/index.js +101 -8
  71. package/dist-lib/spec/hyperschema/schema.json +71 -1
  72. package/dist-lib/spec/hyperschema/types.d.ts +19 -1
  73. package/package.json +35 -2
  74. 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 looksLikePythonSource(command: string): boolean;
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
- export function looksLikePythonSource(command) {
186
- const first = command.trimStart().split('\n')[0];
187
- if (/^\s*(from\s+\w+\s+import\b|import\s+\w+)/.test(first))
188
- return true;
189
- if (/^\s*(from|import)\s+(fpdf|pypdf|pptx)\b/m.test(command))
190
- return true;
191
- return /\b(FPDF|PdfReader|PdfWriter|Presentation)\s*\(/.test(command);
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 declared = (packages ?? []).some((p) => p.split(/[=<>!~[]/)[0].trim().toLowerCase() === root.toLowerCase());
780
- const because = declared
781
- ? `"${root}" was declared in packages but does not provide that module`
782
- : `"${root}" was not declared`;
783
- return `${because}: add the PyPI distribution that provides it to the packages argument (the distribution name can differ from the module, e.g. module "pptx" comes from "python-pptx"). There is no pip and no shell on this path.`;
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
- missingModuleHint(result.stderr, packages) ??
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 — send the Python source that builds the deck in command\n${hinted.stdout}`
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
- function attempt(py, code, output, maxOutputChars) {
41
- try {
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 normalize(name) {
46
- return name.toLowerCase().replace(/[_.]+/g, '-');
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: normalize((name ?? '').trim()), version: version?.trim() };
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
- return runCommand(command, {
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
  }