chalk-remote-call-python 1.2.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (50) hide show
  1. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/PKG-INFO +1 -1
  2. chalk_remote_call_python-1.3.0/chalk_remote_call/_version.py +1 -0
  3. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/servicer.py +53 -2
  4. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call_python.egg-info/PKG-INFO +1 -1
  5. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/tests/test_servicer.py +185 -1
  6. chalk_remote_call_python-1.2.0/chalk_remote_call/_version.py +0 -1
  7. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/README.md +0 -0
  8. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/__init__.py +0 -0
  9. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/__main__.py +0 -0
  10. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/__init__.py +0 -0
  11. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/__init__.py +0 -0
  12. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/auth/__init__.py +0 -0
  13. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/auth/v1/__init__.py +0 -0
  14. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2.py +0 -0
  15. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2_grpc.py +0 -0
  16. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/common/__init__.py +0 -0
  17. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/common/v1/__init__.py +0 -0
  18. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2.py +0 -0
  19. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2_grpc.py +0 -0
  20. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/runtime/__init__.py +0 -0
  21. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/runtime/v1/__init__.py +0 -0
  22. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2.py +0 -0
  23. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2_grpc.py +0 -0
  24. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/__init__.py +0 -0
  25. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/v1/__init__.py +0 -0
  26. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2.py +0 -0
  27. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2_grpc.py +0 -0
  28. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2.py +0 -0
  29. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2_grpc.py +0 -0
  30. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2.py +0 -0
  31. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2_grpc.py +0 -0
  32. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/_native.pyi +0 -0
  33. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/arrow_utils.py +0 -0
  34. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/cli.py +0 -0
  35. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/handler_loader.py +0 -0
  36. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/input_transform.py +0 -0
  37. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call/server.py +0 -0
  38. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call_python.egg-info/SOURCES.txt +0 -0
  39. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call_python.egg-info/dependency_links.txt +0 -0
  40. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call_python.egg-info/entry_points.txt +0 -0
  41. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call_python.egg-info/requires.txt +0 -0
  42. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/chalk_remote_call_python.egg-info/top_level.txt +0 -0
  43. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/pyproject.toml +0 -0
  44. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/setup.cfg +0 -0
  45. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/setup.py +0 -0
  46. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/tests/test_arrow_utils.py +0 -0
  47. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/tests/test_end_to_end.py +0 -0
  48. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/tests/test_error_paths.py +0 -0
  49. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/tests/test_handler_loader.py +0 -0
  50. {chalk_remote_call_python-1.2.0 → chalk_remote_call_python-1.3.0}/tests/test_input_transform.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chalk-remote-call-python
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Chalk remote call Python runtime interface client
5
5
  Author: Chalk AI, Inc.
6
6
  Project-URL: Homepage, https://chalk.ai
@@ -0,0 +1 @@
1
+ __version__ = "1.3.0"
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import dataclasses
5
+ import inspect
4
6
  from collections.abc import Callable
5
7
  from typing import Any
6
8
 
@@ -86,6 +88,41 @@ def _coerce_to_record_batch(result: Any) -> pa.RecordBatch:
86
88
  raise TypeError(f"Cannot convert handler result of type {type(result).__name__} to RecordBatch: {e}") from e
87
89
 
88
90
 
91
+ def _get_or_create_event_loop() -> asyncio.AbstractEventLoop:
92
+ """Get the running event loop or create a new one."""
93
+ try:
94
+ return asyncio.get_running_loop()
95
+ except RuntimeError:
96
+ loop = asyncio.new_event_loop()
97
+ asyncio.set_event_loop(loop)
98
+ return loop
99
+
100
+
101
+ def _run_async(coro):
102
+ """Run a coroutine synchronously."""
103
+ loop = _get_or_create_event_loop()
104
+ if loop.is_running():
105
+ # Already inside an async context — can't use run_until_complete.
106
+ # Create a new loop in a thread.
107
+ import concurrent.futures
108
+
109
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
110
+ return pool.submit(asyncio.run, coro).result()
111
+ return loop.run_until_complete(coro)
112
+
113
+
114
+ def _collect_async_gen(agen) -> list:
115
+ """Collect all items from an async generator synchronously."""
116
+
117
+ async def _collect():
118
+ items = []
119
+ async for item in agen:
120
+ items.append(item)
121
+ return items
122
+
123
+ return _run_async(_collect())
124
+
125
+
89
126
  def process_batches(
90
127
  ipc_bytes: bytes,
91
128
  handler: Callable[..., Any],
@@ -109,12 +146,26 @@ def process_batches(
109
146
 
110
147
  try:
111
148
  result = handler(event, context_metadata)
149
+ # If the handler is async, await the coroutine.
150
+ if inspect.iscoroutine(result):
151
+ result = _run_async(result)
112
152
  except Exception as e:
113
153
  raise RuntimeError(f"Exception raised during handler execution: {e}") from e
114
154
 
115
155
  try:
116
- result_batch = _coerce_to_record_batch(result)
117
- results.append(encode_record_batch(result_batch))
156
+ if inspect.isgenerator(result):
157
+ # Sync generator: encode each yielded value as a separate chunk.
158
+ for item in result:
159
+ result_batch = _coerce_to_record_batch(item)
160
+ results.append(encode_record_batch(result_batch))
161
+ elif inspect.isasyncgen(result):
162
+ # Async generator: collect items and encode each as a separate chunk.
163
+ for item in _collect_async_gen(result):
164
+ result_batch = _coerce_to_record_batch(item)
165
+ results.append(encode_record_batch(result_batch))
166
+ else:
167
+ result_batch = _coerce_to_record_batch(result)
168
+ results.append(encode_record_batch(result_batch))
118
169
  except Exception as e:
119
170
  raise RuntimeError(f"Failed to encode response: {e}") from e
120
171
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chalk-remote-call-python
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Chalk remote call Python runtime interface client
5
5
  Author: Chalk AI, Inc.
6
6
  Project-URL: Homepage, https://chalk.ai
@@ -12,7 +12,7 @@ from chalk_remote_call._gen.chalk.runtime.v1.remote_python_call_pb2_grpc import
12
12
  RemoteCallServiceStub,
13
13
  )
14
14
  from chalk_remote_call.arrow_utils import decode_ipc_stream, encode_record_batch
15
- from chalk_remote_call.servicer import _coerce_to_record_batch
15
+ from chalk_remote_call.servicer import _coerce_to_record_batch, process_batches
16
16
 
17
17
 
18
18
  class TestCoerceToRecordBatch:
@@ -222,3 +222,187 @@ class TestStructuredTypeCoercion:
222
222
  # Goes through the existing list path → struct array in a "result" column
223
223
  assert result.num_columns == 1
224
224
  assert result.schema.field(0).name == "result"
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Generator support tests
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def _generator_handler(event: dict[str, pa.Array], context: Any) -> Any:
233
+ """Handler that yields each row value individually."""
234
+ n_col = list(event.values())[0]
235
+ n = n_col[0].as_py()
236
+ for i in range(n):
237
+ yield i
238
+
239
+
240
+ def _single_handler(event: dict[str, pa.Array], context: Any) -> Any:
241
+ """Normal (non-generator) handler for comparison."""
242
+ a = event["a"]
243
+ b = event["b"]
244
+ return [a[i].as_py() + b[i].as_py() for i in range(len(a))]
245
+
246
+
247
+ class TestProcessBatchesGenerator:
248
+ def test_generator_produces_multiple_chunks(self):
249
+ batch = pa.record_batch([pa.array([5])], names=["n"])
250
+ ipc_bytes = encode_record_batch(batch)
251
+
252
+ results = process_batches(ipc_bytes, _generator_handler, None, {})
253
+
254
+ assert len(results) == 5
255
+ for i, chunk_bytes in enumerate(results):
256
+ decoded = decode_ipc_stream(chunk_bytes)
257
+ assert len(decoded) == 1
258
+ assert decoded[0].column("result").to_pylist() == [i]
259
+
260
+ def test_non_generator_produces_single_chunk(self):
261
+ batch = pa.record_batch(
262
+ [pa.array([1, 2, 3], type=pa.int64()), pa.array([10, 20, 30], type=pa.int64())],
263
+ names=["a", "b"],
264
+ )
265
+ ipc_bytes = encode_record_batch(batch)
266
+
267
+ results = process_batches(ipc_bytes, _single_handler, ["a", "b"], {})
268
+
269
+ assert len(results) == 1
270
+ decoded = decode_ipc_stream(results[0])
271
+ assert decoded[0].column("result").to_pylist() == [11, 22, 33]
272
+
273
+ def test_generator_empty_yields_no_chunks(self):
274
+ batch = pa.record_batch([pa.array([0])], names=["n"])
275
+ ipc_bytes = encode_record_batch(batch)
276
+
277
+ results = process_batches(ipc_bytes, _generator_handler, None, {})
278
+
279
+ assert len(results) == 0
280
+
281
+
282
+ @pytest.mark.parametrize(
283
+ "grpc_server_and_channel",
284
+ [{"handler": _generator_handler, "arg_names": None}],
285
+ indirect=True,
286
+ )
287
+ def test_generator_e2e_through_grpc(grpc_server_and_channel):
288
+ """Generator handler sends multiple response chunks over gRPC."""
289
+ _thread, channel, address = grpc_server_and_channel
290
+ stub = RemoteCallServiceStub(channel)
291
+
292
+ batch = pa.record_batch([pa.array([3])], names=["n"])
293
+ request_bytes = _make_request_bytes(batch)
294
+
295
+ def requests():
296
+ yield pb2.CallFunctionRequest(name="handler", feather_stream=request_bytes)
297
+
298
+ responses = list(stub.CallFunction(requests()))
299
+ # Generator yields 3 values → 3 response chunks
300
+ assert len(responses) == 3
301
+ for i, resp in enumerate(responses):
302
+ decoded = decode_ipc_stream(resp.feather_stream)
303
+ assert decoded[0].column("result").to_pylist() == [i]
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Async support tests
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ async def _async_handler(event: dict[str, pa.Array], context: Any) -> Any:
312
+ """Async coroutine handler that adds two columns."""
313
+ a = event["a"]
314
+ b = event["b"]
315
+ return [a[i].as_py() + b[i].as_py() for i in range(len(a))]
316
+
317
+
318
+ async def _async_generator_handler(event: dict[str, pa.Array], context: Any) -> Any:
319
+ """Async generator handler that yields each integer 0..n-1."""
320
+ n_col = list(event.values())[0]
321
+ n = n_col[0].as_py()
322
+ for i in range(n):
323
+ yield i
324
+
325
+
326
+ class TestProcessBatchesAsync:
327
+ def test_async_coroutine_handler(self):
328
+ """async def handler is awaited and result is encoded."""
329
+ batch = pa.record_batch(
330
+ [pa.array([1, 2], type=pa.int64()), pa.array([10, 20], type=pa.int64())],
331
+ names=["a", "b"],
332
+ )
333
+ ipc_bytes = encode_record_batch(batch)
334
+
335
+ results = process_batches(ipc_bytes, _async_handler, ["a", "b"], {})
336
+
337
+ assert len(results) == 1
338
+ decoded = decode_ipc_stream(results[0])
339
+ assert decoded[0].column("result").to_pylist() == [11, 22]
340
+
341
+ def test_async_generator_produces_multiple_chunks(self):
342
+ """async def with yield produces separate chunks."""
343
+ batch = pa.record_batch([pa.array([4])], names=["n"])
344
+ ipc_bytes = encode_record_batch(batch)
345
+
346
+ results = process_batches(ipc_bytes, _async_generator_handler, None, {})
347
+
348
+ assert len(results) == 4
349
+ for i, chunk_bytes in enumerate(results):
350
+ decoded = decode_ipc_stream(chunk_bytes)
351
+ assert decoded[0].column("result").to_pylist() == [i]
352
+
353
+ def test_async_generator_empty_yields_no_chunks(self):
354
+ """async generator that yields nothing produces 0 chunks."""
355
+ batch = pa.record_batch([pa.array([0])], names=["n"])
356
+ ipc_bytes = encode_record_batch(batch)
357
+
358
+ results = process_batches(ipc_bytes, _async_generator_handler, None, {})
359
+
360
+ assert len(results) == 0
361
+
362
+
363
+ @pytest.mark.parametrize(
364
+ "grpc_server_and_channel",
365
+ [{"handler": _async_generator_handler, "arg_names": None}],
366
+ indirect=True,
367
+ )
368
+ def test_async_generator_e2e_through_grpc(grpc_server_and_channel):
369
+ """Async generator handler sends multiple response chunks over gRPC."""
370
+ _thread, channel, address = grpc_server_and_channel
371
+ stub = RemoteCallServiceStub(channel)
372
+
373
+ batch = pa.record_batch([pa.array([3])], names=["n"])
374
+ request_bytes = _make_request_bytes(batch)
375
+
376
+ def requests():
377
+ yield pb2.CallFunctionRequest(name="handler", feather_stream=request_bytes)
378
+
379
+ responses = list(stub.CallFunction(requests()))
380
+ assert len(responses) == 3
381
+ for i, resp in enumerate(responses):
382
+ decoded = decode_ipc_stream(resp.feather_stream)
383
+ assert decoded[0].column("result").to_pylist() == [i]
384
+
385
+
386
+ @pytest.mark.parametrize(
387
+ "grpc_server_and_channel",
388
+ [{"handler": _async_handler, "arg_names": ["a", "b"]}],
389
+ indirect=True,
390
+ )
391
+ def test_async_coroutine_e2e_through_grpc(grpc_server_and_channel):
392
+ """Async coroutine handler works end-to-end through gRPC."""
393
+ _thread, channel, address = grpc_server_and_channel
394
+ stub = RemoteCallServiceStub(channel)
395
+
396
+ batch = pa.record_batch(
397
+ [pa.array([5, 10], type=pa.int64()), pa.array([3, 7], type=pa.int64())],
398
+ names=["a", "b"],
399
+ )
400
+ request_bytes = _make_request_bytes(batch)
401
+
402
+ def requests():
403
+ yield pb2.CallFunctionRequest(name="handler", feather_stream=request_bytes)
404
+
405
+ responses = list(stub.CallFunction(requests()))
406
+ assert len(responses) == 1
407
+ decoded = decode_ipc_stream(responses[0].feather_stream)
408
+ assert decoded[0].column("result").to_pylist() == [8, 17]
@@ -1 +0,0 @@
1
- __version__ = "1.2.0"