modal-rb 0.0.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 +7 -0
- data/lib/modal/api_client.rb +111 -0
- data/lib/modal/app.rb +65 -0
- data/lib/modal/cls.rb +131 -0
- data/lib/modal/config.rb +69 -0
- data/lib/modal/errors.rb +11 -0
- data/lib/modal/function.rb +122 -0
- data/lib/modal/function_call.rb +30 -0
- data/lib/modal/image.rb +75 -0
- data/lib/modal/invocation.rb +134 -0
- data/lib/modal/pickle.rb +50 -0
- data/lib/modal/sandbox.rb +340 -0
- data/lib/modal/sandbox_filesystem.rb +229 -0
- data/lib/modal/streams.rb +73 -0
- data/lib/modal/version.rb +3 -0
- data/lib/modal.rb +15 -0
- data/lib/modal_proto/api_pb.rb +480 -0
- data/lib/modal_proto/api_services_pb.rb +218 -0
- data/lib/modal_proto/options_pb.rb +18 -0
- metadata +58 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
require_relative 'pickle'
|
2
|
+
|
3
|
+
module Modal
|
4
|
+
module Invocation
|
5
|
+
def await_output(timeout = nil); end
|
6
|
+
def retry(retry_count); end
|
7
|
+
end
|
8
|
+
|
9
|
+
class ControlPlaneInvocation
|
10
|
+
include Invocation
|
11
|
+
|
12
|
+
attr_reader :function_call_id
|
13
|
+
|
14
|
+
def initialize(function_call_id, input = nil, function_call_jwt = nil, input_jwt = nil)
|
15
|
+
@function_call_id = function_call_id
|
16
|
+
@input = input
|
17
|
+
@function_call_jwt = function_call_jwt
|
18
|
+
@input_jwt = input_jwt
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.create(function_id, input, invocation_type)
|
22
|
+
function_put_inputs_item = Modal::Client::FunctionPutInputsItem.new(idx: 0, input: input)
|
23
|
+
request = Modal::Client::FunctionMapRequest.new(
|
24
|
+
function_id: function_id,
|
25
|
+
function_call_type: Modal::Client::FunctionCallType::FUNCTION_CALL_TYPE_UNARY,
|
26
|
+
function_call_invocation_type: invocation_type,
|
27
|
+
pipelined_inputs: [function_put_inputs_item]
|
28
|
+
)
|
29
|
+
function_map_response = Modal.client.call(:function_map, request)
|
30
|
+
|
31
|
+
new(
|
32
|
+
function_map_response.function_call_id,
|
33
|
+
input,
|
34
|
+
function_map_response.function_call_jwt,
|
35
|
+
function_map_response.pipelined_inputs[0].input_jwt
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.from_function_call_id(function_call_id)
|
40
|
+
new(function_call_id)
|
41
|
+
end
|
42
|
+
|
43
|
+
def await_output(timeout = nil)
|
44
|
+
poll_function_output(@function_call_id, timeout)
|
45
|
+
end
|
46
|
+
|
47
|
+
def retry(retry_count)
|
48
|
+
unless @input
|
49
|
+
raise "Cannot retry function invocation - input missing"
|
50
|
+
end
|
51
|
+
|
52
|
+
retry_item = Modal::Client::FunctionRetryInputsItem.new(
|
53
|
+
input_jwt: @input_jwt,
|
54
|
+
input: @input,
|
55
|
+
retry_count: retry_count
|
56
|
+
)
|
57
|
+
request = Modal::Client::FunctionRetryInputsRequest.new(
|
58
|
+
function_call_jwt: @function_call_jwt,
|
59
|
+
inputs: [retry_item]
|
60
|
+
)
|
61
|
+
function_retry_response = Modal.client.call(:function_retry_inputs, request)
|
62
|
+
@input_jwt = function_retry_response.input_jwts[0]
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def poll_function_output(function_call_id, timeout_ms = nil)
|
68
|
+
start_time = Time.now
|
69
|
+
last_entry_id = ""
|
70
|
+
|
71
|
+
loop do
|
72
|
+
request = Modal::Client::FunctionGetOutputsRequest.new(
|
73
|
+
function_call_id: function_call_id,
|
74
|
+
timeout: 55.0, # seconds
|
75
|
+
last_entry_id: last_entry_id,
|
76
|
+
max_values: 1,
|
77
|
+
clear_on_success: true,
|
78
|
+
requested_at: Time.now.to_f
|
79
|
+
)
|
80
|
+
|
81
|
+
resp = Modal.client.call(:function_get_outputs, request)
|
82
|
+
|
83
|
+
if resp.last_entry_id && !resp.last_entry_id.empty?
|
84
|
+
last_entry_id = resp.last_entry_id
|
85
|
+
end
|
86
|
+
|
87
|
+
if resp.outputs && resp.outputs.any?
|
88
|
+
output_item = resp.outputs.first
|
89
|
+
if output_item.result
|
90
|
+
return process_result(output_item.result, output_item.data_format)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
if resp.respond_to?(:num_unfinished_inputs) && resp.num_unfinished_inputs == 0
|
95
|
+
return nil
|
96
|
+
end
|
97
|
+
|
98
|
+
if timeout_ms && (Time.now - start_time) * 1000 > timeout_ms
|
99
|
+
raise FunctionTimeoutError.new("Function call timed out after #{timeout_ms}ms")
|
100
|
+
end
|
101
|
+
|
102
|
+
sleep(1)
|
103
|
+
end
|
104
|
+
rescue GRPC::BadStatus => e
|
105
|
+
if e.code == GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
|
106
|
+
raise FunctionTimeoutError.new("Function call timed out.")
|
107
|
+
else
|
108
|
+
raise e
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def process_result(result, data_format)
|
113
|
+
status = result.status.to_s.to_sym
|
114
|
+
|
115
|
+
case status
|
116
|
+
when :GENERIC_STATUS_SUCCESS
|
117
|
+
if result.data && !result.data.empty?
|
118
|
+
return Pickle.load(result.data)
|
119
|
+
elsif result.data_blob_id && !result.data_blob_id.empty?
|
120
|
+
return nil
|
121
|
+
else
|
122
|
+
return nil
|
123
|
+
end
|
124
|
+
when :GENERIC_STATUS_TIMEOUT
|
125
|
+
raise FunctionTimeoutError.new(result.exception || "Function timed out")
|
126
|
+
when :GENERIC_STATUS_INTERNAL_FAILURE
|
127
|
+
raise InternalFailure.new(result.exception || "Internal failure")
|
128
|
+
else
|
129
|
+
error_msg = result.exception || "Unknown error (status: #{result.status})"
|
130
|
+
raise RemoteError.new("Function execution failed: #{error_msg}")
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
data/lib/modal/pickle.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'pycall'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Pickle
|
5
|
+
class PickleError < StandardError; end
|
6
|
+
|
7
|
+
def self.pickle_module
|
8
|
+
@pickle_module ||= PyCall.import_module('pickle')
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.json_module
|
12
|
+
@json_module ||= PyCall.import_module('json')
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.load(data)
|
16
|
+
begin
|
17
|
+
pickle_data = data.respond_to?(:read) ? data.read : data
|
18
|
+
python_obj = pickle_module.loads(pickle_data)
|
19
|
+
json_str = json_module.dumps(python_obj)
|
20
|
+
JSON.parse(json_str.to_s)
|
21
|
+
rescue => e
|
22
|
+
raise PickleError, "Failed to load pickle data: #{e.message}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.dumps(obj)
|
27
|
+
begin
|
28
|
+
json_str = JSON.generate(obj)
|
29
|
+
python_obj = json_module.loads(json_str)
|
30
|
+
pickle_module.dumps(python_obj).to_s
|
31
|
+
rescue => e
|
32
|
+
raise PickleError, "Failed to dump object to pickle: #{e.message}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.dump(obj, file)
|
37
|
+
begin
|
38
|
+
pickled_data = dumps(obj)
|
39
|
+
if file.respond_to?(:write)
|
40
|
+
file.write(pickled_data)
|
41
|
+
else
|
42
|
+
File.open(file, 'wb') do |f|
|
43
|
+
f.write(pickled_data)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
rescue => e
|
47
|
+
raise PickleError, "Failed to dump object to file: #{e.message}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,340 @@
|
|
1
|
+
require_relative 'sandbox_filesystem'
|
2
|
+
require_relative 'streams'
|
3
|
+
require 'ostruct'
|
4
|
+
|
5
|
+
module Modal
|
6
|
+
class Sandbox
|
7
|
+
attr_reader :sandbox_id, :stdin, :stdout, :stderr
|
8
|
+
attr_accessor :task_id
|
9
|
+
|
10
|
+
def initialize(sandbox_id)
|
11
|
+
@sandbox_id = sandbox_id
|
12
|
+
@task_id = nil
|
13
|
+
|
14
|
+
@stdin = ModalWriteStream.new(SandboxInputStream.new(sandbox_id))
|
15
|
+
@stdout = ModalReadStream.new(SandboxOutputStream.new(sandbox_id, Modal::Client::FileDescriptor::FILE_DESCRIPTOR_STDOUT))
|
16
|
+
@stderr = ModalReadStream.new(SandboxOutputStream.new(sandbox_id, Modal::Client::FileDescriptor::FILE_DESCRIPTOR_STDERR))
|
17
|
+
end
|
18
|
+
|
19
|
+
def exec(command, options = {})
|
20
|
+
ensure_task_id
|
21
|
+
|
22
|
+
workdir = options[:workdir]
|
23
|
+
timeout_secs = options[:timeout] ? options[:timeout] / 1000 : 0
|
24
|
+
|
25
|
+
request = Modal::Client::ContainerExecRequest.new(
|
26
|
+
task_id: @task_id,
|
27
|
+
command: command,
|
28
|
+
workdir: workdir,
|
29
|
+
timeout_secs: timeout_secs
|
30
|
+
)
|
31
|
+
|
32
|
+
resp = Modal.client.call(:container_exec, request)
|
33
|
+
ContainerProcess.new(resp.exec_id, "text")
|
34
|
+
end
|
35
|
+
|
36
|
+
def open(path, mode)
|
37
|
+
ensure_task_id
|
38
|
+
|
39
|
+
request = Modal::Client::ContainerFilesystemExecRequest.new(
|
40
|
+
file_open_request: Modal::Client::ContainerFileOpenRequest.new(
|
41
|
+
path: path,
|
42
|
+
mode: mode
|
43
|
+
),
|
44
|
+
task_id: @task_id
|
45
|
+
)
|
46
|
+
resp = run_filesystem_exec(request)
|
47
|
+
SandboxFile.new(resp.response.file_open_response.file_descriptor, @task_id)
|
48
|
+
end
|
49
|
+
|
50
|
+
def terminate
|
51
|
+
request = Modal::Client::SandboxTerminateRequest.new(sandbox_id: @sandbox_id)
|
52
|
+
Modal.client.call(:sandbox_terminate, request)
|
53
|
+
end
|
54
|
+
|
55
|
+
def wait
|
56
|
+
loop do
|
57
|
+
request = Modal::Client::SandboxWaitRequest.new(
|
58
|
+
sandbox_id: @sandbox_id,
|
59
|
+
timeout: 55 # seconds
|
60
|
+
)
|
61
|
+
resp = Modal.client.call(:sandbox_wait, request)
|
62
|
+
if resp.completed
|
63
|
+
return resp.exit_code || 0
|
64
|
+
end
|
65
|
+
sleep(1) # Poll every second
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
# Helper to run filesystem exec requests and handle output.
|
72
|
+
def run_filesystem_exec(request)
|
73
|
+
response = Modal.client.call(:container_filesystem_exec, request)
|
74
|
+
if response.respond_to?(:file_descriptor) && response.file_descriptor
|
75
|
+
return OpenStruct.new(
|
76
|
+
response: OpenStruct.new(
|
77
|
+
file_open_response: OpenStruct.new(
|
78
|
+
file_descriptor: response.file_descriptor
|
79
|
+
)
|
80
|
+
)
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
exec_id = response.exec_id
|
85
|
+
retries = 10
|
86
|
+
|
87
|
+
while retries > 0
|
88
|
+
begin
|
89
|
+
output_request = Modal::Client::ContainerFilesystemExecGetOutputRequest.new(
|
90
|
+
exec_id: exec_id,
|
91
|
+
timeout: 10
|
92
|
+
)
|
93
|
+
|
94
|
+
stream = Modal.client.call(:container_filesystem_exec_get_output, output_request)
|
95
|
+
|
96
|
+
stream.each do |batch|
|
97
|
+
if batch.respond_to?(:error) && batch.error
|
98
|
+
raise SandboxFilesystemError.new(batch.error.error_message)
|
99
|
+
end
|
100
|
+
|
101
|
+
if batch.respond_to?(:eof) && batch.eof
|
102
|
+
return OpenStruct.new(response: response)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
retries -= 1
|
107
|
+
sleep(0.1)
|
108
|
+
|
109
|
+
rescue GRPC::BadStatus => e
|
110
|
+
if e.code == GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
|
111
|
+
retries -= 1
|
112
|
+
next
|
113
|
+
else
|
114
|
+
raise e
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
raise SandboxFilesystemError.new("Filesystem operation timed out")
|
120
|
+
end
|
121
|
+
|
122
|
+
def ensure_task_id
|
123
|
+
return if @task_id
|
124
|
+
|
125
|
+
request = Modal::Client::SandboxGetTaskIdRequest.new(
|
126
|
+
sandbox_id: @sandbox_id,
|
127
|
+
wait_until_ready: true
|
128
|
+
)
|
129
|
+
resp = Modal.client.call(:sandbox_get_task_id, request)
|
130
|
+
|
131
|
+
if resp.task_id && !resp.task_id.empty?
|
132
|
+
@task_id = resp.task_id
|
133
|
+
else
|
134
|
+
raise "Sandbox #{@sandbox_id} does not have a task ID, it may not be running"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
class ContainerProcess
|
140
|
+
attr_reader :exec_id, :stdin, :stdout, :stderr
|
141
|
+
|
142
|
+
def initialize(exec_id, mode)
|
143
|
+
@exec_id = exec_id
|
144
|
+
@stdin = ModalWriteStream.new(ContainerProcessInputStream.new(exec_id))
|
145
|
+
|
146
|
+
if mode == "text"
|
147
|
+
@stdout = ModalReadStream.new(ContainerProcessOutputStream.new(exec_id, Modal::Client::FileDescriptor::FILE_DESCRIPTOR_STDOUT, true))
|
148
|
+
@stderr = ModalReadStream.new(ContainerProcessOutputStream.new(exec_id, Modal::Client::FileDescriptor::FILE_DESCRIPTOR_STDERR, true))
|
149
|
+
else
|
150
|
+
@stdout = ModalReadStream.new(ContainerProcessOutputStream.new(exec_id, Modal::Client::FileDescriptor::FILE_DESCRIPTOR_STDOUT, false))
|
151
|
+
@stderr = ModalReadStream.new(ContainerProcessOutputStream.new(exec_id, Modal::Client::FileDescriptor::FILE_DESCRIPTOR_STDERR, false))
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def wait
|
156
|
+
loop do
|
157
|
+
request = Modal::Client::ContainerExecWaitRequest.new(
|
158
|
+
exec_id: @exec_id,
|
159
|
+
timeout: 55 # seconds
|
160
|
+
)
|
161
|
+
resp = Modal.client.call(:container_exec_wait, request)
|
162
|
+
if resp.completed
|
163
|
+
return resp.exit_code || 0
|
164
|
+
end
|
165
|
+
sleep(1) # Poll every second
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
class SandboxInputStream
|
171
|
+
def initialize(sandbox_id)
|
172
|
+
@sandbox_id = sandbox_id
|
173
|
+
@index = 1
|
174
|
+
end
|
175
|
+
|
176
|
+
def write(chunk)
|
177
|
+
request = Modal::Client::SandboxStdinWriteRequest.new(
|
178
|
+
sandbox_id: @sandbox_id,
|
179
|
+
input: chunk.bytes.pack('C*'), # Convert to bytes
|
180
|
+
index: @index
|
181
|
+
)
|
182
|
+
Modal.client.call(:sandbox_stdin_write, request)
|
183
|
+
@index += 1
|
184
|
+
end
|
185
|
+
|
186
|
+
def close
|
187
|
+
request = Modal::Client::SandboxStdinWriteRequest.new(
|
188
|
+
sandbox_id: @sandbox_id,
|
189
|
+
index: @index,
|
190
|
+
eof: true
|
191
|
+
)
|
192
|
+
Modal.client.call(:sandbox_stdin_write, request)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
class SandboxOutputStream
|
197
|
+
def initialize(sandbox_id, file_descriptor)
|
198
|
+
@sandbox_id = sandbox_id
|
199
|
+
@file_descriptor = file_descriptor
|
200
|
+
@last_entry_id = ""
|
201
|
+
@data_collected = []
|
202
|
+
@finished = false
|
203
|
+
end
|
204
|
+
|
205
|
+
def each
|
206
|
+
return enum_for(:each) unless block_given?
|
207
|
+
|
208
|
+
return if @finished
|
209
|
+
|
210
|
+
|
211
|
+
# make one call and collect all data until EOF
|
212
|
+
request = Modal::Client::SandboxGetLogsRequest.new(
|
213
|
+
sandbox_id: @sandbox_id,
|
214
|
+
file_descriptor: @file_descriptor,
|
215
|
+
timeout: 10, # Give it more time to get all the data
|
216
|
+
last_entry_id: @last_entry_id
|
217
|
+
)
|
218
|
+
|
219
|
+
begin
|
220
|
+
resp = Modal.client.call(:sandbox_get_logs, request)
|
221
|
+
|
222
|
+
# Process the entire streaming response
|
223
|
+
resp.each do |batch|
|
224
|
+
|
225
|
+
# Update last_entry_id
|
226
|
+
if batch.respond_to?(:entry_id) && batch.entry_id && !batch.entry_id.empty?
|
227
|
+
@last_entry_id = batch.entry_id
|
228
|
+
end
|
229
|
+
|
230
|
+
# Collect data from this batch
|
231
|
+
if batch.respond_to?(:items) && batch.items
|
232
|
+
batch.items.each do |item|
|
233
|
+
if item.respond_to?(:data) && item.data && !item.data.empty?
|
234
|
+
@data_collected << item.data
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
# Check for EOF
|
240
|
+
if batch.respond_to?(:eof) && batch.eof
|
241
|
+
@finished = true
|
242
|
+
break
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
rescue GRPC::BadStatus => e
|
247
|
+
if e.code == GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
|
248
|
+
@finished = true
|
249
|
+
else
|
250
|
+
raise e
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
# Yield all collected data
|
255
|
+
@data_collected.each { |data| yield data }
|
256
|
+
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
class ContainerProcessInputStream
|
261
|
+
def initialize(exec_id)
|
262
|
+
@exec_id = exec_id
|
263
|
+
@message_index = 1
|
264
|
+
end
|
265
|
+
|
266
|
+
def write(chunk)
|
267
|
+
request = Modal::Client::ContainerExecPutInputRequest.new(
|
268
|
+
exec_id: @exec_id,
|
269
|
+
input: Modal::Client::ContainerExecInput.new(
|
270
|
+
message: chunk.bytes.pack('C*'), # Convert to bytes
|
271
|
+
message_index: @message_index
|
272
|
+
)
|
273
|
+
)
|
274
|
+
Modal.client.call(:container_exec_put_input, request)
|
275
|
+
@message_index += 1
|
276
|
+
end
|
277
|
+
|
278
|
+
def close
|
279
|
+
request = Modal::Client::ContainerExecPutInputRequest.new(
|
280
|
+
exec_id: @exec_id,
|
281
|
+
input: Modal::Client::ContainerExecInput.new(
|
282
|
+
message_index: @message_index,
|
283
|
+
eof: true
|
284
|
+
)
|
285
|
+
)
|
286
|
+
Modal.client.call(:container_exec_put_input, request)
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
class ContainerProcessOutputStream
|
291
|
+
def initialize(exec_id, file_descriptor, decode_text)
|
292
|
+
@exec_id = exec_id
|
293
|
+
@file_descriptor = file_descriptor
|
294
|
+
@decode_text = decode_text
|
295
|
+
@last_batch_index = 0
|
296
|
+
@finished = false
|
297
|
+
end
|
298
|
+
|
299
|
+
def each
|
300
|
+
return enum_for(:each) unless block_given?
|
301
|
+
return if @finished
|
302
|
+
|
303
|
+
|
304
|
+
begin
|
305
|
+
request = Modal::Client::ContainerExecGetOutputRequest.new(
|
306
|
+
exec_id: @exec_id,
|
307
|
+
file_descriptor: @file_descriptor,
|
308
|
+
timeout: 55,
|
309
|
+
get_raw_bytes: true,
|
310
|
+
last_batch_index: @last_batch_index
|
311
|
+
)
|
312
|
+
|
313
|
+
stream = Modal.client.call(:container_exec_get_output, request)
|
314
|
+
|
315
|
+
stream.each do |batch|
|
316
|
+
@last_batch_index = batch.batch_index if batch.respond_to?(:batch_index)
|
317
|
+
if batch.respond_to?(:items) && batch.items
|
318
|
+
batch.items.each do |item|
|
319
|
+
if item.message_bytes && !item.message_bytes.empty?
|
320
|
+
yield item.message_bytes
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
if (batch.respond_to?(:has_exit_code) && batch.has_exit_code) || batch.items.empty?
|
326
|
+
break
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
rescue GRPC::BadStatus => e
|
331
|
+
if e.code == GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
|
332
|
+
else
|
333
|
+
raise e
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
@finished = true
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|