diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9d2be..fa15fbe 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`, then the `py` launcher) 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) 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 58a576a..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,11 +20,16 @@ class Subprocess # Seconds a TERM'd process is given to exit before escalating to KILL. GRACE_PERIOD = 0.1 + # 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' => { cmd: 'python3', ext: 'py' }, - 'ruby' => { cmd: 'ruby', ext: 'rb' }, - 'node' => { cmd: 'node', ext: 'js' }, - 'php' => { cmd: '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) @@ -40,7 +46,7 @@ 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(Which.resolve(spec[:cmds]), src_path, stdin, timeout_seconds, output_path) end rescue Errno::ENOENT, Errno::EACCES => e failure("interpreter not available: #{e.message}") 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 a21fab3..0e37222 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: '') 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