squishling 0.1.0

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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Ruby source for append_instructions items: a class or module body, or a single method, rendered as a
5
+ # fenced code block. Parsed with Prism (a default gem since Ruby 3.3), loaded the first time it's needed.
6
+ module Source
7
+ # owner => { method name, or nil for the module itself => rendered source }. Weak keys, so classes replaced
8
+ # by code reloading (e.g. Rails in development) can be collected.
9
+ CACHE = ObjectSpace::WeakKeyMap.new
10
+ CACHE_LOCK = Mutex.new
11
+ CLASS_NODES = %i[class_node module_node].freeze
12
+
13
+ class << self
14
+ def render(item)
15
+ # Methods are keyed by owner and name: `method(:call)` builds a new object on every call.
16
+ owner, name = item.is_a?(Module) ? [item, nil] : [item.owner, item.name]
17
+ CACHE_LOCK.synchronize do
18
+ rendered = (CACHE[owner] ||= {})
19
+ rendered.fetch(name) { rendered[name] = fence(*extract(item)) }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def extract(item)
26
+ require "prism"
27
+ item.is_a?(Module) ? module_source(item) : method_source(unwrap(item))
28
+ end
29
+
30
+ def module_source(mod)
31
+ constant = constant_location(mod)
32
+ sources = constant ? class_bodies(mod, constant) : method_defs(mod)
33
+ raise ConfigurationError, "append_instructions: source for #{mod.inspect} isn't available" if sources.empty?
34
+
35
+ [display_name(mod), sources.join("\n\n")]
36
+ end
37
+
38
+ # Every `class X`/`module X` body for the constant that holds one of its methods, so a class reopened across
39
+ # files is covered, or the Class.new/Module.new block the constant is assigned from (`X = Class.new do`).
40
+ def class_bodies(mod, constant)
41
+ anchors = [constant, *own_methods(mod).map { |method| location(method) }].compact
42
+ anchors.group_by(&:first).flat_map do |file, pairs|
43
+ tree, text = parse(file)
44
+ nodes = pairs.map(&:last).uniq.filter_map do |line|
45
+ innermost(tree, line) { |node, enclosing| defines?(node, enclosing, mod, [file, line] == constant) }
46
+ end
47
+ nodes.uniq.sort_by { |node| node.location.start_line }.map { |node| slice(node, text) }
48
+ end
49
+ end
50
+
51
+ # Without a constant there's no class body to find reliably (its methods may come from a factory or a
52
+ # class_eval inside unrelated code), so each of its own `def`s is sent instead, and nothing around them.
53
+ def method_defs(mod)
54
+ parsed = {}
55
+ own_methods(mod).sort_by { |method| location(method) || [] }.filter_map { |method| def_source(method, parsed) }
56
+ end
57
+
58
+ def method_source(method)
59
+ label = method_label(method)
60
+ source = def_source(method)
61
+ raise ConfigurationError, "append_instructions: source for #{label} isn't available" unless source
62
+
63
+ [label, source]
64
+ end
65
+
66
+ # The method's own `def`, matched by name and line, or nil (define_method, attr_*, eval'd code).
67
+ def def_source(method, parsed = {})
68
+ file, line = location(method)
69
+ return unless file
70
+
71
+ tree, text = (parsed[file] ||= parse(file))
72
+ node = innermost(tree, line) do |candidate|
73
+ candidate.type == :def_node && candidate.name == method.original_name && candidate.location.start_line == line
74
+ end
75
+ node && slice(node, text)
76
+ end
77
+
78
+ # [file, line] for code in a readable file. Some Ruby builds add columns to source_location.
79
+ def location(method)
80
+ file, line = method.source_location
81
+ [file, line] if file && File.file?(file)
82
+ end
83
+
84
+ # A squished method resolves to Squishling's wrapper; show the implementation beneath it.
85
+ def unwrap(method)
86
+ Wrapper.implementation(method) or
87
+ raise ConfigurationError, "append_instructions: #{method.name} has no implementation to show"
88
+ end
89
+
90
+ # Named after the def that's shown, so an alias is labeled by its original name.
91
+ def method_label(method)
92
+ owner = method.owner
93
+ return "#{display_name(owner.attached_object)}.#{method.original_name}" if owner.singleton_class?
94
+
95
+ "#{display_name(owner)}##{method.original_name}"
96
+ end
97
+
98
+ # The module's own instance and singleton methods, as UnboundMethods.
99
+ def own_methods(mod)
100
+ names = mod.instance_methods(false) + mod.private_instance_methods(false)
101
+ singleton = mod.singleton_class
102
+ singleton_names = mod.singleton_methods(false) + singleton.private_instance_methods(false)
103
+ names.filter_map { |name| own_method(mod, name) } +
104
+ singleton_names.filter_map { |name| own_method(singleton, name) }
105
+ end
106
+
107
+ # instance_method resolves to a prepended module (Squishling's wrapper, or any other) first. Going through
108
+ # instance_method also bypasses a class's own `self.method` (e.g. an HTTP request model).
109
+ def own_method(mod, name)
110
+ method = mod.instance_method(name)
111
+ method = method.super_method until method.nil? || method.owner == mod
112
+ method
113
+ end
114
+
115
+ # A `class X`/`module X` node whose full lexical name is the module's (so a monkeypatch from inside an
116
+ # unrelated `Vendor::Client` never sends that class for `Billing::Client`), or the Class.new/Module.new block
117
+ # at the constant's assignment, never an unrelated block that happens to define one of its methods.
118
+ def defines?(node, enclosing, mod, at_constant)
119
+ if CLASS_NODES.include?(node.type)
120
+ lexical_name([*enclosing, node]) == module_name(mod)
121
+ else
122
+ at_constant && node.type == :call_node && node.name == :new && node.block &&
123
+ %w[Class Module].include?(node.receiver&.slice)
124
+ end
125
+ end
126
+
127
+ # Where the module's constant is assigned, or nil when it has none in a readable file: anonymous, nested in
128
+ # an anonymous namespace ("#<Module:0x...>::Parser"), given a temporary name (set_temporary_name), or eval'd.
129
+ def constant_location(mod)
130
+ name = module_name(mod)
131
+ file, line = name && Object.const_source_location(name)
132
+ [file, line] if file && File.file?(file)
133
+ rescue NameError
134
+ nil
135
+ end
136
+
137
+ # The deepest node containing the line that satisfies the block, which also gets the enclosing nodes.
138
+ def innermost(node, line, enclosing = [], &)
139
+ location = node.location
140
+ return nil unless line.between?(location.start_line, location.end_line)
141
+
142
+ node.compact_child_nodes.each do |child|
143
+ found = innermost(child, line, [*enclosing, node], &)
144
+ return found if found
145
+ end
146
+ yield(node, enclosing) ? node : nil
147
+ end
148
+
149
+ # The constant a class/module node defines, from it and the class/module nodes around it
150
+ # (`module Billing; class Client` and `class Billing::Client` are both "Billing::Client").
151
+ def lexical_name(nodes)
152
+ nodes.select { |node| CLASS_NODES.include?(node.type) }.reduce("") do |name, node|
153
+ path = node.constant_path.slice
154
+ next path.delete_prefix("::") if path.start_with?("::") || name.empty?
155
+
156
+ "#{name}::#{path}"
157
+ end
158
+ end
159
+
160
+ # Ruby reads source as UTF-8 whatever the locale (e.g. LANG=C in a container).
161
+ def parse(file)
162
+ text = File.read(file, encoding: Encoding::UTF_8).scrub
163
+ [Prism.parse(text).value, text.lines]
164
+ rescue SystemCallError => e
165
+ raise ConfigurationError, "append_instructions: can't read #{file} (#{e.class})"
166
+ end
167
+
168
+ # Give the node's first line its source line's indentation (it may start mid-line, as in
169
+ # `parser = Class.new do`), then strip the indentation shared by every line.
170
+ def slice(node, text)
171
+ lines = "#{text[node.location.start_line - 1][/\A */]}#{node.slice}".lines
172
+ indent = lines.reject { |line| line.strip.empty? }.map { |line| line[/\A */].size }.min || 0
173
+ lines.map { |line| line.strip.empty? ? "\n" : line[indent..] }.join.chomp
174
+ end
175
+
176
+ def display_name(mod)
177
+ return "(#{mod.class} instance)" unless mod.is_a?(Module)
178
+
179
+ module_name(mod) || "(anonymous #{mod.is_a?(Class) ? 'class' : 'module'})"
180
+ end
181
+
182
+ # The constant name Ruby assigned, ignoring any `def self.name` override on the class.
183
+ def module_name(mod)
184
+ Module.instance_method(:name).bind_call(mod)
185
+ end
186
+
187
+ # A fence longer than any backtick run in the source, so the code can't close it early.
188
+ def fence(label, code)
189
+ ticks = "`" * [3, (code.scan(/`+/).map(&:size).max || 0) + 1].max
190
+ "#{ticks}ruby\n# Source: #{label}\n#{code}\n#{ticks}"
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Module prepended to a squishling class. It intercepts squished methods regardless of
5
+ # whether they're defined before or after the `squish` declaration (or at all).
6
+ class Wrapper < Module
7
+ # The method beneath any Squishling wrappers (instance_method resolves to the prepended wrapper first),
8
+ # or nil when the method has no implementation.
9
+ def self.implementation(method)
10
+ method = method.super_method while method&.owner.is_a?(Wrapper)
11
+ method
12
+ end
13
+
14
+ def wrap(name)
15
+ return if method_defined?(name, false)
16
+
17
+ wrapper = self
18
+ define_method(name) do |*args, **kwargs, &block|
19
+ impl =
20
+ if defined?(super)
21
+ -> { super(*args, **kwargs, &block) }
22
+ else
23
+ -> { raise NotImplementedError, "#{self.class}##{name} has no implementation" }
24
+ end
25
+
26
+ Router.dispatch(self, name, args, kwargs, impl, wrapper:)
27
+ end
28
+ end
29
+
30
+ def inspect
31
+ "#<Squishling::Wrapper>"
32
+ end
33
+ end
34
+ end
data/lib/squishling.rb ADDED
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+ require "ruby_llm"
6
+ require "schematist"
7
+
8
+ require_relative "squishling/version"
9
+ require_relative "squishling/errors"
10
+ require_relative "squishling/configuration"
11
+ require_relative "squishling/params"
12
+ require_relative "squishling/result"
13
+ require_relative "squishling/schema"
14
+ require_relative "squishling/source"
15
+ require_relative "squishling/appendices"
16
+ require_relative "squishling/definition"
17
+ require_relative "squishling/invoker"
18
+ require_relative "squishling/router"
19
+ require_relative "squishling/wrapper"
20
+ require_relative "squishling/class_methods"
21
+
22
+ # Include Squishling in a class to make it elastic: its squished methods either run their
23
+ # Ruby implementation or send their inputs through an LLM and return schema-validated results.
24
+ module Squishling
25
+ class << self
26
+ def config
27
+ @config ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield config
32
+ end
33
+
34
+ def reset_config!
35
+ @config = Configuration.new
36
+ end
37
+
38
+ def included(base)
39
+ raise ConfigurationError, "Squishling can only be included in a class" unless base.is_a?(Class)
40
+
41
+ base.extend(ClassMethods)
42
+ base.send(:squishling_install_wrapper)
43
+ end
44
+ end
45
+
46
+ # Build the typed result for the squished method currently executing, validating it against
47
+ # the output schema. Use this from the deterministic path so both paths return the same type.
48
+ def squishling_result(attrs = nil, **kwargs)
49
+ frame = Router.current_frame(self)
50
+ raise Error, "#{self.class}#squishling_result called outside a squished method" unless frame
51
+
52
+ frame.definition.build_result(attrs || kwargs, squished: false)
53
+ end
54
+
55
+ alias_method :result, :squishling_result
56
+
57
+ # Hand the squished method currently executing to the LLM, e.g. from a `rescue` when the Ruby path can't
58
+ # handle this input. Returns the typed result (squished? true, or false when the declared fallback supplied
59
+ # it); return it from the method. The overrides apply to this call only: append_instructions adds to (or,
60
+ # with false, replaces) the declared sections, context is sent alongside the declared squish_context,
61
+ # model/provider/instructions replace the declared ones, and params merge key by key over them.
62
+ def squish!(append_instructions: nil, context: nil, instructions: nil, model: nil, provider: nil, params: nil)
63
+ Router.escalate(self, { append_instructions:, context:, instructions:, model:, provider:, params: })
64
+ end
65
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: squishling
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michael Carroll
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json_schemer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ruby_llm
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: schematist
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.1'
54
+ description: 'Squishling makes Ruby classes elastic. Add a squishling to a class and
55
+ it can send inputs straight to an LLM (OpenAI, Anthropic Claude, Google Gemini,
56
+ or any provider RubyLLM supports) and return strict-schema-validated, typed results:
57
+ the same type your Ruby code returns. Use it to gracefully maintain fickle integrations,
58
+ build interfaces for unknown data formats, and rescue production errors by handing
59
+ the failing call to the LLM with the source class as context. Write Ruby only for
60
+ the paths where token costs justify maintaining it. Per-input routing, validated
61
+ retries, and fallbacks for your LLM calls included.'
62
+ email:
63
+ - mc@coolhandlabs.com
64
+ executables: []
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - CHANGELOG.md
69
+ - LICENSE
70
+ - README.md
71
+ - Rakefile
72
+ - SECURITY.md
73
+ - docs/configuration.md
74
+ - docs/failures.md
75
+ - docs/routing.md
76
+ - docs/schemas.md
77
+ - lib/squishling.rb
78
+ - lib/squishling/appendices.rb
79
+ - lib/squishling/class_methods.rb
80
+ - lib/squishling/configuration.rb
81
+ - lib/squishling/definition.rb
82
+ - lib/squishling/errors.rb
83
+ - lib/squishling/invoker.rb
84
+ - lib/squishling/params.rb
85
+ - lib/squishling/result.rb
86
+ - lib/squishling/router.rb
87
+ - lib/squishling/schema.rb
88
+ - lib/squishling/source.rb
89
+ - lib/squishling/version.rb
90
+ - lib/squishling/wrapper.rb
91
+ homepage: https://github.com/Coolhand-Labs/squishling
92
+ licenses:
93
+ - Apache-2.0
94
+ metadata:
95
+ allowed_push_host: https://rubygems.org
96
+ homepage_uri: https://github.com/Coolhand-Labs/squishling
97
+ source_code_uri: https://github.com/Coolhand-Labs/squishling
98
+ changelog_uri: https://github.com/Coolhand-Labs/squishling/blob/main/CHANGELOG.md
99
+ documentation_uri: https://github.com/Coolhand-Labs/squishling/tree/main/docs
100
+ rubygems_mfa_required: 'true'
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '3.3'
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.6.9
116
+ specification_version: 4
117
+ summary: 'Elastic Ruby classes with LLM batteries included: zero-code integrations,
118
+ graceful error rescue, and code only where it''s worth maintaining.'
119
+ test_files: []