chalk-remote-call-python 1.10.5__cp315-cp315-win_amd64.whl

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 (45) hide show
  1. chalk_remote_call/__init__.py +4 -0
  2. chalk_remote_call/__main__.py +3 -0
  3. chalk_remote_call/_gen/__init__.py +0 -0
  4. chalk_remote_call/_gen/chalk/__init__.py +0 -0
  5. chalk_remote_call/_gen/chalk/auth/__init__.py +0 -0
  6. chalk_remote_call/_gen/chalk/auth/v1/__init__.py +0 -0
  7. chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2.py +115 -0
  8. chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2_grpc.py +4 -0
  9. chalk_remote_call/_gen/chalk/common/__init__.py +0 -0
  10. chalk_remote_call/_gen/chalk/common/v1/__init__.py +0 -0
  11. chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2.py +33 -0
  12. chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2_grpc.py +4 -0
  13. chalk_remote_call/_gen/chalk/runtime/__init__.py +0 -0
  14. chalk_remote_call/_gen/chalk/runtime/v1/__init__.py +0 -0
  15. chalk_remote_call/_gen/chalk/runtime/v1/fnq_consumer_pb2.py +53 -0
  16. chalk_remote_call/_gen/chalk/runtime/v1/fnq_consumer_pb2_grpc.py +138 -0
  17. chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2.py +104 -0
  18. chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2_grpc.py +363 -0
  19. chalk_remote_call/_gen/chalk/utils/__init__.py +0 -0
  20. chalk_remote_call/_gen/chalk/utils/v1/__init__.py +0 -0
  21. chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2.py +32 -0
  22. chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2_grpc.py +4 -0
  23. chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2.py +29 -0
  24. chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2_grpc.py +4 -0
  25. chalk_remote_call/_gen/chalk/utils/v1/large_pb2.py +26 -0
  26. chalk_remote_call/_gen/chalk/utils/v1/large_pb2_grpc.py +4 -0
  27. chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2.py +26 -0
  28. chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2_grpc.py +4 -0
  29. chalk_remote_call/_native.cp315-win_amd64.pyd +0 -0
  30. chalk_remote_call/_native.pyi +13 -0
  31. chalk_remote_call/_version.py +1 -0
  32. chalk_remote_call/arrow_utils.py +21 -0
  33. chalk_remote_call/cli.py +160 -0
  34. chalk_remote_call/errors.py +118 -0
  35. chalk_remote_call/handler_loader.py +57 -0
  36. chalk_remote_call/input_transform.py +50 -0
  37. chalk_remote_call/logging.py +29 -0
  38. chalk_remote_call/server.py +117 -0
  39. chalk_remote_call/servicer.py +400 -0
  40. chalk_remote_call/tracing.py +445 -0
  41. chalk_remote_call_python-1.10.5.dist-info/METADATA +288 -0
  42. chalk_remote_call_python-1.10.5.dist-info/RECORD +45 -0
  43. chalk_remote_call_python-1.10.5.dist-info/WHEEL +5 -0
  44. chalk_remote_call_python-1.10.5.dist-info/entry_points.txt +2 -0
  45. chalk_remote_call_python-1.10.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import pyarrow as pa
10
+ import pyarrow.ipc as ipc
11
+
12
+ from chalk_remote_call.arrow_utils import decode_ipc_stream, encode_record_batch
13
+ from chalk_remote_call.input_transform import resolve_column_names, transform
14
+ from chalk_remote_call.tracing import remote_function_invocation_span
15
+
16
+
17
+ def _is_structured(obj: Any) -> bool:
18
+ """Return True if *obj* is a recognised structured-type instance."""
19
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
20
+ return True
21
+ if hasattr(obj, "_asdict"): # namedtuple
22
+ return True
23
+ if hasattr(obj, "model_dump") and hasattr(obj, "model_fields"): # pydantic v2
24
+ return True
25
+ return hasattr(obj, "__attrs_attrs__") # attrs
26
+
27
+
28
+ def _to_dict(obj: Any) -> dict[str, Any]:
29
+ """Convert a single structured-type instance to a plain dict."""
30
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
31
+ return dataclasses.asdict(obj)
32
+ val: Any = obj
33
+ if hasattr(val, "_asdict"): # namedtuple
34
+ return val._asdict()
35
+ if hasattr(val, "model_dump") and hasattr(val, "model_fields"): # pydantic v2
36
+ return val.model_dump()
37
+ if hasattr(val, "__attrs_attrs__"): # attrs
38
+ return {a.name: getattr(val, a.name) for a in val.__attrs_attrs__}
39
+ raise TypeError(f"Cannot convert {type(val).__name__} to dict")
40
+
41
+
42
+ def _structured_field_names(obj: Any) -> list[str] | None:
43
+ """Field names in declaration order for a structured instance, or None.
44
+
45
+ Avoids dataclasses.asdict / pydantic model_dump, which both deep-copy
46
+ recursively. We only need a flat columnar view.
47
+ """
48
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
49
+ return [f.name for f in dataclasses.fields(obj)]
50
+ val: Any = obj
51
+ fields_attr = getattr(val, "_fields", None)
52
+ if fields_attr is not None and isinstance(fields_attr, tuple): # namedtuple
53
+ return list(fields_attr)
54
+ model_fields = getattr(type(val), "model_fields", None)
55
+ if model_fields is not None and hasattr(val, "model_dump"): # pydantic v2
56
+ return list(model_fields.keys())
57
+ attrs_attr = getattr(val, "__attrs_attrs__", None)
58
+ if attrs_attr is not None: # attrs
59
+ return [a.name for a in attrs_attr]
60
+ return None
61
+
62
+
63
+ def _structured_list_to_dict(items: list[Any]) -> dict[str, list[Any]]:
64
+ """Transpose a list of structured instances into columnar form.
65
+
66
+ Fast path: flat rows (no structured-type field values) use direct getattr
67
+ per field — avoids the recursive deep-copy in dataclasses.asdict /
68
+ pydantic model_dump.
69
+
70
+ Slow path: if any field holds a nested structured instance, fall back to
71
+ _to_dict so pa.array receives plain dicts/lists.
72
+ """
73
+ first = items[0]
74
+ names = _structured_field_names(first)
75
+ has_nested = names is not None and any(_is_structured(getattr(first, n)) for n in names)
76
+ if names is None or has_nested:
77
+ dicts = [_to_dict(item) for item in items]
78
+ keys = list(dicts[0].keys())
79
+ return {key: [d[key] for d in dicts] for key in keys}
80
+ return {name: [getattr(it, name) for it in items] for name in names}
81
+
82
+
83
+ def _coerce_to_record_batch(result: Any) -> pa.RecordBatch:
84
+ """Convert a handler result to a RecordBatch for the response stream."""
85
+ if isinstance(result, pa.RecordBatch):
86
+ return result
87
+ if isinstance(result, pa.Table):
88
+ if result.num_rows > 0:
89
+ return result.combine_chunks().to_batches()[0]
90
+ return pa.RecordBatch.from_pydict(
91
+ {name: [] for name in result.schema.names},
92
+ schema=result.schema,
93
+ )
94
+ if isinstance(result, pa.Array):
95
+ return pa.record_batch([result], names=["result"])
96
+ if isinstance(result, pa.ChunkedArray):
97
+ return pa.record_batch([result.combine_chunks()], names=["result"])
98
+ # For list, dict, scalar — convert via pa.array
99
+ try:
100
+ # Convert structured types (dataclass, namedtuple, pydantic, attrs) to dicts
101
+ if isinstance(result, list) and len(result) > 0 and _is_structured(result[0]):
102
+ result = _structured_list_to_dict(result)
103
+ elif not isinstance(result, dict | list) and _is_structured(result):
104
+ result = {k: [v] for k, v in _to_dict(result).items()}
105
+
106
+ if isinstance(result, dict):
107
+ # dict of column_name -> values
108
+ arrays = []
109
+ names = []
110
+ for key, values in result.items():
111
+ names.append(str(key))
112
+ if isinstance(values, pa.Array):
113
+ arrays.append(values)
114
+ else:
115
+ arrays.append(pa.array(values if isinstance(values, list) else [values]))
116
+ return pa.record_batch(arrays, names=names)
117
+ if isinstance(result, list):
118
+ arr = pa.array(result)
119
+ return pa.record_batch([arr], names=["result"])
120
+ # Scalar
121
+ arr = pa.array([result])
122
+ return pa.record_batch([arr], names=["result"])
123
+ except Exception as e:
124
+ raise TypeError(f"Cannot convert handler result of type {type(result).__name__} to RecordBatch: {e}") from e
125
+
126
+
127
+ def _get_or_create_event_loop() -> asyncio.AbstractEventLoop:
128
+ """Get the running event loop or create a new one."""
129
+ try:
130
+ return asyncio.get_running_loop()
131
+ except RuntimeError:
132
+ loop = asyncio.new_event_loop()
133
+ asyncio.set_event_loop(loop)
134
+ return loop
135
+
136
+
137
+ def _run_async(coro):
138
+ """Run a coroutine synchronously."""
139
+ loop = _get_or_create_event_loop()
140
+ if loop.is_running():
141
+ # Already inside an async context — can't use run_until_complete.
142
+ # Create a new loop in a thread.
143
+ import concurrent.futures
144
+
145
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
146
+ return pool.submit(asyncio.run, coro).result()
147
+ return loop.run_until_complete(coro)
148
+
149
+
150
+ def _drain_async_gen(agen, emit: Callable[[bytes], None]) -> None:
151
+ """Drain an async generator and emit each encoded item immediately."""
152
+
153
+ async def _drain():
154
+ async for item in agen:
155
+ emit(encode_record_batch(_coerce_to_record_batch(item)))
156
+
157
+ _run_async(_drain())
158
+
159
+
160
+ def process_batches(
161
+ ipc_bytes: bytes,
162
+ handler: Callable[..., Any],
163
+ arg_names: list[str] | None,
164
+ context_metadata: dict[str, Any],
165
+ emit: Callable[[bytes], None] | None = None,
166
+ ) -> list[bytes]:
167
+ """Bridge function called from Rust via PyO3.
168
+
169
+ Decodes IPC stream, transforms each batch, calls the handler,
170
+ coerces the result, and encodes the response.
171
+
172
+ Returns IPC-encoded responses (one per input batch, or per generator yield).
173
+ When ``emit`` is provided, each encoded response is emitted immediately and
174
+ the returned list is empty. Input batches are decoded incrementally; a
175
+ malformed later batch can raise after earlier handlers/responses have run.
176
+ """
177
+ results: list[bytes] = []
178
+ append_or_emit = results.append if emit is None else emit
179
+
180
+ with ipc.open_stream(ipc_bytes) as batches:
181
+ for batch in batches:
182
+ try:
183
+ event = transform(batch, arg_names)
184
+ except Exception as e:
185
+ raise ValueError(f"Input transformation failed: {e}") from e
186
+
187
+ with remote_function_invocation_span(context_metadata):
188
+ result = handler(event, context_metadata)
189
+ # If the handler is async, await the coroutine.
190
+ if inspect.iscoroutine(result):
191
+ result = _run_async(result)
192
+
193
+ if inspect.isgenerator(result):
194
+ # Sync generator: encode each yielded value as a separate chunk.
195
+ for item in result:
196
+ result_batch = _coerce_to_record_batch(item)
197
+ append_or_emit(encode_record_batch(result_batch))
198
+ elif inspect.isasyncgen(result):
199
+ _drain_async_gen(result, append_or_emit)
200
+ else:
201
+ result_batch = _coerce_to_record_batch(result)
202
+ append_or_emit(encode_record_batch(result_batch))
203
+ return results
204
+
205
+
206
+ def _concat_batches(batches: list[pa.RecordBatch]) -> pa.RecordBatch:
207
+ """Combine a caller's batches into a single RecordBatch (vertical concat)."""
208
+ if len(batches) == 1:
209
+ return batches[0]
210
+ return pa.Table.from_batches(batches).combine_chunks().to_batches()[0]
211
+
212
+
213
+ def process_batches_coalesced(
214
+ items_ipc_bytes: list[bytes],
215
+ handler: Callable[..., Any],
216
+ arg_names: list[str] | None,
217
+ context_metadatas: list[dict[str, Any]],
218
+ ) -> list[bytes]:
219
+ """Batched bridge: coalesces N concurrent callers into one handler invocation.
220
+
221
+ Args:
222
+ items_ipc_bytes: One IPC-encoded byte string per caller.
223
+ handler: (events: list[dict[str, pa.Array]], contexts: list[dict]) -> list[Any]
224
+ arg_names: Column-renaming spec (applied identically to every caller).
225
+ context_metadatas: One context dict per caller.
226
+
227
+ Returns:
228
+ list[bytes], one IPC-encoded RecordBatch per caller, in input order.
229
+
230
+ Raises:
231
+ ValueError: Schemas differ across callers, input is empty, or len mismatch
232
+ between inputs and handler output.
233
+ TypeError: Handler is a generator / async generator (not supported in
234
+ coalesced mode — each caller expects exactly one response chunk).
235
+ """
236
+ if not items_ipc_bytes:
237
+ raise ValueError("process_batches_coalesced called with no items")
238
+ if len(items_ipc_bytes) != len(context_metadatas):
239
+ raise ValueError(
240
+ f"items_ipc_bytes ({len(items_ipc_bytes)}) and context_metadatas "
241
+ f"({len(context_metadatas)}) lengths must match"
242
+ )
243
+ if inspect.isgeneratorfunction(handler) or inspect.isasyncgenfunction(handler):
244
+ raise TypeError("Generator/async-generator handlers are not supported when batching is enabled")
245
+
246
+ # Decode each caller's bytes to a single concatenated RecordBatch.
247
+ per_caller_batches: list[pa.RecordBatch] = []
248
+ reference_schema: pa.Schema | None = None
249
+ for i, ipc_bytes in enumerate(items_ipc_bytes):
250
+ batches = decode_ipc_stream(ipc_bytes)
251
+ if not batches:
252
+ raise ValueError(f"Caller {i} produced no batches")
253
+ combined = _concat_batches(batches)
254
+ if reference_schema is None:
255
+ reference_schema = combined.schema
256
+ elif not combined.schema.equals(reference_schema):
257
+ raise ValueError(
258
+ f"Schema mismatch in coalesced batch: caller 0 has {reference_schema}, caller {i} has {combined.schema}"
259
+ )
260
+ per_caller_batches.append(combined)
261
+
262
+ # Build events (list of dicts, one per caller) via the same transform used in
263
+ # the single-caller path — keeps behaviour consistent for column renaming.
264
+ events: list[dict[str, pa.Array]] = []
265
+ for batch in per_caller_batches:
266
+ try:
267
+ events.append(transform(batch, arg_names))
268
+ except Exception as e:
269
+ raise ValueError(f"Input transformation failed: {e}") from e
270
+
271
+ # Dispatch. Contexts are passed as a parallel list — handler can correlate
272
+ # per-caller metadata with each event by index.
273
+ with remote_function_invocation_span(context_metadatas, coalesced_count=len(context_metadatas)):
274
+ result = handler(events, context_metadatas)
275
+ if inspect.iscoroutine(result):
276
+ result = _run_async(result)
277
+
278
+ if not isinstance(result, list):
279
+ raise TypeError(f"Coalesced handler must return a list, got {type(result).__name__}")
280
+ if len(result) != len(events):
281
+ raise ValueError(f"Coalesced handler must return one result per input ({len(events)}); got {len(result)}")
282
+
283
+ # Coerce and encode each per-caller result.
284
+ output: list[bytes] = []
285
+ for i, item in enumerate(result):
286
+ try:
287
+ rb = _coerce_to_record_batch(item)
288
+ output.append(encode_record_batch(rb))
289
+ except Exception as e:
290
+ raise RuntimeError(f"Failed to encode response for caller {i}: {e}") from e
291
+
292
+ return output
293
+
294
+
295
+ def process_batches_coalesced_combined(
296
+ items_ipc_bytes: list[bytes],
297
+ handler: Callable[..., Any],
298
+ arg_names: list[str] | None,
299
+ context_metadatas: list[dict[str, Any]],
300
+ ) -> list[bytes]:
301
+ """Coalesced bridge, combined-RecordBatch variant.
302
+
303
+ Vertically concatenates every caller's batches into one RecordBatch,
304
+ builds a CSR-style offsets array marking caller boundaries, and invokes
305
+ the handler once. The handler returns one RecordBatch with row count
306
+ equal to the combined input; the framework slices it back into per-caller
307
+ responses (zero-copy on Arrow buffers).
308
+
309
+ Handler signature:
310
+ (combined: pa.RecordBatch, offsets: list[int], contexts: list[dict])
311
+ -> pa.RecordBatch
312
+
313
+ `offsets` has length `len(contexts) + 1`; caller `i` owns rows
314
+ `[offsets[i] : offsets[i+1])`.
315
+
316
+ Raises:
317
+ ValueError: empty input, length mismatch, schema mismatch across
318
+ callers, arg_names length mismatch, or handler output row count
319
+ doesn't match combined input.
320
+ TypeError: handler is a generator / async generator (not supported
321
+ in coalesced modes — each caller expects exactly one response
322
+ chunk).
323
+ """
324
+ if not items_ipc_bytes:
325
+ raise ValueError("process_batches_coalesced_combined called with no items")
326
+ if len(items_ipc_bytes) != len(context_metadatas):
327
+ raise ValueError(
328
+ f"items_ipc_bytes ({len(items_ipc_bytes)}) and context_metadatas "
329
+ f"({len(context_metadatas)}) lengths must match"
330
+ )
331
+ if inspect.isgeneratorfunction(handler) or inspect.isasyncgenfunction(handler):
332
+ raise TypeError("Generator/async-generator handlers are not supported when batching is enabled")
333
+
334
+ per_caller: list[pa.RecordBatch] = []
335
+ reference_schema: pa.Schema | None = None
336
+ for i, ipc_bytes in enumerate(items_ipc_bytes):
337
+ batches = decode_ipc_stream(ipc_bytes)
338
+ if not batches:
339
+ raise ValueError(f"Caller {i} produced no batches")
340
+ rb = _concat_batches(batches)
341
+ if reference_schema is None:
342
+ reference_schema = rb.schema
343
+ elif not rb.schema.equals(reference_schema):
344
+ raise ValueError(
345
+ f"Schema mismatch in coalesced batch: caller 0 has {reference_schema}, caller {i} has {rb.schema}"
346
+ )
347
+ per_caller.append(rb)
348
+
349
+ assert reference_schema is not None # guaranteed by the empty-input check above
350
+
351
+ offsets: list[int] = [0]
352
+ for rb in per_caller:
353
+ offsets.append(offsets[-1] + rb.num_rows)
354
+
355
+ table = pa.Table.from_batches(per_caller)
356
+ combined_batches = table.combine_chunks().to_batches()
357
+ if combined_batches:
358
+ combined = combined_batches[0]
359
+ else:
360
+ combined = pa.RecordBatch.from_pydict(
361
+ {name: [] for name in reference_schema.names},
362
+ schema=reference_schema,
363
+ )
364
+
365
+ if arg_names is not None:
366
+ try:
367
+ names = resolve_column_names(combined.schema.names, arg_names)
368
+ except ValueError as e:
369
+ raise ValueError(str(e).replace("RecordBatch has", "combined batch has")) from e
370
+ combined = combined.rename_columns(names)
371
+
372
+ with remote_function_invocation_span(context_metadatas, coalesced_count=len(context_metadatas)):
373
+ result = handler(combined, offsets, context_metadatas)
374
+ if inspect.iscoroutine(result):
375
+ result = _run_async(result)
376
+
377
+ if isinstance(result, pa.RecordBatch):
378
+ result_rb = result
379
+ else:
380
+ try:
381
+ result_rb = _coerce_to_record_batch(result)
382
+ except Exception as e:
383
+ raise RuntimeError(f"Failed to coerce handler output: {e}") from e
384
+
385
+ if result_rb.num_rows != combined.num_rows:
386
+ raise ValueError(
387
+ f"Combined handler must return a RecordBatch with {combined.num_rows} rows "
388
+ f"(matching input); got {result_rb.num_rows}"
389
+ )
390
+
391
+ output: list[bytes] = []
392
+ for i in range(len(per_caller)):
393
+ start = offsets[i]
394
+ length = offsets[i + 1] - start
395
+ sliced = result_rb.slice(start, length)
396
+ try:
397
+ output.append(encode_record_batch(sliced))
398
+ except Exception as e:
399
+ raise RuntimeError(f"Failed to encode response for caller {i}: {e}") from e
400
+ return output