diff --git a/scripts/build/icon_environment.py b/scripts/build/icon_environment.py new file mode 100644 index 0000000000..f7f2e76a30 --- /dev/null +++ b/scripts/build/icon_environment.py @@ -0,0 +1,36 @@ +"""Prepare the locked icon-only environment through PM, then run the generator.""" +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys +from tempfile import TemporaryDirectory + +# The Node wrapper can run this file from a separately prepared source tree. +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import pm + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) + parser.add_argument("--source", type=Path, default=ROOT) + args, _ = parser.parse_known_args(argv) + source = args.source.resolve() + with TemporaryDirectory(prefix="hermes-icon-build-") as temporary: + python = pm.build_environment( + source=source, out=Path(temporary) / "venv", + groups=["icon-build"], only_groups=True, explicit=True, + cache=source / ".cache/icon-build", + ) + return subprocess.run( + [str(python), "-I", str(ROOT / "scripts/generate_icons.py"), *argv], + cwd=source, + ).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 8029a890e3..2980f5ffe3 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -29,16 +29,13 @@ export function generateIcons(args = [], { root = repoRoot, run = spawnSync, env // Parent payload paths must not shadow the isolated build dependencies. delete childEnv.PYTHONPATH delete childEnv.PYTHONHOME - const result = run('uv', [ - 'run', '--isolated', '--locked', '--only-group', 'icon-build', - // PM copies its wheel cache into payloads. Keep build wheels outside it. - '--cache-dir', path.join(source, '.cache', 'icon-build'), - 'python', path.join(root, 'scripts', 'generate_icons.py'), '--source', source, '--out', out, + const result = run(env.HERMES_PYTHON || 'python', [ + path.join(root, 'scripts', 'build', 'icon_environment.py'), '--source', source, '--out', out, ...(values.check ? ['--check'] : []) ], { cwd: source, stdio: 'inherit', windowsHide: true, env: childEnv }) if (result.error) { console.error('[generate-icons] failed to launch icon generator:', result.error.message) - console.error('[generate-icons] uv is required to run the isolated icon-build group') + console.error('[generate-icons] a prepared Python (HERMES_PYTHON or PATH) is required to run the PM build driver') return 1 } return result.status ?? 1 diff --git a/tests-js/generate-icons.test.mjs b/tests-js/generate-icons.test.mjs index 59d796a13e..5a1ac7539d 100644 --- a/tests-js/generate-icons.test.mjs +++ b/tests-js/generate-icons.test.mjs @@ -1,17 +1,17 @@ +import { spawnSync } from 'node:child_process' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { expect, test, vi } from 'vitest' import { generateIcons } from '../scripts/generate-icons.mjs' -test('icon builds use only the locked build group outside every application venv', () => { +test('icon builds delegate environment preparation to PM using the prepared Python', () => { const run = vi.fn(() => ({ status: 0 })) const root = path.resolve('icon-build-fixture') - const env = { PATH: 'tools', UV_PYTHON: 'pm-locked-python', VIRTUAL_ENV: 'runtime-venv', PYTHONPATH: 'payload-libraries', PYTHONHOME: 'payload-python' } + const env = { PATH: 'tools', HERMES_PYTHON: 'prepared-python', VIRTUAL_ENV: 'runtime-venv', PYTHONPATH: 'payload-libraries', PYTHONHOME: 'payload-python' } expect(generateIcons(['--check'], { root, run, env })).toBe(0) - expect(run).toHaveBeenCalledExactlyOnceWith('uv', [ - 'run', '--isolated', '--locked', '--only-group', 'icon-build', - '--cache-dir', path.join(root, '.cache', 'icon-build'), - 'python', path.join(root, 'scripts', 'generate_icons.py'), '--source', root, '--out', root, '--check' - ], { cwd: root, stdio: 'inherit', windowsHide: true, env: { PATH: 'tools', UV_PYTHON: env.UV_PYTHON, VIRTUAL_ENV: 'runtime-venv' } }) + expect(run).toHaveBeenCalledExactlyOnceWith(env.HERMES_PYTHON, [ + path.join(root, 'scripts', 'build', 'icon_environment.py'), '--source', root, '--out', root, '--check' + ], { cwd: root, stdio: 'inherit', windowsHide: true, env: { PATH: 'tools', HERMES_PYTHON: env.HERMES_PYTHON, VIRTUAL_ENV: 'runtime-venv' } }) expect(env.PYTHONPATH).toBe('payload-libraries') }) @@ -19,19 +19,57 @@ test('icon preparation passes explicit source and independent output roots', () const run = vi.fn(() => ({ status: 0 })) const source = path.resolve('source with spaces') const out = path.resolve('icon outputs') - expect(generateIcons(['--source', source, '--out', out], { run })).toBe(0) - const [, args, options] = run.mock.calls[0] + expect(generateIcons(['--source', source, '--out', out], { run, env: {} })).toBe(0) + const [python, args, options] = run.mock.calls[0] + expect(python).toBe('python') expect(args.slice(-4)).toEqual(['--source', source, '--out', out]) expect(options.cwd).toBe(source) }) +test('the PM driver owns a temporary group-only environment and preserves generator argv and status', () => { + const result = spawnSync(process.env.HERMES_PYTHON || 'python', ['-c', ` +from pathlib import Path +import subprocess +import sys +from tempfile import TemporaryDirectory +from unittest.mock import patch +from scripts.build import icon_environment + +with TemporaryDirectory() as directory: + source = Path(directory) / 'source with spaces' + source.mkdir() + argv = ['--source', str(source), '--out', str(Path(directory) / 'icon outputs'), '--check'] + outputs = [] + def build(**options): + output = options.pop('out') + assert output.parent.is_dir() and not output.exists() + assert output.name == 'venv' + assert options == dict(source=source, groups=['icon-build'], only_groups=True, + explicit=True, cache=source / '.cache/icon-build') + outputs.append(output) + return Path(sys.executable) + def generate(command, **options): + assert outputs[0].parent.is_dir() + assert command == [sys.executable, '-I', str(Path(icon_environment.__file__).resolve().parents[1] / 'generate_icons.py'), *argv] + assert options == dict(cwd=source) + return subprocess.CompletedProcess(command, 7) + with patch.object(icon_environment.pm, 'build_environment', side_effect=build) as prepare, patch.object(icon_environment.subprocess, 'run', side_effect=generate) as run: + assert icon_environment.main(argv) == 7 + prepare.assert_called_once() + run.assert_called_once() + assert not outputs[0].parent.exists() +`], { cwd: fileURLToPath(new URL('..', import.meta.url)), encoding: 'utf8' }) + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) +}) + test('failed icon processes cannot report a successful build', () => { expect(generateIcons([], { run: () => ({ status: 7 }), env: {} })).toBe(7) expect(generateIcons([], { run: () => ({ status: null, signal: 'SIGTERM' }), env: {} })).toBe(1) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - expect(generateIcons([], { run: () => ({ error: new Error('uv missing') }), env: {} })).toBe(1) - expect(error).toHaveBeenCalledWith(expect.stringContaining('failed to launch'), 'uv missing') + expect(generateIcons([], { run: () => ({ error: new Error('prepared Python missing') }), env: {} })).toBe(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining('failed to launch'), 'prepared Python missing') } finally { error.mockRestore() }