Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
16 changes: 11 additions & 5 deletions lib/trmnlp/transform_backend/subprocess.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'tmpdir'

require_relative '../transform_client'
require_relative 'which'
require_relative 'wrapper'

module TRMNLP
Expand All @@ -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)
Expand All @@ -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}")
Expand Down
29 changes: 29 additions & 0 deletions lib/trmnlp/transform_backend/which.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/trmnlp/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module TRMNLP
VERSION = '0.8.9'
VERSION = '0.8.10'
end
2 changes: 1 addition & 1 deletion spec/lib/trmnlp/transform_backend/subprocess_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: '')
Expand Down
69 changes: 69 additions & 0 deletions spec/lib/trmnlp/transform_backend/which_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading