diff --git a/Appraisals b/Appraisals index 3a87121..022a2fb 100644 --- a/Appraisals +++ b/Appraisals @@ -1,5 +1,16 @@ # frozen_string_literal: true +# Additional dependencies needed for specific gems. +# These work around upstream gems that haven't declared dependencies properly. +# See docs/appraisal-ruby-version-support.md for future Ruby version support. +GEM_DEPENDENCIES = { + "openai" => ["base64"] # openai uses base64 but doesn't declare it (needed for Ruby 3.4+) +} + +def gem_dependencies_for(gem_name) + GEM_DEPENDENCIES.fetch(gem_name, []) +end + # Optional dependencies to test OPTIONAL_GEMS = { "openai" => { @@ -26,10 +37,13 @@ OPTIONAL_GEMS = { # Generate appraisals for each optional gem OPTIONAL_GEMS.each do |gem_name, versions| + extra_deps = gem_dependencies_for(gem_name) + versions.each do |name, constraint| suffix = (name == "latest") ? "" : "-#{name.tr(".", "-")}" appraise "#{gem_name}#{suffix}" do gem gem_name, constraint + extra_deps.each { |dep| gem dep } end end diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d11826..c1301c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,3 +77,28 @@ for more details. ```bash rake -T test:vcr ``` + +### Testing with Different Ruby Versions + +CI tests against Ruby 3.2, 3.3, and 3.4. To test locally with a different Ruby version: + +```bash +# Install a different Ruby version +mise install ruby@3.4 + +# Run tests with that version +mise exec ruby@3.4 -- bundle install +mise exec ruby@3.4 -- bundle exec rake test + +# Run appraisal tests with that version +mise exec ruby@3.4 -- bundle exec appraisal install +mise exec ruby@3.4 -- bundle exec appraisal openai-0-33 rake test +``` + +To temporarily switch your shell to a different Ruby: + +```bash +mise use ruby@3.4 +ruby --version # => 3.4.x +bundle exec rake test +``` diff --git a/Gemfile.lock b/Gemfile.lock index 7a03578..b2e79fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,7 +17,6 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - base64 (0.3.0) bigdecimal (3.3.1) builder (3.3.0) crack (1.0.1) @@ -122,8 +121,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - vcr (6.3.1) - base64 + vcr (6.4.0) webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) diff --git a/gemfiles/openai.gemfile b/gemfiles/openai.gemfile index 8a73574..edfe9d3 100644 --- a/gemfiles/openai.gemfile +++ b/gemfiles/openai.gemfile @@ -4,5 +4,6 @@ source "https://rubygems.org" gem "minitest-reporters", "~> 1.6" gem "openai", ">= 0.34" +gem "base64" gemspec path: "../" diff --git a/gemfiles/openai_0_33.gemfile b/gemfiles/openai_0_33.gemfile index af9be30..a9eff3e 100644 --- a/gemfiles/openai_0_33.gemfile +++ b/gemfiles/openai_0_33.gemfile @@ -4,5 +4,6 @@ source "https://rubygems.org" gem "minitest-reporters", "~> 1.6" gem "openai", "~> 0.33.0" +gem "base64" gemspec path: "../" diff --git a/gemfiles/openai_0_34.gemfile b/gemfiles/openai_0_34.gemfile index 2b9b038..2e2d6ab 100644 --- a/gemfiles/openai_0_34.gemfile +++ b/gemfiles/openai_0_34.gemfile @@ -4,5 +4,6 @@ source "https://rubygems.org" gem "minitest-reporters", "~> 1.6" gem "openai", "~> 0.34.0" +gem "base64" gemspec path: "../" diff --git a/lib/braintrust/internal/encoding.rb b/lib/braintrust/internal/encoding.rb new file mode 100644 index 0000000..29d7923 --- /dev/null +++ b/lib/braintrust/internal/encoding.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Braintrust + module Internal + # Encoding utilities using Ruby's native pack/unpack methods. + # Avoids dependency on external gems that became bundled gems in Ruby 3.4. + module Encoding + # Base64 encoding/decoding using Ruby's native pack/unpack methods. + # Drop-in replacement for the base64 gem's strict methods. + # + # @example Encode binary data + # Encoding::Base64.strict_encode64(image_bytes) + # # => "iVBORw0KGgo..." + # + # @example Decode base64 string + # Encoding::Base64.strict_decode64("iVBORw0KGgo...") + # # => "\x89PNG..." + # + module Base64 + module_function + + # Encodes binary data to base64 without newlines (strict encoding). + # + # @param data [String] Binary data to encode + # @return [String] Base64-encoded string without newlines + def strict_encode64(data) + [data].pack("m0") + end + + # Decodes a base64 string to binary data (strict decoding). + # + # @param str [String] Base64-encoded string + # @return [String] Decoded binary data + def strict_decode64(str) + str.unpack1("m0") + end + end + end + end +end diff --git a/lib/braintrust/trace/attachment.rb b/lib/braintrust/trace/attachment.rb index 81cf770..51a98d4 100644 --- a/lib/braintrust/trace/attachment.rb +++ b/lib/braintrust/trace/attachment.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "base64" require "net/http" +require_relative "../internal/encoding" require "uri" module Braintrust @@ -110,7 +110,7 @@ def self.from_url(url) # att.to_data_url # # => "data:image/png;base64,iVBORw0KGgo..." def to_data_url - encoded = Base64.strict_encode64(@data) + encoded = Internal::Encoding::Base64.strict_encode64(@data) "data:#{@content_type};base64,#{encoded}" end diff --git a/test/braintrust/internal/encoding_test.rb b/test/braintrust/internal/encoding_test.rb new file mode 100644 index 0000000..ffee0ff --- /dev/null +++ b/test/braintrust/internal/encoding_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "test_helper" +require "braintrust/internal/encoding" + +class Braintrust::Internal::Encoding::Base64Test < Minitest::Test + BASE64_STDLIB_AVAILABLE = begin + require "base64" + true + rescue LoadError + false + end + + # strict_encode64 + + def test_strict_encode64_encodes_string + result = Braintrust::Internal::Encoding::Base64.strict_encode64("Hello, World!") + assert_equal "SGVsbG8sIFdvcmxkIQ==", result + end + + def test_strict_encode64_encodes_binary_data + binary = [0x89, 0x50, 0x4E, 0x47].pack("C*") + result = Braintrust::Internal::Encoding::Base64.strict_encode64(binary) + assert_equal "iVBORw==", result + end + + def test_strict_encode64_encodes_empty_string + result = Braintrust::Internal::Encoding::Base64.strict_encode64("") + assert_equal "", result + end + + def test_strict_encode64_produces_no_newlines + long_string = "x" * 1000 + result = Braintrust::Internal::Encoding::Base64.strict_encode64(long_string) + refute_includes result, "\n" + end + + def test_strict_encode64_matches_stdlib + skip "base64 stdlib not available" unless BASE64_STDLIB_AVAILABLE + + test_cases = [ + "Hello, World!", + "", + "x" * 1000, + [0x00, 0xFF, 0x89, 0x50].pack("C*") + ] + + test_cases.each do |input| + expected = ::Base64.strict_encode64(input) + actual = Braintrust::Internal::Encoding::Base64.strict_encode64(input) + assert_equal expected, actual, "Mismatch for input: #{input.inspect}" + end + end + + # strict_decode64 + + def test_strict_decode64_decodes_string + result = Braintrust::Internal::Encoding::Base64.strict_decode64("SGVsbG8sIFdvcmxkIQ==") + assert_equal "Hello, World!", result + end + + def test_strict_decode64_decodes_binary_data + result = Braintrust::Internal::Encoding::Base64.strict_decode64("iVBORw==") + expected = [0x89, 0x50, 0x4E, 0x47].pack("C*") + assert_equal expected, result + end + + def test_strict_decode64_decodes_empty_string + result = Braintrust::Internal::Encoding::Base64.strict_decode64("") + assert_equal "", result + end + + def test_strict_decode64_matches_stdlib + skip "base64 stdlib not available" unless BASE64_STDLIB_AVAILABLE + + test_cases = [ + "SGVsbG8sIFdvcmxkIQ==", + "", + "eHh4eHh4eHg=", + "AP+JUA==" + ] + + test_cases.each do |input| + expected = ::Base64.strict_decode64(input) + actual = Braintrust::Internal::Encoding::Base64.strict_decode64(input) + assert_equal expected, actual, "Mismatch for input: #{input.inspect}" + end + end + + # round-trip + + def test_round_trip_preserves_string_data + original = "The quick brown fox jumps over the lazy dog" + encoded = Braintrust::Internal::Encoding::Base64.strict_encode64(original) + decoded = Braintrust::Internal::Encoding::Base64.strict_decode64(encoded) + assert_equal original, decoded + end + + def test_round_trip_preserves_binary_data + original = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF].pack("C*") + encoded = Braintrust::Internal::Encoding::Base64.strict_encode64(original) + decoded = Braintrust::Internal::Encoding::Base64.strict_decode64(encoded) + assert_equal original, decoded + end +end diff --git a/test/braintrust/trace/attachment_test.rb b/test/braintrust/trace/attachment_test.rb index 51b0031..8674c37 100644 --- a/test/braintrust/trace/attachment_test.rb +++ b/test/braintrust/trace/attachment_test.rb @@ -2,6 +2,7 @@ require "test_helper" require "braintrust/trace/attachment" +require "braintrust/internal/encoding" class Braintrust::Trace::AttachmentTest < Minitest::Test def setup @@ -58,7 +59,7 @@ def test_to_data_url_returns_correct_format # Verify it's valid base64 by extracting and decoding base64_part = data_url.sub(/^data:image\/png;base64,/, "") - decoded = Base64.strict_decode64(base64_part) + decoded = Braintrust::Internal::Encoding::Base64.strict_decode64(base64_part) assert_equal @test_png_data, decoded end