braintrust 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vendored from mustache gem v1.1.1
4
+ # https://github.com/mustache/mustache
5
+ # License: MIT
6
+ # Modifications:
7
+ # - Namespaced under Braintrust::Vendor
8
+ # - Disabled HTML escaping (LLM prompts don't need HTML escaping)
9
+
10
+ require_relative "enumerable"
11
+ require_relative "template"
12
+ require_relative "context"
13
+ require_relative "settings"
14
+ require_relative "utils"
15
+
16
+ module Braintrust
17
+ module Vendor
18
+ # Mustache is the base class from which your Mustache subclasses
19
+ # should inherit (though it can be used on its own).
20
+ #
21
+ # The typical Mustache workflow is as follows:
22
+ #
23
+ # * Create a Mustache subclass: class Stats < Mustache
24
+ # * Create a template: stats.mustache
25
+ # * Instantiate an instance: view = Stats.new
26
+ # * Render that instance: view.render
27
+ #
28
+ # You can skip the instantiation by calling `Stats.render` directly.
29
+ class Mustache
30
+ # Initialize a new Mustache instance.
31
+ #
32
+ # @param [Hash] options An options hash
33
+ # @option options [String] template_path
34
+ # @option options [String] template_extension
35
+ # @option options [String] template_file
36
+ # @option options [String] template
37
+ # @option options [String] view_namespace
38
+ # @option options [String] view_path
39
+ def initialize(options = {})
40
+ @options = options
41
+
42
+ initialize_settings
43
+ end
44
+
45
+ # Instantiates an instance of this class and calls `render` with
46
+ # the passed args.
47
+ #
48
+ # @return A rendered String version of a template.
49
+ def self.render(*args)
50
+ new.render(*args)
51
+ end
52
+
53
+ # Parses our fancy pants template file and returns normal file with
54
+ # all special {{tags}} and {{#sections}}replaced{{/sections}}.
55
+ #
56
+ # @example Render view
57
+ # @view.render("Hi {{thing}}!", :thing => :world)
58
+ #
59
+ # @example Set view template and then render
60
+ # View.template = "Hi {{thing}}!"
61
+ # @view = View.new
62
+ # @view.render(:thing => :world)
63
+ #
64
+ # @param [String,Hash] data A String template or a Hash context.
65
+ # If a Hash is given, we'll try to figure
66
+ # out the template from the class.
67
+ # @param [Hash] ctx A Hash context if `data` is a String template.
68
+ # @return [String] Returns a rendered version of a template.
69
+ def render(data = template, ctx = {})
70
+ case data
71
+ when Hash
72
+ ctx = data
73
+ when Symbol
74
+ self.template_name = data
75
+ end
76
+
77
+ tpl = case data
78
+ when Hash
79
+ templateify(template)
80
+ when Symbol
81
+ templateify(template)
82
+ else
83
+ templateify(data)
84
+ end
85
+
86
+ return tpl.render(context) if ctx == {}
87
+
88
+ begin
89
+ context.push(ctx)
90
+ tpl.render(context)
91
+ ensure
92
+ context.pop
93
+ end
94
+ end
95
+
96
+ # Context accessors.
97
+ #
98
+ # @example Context accessors
99
+ # view = Mustache.new
100
+ # view[:name] = "Jon"
101
+ # view.template = "Hi, {{name}}!"
102
+ # view.render # => "Hi, Jon!"
103
+ def [](key)
104
+ context[key.to_sym]
105
+ end
106
+
107
+ def []=(key, value)
108
+ context[key.to_sym] = value
109
+ end
110
+
111
+ # A helper method which gives access to the context at a given time.
112
+ # Kind of a hack for now, but useful when you're in an iterating section
113
+ # and want access to the hash currently being iterated over.
114
+ def context
115
+ @context ||= Context.new(self)
116
+ end
117
+
118
+ # Given a file name and an optional context, attempts to load and
119
+ # render the file as a template.
120
+ def self.render_file(name, context = {})
121
+ render(partial(name), context)
122
+ end
123
+
124
+ # Given a file name and an optional context, attempts to load and
125
+ # render the file as a template.
126
+ def render_file(name, context = {})
127
+ self.class.render_file(name, context)
128
+ end
129
+
130
+ # Given a name, attempts to read a file and return the contents as a
131
+ # string. The file is not rendered, so it might contain
132
+ # {{mustaches}}.
133
+ #
134
+ # Call `render` if you need to process it.
135
+ def self.partial(name)
136
+ new.partial(name)
137
+ end
138
+
139
+ # Override this in your subclass if you want to do fun things like
140
+ # reading templates from a database. It will be rendered by the
141
+ # context, so all you need to do is return a string.
142
+ def partial(name)
143
+ path = "#{template_path}/#{name}.#{template_extension}"
144
+
145
+ begin
146
+ File.read(path)
147
+ rescue
148
+ raise if raise_on_context_miss?
149
+ ""
150
+ end
151
+ end
152
+
153
+ # BRAINTRUST MODIFICATION: No HTML escaping for LLM prompts.
154
+ # Original mustache uses CGI.escapeHTML which would turn
155
+ # characters like < > & " into HTML entities.
156
+ # For LLM prompts, we want the raw text without escaping.
157
+ #
158
+ # @param [Object] value Value to escape.
159
+ # @return [String] Unescaped content (just converted to string).
160
+ def escape(value)
161
+ value.to_s
162
+ end
163
+
164
+ # @deprecated Use {#escape} instead.
165
+ # Kept for compatibility but also does no escaping.
166
+ def escapeHTML(str)
167
+ str.to_s
168
+ end
169
+
170
+ # Has this instance or its class already compiled a template?
171
+ def compiled?
172
+ (@template && @template.is_a?(Template)) || self.class.compiled?
173
+ end
174
+
175
+ private
176
+
177
+ # When given a symbol or string representing a class, will try to produce an
178
+ # appropriate view class.
179
+ #
180
+ # @example
181
+ # Mustache.view_namespace = Hurl::Views
182
+ # Mustache.view_class(:Partial) # => Hurl::Views::Partial
183
+ def self.view_class(name)
184
+ name = classify(name.to_s)
185
+
186
+ # Emptiness begets emptiness.
187
+ return Mustache if name.to_s.empty?
188
+
189
+ name = "#{view_namespace}::#{name}"
190
+ const = rescued_const_get(name)
191
+
192
+ return const if const
193
+
194
+ const_from_file(name)
195
+ end
196
+
197
+ def self.rescued_const_get(name)
198
+ const_get(name, true) || Mustache
199
+ rescue NameError
200
+ nil
201
+ end
202
+
203
+ def self.const_from_file(name)
204
+ file_name = underscore(name)
205
+ file_path = "#{view_path}/#{file_name}.rb"
206
+
207
+ return Mustache unless File.exist?(file_path)
208
+
209
+ require file_path.chomp(".rb")
210
+ rescued_const_get(name)
211
+ end
212
+
213
+ # Has this template already been compiled? Compilation is somewhat
214
+ # expensive so it may be useful to check this before attempting it.
215
+ def self.compiled?
216
+ @template.is_a? Template
217
+ end
218
+
219
+ # template_partial => TemplatePartial
220
+ # template/partial => Template::Partial
221
+ def self.classify(underscored)
222
+ Mustache::Utils::String.new(underscored).classify
223
+ end
224
+
225
+ # TemplatePartial => template_partial
226
+ # Template::Partial => template/partial
227
+ # Takes a string but defaults to using the current class' name.
228
+ def self.underscore(classified = name)
229
+ classified = superclass.name if classified.to_s.empty?
230
+
231
+ Mustache::Utils::String.new(classified).underscore(view_namespace)
232
+ end
233
+
234
+ # @param [Template,String] obj Turns `obj` into a template
235
+ # @param [Hash] options Options for template creation
236
+ def self.templateify(obj, options = {})
237
+ obj.is_a?(Template) ? obj : Template.new(obj, options)
238
+ end
239
+
240
+ def templateify(obj)
241
+ opts = {partial_resolver: method(:partial)}
242
+ opts.merge!(@options) if @options.is_a?(Hash)
243
+ self.class.templateify(obj, opts)
244
+ end
245
+
246
+ # Return the value of the configuration setting on the superclass, or return
247
+ # the default.
248
+ #
249
+ # @param [Symbol] attr_name Name of the attribute. It should match
250
+ # the instance variable.
251
+ # @param [Object] default Default value to use if the superclass does
252
+ # not respond.
253
+ #
254
+ # @return Inherited or default configuration setting.
255
+ def self.inheritable_config_for(attr_name, default)
256
+ superclass.respond_to?(attr_name) ? superclass.send(attr_name) : default
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vendored from mustache gem v1.1.1
4
+ # https://github.com/mustache/mustache
5
+ # License: MIT
6
+ # Modifications: Namespaced under Braintrust::Vendor
7
+
8
+ require "strscan"
9
+
10
+ module Braintrust
11
+ module Vendor
12
+ class Mustache
13
+ # The Parser is responsible for taking a string template and
14
+ # converting it into an array of tokens and, really, expressions. It
15
+ # raises SyntaxError if there is anything it doesn't understand and
16
+ # knows which sigil corresponds to which tag type.
17
+ #
18
+ # For example, given this template:
19
+ #
20
+ # Hi {{thing}}!
21
+ #
22
+ # Run through the Parser we'll get these tokens:
23
+ #
24
+ # [:multi,
25
+ # [:static, "Hi "],
26
+ # [:mustache, :etag, "thing"],
27
+ # [:static, "!\n"]]
28
+ class Parser
29
+ # A SyntaxError is raised when the Parser comes across unclosed
30
+ # tags, sections, illegal content in tags, or anything of that
31
+ # sort.
32
+ class SyntaxError < StandardError
33
+ def initialize(message, position)
34
+ @message = message
35
+ @lineno, @column, @line, _ = position
36
+ @stripped_line = @line.strip
37
+ @stripped_column = @column - (@line.size - @line.lstrip.size)
38
+ end
39
+
40
+ def to_s
41
+ <<-EOF
42
+ #{@message}
43
+ Line #{@lineno}
44
+ #{@stripped_line}
45
+ #{" " * @stripped_column}^
46
+ EOF
47
+ end
48
+ end
49
+
50
+ # The sigil types which are valid after an opening `{{`
51
+ VALID_TYPES = ["#", "^", "/", "=", "!", "<", ">", "&", "{"].map(&:freeze)
52
+
53
+ def self.valid_types
54
+ @valid_types ||= Regexp.new(VALID_TYPES.map { |t| Regexp.escape(t) }.join("|"))
55
+ end
56
+
57
+ # Add a supported sigil type (with optional aliases) to the Parser.
58
+ #
59
+ # Requires a block, which will be sent the following parameters:
60
+ #
61
+ # * content - The raw content of the tag
62
+ # * fetch- A mustache context fetch expression for the content
63
+ # * padding - Indentation whitespace from the currently-parsed line
64
+ # * pre_match_position - Location of the scanner before a match was made
65
+ #
66
+ # The provided block will be evaluated against the current instance of
67
+ # Parser, and may append to the Parser's @result as needed.
68
+ def self.add_type(*types, &block)
69
+ types = types.map(&:to_s)
70
+ type, *aliases = types
71
+ method_name = :"scan_tag_#{type}"
72
+ define_method(method_name, &block)
73
+ aliases.each { |a| alias_method :"scan_tag_#{a}", method_name }
74
+ types.each { |t| VALID_TYPES << t unless VALID_TYPES.include?(t) }
75
+ @valid_types = nil
76
+ end
77
+
78
+ # After these types of tags, all whitespace until the end of the line will
79
+ # be skipped if they are the first (and only) non-whitespace content on
80
+ # the line.
81
+ SKIP_WHITESPACE = ["#", "^", "/", "<", ">", "=", "!"].map(&:freeze)
82
+
83
+ # The content allowed in a tag name.
84
+ ALLOWED_CONTENT = /(\w|[?!\/.=-])*/
85
+
86
+ # These types of tags allow any content,
87
+ # the rest only allow ALLOWED_CONTENT.
88
+ ANY_CONTENT = ["!", "="].map(&:freeze)
89
+
90
+ attr_reader :otag, :ctag
91
+
92
+ # Accepts an options hash which does nothing but may be used in
93
+ # the future.
94
+ def initialize(options = {})
95
+ @options = options
96
+ @option_inline_partials_at_compile_time = options[:inline_partials_at_compile_time]
97
+ if @option_inline_partials_at_compile_time
98
+ @partial_resolver = options[:partial_resolver]
99
+ raise ArgumentError.new "Missing or invalid partial_resolver" unless @partial_resolver.respond_to? :call
100
+ end
101
+
102
+ # Initialize default tags
103
+ self.otag ||= "{{"
104
+ self.ctag ||= "}}"
105
+ end
106
+
107
+ # The opening tag delimiter. This may be changed at runtime.
108
+ def otag=(value)
109
+ regex = regexp value
110
+ @otag_regex = /([ \t]*)?#{regex}/
111
+ @otag_not_regex = /(^[ \t]*)?#{regex}/
112
+ @otag = value
113
+ end
114
+
115
+ # The closing tag delimiter. This too may be changed at runtime.
116
+ def ctag=(value)
117
+ @ctag_regex = regexp value
118
+ @ctag = value
119
+ end
120
+
121
+ # Given a string template, returns an array of tokens.
122
+ def compile(template)
123
+ @encoding = nil
124
+
125
+ if template.respond_to?(:encoding)
126
+ @encoding = template.encoding
127
+ template = template.dup.force_encoding("BINARY")
128
+ end
129
+
130
+ # Keeps information about opened sections.
131
+ @sections = []
132
+ @result = [:multi]
133
+ @scanner = StringScanner.new(template)
134
+
135
+ # Scan until the end of the template.
136
+ until @scanner.eos?
137
+ scan_tags || scan_text
138
+ end
139
+
140
+ unless @sections.empty?
141
+ # We have parsed the whole file, but there's still opened sections.
142
+ type, pos, _ = @sections.pop
143
+ error "Unclosed section #{type.inspect}", pos
144
+ end
145
+
146
+ @result
147
+ end
148
+
149
+ private
150
+
151
+ def content_tags(type, current_ctag_regex)
152
+ if ANY_CONTENT.include?(type)
153
+ r = /\s*#{regexp(type)}?#{current_ctag_regex}/
154
+ scan_until_exclusive(r)
155
+ else
156
+ @scanner.scan(ALLOWED_CONTENT)
157
+ end
158
+ end
159
+
160
+ def dispatch_based_on_type(type, content, fetch, padding, pre_match_position)
161
+ send(:"scan_tag_#{type}", content, fetch, padding, pre_match_position)
162
+ end
163
+
164
+ def find_closing_tag(scanner, current_ctag_regex)
165
+ error "Unclosed tag" unless scanner.scan(current_ctag_regex)
166
+ end
167
+
168
+ # Find {{mustaches}} and add them to the @result array.
169
+ def scan_tags
170
+ # Scan until we hit an opening delimiter.
171
+ start_of_line = @scanner.beginning_of_line?
172
+ pre_match_position = @scanner.pos
173
+ last_index = @result.length
174
+
175
+ return unless @scanner.scan @otag_regex
176
+ padding = @scanner[1] || ""
177
+
178
+ # Don't touch the preceding whitespace unless we're matching the start
179
+ # of a new line.
180
+ unless start_of_line
181
+ @result << [:static, padding] unless padding.empty?
182
+ pre_match_position += padding.length
183
+ padding = ""
184
+ end
185
+
186
+ # Since {{= rewrites ctag, we store the ctag which should be used
187
+ # when parsing this specific tag.
188
+ current_ctag_regex = @ctag_regex
189
+ type = @scanner.scan(self.class.valid_types)
190
+ @scanner.skip(/\s*/)
191
+
192
+ # ANY_CONTENT tags allow any character inside of them, while
193
+ # other tags (such as variables) are more strict.
194
+ content = content_tags(type, current_ctag_regex)
195
+
196
+ # We found {{ but we can't figure out what's going on inside.
197
+ error "Illegal content in tag" if content.empty?
198
+
199
+ fetch = [:mustache, :fetch, content.split(".")]
200
+ prev = @result
201
+
202
+ dispatch_based_on_type(type, content, fetch, padding, pre_match_position)
203
+
204
+ # The closing } in unescaped tags is just a hack for
205
+ # aesthetics.
206
+ type = "}" if type == "{"
207
+
208
+ # Skip whitespace and any balancing sigils after the content
209
+ # inside this tag.
210
+ @scanner.skip(/\s+/)
211
+ @scanner.skip(regexp(type)) if type
212
+
213
+ find_closing_tag(@scanner, current_ctag_regex)
214
+
215
+ # If this tag was the only non-whitespace content on this line, strip
216
+ # the remaining whitespace. If not, but we've been hanging on to padding
217
+ # from the beginning of the line, re-insert the padding as static text.
218
+ if start_of_line && !@scanner.eos?
219
+ if @scanner.peek(2) =~ /\r?\n/ && SKIP_WHITESPACE.include?(type)
220
+ @scanner.skip(/\r?\n/)
221
+ else
222
+ prev.insert(last_index, [:static, padding]) unless padding.empty?
223
+ end
224
+ end
225
+
226
+ # Store off the current scanner position now that we've closed the tag
227
+ # and consumed any irrelevant whitespace.
228
+ @sections.last[1] << @scanner.pos unless @sections.empty?
229
+
230
+ return unless @result == [:multi]
231
+ end
232
+
233
+ # Try to find static text, e.g. raw HTML with no {{mustaches}}.
234
+ def scan_text
235
+ text = scan_until_exclusive @otag_not_regex
236
+
237
+ if text.nil?
238
+ # Couldn't find any otag, which means the rest is just static text.
239
+ text = @scanner.rest
240
+ # Mark as done.
241
+ @scanner.terminate
242
+ end
243
+
244
+ text.force_encoding(@encoding) if @encoding
245
+
246
+ @result << [:static, text] unless text.empty?
247
+ end
248
+
249
+ # Scans the string until the pattern is matched. Returns the substring
250
+ # *excluding* the end of the match, advancing the scan pointer to that
251
+ # location. If there is no match, nil is returned.
252
+ def scan_until_exclusive(regexp)
253
+ pos = @scanner.pos
254
+ if @scanner.scan_until(regexp)
255
+ @scanner.pos -= @scanner.matched.size
256
+ @scanner.pre_match[pos..-1]
257
+ end
258
+ end
259
+
260
+ def offset
261
+ position[0, 2]
262
+ end
263
+
264
+ # Returns [lineno, column, line]
265
+ def position
266
+ # The rest of the current line
267
+ rest = @scanner.check_until(/\n|\Z/).to_s.chomp
268
+
269
+ # What we have parsed so far
270
+ parsed = @scanner.string[0...@scanner.pos]
271
+
272
+ lines = parsed.split("\n")
273
+
274
+ [lines.size, lines.last.size - 1, lines.last + rest]
275
+ end
276
+
277
+ # Used to quickly convert a string into a regular expression
278
+ # usable by the string scanner.
279
+ def regexp(thing)
280
+ Regexp.new Regexp.escape(thing) if thing
281
+ end
282
+
283
+ # Raises a SyntaxError. The message should be the name of the
284
+ # error - other details such as line number and position are
285
+ # handled for you.
286
+ def error(message, pos = position)
287
+ raise SyntaxError.new(message, pos)
288
+ end
289
+
290
+ #
291
+ # Scan tags
292
+ #
293
+ # These methods are called in `scan_tags`. Because they contain nonstandard
294
+ # characters in their method names, they are aliased to
295
+ # better named methods.
296
+ #
297
+
298
+ # This function handles the cases where the scanned tag does not have
299
+ # a type.
300
+ def scan_tag_(content, fetch, padding, pre_match_position)
301
+ @result << [:mustache, :etag, fetch, offset]
302
+ end
303
+
304
+ def scan_tag_block(content, fetch, padding, pre_match_position)
305
+ block = [:multi]
306
+ @result << [:mustache, :section, fetch, offset, block]
307
+ @sections << [content, position, @result]
308
+ @result = block
309
+ end
310
+ alias_method :'scan_tag_#', :scan_tag_block
311
+
312
+ def scan_tag_inverted(content, fetch, padding, pre_match_position)
313
+ block = [:multi]
314
+ @result << [:mustache, :inverted_section, fetch, offset, block]
315
+ @sections << [content, position, @result]
316
+ @result = block
317
+ end
318
+ alias_method :'scan_tag_^', :scan_tag_inverted
319
+
320
+ def scan_tag_close(content, fetch, padding, pre_match_position)
321
+ section, pos, result = @sections.pop
322
+ if section.nil?
323
+ error "Closing unopened #{content.inspect}"
324
+ end
325
+
326
+ raw = @scanner.pre_match[pos[3]...pre_match_position] + padding
327
+ (@result = result).last << raw << [otag, ctag]
328
+
329
+ if section != content
330
+ error "Unclosed section #{section.inspect}", pos
331
+ end
332
+ end
333
+ alias_method :'scan_tag_/', :scan_tag_close
334
+
335
+ def scan_tag_comment(content, fetch, padding, pre_match_position)
336
+ end
337
+ alias_method :'scan_tag_!', :scan_tag_comment
338
+
339
+ def scan_tag_delimiter(content, fetch, padding, pre_match_position)
340
+ self.otag, self.ctag = content.split(" ", 2)
341
+ end
342
+ alias_method :'scan_tag_=', :scan_tag_delimiter
343
+
344
+ def scan_tag_open_partial(content, fetch, padding, pre_match_position)
345
+ @result << if @option_inline_partials_at_compile_time
346
+ partial = @partial_resolver.call content
347
+ partial.gsub!(/^/, padding) unless padding.empty?
348
+ self.class.new(@options).compile partial
349
+ else
350
+ [:mustache, :partial, content, offset, padding]
351
+ end
352
+ end
353
+ alias_method :'scan_tag_<', :scan_tag_open_partial
354
+ alias_method :'scan_tag_>', :scan_tag_open_partial
355
+
356
+ def scan_tag_unescaped(content, fetch, padding, pre_match_position)
357
+ @result << [:mustache, :utag, fetch, offset]
358
+ end
359
+ alias_method :'scan_tag_{', :scan_tag_unescaped
360
+ alias_method :'scan_tag_&', :scan_tag_unescaped
361
+ end
362
+ end
363
+ end
364
+ end