braintrust 0.1.0 → 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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/braintrust/api/functions.rb +11 -0
- data/lib/braintrust/contrib/anthropic/instrumentation/beta_messages.rb +242 -0
- data/lib/braintrust/contrib/anthropic/integration.rb +2 -2
- data/lib/braintrust/contrib/anthropic/patcher.rb +83 -0
- data/lib/braintrust/contrib/openai/instrumentation/moderations.rb +93 -0
- data/lib/braintrust/contrib/openai/integration.rb +1 -1
- data/lib/braintrust/contrib/openai/patcher.rb +43 -0
- data/lib/braintrust/contrib/ruby_openai/instrumentation/moderations.rb +94 -0
- data/lib/braintrust/contrib/ruby_openai/integration.rb +1 -1
- data/lib/braintrust/contrib/ruby_openai/patcher.rb +35 -0
- data/lib/braintrust/internal/env.rb +6 -0
- data/lib/braintrust/internal/template.rb +91 -0
- data/lib/braintrust/prompt.rb +143 -0
- data/lib/braintrust/state.rb +1 -1
- data/lib/braintrust/trace.rb +41 -0
- data/lib/braintrust/vendor/mustache/context.rb +180 -0
- data/lib/braintrust/vendor/mustache/context_miss.rb +22 -0
- data/lib/braintrust/vendor/mustache/enumerable.rb +14 -0
- data/lib/braintrust/vendor/mustache/generator.rb +188 -0
- data/lib/braintrust/vendor/mustache/mustache.rb +260 -0
- data/lib/braintrust/vendor/mustache/parser.rb +364 -0
- data/lib/braintrust/vendor/mustache/settings.rb +252 -0
- data/lib/braintrust/vendor/mustache/template.rb +138 -0
- data/lib/braintrust/vendor/mustache/utils.rb +42 -0
- data/lib/braintrust/vendor/mustache.rb +16 -0
- data/lib/braintrust/version.rb +1 -1
- data/lib/braintrust.rb +1 -0
- metadata +16 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../patcher"
|
|
4
4
|
require_relative "instrumentation/chat"
|
|
5
5
|
require_relative "instrumentation/responses"
|
|
6
|
+
require_relative "instrumentation/moderations"
|
|
6
7
|
|
|
7
8
|
module Braintrust
|
|
8
9
|
module Contrib
|
|
@@ -80,6 +81,40 @@ module Braintrust
|
|
|
80
81
|
end
|
|
81
82
|
end
|
|
82
83
|
end
|
|
84
|
+
|
|
85
|
+
# Patcher for ruby-openai moderations API.
|
|
86
|
+
# Instruments OpenAI::Client#moderations method.
|
|
87
|
+
class ModerationsPatcher < Braintrust::Contrib::Patcher
|
|
88
|
+
class << self
|
|
89
|
+
def applicable?
|
|
90
|
+
defined?(::OpenAI::Client) && ::OpenAI::Client.method_defined?(:moderations)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def patched?(**options)
|
|
94
|
+
target_class = options[:target]&.singleton_class || ::OpenAI::Client
|
|
95
|
+
Instrumentation::Moderations.applied?(target_class)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Perform the actual patching.
|
|
99
|
+
# @param options [Hash] Configuration options passed from integration
|
|
100
|
+
# @option options [Object] :target Optional target instance to patch
|
|
101
|
+
# @option options [OpenTelemetry::SDK::Trace::TracerProvider] :tracer_provider Optional tracer provider
|
|
102
|
+
# @return [void]
|
|
103
|
+
def perform_patch(**options)
|
|
104
|
+
return unless applicable?
|
|
105
|
+
|
|
106
|
+
if options[:target]
|
|
107
|
+
# Instance-level (for only this client)
|
|
108
|
+
raise ArgumentError, "target must be a kind of ::OpenAI::Client" unless options[:target].is_a?(::OpenAI::Client)
|
|
109
|
+
|
|
110
|
+
options[:target].singleton_class.include(Instrumentation::Moderations)
|
|
111
|
+
else
|
|
112
|
+
# Class-level (for all clients)
|
|
113
|
+
::OpenAI::Client.include(Instrumentation::Moderations)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
83
118
|
end
|
|
84
119
|
end
|
|
85
120
|
end
|
|
@@ -7,11 +7,17 @@ module Braintrust
|
|
|
7
7
|
ENV_AUTO_INSTRUMENT = "BRAINTRUST_AUTO_INSTRUMENT"
|
|
8
8
|
ENV_INSTRUMENT_EXCEPT = "BRAINTRUST_INSTRUMENT_EXCEPT"
|
|
9
9
|
ENV_INSTRUMENT_ONLY = "BRAINTRUST_INSTRUMENT_ONLY"
|
|
10
|
+
ENV_FLUSH_ON_EXIT = "BRAINTRUST_FLUSH_ON_EXIT"
|
|
10
11
|
|
|
11
12
|
def self.auto_instrument
|
|
12
13
|
ENV[ENV_AUTO_INSTRUMENT] != "false"
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# Whether to automatically flush spans on program exit. Default: true
|
|
17
|
+
def self.flush_on_exit
|
|
18
|
+
ENV[ENV_FLUSH_ON_EXIT] != "false"
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
def self.instrument_except
|
|
16
22
|
parse_list(ENV_INSTRUMENT_EXCEPT)
|
|
17
23
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../vendor/mustache"
|
|
4
|
+
|
|
5
|
+
module Braintrust
|
|
6
|
+
module Internal
|
|
7
|
+
# Template rendering utilities for Mustache templates
|
|
8
|
+
module Template
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Render a template string with variable substitution
|
|
12
|
+
#
|
|
13
|
+
# @param text [String] Template text to render
|
|
14
|
+
# @param variables [Hash] Variables to substitute
|
|
15
|
+
# @param format [String] Template format: "mustache", "none", or "nunjucks"
|
|
16
|
+
# @param strict [Boolean] Raise error on missing variables (default: false)
|
|
17
|
+
# @return [String] Rendered text
|
|
18
|
+
def render(text, variables, format:, strict: false)
|
|
19
|
+
return text unless text.is_a?(String)
|
|
20
|
+
|
|
21
|
+
case format
|
|
22
|
+
when "none"
|
|
23
|
+
# No templating - return text unchanged
|
|
24
|
+
text
|
|
25
|
+
when "nunjucks"
|
|
26
|
+
# Nunjucks is a UI-only feature in Braintrust
|
|
27
|
+
raise Error, "Nunjucks templates are not supported in the Ruby SDK. " \
|
|
28
|
+
"Nunjucks only works in Braintrust playgrounds. " \
|
|
29
|
+
"Please use 'mustache' or 'none' template format, or invoke the prompt via the API proxy."
|
|
30
|
+
when "mustache", "", nil
|
|
31
|
+
# Default: Mustache templating
|
|
32
|
+
if strict
|
|
33
|
+
missing = find_missing_variables(text, variables)
|
|
34
|
+
if missing.any?
|
|
35
|
+
raise Error, "Missing required variables: #{missing.join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Vendor::Mustache.render(text, variables)
|
|
40
|
+
else
|
|
41
|
+
raise Error, "Unknown template format: #{format.inspect}. " \
|
|
42
|
+
"Supported formats are 'mustache' and 'none'."
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Find Mustache variables in template that are not provided
|
|
47
|
+
#
|
|
48
|
+
# @param text [String] Template text
|
|
49
|
+
# @param variables [Hash] Available variables
|
|
50
|
+
# @return [Array<String>] List of missing variable names
|
|
51
|
+
def find_missing_variables(text, variables)
|
|
52
|
+
# Extract {{variable}} and {{variable.path}} patterns
|
|
53
|
+
# Mustache uses {{name}} syntax
|
|
54
|
+
text.scan(/\{\{([^}#^\/!>]+)\}\}/).flatten.map(&:strip).uniq.reject do |var|
|
|
55
|
+
resolve_variable(var, variables)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if a variable path exists in a hash
|
|
60
|
+
#
|
|
61
|
+
# @param path [String] Dot-separated variable path (e.g., "user.name")
|
|
62
|
+
# @param variables [Hash] Variables to search
|
|
63
|
+
# @return [Object, nil] The value if found, nil otherwise
|
|
64
|
+
def resolve_variable(path, variables)
|
|
65
|
+
parts = path.split(".")
|
|
66
|
+
value = variables
|
|
67
|
+
|
|
68
|
+
parts.each do |part|
|
|
69
|
+
return nil unless value.is_a?(Hash)
|
|
70
|
+
# Try both string and symbol keys
|
|
71
|
+
value = value[part] || value[part.to_sym]
|
|
72
|
+
return nil if value.nil?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Convert hash keys to strings recursively
|
|
79
|
+
#
|
|
80
|
+
# @param hash [Hash] Hash with symbol or string keys
|
|
81
|
+
# @return [Hash] Hash with all string keys
|
|
82
|
+
def stringify_keys(hash)
|
|
83
|
+
return {} unless hash.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
hash.transform_keys(&:to_s).transform_values do |v|
|
|
86
|
+
v.is_a?(Hash) ? stringify_keys(v) : v
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "internal/template"
|
|
5
|
+
|
|
6
|
+
module Braintrust
|
|
7
|
+
# Prompt class for loading and building prompts from Braintrust
|
|
8
|
+
#
|
|
9
|
+
# @example Load and use a prompt
|
|
10
|
+
# prompt = Braintrust::Prompt.load(project: "my-project", slug: "summarizer")
|
|
11
|
+
# params = prompt.build(text: "Article to summarize...")
|
|
12
|
+
# client.messages.create(**params)
|
|
13
|
+
class Prompt
|
|
14
|
+
attr_reader :id, :name, :slug, :project_id
|
|
15
|
+
|
|
16
|
+
# Load a prompt from Braintrust
|
|
17
|
+
#
|
|
18
|
+
# @param project [String] Project name
|
|
19
|
+
# @param slug [String] Prompt slug
|
|
20
|
+
# @param version [String, nil] Specific version (default: latest)
|
|
21
|
+
# @param defaults [Hash] Default variable values for build()
|
|
22
|
+
# @param api [API, nil] Braintrust API client (default: creates one using global state)
|
|
23
|
+
# @return [Prompt]
|
|
24
|
+
def self.load(project:, slug:, version: nil, defaults: {}, api: nil)
|
|
25
|
+
api ||= API.new
|
|
26
|
+
|
|
27
|
+
# Find the function by project + slug
|
|
28
|
+
result = api.functions.list(project_name: project, slug: slug)
|
|
29
|
+
function = result.dig("objects")&.first
|
|
30
|
+
raise Error, "Prompt '#{slug}' not found in project '#{project}'" unless function
|
|
31
|
+
|
|
32
|
+
# Fetch full function data including prompt_data
|
|
33
|
+
full_data = api.functions.get(id: function["id"], version: version)
|
|
34
|
+
|
|
35
|
+
new(full_data, defaults: defaults)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Initialize a Prompt from function data
|
|
39
|
+
#
|
|
40
|
+
# @param data [Hash] Function data from API
|
|
41
|
+
# @param defaults [Hash] Default variable values for build()
|
|
42
|
+
def initialize(data, defaults: {})
|
|
43
|
+
@data = data
|
|
44
|
+
@defaults = Internal::Template.stringify_keys(defaults)
|
|
45
|
+
|
|
46
|
+
@id = data["id"]
|
|
47
|
+
@name = data["name"]
|
|
48
|
+
@slug = data["slug"]
|
|
49
|
+
@project_id = data["project_id"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the raw prompt definition
|
|
53
|
+
# @return [Hash, nil]
|
|
54
|
+
def prompt
|
|
55
|
+
@data.dig("prompt_data", "prompt")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get the prompt messages
|
|
59
|
+
# @return [Array<Hash>]
|
|
60
|
+
def messages
|
|
61
|
+
prompt&.dig("messages") || []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get the tools definition (parsed from JSON string)
|
|
65
|
+
# @return [Array<Hash>, nil]
|
|
66
|
+
def tools
|
|
67
|
+
tools_json = prompt&.dig("tools")
|
|
68
|
+
return nil unless tools_json.is_a?(String) && !tools_json.empty?
|
|
69
|
+
|
|
70
|
+
JSON.parse(tools_json)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get the model name
|
|
76
|
+
# @return [String, nil]
|
|
77
|
+
def model
|
|
78
|
+
@data.dig("prompt_data", "options", "model")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get model options
|
|
82
|
+
# @return [Hash]
|
|
83
|
+
def options
|
|
84
|
+
@data.dig("prompt_data", "options") || {}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get the template format
|
|
88
|
+
# @return [String] "mustache" (default), "nunjucks", or "none"
|
|
89
|
+
def template_format
|
|
90
|
+
@data.dig("prompt_data", "template_format") || "mustache"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build the prompt with variable substitution
|
|
94
|
+
#
|
|
95
|
+
# Returns a hash ready to pass to an LLM client:
|
|
96
|
+
# {model: "...", messages: [...], temperature: ..., ...}
|
|
97
|
+
#
|
|
98
|
+
# @param variables [Hash] Variables to substitute (e.g., {name: "Alice"})
|
|
99
|
+
# @param strict [Boolean] Raise error on missing variables (default: false)
|
|
100
|
+
# @return [Hash] Built prompt ready for LLM client
|
|
101
|
+
#
|
|
102
|
+
# @example With keyword arguments
|
|
103
|
+
# prompt.build(name: "Alice", task: "coding")
|
|
104
|
+
#
|
|
105
|
+
# @example With explicit hash
|
|
106
|
+
# prompt.build({name: "Alice"}, strict: true)
|
|
107
|
+
def build(variables = nil, strict: false, **kwargs)
|
|
108
|
+
# Support both explicit hash and keyword arguments
|
|
109
|
+
variables_hash = variables.is_a?(Hash) ? variables : {}
|
|
110
|
+
vars = @defaults
|
|
111
|
+
.merge(Internal::Template.stringify_keys(variables_hash))
|
|
112
|
+
.merge(Internal::Template.stringify_keys(kwargs))
|
|
113
|
+
|
|
114
|
+
# Render Mustache templates in messages
|
|
115
|
+
built_messages = messages.map do |msg|
|
|
116
|
+
{
|
|
117
|
+
role: msg["role"].to_sym,
|
|
118
|
+
content: Internal::Template.render(msg["content"], vars, format: template_format, strict: strict)
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Build result with model and messages
|
|
123
|
+
result = {
|
|
124
|
+
model: model,
|
|
125
|
+
messages: built_messages
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Add tools if defined
|
|
129
|
+
parsed_tools = tools
|
|
130
|
+
result[:tools] = parsed_tools if parsed_tools
|
|
131
|
+
|
|
132
|
+
# Add params (temperature, max_tokens, etc.) to top level
|
|
133
|
+
params = options.dig("params")
|
|
134
|
+
if params.is_a?(Hash)
|
|
135
|
+
params.each do |key, value|
|
|
136
|
+
result[key.to_sym] = value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
result
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/braintrust/state.rb
CHANGED
data/lib/braintrust/trace.rb
CHANGED
|
@@ -4,10 +4,18 @@ require "opentelemetry/sdk"
|
|
|
4
4
|
require "opentelemetry/exporter/otlp"
|
|
5
5
|
require_relative "trace/span_processor"
|
|
6
6
|
require_relative "trace/span_filter"
|
|
7
|
+
require_relative "internal/env"
|
|
7
8
|
require_relative "logger"
|
|
8
9
|
|
|
9
10
|
module Braintrust
|
|
10
11
|
module Trace
|
|
12
|
+
# Track whether the at_exit hook has been registered
|
|
13
|
+
@exit_hook_registered = false
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_accessor :exit_hook_registered
|
|
17
|
+
end
|
|
18
|
+
|
|
11
19
|
# Set up OpenTelemetry tracing with Braintrust
|
|
12
20
|
# @param state [State] Braintrust state
|
|
13
21
|
# @param tracer_provider [TracerProvider, nil] Optional tracer provider
|
|
@@ -32,6 +40,9 @@ module Braintrust
|
|
|
32
40
|
OpenTelemetry.tracer_provider = tracer_provider
|
|
33
41
|
Log.debug("Created OpenTelemetry tracer provider")
|
|
34
42
|
end
|
|
43
|
+
|
|
44
|
+
# Register at_exit hook for global provider (only once)
|
|
45
|
+
register_exit_hook
|
|
35
46
|
end
|
|
36
47
|
|
|
37
48
|
# Enable Braintrust tracing (adds span processor)
|
|
@@ -39,6 +50,36 @@ module Braintrust
|
|
|
39
50
|
enable(tracer_provider, state: state, config: config, exporter: exporter)
|
|
40
51
|
end
|
|
41
52
|
|
|
53
|
+
# Register an at_exit hook to flush spans before program exit.
|
|
54
|
+
# This ensures buffered spans in BatchSpanProcessor are exported.
|
|
55
|
+
# Only registers once, and only for the global tracer provider.
|
|
56
|
+
# Controlled by BRAINTRUST_FLUSH_ON_EXIT env var (default: true).
|
|
57
|
+
def self.register_exit_hook
|
|
58
|
+
return if @exit_hook_registered
|
|
59
|
+
return unless Internal::Env.flush_on_exit
|
|
60
|
+
|
|
61
|
+
@exit_hook_registered = true
|
|
62
|
+
at_exit { flush_spans }
|
|
63
|
+
Log.debug("Registered at_exit hook for span flushing")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Flush buffered spans from the global tracer provider.
|
|
67
|
+
# Forces immediate export of any spans buffered by BatchSpanProcessor.
|
|
68
|
+
# @return [Boolean] true if flush succeeded, false otherwise
|
|
69
|
+
def self.flush_spans
|
|
70
|
+
provider = OpenTelemetry.tracer_provider
|
|
71
|
+
return false unless provider.respond_to?(:force_flush)
|
|
72
|
+
|
|
73
|
+
Log.debug("Flushing spans")
|
|
74
|
+
begin
|
|
75
|
+
provider.force_flush
|
|
76
|
+
true
|
|
77
|
+
rescue => e
|
|
78
|
+
Log.debug("Failed to flush spans: #{e.message}")
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
42
83
|
def self.enable(tracer_provider, state: nil, exporter: nil, config: nil)
|
|
43
84
|
state ||= Braintrust.current_state
|
|
44
85
|
raise Error, "No state available" unless state
|
|
@@ -0,0 +1,180 @@
|
|
|
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_relative "context_miss"
|
|
9
|
+
|
|
10
|
+
module Braintrust
|
|
11
|
+
module Vendor
|
|
12
|
+
class Mustache
|
|
13
|
+
# A Context represents the context which a Mustache template is
|
|
14
|
+
# executed within. All Mustache tags reference keys in the Context.
|
|
15
|
+
class Context
|
|
16
|
+
# Initializes a Mustache::Context.
|
|
17
|
+
#
|
|
18
|
+
# @param [Mustache] mustache A Mustache instance.
|
|
19
|
+
#
|
|
20
|
+
def initialize(mustache)
|
|
21
|
+
@stack = [mustache]
|
|
22
|
+
@partial_template_cache = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A {{>partial}} tag translates into a call to the context's
|
|
26
|
+
# `partial` method, which would be this sucker right here.
|
|
27
|
+
#
|
|
28
|
+
# If the Mustache view handling the rendering (e.g. the view
|
|
29
|
+
# representing your profile page or some other template) responds
|
|
30
|
+
# to `partial`, we call it and render the result.
|
|
31
|
+
#
|
|
32
|
+
def partial(name, indentation = "")
|
|
33
|
+
# Look for the first Mustache in the stack.
|
|
34
|
+
mustache = mustache_in_stack
|
|
35
|
+
|
|
36
|
+
# Indent the partial template by the given indentation.
|
|
37
|
+
part = mustache.partial(name).to_s.gsub(/^/, indentation)
|
|
38
|
+
|
|
39
|
+
# Get a template object for the partial and render the result.
|
|
40
|
+
template_for_partial(part).render(self)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def template_for_partial(partial)
|
|
44
|
+
@partial_template_cache[partial] ||= Template.new(partial)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Find the first Mustache in the stack.
|
|
48
|
+
#
|
|
49
|
+
# If we're being rendered inside a Mustache object as a context,
|
|
50
|
+
# we'll use that one.
|
|
51
|
+
#
|
|
52
|
+
# @return [Mustache] First Mustache in the stack.
|
|
53
|
+
#
|
|
54
|
+
def mustache_in_stack
|
|
55
|
+
@mustache_in_stack ||= @stack.find { |frame| frame.is_a?(Mustache) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Allows customization of how Mustache escapes things.
|
|
59
|
+
#
|
|
60
|
+
# @param [Object] value Value to escape.
|
|
61
|
+
#
|
|
62
|
+
# @return [String] Escaped string.
|
|
63
|
+
#
|
|
64
|
+
def escape(value)
|
|
65
|
+
mustache_in_stack.escape(value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Adds a new object to the context's internal stack.
|
|
69
|
+
#
|
|
70
|
+
# @param [Object] new_obj Object to be added to the internal stack.
|
|
71
|
+
#
|
|
72
|
+
# @return [Context] Returns the Context.
|
|
73
|
+
#
|
|
74
|
+
def push(new_obj)
|
|
75
|
+
@stack.unshift(new_obj)
|
|
76
|
+
@mustache_in_stack = nil
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Removes the most recently added object from the context's
|
|
81
|
+
# internal stack.
|
|
82
|
+
#
|
|
83
|
+
# @return [Context] Returns the Context.
|
|
84
|
+
#
|
|
85
|
+
def pop
|
|
86
|
+
@stack.shift
|
|
87
|
+
@mustache_in_stack = nil
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Can be used to add a value to the context in a hash-like way.
|
|
92
|
+
#
|
|
93
|
+
# context[:name] = "Chris"
|
|
94
|
+
def []=(name, value)
|
|
95
|
+
push(name => value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Alias for `fetch`.
|
|
99
|
+
def [](name)
|
|
100
|
+
fetch(name, nil)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Do we know about a particular key? In other words, will calling
|
|
104
|
+
# `context[key]` give us a result that was set. Basically.
|
|
105
|
+
def has_key?(key)
|
|
106
|
+
fetch(key, false)
|
|
107
|
+
rescue ContextMiss
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Similar to Hash#fetch, finds a value by `name` in the context's
|
|
112
|
+
# stack. You may specify the default return value by passing a
|
|
113
|
+
# second parameter.
|
|
114
|
+
#
|
|
115
|
+
# If no second parameter is passed (or raise_on_context_miss is
|
|
116
|
+
# set to true), will raise a ContextMiss exception on miss.
|
|
117
|
+
def fetch(name, default = :__raise)
|
|
118
|
+
@stack.each do |frame|
|
|
119
|
+
# Prevent infinite recursion.
|
|
120
|
+
next if frame == self
|
|
121
|
+
|
|
122
|
+
value = find(frame, name, :__missing)
|
|
123
|
+
return value if :__missing != value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if default == :__raise || mustache_in_stack.raise_on_context_miss?
|
|
127
|
+
raise ContextMiss.new("Can't find #{name} in #{@stack.inspect}")
|
|
128
|
+
else
|
|
129
|
+
default
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Finds a key in an object, using whatever method is most
|
|
134
|
+
# appropriate. If the object is a hash, does a simple hash lookup.
|
|
135
|
+
# If it's an object that responds to the key as a method call,
|
|
136
|
+
# invokes that method. You get the idea.
|
|
137
|
+
#
|
|
138
|
+
# @param [Object] obj The object to perform the lookup on.
|
|
139
|
+
# @param [String,Symbol] key The key whose value you want
|
|
140
|
+
# @param [Object] default An optional default value, to return if the key is not found.
|
|
141
|
+
#
|
|
142
|
+
# @return [Object] The value of key in object if it is found, and default otherwise.
|
|
143
|
+
#
|
|
144
|
+
def find(obj, key, default = nil)
|
|
145
|
+
return find_in_hash(obj.to_hash, key, default) if obj.respond_to?(:to_hash)
|
|
146
|
+
|
|
147
|
+
unless obj.respond_to?(key)
|
|
148
|
+
# no match for the key, but it may include a hyphen, so try again replacing hyphens with underscores.
|
|
149
|
+
key = key.to_s.tr("-", "_")
|
|
150
|
+
return default unless obj.respond_to?(key)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
meth = obj.method(key) rescue proc { obj.send(key) }
|
|
154
|
+
meth.arity == 1 ? meth.to_proc : meth.call
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def current
|
|
158
|
+
@stack.first
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# Fetches a hash key if it exists, or returns the given default.
|
|
164
|
+
def find_in_hash(obj, key, default)
|
|
165
|
+
return obj[key] if obj.has_key?(key)
|
|
166
|
+
return obj[key.to_s] if obj.has_key?(key.to_s)
|
|
167
|
+
return obj[key] if obj.respond_to?(:default_proc) && obj.default_proc && obj[key]
|
|
168
|
+
|
|
169
|
+
# If default is :__missing then we are from #fetch which is hunting through the stack
|
|
170
|
+
# If default is nil then we are reducing dot notation
|
|
171
|
+
if :__missing != default && mustache_in_stack.raise_on_context_miss?
|
|
172
|
+
raise ContextMiss.new("Can't find #{key} in #{obj}")
|
|
173
|
+
else
|
|
174
|
+
obj.fetch(key, default)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
module Braintrust
|
|
9
|
+
module Vendor
|
|
10
|
+
class Mustache
|
|
11
|
+
# A ContextMiss is raised whenever a tag's target can not be found
|
|
12
|
+
# in the current context if `Mustache#raise_on_context_miss?` is
|
|
13
|
+
# set to true.
|
|
14
|
+
#
|
|
15
|
+
# For example, if your View class does not respond to `music` but
|
|
16
|
+
# your template contains a `{{music}}` tag this exception will be raised.
|
|
17
|
+
#
|
|
18
|
+
# By default it is not raised. See Mustache.raise_on_context_miss.
|
|
19
|
+
class ContextMiss < RuntimeError; end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
module Braintrust
|
|
9
|
+
module Vendor
|
|
10
|
+
class Mustache
|
|
11
|
+
Enumerable = Module.new
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|