riffer 0.43.0 → 0.44.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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +8 -0
- data/Steepfile +1 -0
- data/docs/AGENTS.md +23 -2
- data/docs/CONFIGURATION.md +32 -0
- data/docs/MESSAGES.md +10 -0
- data/docs/OVERVIEW.md +1 -0
- data/docs/TESTING.md +129 -0
- data/docs/TOOLS.md +23 -2
- data/docs/providers/AMAZON_BEDROCK.md +17 -0
- data/docs/providers/GEMINI.md +2 -3
- data/docs-site/manifest.yml +3 -0
- data/lib/riffer/config.rb +94 -0
- data/lib/riffer/files/downloader.rb +78 -0
- data/lib/riffer/files/resolver.rb +97 -0
- data/lib/riffer/files.rb +5 -0
- data/lib/riffer/helpers/identifier.rb +11 -3
- data/lib/riffer/messages/file_part.rb +61 -9
- data/lib/riffer/providers/amazon_bedrock.rb +7 -7
- data/lib/riffer/providers/base.rb +8 -0
- data/lib/riffer/providers/gemini.rb +6 -5
- data/lib/riffer/providers/open_ai.rb +6 -0
- data/lib/riffer/providers/open_router.rb +6 -0
- data/lib/riffer/registrable.rb +105 -22
- data/lib/riffer/testing/minitest.rb +21 -0
- data/lib/riffer/testing/rspec.rb +11 -0
- data/lib/riffer/testing.rb +130 -0
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +31 -0
- data/sig/_private/minitest.rbs +9 -0
- data/sig/_private/riffer/testing/minitest.rbs +6 -0
- data/sig/_private/rspec.rbs +7 -0
- data/sig/_private/zeitwerk.rbs +2 -0
- data/sig/generated/riffer/config.rbs +64 -0
- data/sig/generated/riffer/files/downloader.rbs +16 -0
- data/sig/generated/riffer/files/resolver.rbs +36 -0
- data/sig/generated/riffer/files.rbs +4 -0
- data/sig/generated/riffer/helpers/identifier.rbs +8 -0
- data/sig/generated/riffer/messages/file_part.rbs +38 -6
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +4 -0
- data/sig/generated/riffer/providers/base.rbs +4 -0
- data/sig/generated/riffer/providers/gemini.rbs +4 -0
- data/sig/generated/riffer/providers/open_ai.rbs +4 -0
- data/sig/generated/riffer/providers/open_router.rbs +4 -0
- data/sig/generated/riffer/registrable.rbs +57 -9
- data/sig/generated/riffer/testing/rspec.rbs +2 -0
- data/sig/generated/riffer/testing.rbs +75 -0
- data/sig/generated/riffer.rbs +32 -0
- data/sig/manifest.yaml +1 -0
- data/sig/manual/riffer/testing.rbs +5 -0
- metadata +31 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "base64"
|
|
6
|
+
|
|
7
|
+
class Riffer::Files::Resolver
|
|
8
|
+
# @rbs @provider: Riffer::Providers::Base
|
|
9
|
+
# @rbs @config: Riffer::Config::Files
|
|
10
|
+
|
|
11
|
+
#: (provider: Riffer::Providers::Base) -> void
|
|
12
|
+
def initialize(provider:)
|
|
13
|
+
@provider = provider
|
|
14
|
+
@config = Riffer.config.files
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolves every file in every User message in place - downloading,
|
|
18
|
+
# verifying, and caching as the provider's capability and each file's
|
|
19
|
+
# sha256 require. Raises Riffer::FileError on any file that can't be
|
|
20
|
+
# resolved
|
|
21
|
+
#: (Array[Riffer::Messages::Base]) -> void
|
|
22
|
+
def resolve!(messages)
|
|
23
|
+
files = messages.flat_map do |message|
|
|
24
|
+
next [] unless message.is_a?(Riffer::Messages::User)
|
|
25
|
+
|
|
26
|
+
check_file_count!(message)
|
|
27
|
+
message.files
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@config.runner.map(files, context: nil) { |file| resolve_file!(file) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
#: (Riffer::Messages::User) -> void
|
|
36
|
+
def check_file_count!(message)
|
|
37
|
+
max = @config.max_per_message
|
|
38
|
+
return if max.nil? || message.files.size <= max
|
|
39
|
+
|
|
40
|
+
raise Riffer::TooManyFilesError, "Too many files specified in user message"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (Riffer::Messages::FilePart) -> void
|
|
44
|
+
def resolve_file!(file)
|
|
45
|
+
delivery = @provider.file_delivery(file)
|
|
46
|
+
if delivery == :unsupported
|
|
47
|
+
raise Riffer::FileUnsupportedError,
|
|
48
|
+
"Provider does not support user message file attachments"
|
|
49
|
+
end
|
|
50
|
+
return verify_inline!(file) if file.inline_data?
|
|
51
|
+
|
|
52
|
+
case delivery
|
|
53
|
+
when :url
|
|
54
|
+
download!(file, cache: false) if file.sha256
|
|
55
|
+
when :base64
|
|
56
|
+
file.data ? verify_inline!(file) : download!(file, cache: :base64)
|
|
57
|
+
when :bytes
|
|
58
|
+
file.data_bytes ? verify_inline!(file) : download!(file, cache: :bytes)
|
|
59
|
+
else
|
|
60
|
+
raise Riffer::ArgumentError,
|
|
61
|
+
"Unknown file_delivery result #{delivery.inspect} from #{@provider.class}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: (Riffer::Messages::FilePart) -> void
|
|
66
|
+
def verify_inline!(file)
|
|
67
|
+
return unless file.sha256
|
|
68
|
+
|
|
69
|
+
verify_bytes!(file.data_bytes, file.sha256)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# +cache:+ is false for a :url-delivery provider verifying a sha256 — the
|
|
73
|
+
# request still sends the URL, never the downloaded bytes, so caching them
|
|
74
|
+
# would hold memory nothing reads and let later turns skip re-verifying.
|
|
75
|
+
#: (Riffer::Messages::FilePart, cache: (false | Symbol)) -> void
|
|
76
|
+
def download!(file, cache:)
|
|
77
|
+
raise Riffer::FileDownloadsDisabledError, "File attachments are disabled" unless @config.allow_downloads
|
|
78
|
+
|
|
79
|
+
raw = @config.downloader.call(file.url, max_bytes: @config.max_bytes, timeout: @config.timeout)
|
|
80
|
+
verify_bytes!(raw, file.sha256) if file.sha256
|
|
81
|
+
|
|
82
|
+
case cache
|
|
83
|
+
when :base64
|
|
84
|
+
file.cache_downloaded_data(Base64.strict_encode64(raw))
|
|
85
|
+
when :bytes
|
|
86
|
+
file.cache_data_bytes(raw)
|
|
87
|
+
when false
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def verify_bytes!(bytes, sha256)
|
|
93
|
+
return if Digest::SHA256.hexdigest(bytes) == sha256
|
|
94
|
+
|
|
95
|
+
raise Riffer::FileChecksumMismatchError, "File checksum mismatch"
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/riffer/files.rb
ADDED
|
@@ -29,13 +29,21 @@ module Riffer::Helpers::Identifier
|
|
|
29
29
|
cached = klass.instance_variable_get(:@derived_identifier) #: String?
|
|
30
30
|
return cached if cached
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
# class-path name must come from Module's own implementation.
|
|
34
|
-
real_name = Module.instance_method(:name).bind_call(klass) #: String?
|
|
32
|
+
real_name = real_name(klass)
|
|
35
33
|
return "" if real_name.nil?
|
|
36
34
|
|
|
37
35
|
derived = derive(real_name)
|
|
38
36
|
klass.instance_variable_set(:@derived_identifier, derived)
|
|
39
37
|
derived
|
|
40
38
|
end
|
|
39
|
+
|
|
40
|
+
# Returns the class-path name of a class or module, or +nil+ when anonymous.
|
|
41
|
+
# Tool classes shadow Module#name with the identifier DSL, so the real name
|
|
42
|
+
# must come from Module's own implementation.
|
|
43
|
+
#
|
|
44
|
+
#--
|
|
45
|
+
#: (Module) -> String?
|
|
46
|
+
def real_name(klass)
|
|
47
|
+
Module.instance_method(:name).bind_call(klass) #: String?
|
|
48
|
+
end
|
|
41
49
|
end
|
|
@@ -8,6 +8,9 @@ require "uri"
|
|
|
8
8
|
# raw base64 data (+new+).
|
|
9
9
|
class Riffer::Messages::FilePart
|
|
10
10
|
# @rbs @url_string: String?
|
|
11
|
+
# @rbs @data: String?
|
|
12
|
+
# @rbs @downloaded_data: String?
|
|
13
|
+
# @rbs @data_bytes: String?
|
|
11
14
|
|
|
12
15
|
MEDIA_TYPES = {
|
|
13
16
|
".jpg" => "image/jpeg",
|
|
@@ -23,6 +26,7 @@ class Riffer::Messages::FilePart
|
|
|
23
26
|
}.freeze #: Hash[String, String]
|
|
24
27
|
|
|
25
28
|
SUPPORTED_MEDIA_TYPES = MEDIA_TYPES.values.uniq.freeze #: Array[String]
|
|
29
|
+
SHA256_PATTERN = /\A[0-9a-f]{64}\z/i #: Regexp
|
|
26
30
|
|
|
27
31
|
# The MIME type of the file.
|
|
28
32
|
attr_reader :media_type #: String
|
|
@@ -30,17 +34,25 @@ class Riffer::Messages::FilePart
|
|
|
30
34
|
# The filename, if available.
|
|
31
35
|
attr_reader :filename #: String?
|
|
32
36
|
|
|
37
|
+
# The expected SHA-256 of the file contents, if the caller supplied one.
|
|
38
|
+
attr_reader :sha256 #: String?
|
|
39
|
+
|
|
33
40
|
# Raises Riffer::ArgumentError unless +data+ or +url+ is given and
|
|
34
41
|
# +media_type+ is supported.
|
|
35
42
|
#--
|
|
36
|
-
#: (media_type: String, ?data: String?, ?filename: String?, ?url: String?) -> void
|
|
37
|
-
def initialize(media_type:, data: nil, filename: nil, url: nil)
|
|
43
|
+
#: (media_type: String, ?data: String?, ?filename: String?, ?url: String?, ?sha256: String?) -> void
|
|
44
|
+
def initialize(media_type:, data: nil, filename: nil, url: nil, sha256: nil)
|
|
38
45
|
raise Riffer::ArgumentError, "Either data or url must be provided" if data.nil? && url.nil?
|
|
39
46
|
unless SUPPORTED_MEDIA_TYPES.include?(media_type)
|
|
40
47
|
raise Riffer::ArgumentError,
|
|
41
48
|
"Unsupported media type: #{media_type}"
|
|
42
49
|
end
|
|
50
|
+
unless sha256.nil? || (sha256.is_a?(String) && sha256.match?(SHA256_PATTERN))
|
|
51
|
+
raise Riffer::ArgumentError,
|
|
52
|
+
"Invalid sha256: #{sha256}"
|
|
53
|
+
end
|
|
43
54
|
|
|
55
|
+
@sha256 = sha256&.downcase
|
|
44
56
|
@data = data
|
|
45
57
|
@media_type = media_type
|
|
46
58
|
@filename = filename
|
|
@@ -50,15 +62,15 @@ class Riffer::Messages::FilePart
|
|
|
50
62
|
# Creates a FilePart from a URL, detecting +media_type+ from the path
|
|
51
63
|
# extension when omitted. Raises Riffer::ArgumentError if it can't be detected.
|
|
52
64
|
#--
|
|
53
|
-
#: (String, ?media_type: String?) -> Riffer::Messages::FilePart
|
|
54
|
-
def self.from_url(url, media_type: nil)
|
|
65
|
+
#: (String, ?media_type: String?, ?filename: String?, ?sha256: String?) -> Riffer::Messages::FilePart
|
|
66
|
+
def self.from_url(url, media_type: nil, filename: nil, sha256: nil)
|
|
55
67
|
unless media_type
|
|
56
68
|
ext = ::File.extname(URI.parse(url).path.to_s).downcase
|
|
57
69
|
media_type = MEDIA_TYPES[ext]
|
|
58
70
|
raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" unless media_type
|
|
59
71
|
end
|
|
60
72
|
|
|
61
|
-
new(url: url, media_type: media_type)
|
|
73
|
+
new(url: url, media_type: media_type, filename: filename, sha256: sha256)
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
# Builds a FilePart from a +{url:, media_type:}+ or +{data:, media_type:}+ hash,
|
|
@@ -75,18 +87,57 @@ class Riffer::Messages::FilePart
|
|
|
75
87
|
data = file[:data]
|
|
76
88
|
media_type = file[:media_type]
|
|
77
89
|
filename = file[:filename]
|
|
90
|
+
sha256 = file[:sha256]
|
|
78
91
|
|
|
79
92
|
if url
|
|
80
|
-
from_url(url, media_type: media_type)
|
|
93
|
+
from_url(url, media_type: media_type, filename: filename, sha256: sha256)
|
|
81
94
|
elsif data && media_type
|
|
82
|
-
new(data: data, media_type: media_type, filename: filename)
|
|
95
|
+
new(data: data, media_type: media_type, filename: filename, sha256: sha256)
|
|
83
96
|
else
|
|
84
97
|
raise Riffer::ArgumentError, "File hash must include :url or :data with :media_type"
|
|
85
98
|
end
|
|
86
99
|
end
|
|
87
100
|
|
|
88
|
-
#
|
|
89
|
-
|
|
101
|
+
# The base64-encoded contents - caller-supplied, or filled in by the file
|
|
102
|
+
# resolver after a download. Nil for a URL source riffer hasn't fetched.
|
|
103
|
+
#--
|
|
104
|
+
#: () -> String?
|
|
105
|
+
def data
|
|
106
|
+
@data || @downloaded_data
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: () -> String?
|
|
110
|
+
def data_bytes
|
|
111
|
+
return @data_bytes if @data_bytes
|
|
112
|
+
|
|
113
|
+
encoded = data
|
|
114
|
+
return nil unless encoded
|
|
115
|
+
|
|
116
|
+
@data_bytes = Base64.strict_decode64(encoded.gsub(/\s/, ""))
|
|
117
|
+
rescue ArgumentError
|
|
118
|
+
raise Riffer::FileEncodingError, "Invalid base64 data"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (String) -> void
|
|
122
|
+
def cache_data_bytes(bytes)
|
|
123
|
+
@data_bytes = bytes
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Whether data was supplied directly, as opposed to filled in later by the
|
|
127
|
+
# file resolver after a download
|
|
128
|
+
#: () -> bool
|
|
129
|
+
def inline_data?
|
|
130
|
+
!@data.nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Caches bytes fetched for a URL source. Deliberately absent from +to_h+:
|
|
134
|
+
# the agent loop re-sends history on every turn, so the cache saves refreshing
|
|
135
|
+
# the same file, while persisted history stays free of megabytes of base64
|
|
136
|
+
#--
|
|
137
|
+
#: (String) -> void
|
|
138
|
+
def cache_downloaded_data(data)
|
|
139
|
+
@downloaded_data = data
|
|
140
|
+
end
|
|
90
141
|
|
|
91
142
|
# Returns the URL if the source was a URL, nil otherwise.
|
|
92
143
|
#
|
|
@@ -129,6 +180,7 @@ class Riffer::Messages::FilePart
|
|
|
129
180
|
hash[:data] = @data if @data
|
|
130
181
|
hash[:url] = @url_string if @url_string
|
|
131
182
|
hash[:filename] = filename if filename
|
|
183
|
+
hash[:sha256] = sha256 if sha256
|
|
132
184
|
hash
|
|
133
185
|
end
|
|
134
186
|
end
|
|
@@ -43,6 +43,12 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
|
|
|
43
43
|
depends_on "aws-sdk-bedrockruntime"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
#--
|
|
47
|
+
#: (Riffer::Messages::FilePart) -> Symbol
|
|
48
|
+
def file_delivery(file)
|
|
49
|
+
file.url&.start_with?("s3://") ? :url : :bytes
|
|
50
|
+
end
|
|
51
|
+
|
|
46
52
|
private
|
|
47
53
|
|
|
48
54
|
#--
|
|
@@ -413,13 +419,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
|
|
|
413
419
|
def convert_file_part_to_bedrock_format(file)
|
|
414
420
|
format = bedrock_format(file.media_type)
|
|
415
421
|
|
|
416
|
-
source =
|
|
417
|
-
{ bytes: Base64.decode64(file.data) }
|
|
418
|
-
elsif file.url&.start_with?("s3://")
|
|
419
|
-
{ s3_location: { uri: file.url } }
|
|
420
|
-
else
|
|
421
|
-
raise Riffer::ArgumentError, "Amazon Bedrock only supports S3 URI or base64 data file sources"
|
|
422
|
-
end
|
|
422
|
+
source = file.data_bytes ? { bytes: file.data_bytes } : { s3_location: { uri: file.url } }
|
|
423
423
|
|
|
424
424
|
if file.image?
|
|
425
425
|
{ image: { format: format, source: source } }
|
|
@@ -48,6 +48,7 @@ class Riffer::Providers::Base
|
|
|
48
48
|
@current_model = model
|
|
49
49
|
messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
|
|
50
50
|
validate_normalized_messages!(messages)
|
|
51
|
+
Riffer::Files::Resolver.new(provider: self).resolve!(messages)
|
|
51
52
|
messages = merge_consecutive_messages(messages)
|
|
52
53
|
params = build_request_params(messages, model, options)
|
|
53
54
|
|
|
@@ -84,6 +85,7 @@ class Riffer::Providers::Base
|
|
|
84
85
|
@current_model = model
|
|
85
86
|
messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
|
|
86
87
|
validate_normalized_messages!(messages)
|
|
88
|
+
Riffer::Files::Resolver.new(provider: self).resolve!(messages)
|
|
87
89
|
messages = merge_consecutive_messages(messages)
|
|
88
90
|
params = build_request_params(messages, model, options)
|
|
89
91
|
|
|
@@ -102,6 +104,12 @@ class Riffer::Providers::Base
|
|
|
102
104
|
end
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
#--
|
|
108
|
+
#: (Riffer::Messages::FilePart) -> Symbol
|
|
109
|
+
def file_delivery(_file)
|
|
110
|
+
:url
|
|
111
|
+
end
|
|
112
|
+
|
|
105
113
|
private
|
|
106
114
|
|
|
107
115
|
#: (String) -> true
|
|
@@ -27,6 +27,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
27
27
|
"gcp.gemini"
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
#--
|
|
31
|
+
#: (Riffer::Messages::FilePart) -> Symbol
|
|
32
|
+
def file_delivery(_file)
|
|
33
|
+
:base64
|
|
34
|
+
end
|
|
35
|
+
|
|
30
36
|
private
|
|
31
37
|
|
|
32
38
|
#--
|
|
@@ -274,11 +280,6 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
274
280
|
#--
|
|
275
281
|
#: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
|
|
276
282
|
def convert_file_part_to_gemini_format(file)
|
|
277
|
-
if file.url?
|
|
278
|
-
raise Riffer::ArgumentError,
|
|
279
|
-
"Gemini provider does not support URL-based file references. Provide base64-encoded data instead."
|
|
280
|
-
end
|
|
281
|
-
|
|
282
283
|
{ inlineData: { mimeType: file.media_type, data: file.data } }
|
|
283
284
|
end
|
|
284
285
|
|
data/lib/riffer/registrable.rb
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
|
-
# Registry of a class's
|
|
4
|
+
# Registry of a class's direct subclasses, keyed by identifier. Extend it
|
|
5
5
|
# onto a base class to look up subclasses in constant time via +find+ and +all+.
|
|
6
|
+
# Subclasses join implicitly by inheriting; +register+ adds one explicitly, for
|
|
7
|
+
# ephemeral classes a test suite builds and tears down. Registration is not
|
|
8
|
+
# synchronized — register during boot or from a single-threaded test, before
|
|
9
|
+
# concurrent lookups begin.
|
|
6
10
|
#
|
|
7
11
|
# class Riffer::Tool
|
|
8
12
|
# extend Riffer::Registrable
|
|
@@ -13,12 +17,15 @@
|
|
|
13
17
|
# @rbs module-self Class
|
|
14
18
|
module Riffer::Registrable
|
|
15
19
|
# @rbs @identifier_registry: Hash[String, Class]?
|
|
20
|
+
# @rbs @explicit_registrations: Hash[String, Class]?
|
|
16
21
|
|
|
17
22
|
# Finds a registered subclass by identifier, or +nil+ when none matches.
|
|
18
|
-
#
|
|
19
|
-
# visible to a grandparent's +find+ (call +find+ on their direct
|
|
20
|
-
# instead), anonymous classes are never registered, and
|
|
21
|
-
#
|
|
23
|
+
# Implicit registration covers only *named direct* subclasses: grandchildren
|
|
24
|
+
# are not visible to a grandparent's +find+ (call +find+ on their direct
|
|
25
|
+
# parent instead), anonymous classes are never registered implicitly, and a
|
|
26
|
+
# subclass whose name no longer resolves back to it is dropped at the next
|
|
27
|
+
# registry rebuild. Duplicate identifiers raise
|
|
28
|
+
# Riffer::DuplicateIdentifierError at first lookup.
|
|
22
29
|
#
|
|
23
30
|
#--
|
|
24
31
|
#: (String | Symbol) -> Class?
|
|
@@ -26,10 +33,8 @@ module Riffer::Registrable
|
|
|
26
33
|
identifier_registry[identifier.to_s]
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
# Returns all registered subclasses
|
|
30
|
-
#
|
|
31
|
-
# parent instead), anonymous classes are never registered, and duplicate
|
|
32
|
-
# identifiers raise Riffer::DuplicateIdentifierError at first lookup.
|
|
36
|
+
# Returns all registered subclasses, implicit and explicit. Carries the same
|
|
37
|
+
# registration rules as +find+.
|
|
33
38
|
#
|
|
34
39
|
#--
|
|
35
40
|
#: () -> Array[Class]
|
|
@@ -37,6 +42,45 @@ module Riffer::Registrable
|
|
|
37
42
|
identifier_registry.values
|
|
38
43
|
end
|
|
39
44
|
|
|
45
|
+
# Registers a direct subclass under its +identifier+, whether or not it is
|
|
46
|
+
# named — unlike implicit registration, it survives a name that no longer
|
|
47
|
+
# resolves, so an ephemeral class stays findable until +unregister+. Prefer
|
|
48
|
+
# Riffer::Testing for ordinary test setup, which stubs and cleans up
|
|
49
|
+
# automatically.
|
|
50
|
+
#
|
|
51
|
+
# Raises Riffer::ArgumentError when the identifier is blank or the class is
|
|
52
|
+
# not a direct subclass, and Riffer::DuplicateIdentifierError when the
|
|
53
|
+
# identifier is already taken — including by this same class.
|
|
54
|
+
#
|
|
55
|
+
#--
|
|
56
|
+
#: (Class) -> void
|
|
57
|
+
def register(klass)
|
|
58
|
+
unless klass.superclass.equal?(self)
|
|
59
|
+
raise Riffer::ArgumentError, "#{klass} must be a direct subclass of #{self} to register"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
key = identifier_key(klass)
|
|
63
|
+
raise Riffer::ArgumentError, "#{klass} must declare a non-blank identifier to register" if key.strip.empty?
|
|
64
|
+
|
|
65
|
+
existing = identifier_registry[key]
|
|
66
|
+
raise_duplicate_identifier!(key, existing, klass) if existing
|
|
67
|
+
|
|
68
|
+
explicit_registrations[key] = klass
|
|
69
|
+
@identifier_registry = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Removes an explicit registration of +klass+, leaving implicit registrations
|
|
73
|
+
# untouched.
|
|
74
|
+
#--
|
|
75
|
+
#: (Class) -> void
|
|
76
|
+
def unregister(klass)
|
|
77
|
+
key, = explicit_registrations.find { |_key, registered| registered.equal?(klass) }
|
|
78
|
+
return if key.nil?
|
|
79
|
+
|
|
80
|
+
explicit_registrations.delete(key)
|
|
81
|
+
@identifier_registry = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
40
84
|
private
|
|
41
85
|
|
|
42
86
|
# Ruby invokes +inherited+ with +self+ bound to the direct superclass — the
|
|
@@ -55,27 +99,66 @@ module Riffer::Registrable
|
|
|
55
99
|
@identifier_registry ||= build_identifier_registry
|
|
56
100
|
end
|
|
57
101
|
|
|
102
|
+
#--
|
|
103
|
+
#: () -> Hash[String, Class]
|
|
104
|
+
def explicit_registrations
|
|
105
|
+
@explicit_registrations ||= {}
|
|
106
|
+
end
|
|
107
|
+
|
|
58
108
|
#--
|
|
59
109
|
#: () -> Hash[String, Class]
|
|
60
110
|
def build_identifier_registry
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# classes whose registration would flake with GC timing.
|
|
66
|
-
next if Riffer::Helpers::Identifier.for(subclass).empty?
|
|
67
|
-
|
|
68
|
-
candidate = subclass #: untyped
|
|
69
|
-
key = candidate.identifier.to_s
|
|
111
|
+
subclasses.each_with_object(explicit_registrations.dup) do |subclass, acc|
|
|
112
|
+
next unless live?(subclass)
|
|
113
|
+
|
|
114
|
+
key = identifier_key(subclass)
|
|
70
115
|
next if key.strip.empty?
|
|
71
116
|
|
|
72
117
|
existing = acc[key]
|
|
73
|
-
if existing
|
|
74
|
-
raise Riffer::DuplicateIdentifierError,
|
|
75
|
-
"Duplicate identifier #{key.inspect} for #{existing} and #{subclass}"
|
|
76
|
-
end
|
|
118
|
+
raise_duplicate_identifier!(key, existing, subclass) if existing && !existing.equal?(subclass)
|
|
77
119
|
|
|
78
120
|
acc[key] = subclass
|
|
79
121
|
end.freeze
|
|
80
122
|
end
|
|
123
|
+
|
|
124
|
+
# Class#subclasses keeps returning superseded generations of a reloaded or
|
|
125
|
+
# stubbed class, so a subclass counts only while its own name still resolves
|
|
126
|
+
# back to it. An anonymous class has no name to resolve and is skipped even
|
|
127
|
+
# with an explicit identifier — the MCP factory and serializer shells
|
|
128
|
+
# synthesize short-lived anonymous classes whose registration would flake
|
|
129
|
+
# with GC timing.
|
|
130
|
+
#--
|
|
131
|
+
#: (Class) -> bool
|
|
132
|
+
def live?(subclass)
|
|
133
|
+
real_name = Riffer::Helpers::Identifier.real_name(subclass)
|
|
134
|
+
return false if real_name.nil?
|
|
135
|
+
|
|
136
|
+
# Module#autoload? does not traverse a qualified path, so each segment is
|
|
137
|
+
# resolved against its own owner: probing a pending autoload would trigger
|
|
138
|
+
# the load, whose +inherited+ hook busts the memo this build is populating.
|
|
139
|
+
root = Object #: Module
|
|
140
|
+
resolved = real_name.split("::").reduce(root) do |owner, segment|
|
|
141
|
+
return false if owner.autoload?(segment, false)
|
|
142
|
+
|
|
143
|
+
owner.const_get(segment, false) #: Module
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
resolved.equal?(subclass)
|
|
147
|
+
rescue NameError
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
#--
|
|
152
|
+
#: (Class) -> String
|
|
153
|
+
def identifier_key(klass)
|
|
154
|
+
candidate = klass #: untyped
|
|
155
|
+
candidate.identifier.to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
#--
|
|
159
|
+
#: (String, Class, Class) -> void
|
|
160
|
+
def raise_duplicate_identifier!(key, existing, klass)
|
|
161
|
+
raise Riffer::DuplicateIdentifierError,
|
|
162
|
+
"Duplicate identifier #{key.inspect} for #{existing} and #{klass}"
|
|
163
|
+
end
|
|
81
164
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs-inline stays disabled here: the generated signature would name Minitest,
|
|
4
|
+
# which a shipped signature must never reference. The hand-written stub lives in
|
|
5
|
+
# sig/_private/riffer/testing/minitest.rbs.
|
|
6
|
+
|
|
7
|
+
require "riffer"
|
|
8
|
+
|
|
9
|
+
# Wiring only — minitest is never a riffer dependency; a consumer requires this
|
|
10
|
+
# file from a test_helper that has already loaded the framework.
|
|
11
|
+
module Riffer::Testing::MinitestCleanup
|
|
12
|
+
# Minitest reserves +after_teardown+ for library extensions; +teardown+
|
|
13
|
+
# belongs to the test author.
|
|
14
|
+
def after_teardown
|
|
15
|
+
Riffer::Testing.reset!
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Minitest::Test.include(Riffer::Testing)
|
|
21
|
+
Minitest::Test.include(Riffer::Testing::MinitestCleanup)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "riffer"
|
|
5
|
+
|
|
6
|
+
# Wiring only — RSpec is never a riffer dependency; a consumer requires this
|
|
7
|
+
# file from a spec_helper that has already loaded the framework.
|
|
8
|
+
RSpec.configure do |config|
|
|
9
|
+
config.include Riffer::Testing
|
|
10
|
+
config.after { Riffer::Testing.reset! }
|
|
11
|
+
end
|