From b9f6e886c08e9b78c50ea5b74e45bfdcb96c0456 Mon Sep 17 00:00:00 2001 From: Vadim <131760609+vadimitri@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:43:55 +0200 Subject: [PATCH 1/3] Resolve transform interpreter from PATH candidates (fix Windows python3) The local transform subprocess backend hardcoded `python3` as the Python interpreter. On Windows the python.org installer provides `python` and the `py` launcher but no `python3` on PATH, so every Python transform failed with "interpreter not available" (Errno::ENOENT). INTERPRETERS now lists command candidates per language in priority order, and the backend runs the first one found on PATH via a small cross-platform `which` that honors PATHEXT on Windows and bare names on POSIX. Python resolves `python3` first, then `python`, so existing POSIX setups are unchanged while Windows works out of the box. A missing interpreter still falls back to the first candidate so the "not available" error is unchanged. Co-Authored-By: Claude Opus 4.8 --- lib/trmnlp/transform_backend/subprocess.rb | 38 ++++++++++++++++--- .../transform_backend/subprocess_spec.rb | 17 ++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/lib/trmnlp/transform_backend/subprocess.rb b/lib/trmnlp/transform_backend/subprocess.rb index 58a576a..6a1456f 100644 --- a/lib/trmnlp/transform_backend/subprocess.rb +++ b/lib/trmnlp/transform_backend/subprocess.rb @@ -19,11 +19,16 @@ class Subprocess # Seconds a TERM'd process is given to exit before escalating to KILL. GRACE_PERIOD = 0.1 + # Each language lists its interpreter command candidates in priority + # order. The first one found on PATH wins, so transforms stay portable + # across platforms that name the same interpreter differently — most + # notably Windows, where Python is installed as `python` with no + # `python3` on PATH. INTERPRETERS = { - 'python' => { cmd: 'python3', ext: 'py' }, - 'ruby' => { cmd: 'ruby', ext: 'rb' }, - 'node' => { cmd: 'node', ext: 'js' }, - 'php' => { cmd: 'php', ext: 'php' } + 'python' => { cmds: %w[python3 python], ext: 'py' }, + 'ruby' => { cmds: %w[ruby], ext: 'rb' }, + 'node' => { cmds: %w[node], ext: 'js' }, + 'php' => { cmds: %w[php], ext: 'php' } }.freeze def execute(code:, language:, stdin: '', timeout_seconds: DEFAULT_TIMEOUT) @@ -40,12 +45,35 @@ def invoke(spec, language, code, stdin, timeout_seconds) output_path = File.join(dir, 'output.json') src_path = File.join(dir, "transform.#{spec[:ext]}") File.write(src_path, Wrapper.for(language, code, sink_for(language, output_path))) - run_process(spec[:cmd], src_path, stdin, timeout_seconds, output_path) + run_process(resolve_cmd(spec[:cmds]), src_path, stdin, timeout_seconds, output_path) end rescue Errno::ENOENT, Errno::EACCES => e failure("interpreter not available: #{e.message}") end + # First candidate present on PATH, falling back to the first name so a + # genuinely missing interpreter still surfaces a sensible + # "interpreter not available" error from run_process. + def resolve_cmd(cmds) + cmds.find { |c| which(c) } || cmds.first + end + + # Minimal cross-platform `which`. On Windows PATHEXT lists the + # executable suffixes (.EXE/.BAT/...) to try; on POSIX it is unset, so + # we fall back to a single bare-name lookup. File.executable? maps to + # the exec bit on POSIX and to a recognized extension on Windows. + def which(cmd) + exts = ENV.fetch('PATHEXT', '').split(File::PATH_SEPARATOR) + exts = [''] if exts.empty? + ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir| + exts.each do |ext| + candidate = File.join(dir, "#{cmd}#{ext}") + return candidate if File.file?(candidate) && File.executable?(candidate) + end + end + nil + end + def run_process(cmd, src_path, stdin, timeout_seconds, output_path) started = monotonic_ms diff --git a/spec/lib/trmnlp/transform_backend/subprocess_spec.rb b/spec/lib/trmnlp/transform_backend/subprocess_spec.rb index a21fab3..48bad27 100644 --- a/spec/lib/trmnlp/transform_backend/subprocess_spec.rb +++ b/spec/lib/trmnlp/transform_backend/subprocess_spec.rb @@ -94,7 +94,7 @@ it 'reports the interpreter command as failure when it is not on PATH' do stub_const("#{described_class}::INTERPRETERS", { - 'ghost' => { cmd: 'this-binary-does-not-exist', ext: 'rb', wrapper: :ruby_wrapper } + 'ghost' => { cmds: %w[this-binary-does-not-exist], ext: 'rb' } }) result = backend.execute(code: 'noop', language: 'ghost', stdin: '') @@ -102,5 +102,20 @@ expect(result).not_to be_success expect(result.error).to match(/interpreter not available/) end + + it 'falls back to the next interpreter candidate when the first is missing (Windows has `python`, not `python3`)' do + stub_const("#{described_class}::INTERPRETERS", { + 'python' => { cmds: %w[this-binary-does-not-exist python3], ext: 'py' } + }) + + result = backend.execute( + code: "def run(input):\n return { 'echoed': input['greeting'] }", + language: 'python', + stdin: JSON.generate('greeting' => 'hello') + ) + + expect(result).to be_success + expect(JSON.parse(result.output)).to eq('echoed' => 'hello') + end end end From fcdcf51bb286cd30741d4afbef86c4ddf87909be Mon Sep 17 00:00:00 2001 From: Vadim <131760609+vadimitri@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:44:32 +0200 Subject: [PATCH 2/3] Add CHANGELOG entry for Windows python3 fix (#116) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9d2be..3904794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog +## 0.8.10 + +- Fixed Python serverless transforms failing on Windows. The local subprocess backend hardcoded `python3`, which the python.org Windows installer does not put on PATH (it installs `python` and the `py` launcher), so every Python transform raised "interpreter not available". The interpreter is now resolved from a per-language list of command candidates (`python3` then `python`) using a cross-platform PATH lookup, leaving POSIX behavior unchanged. (#116) + ## 0.8.9 - Fixed `trmnlp serve` binding to localhost under Podman, which left the dev server unreachable through published ports. Container detection only looked for `/.dockerenv`, which Docker writes but Podman does not, so `serve` fell back to `127.0.0.1`. It now also checks for `/run/.containerenv`, which Podman writes, so the automatic `0.0.0.0` bind works for both runtimes. (#112) From dfaa4323922fd37267f48bd5681d62dd2d8ae90c Mon Sep 17 00:00:00 2001 From: Ikraam Ghoor Date: Mon, 29 Jun 2026 22:08:23 +0200 Subject: [PATCH 3/3] Harden Windows Python interpreter resolution The Windows fix shipped python3-then-python candidates, but the python.org installer also registers the `py` launcher and keeps it on PATH even when the optional "Add to PATH" step is skipped. Add `py` as a final candidate so Python transforms resolve in that install too. Move the PATH lookup out of Subprocess into TransformBackend::Which. Resolution is a separate responsibility and was previously reachable only by spawning a real interpreter; on its own it is unit-tested directly. Bump the version to 0.8.10 to match the CHANGELOG entry for this fix. --- CHANGELOG.md | 2 +- Gemfile.lock | 2 +- lib/trmnlp/transform_backend/subprocess.rb | 44 +++--------- lib/trmnlp/transform_backend/which.rb | 29 ++++++++ lib/trmnlp/version.rb | 2 +- .../transform_backend/subprocess_spec.rb | 15 ---- .../trmnlp/transform_backend/which_spec.rb | 69 +++++++++++++++++++ 7 files changed, 112 insertions(+), 51 deletions(-) create mode 100644 lib/trmnlp/transform_backend/which.rb create mode 100644 spec/lib/trmnlp/transform_backend/which_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3904794..fa15fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.8.10 -- Fixed Python serverless transforms failing on Windows. The local subprocess backend hardcoded `python3`, which the python.org Windows installer does not put on PATH (it installs `python` and the `py` launcher), so every Python transform raised "interpreter not available". The interpreter is now resolved from a per-language list of command candidates (`python3` then `python`) using a cross-platform PATH lookup, leaving POSIX behavior unchanged. (#116) +- Fixed Python serverless transforms failing on Windows. The local subprocess backend hardcoded `python3`, which the python.org Windows installer does not put on PATH (it installs `python` and the `py` launcher), so every Python transform raised "interpreter not available". The interpreter is now resolved from a per-language list of command candidates (`python3`, then `python`, then the `py` launcher) using a cross-platform PATH lookup, leaving POSIX behavior unchanged. (#116) ## 0.8.9 diff --git a/Gemfile.lock b/Gemfile.lock index 3caf23b..2ae9460 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - trmnl_preview (0.8.9) + trmnl_preview (0.8.10) activesupport (~> 8.0) cgi (~> 0.5) faraday (~> 2.1) diff --git a/lib/trmnlp/transform_backend/subprocess.rb b/lib/trmnlp/transform_backend/subprocess.rb index 6a1456f..150ddc4 100644 --- a/lib/trmnlp/transform_backend/subprocess.rb +++ b/lib/trmnlp/transform_backend/subprocess.rb @@ -4,6 +4,7 @@ require 'tmpdir' require_relative '../transform_client' +require_relative 'which' require_relative 'wrapper' module TRMNLP @@ -19,16 +20,16 @@ class Subprocess # Seconds a TERM'd process is given to exit before escalating to KILL. GRACE_PERIOD = 0.1 - # Each language lists its interpreter command candidates in priority - # order. The first one found on PATH wins, so transforms stay portable - # across platforms that name the same interpreter differently — most - # notably Windows, where Python is installed as `python` with no - # `python3` on PATH. + # Candidate commands per language, highest priority first. Windows is + # why a language needs more than one: its python.org installer provides + # `python` and the `py` launcher but no `python3`. `py` ranks last yet + # is the surest Windows hit — it stays on PATH even when the installer's + # optional "Add to PATH" step is skipped. INTERPRETERS = { - 'python' => { cmds: %w[python3 python], ext: 'py' }, - 'ruby' => { cmds: %w[ruby], ext: 'rb' }, - 'node' => { cmds: %w[node], ext: 'js' }, - 'php' => { cmds: %w[php], ext: 'php' } + 'python' => { cmds: %w[python3 python py], ext: 'py' }, + 'ruby' => { cmds: %w[ruby], ext: 'rb' }, + 'node' => { cmds: %w[node], ext: 'js' }, + 'php' => { cmds: %w[php], ext: 'php' } }.freeze def execute(code:, language:, stdin: '', timeout_seconds: DEFAULT_TIMEOUT) @@ -45,35 +46,12 @@ def invoke(spec, language, code, stdin, timeout_seconds) output_path = File.join(dir, 'output.json') src_path = File.join(dir, "transform.#{spec[:ext]}") File.write(src_path, Wrapper.for(language, code, sink_for(language, output_path))) - run_process(resolve_cmd(spec[:cmds]), src_path, stdin, timeout_seconds, output_path) + run_process(Which.resolve(spec[:cmds]), src_path, stdin, timeout_seconds, output_path) end rescue Errno::ENOENT, Errno::EACCES => e failure("interpreter not available: #{e.message}") end - # First candidate present on PATH, falling back to the first name so a - # genuinely missing interpreter still surfaces a sensible - # "interpreter not available" error from run_process. - def resolve_cmd(cmds) - cmds.find { |c| which(c) } || cmds.first - end - - # Minimal cross-platform `which`. On Windows PATHEXT lists the - # executable suffixes (.EXE/.BAT/...) to try; on POSIX it is unset, so - # we fall back to a single bare-name lookup. File.executable? maps to - # the exec bit on POSIX and to a recognized extension on Windows. - def which(cmd) - exts = ENV.fetch('PATHEXT', '').split(File::PATH_SEPARATOR) - exts = [''] if exts.empty? - ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir| - exts.each do |ext| - candidate = File.join(dir, "#{cmd}#{ext}") - return candidate if File.file?(candidate) && File.executable?(candidate) - end - end - nil - end - def run_process(cmd, src_path, stdin, timeout_seconds, output_path) started = monotonic_ms diff --git a/lib/trmnlp/transform_backend/which.rb b/lib/trmnlp/transform_backend/which.rb new file mode 100644 index 0000000..4659bcc --- /dev/null +++ b/lib/trmnlp/transform_backend/which.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module TRMNLP + module TransformBackend + # Resolves a command to the interpreter actually present on PATH, so one + # transform runs unchanged on platforms that name an interpreter + # differently — most pressingly Windows, which ships no `python3`. + module Which + module_function + + # Falls back to the first candidate when none resolve, leaving the + # caller to surface the usual "interpreter not available" ENOENT. + def resolve(candidates, path: ENV.fetch('PATH', ''), pathext: ENV.fetch('PATHEXT', '')) + candidates.find { |cmd| locate(cmd, path: path, pathext: pathext) } || candidates.first + end + + # Windows marks executables by extension, enumerated in PATHEXT + # (.EXE/.BAT/...); POSIX leaves PATHEXT unset and relies on the exec bit. + def locate(cmd, path: ENV.fetch('PATH', ''), pathext: ENV.fetch('PATHEXT', '')) + suffixes = pathext.split(File::PATH_SEPARATOR) + suffixes = [''] if suffixes.empty? + path.split(File::PATH_SEPARATOR) + .product(suffixes) + .map { |dir, suffix| File.join(dir, "#{cmd}#{suffix}") } + .find { |candidate| File.file?(candidate) && File.executable?(candidate) } + end + end + end +end diff --git a/lib/trmnlp/version.rb b/lib/trmnlp/version.rb index 0729f2d..90afeb0 100644 --- a/lib/trmnlp/version.rb +++ b/lib/trmnlp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module TRMNLP - VERSION = '0.8.9' + VERSION = '0.8.10' end diff --git a/spec/lib/trmnlp/transform_backend/subprocess_spec.rb b/spec/lib/trmnlp/transform_backend/subprocess_spec.rb index 48bad27..0e37222 100644 --- a/spec/lib/trmnlp/transform_backend/subprocess_spec.rb +++ b/spec/lib/trmnlp/transform_backend/subprocess_spec.rb @@ -102,20 +102,5 @@ expect(result).not_to be_success expect(result.error).to match(/interpreter not available/) end - - it 'falls back to the next interpreter candidate when the first is missing (Windows has `python`, not `python3`)' do - stub_const("#{described_class}::INTERPRETERS", { - 'python' => { cmds: %w[this-binary-does-not-exist python3], ext: 'py' } - }) - - result = backend.execute( - code: "def run(input):\n return { 'echoed': input['greeting'] }", - language: 'python', - stdin: JSON.generate('greeting' => 'hello') - ) - - expect(result).to be_success - expect(JSON.parse(result.output)).to eq('echoed' => 'hello') - end end end diff --git a/spec/lib/trmnlp/transform_backend/which_spec.rb b/spec/lib/trmnlp/transform_backend/which_spec.rb new file mode 100644 index 0000000..24a1ebf --- /dev/null +++ b/spec/lib/trmnlp/transform_backend/which_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'tmpdir' +require 'trmnlp/transform_backend/which' + +RSpec.describe TRMNLP::TransformBackend::Which do + subject(:which) { described_class } + + let(:bin) { Dir.mktmpdir('trmnlp-which-') } + # Joined with the platform separator so the PATHEXT split matches Windows + # (`;`) and POSIX (`:`) without the spec caring which it runs on. + let(:windows_pathext) { ['.COM', '.BAT'].join(File::PATH_SEPARATOR) } + + after { FileUtils.remove_entry(bin) } + + describe '.locate' do + it 'answers the full path of an executable found on PATH' do + executable = File.join(bin, 'widget') + File.write(executable, '') + File.chmod(0o755, executable) + + expect(which.locate('widget', path: bin)).to eq(executable) + end + + it 'answers nil when the command is absent from PATH' do + expect(which.locate('ghost', path: bin)).to be(nil) + end + + it 'ignores a matching file that is not executable' do + File.write(File.join(bin, 'widget'), '') + + expect(which.locate('widget', path: bin)).to be(nil) + end + + it 'appends a PATHEXT suffix when locating a Windows executable' do + executable = File.join(bin, 'widget.BAT') + File.write(executable, '') + File.chmod(0o755, executable) + + expect(which.locate('widget', path: bin, pathext: windows_pathext)).to eq(executable) + end + end + + describe '.resolve' do + it 'answers the first candidate present on PATH' do + %w[python py].each do |name| + File.write(File.join(bin, name), '') + File.chmod(0o755, File.join(bin, name)) + end + + expect(which.resolve(%w[python3 python py], path: bin)).to eq('python') + end + + it 'answers the highest-priority candidate when several are present' do + %w[python3 python].each do |name| + File.write(File.join(bin, name), '') + File.chmod(0o755, File.join(bin, name)) + end + + expect(which.resolve(%w[python3 python py], path: bin)).to eq('python3') + end + + it 'answers the first candidate name when none resolve, preserving the ENOENT path' do + expect(which.resolve(%w[python3 python py], path: bin)).to eq('python3') + end + end +end