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.
@@ -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
@@ -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