Callbacks: .NET delegates as Python callables
Values have always crossed into Python. A .NET method could not: there was no way to
hand one to something that expects a function — key= to sorted(), DataFrame.apply,
a matplotlib event handler, a torch hook, a callback into business logic from a Python
script.
Any Action or Func<> can now be passed where Python expects a callable.
Passing a delegate
Anywhere a value is marshaled, a delegate becomes a Python function:
using var sorted = builtins.Call(
"sorted",
new object?[] { words },
new Dictionary<string, object?> { ["key"] = new Func<string, int>(s => s.Length) });
PyObject.FromDelegate returns the callable itself, for holding on to or passing more
than once:
using var classify = PyObject.FromDelegate(Classify);
using var result = module.Call("process", records, classify);
Disposing the PyObject releases PyDotNet's reference. The delegate stays alive for as
long as Python still holds one of its own — a callable stored in a Python object, a
registered hook, a partially applied function all keep working, and everything is released
when Python collects the last reference.
Arguments
Python's own rules apply, because a caller cannot tell this is not a Python function:
static string Classify(double amount, string currency = "USD") => ...;
classify(250.0) # currency falls back to the .NET default
classify(250.0, "EUR") # positional
classify(amount=250.0, currency="E") # keywords bind by .NET parameter name
A call that cannot be satisfied raises TypeError rather than being quietly adjusted:
| Call | Result |
|---|---|
| Too many positional arguments | TypeError: classify() takes 2 arguments but 3 were given |
| A required parameter with nothing to bind | TypeError: classify() missing required argument 'amount' |
| A keyword with no matching parameter | TypeError: classify() got an unexpected keyword argument 'currancy' |
| An argument that cannot be converted | TypeError: classify() could not convert argument 'amount' to Double: … |
The last two matter most. A misspelled keyword that was accepted silently would simply not happen, and there would be nothing to show for it.
Arguments and return values marshal by the usual rules. Named methods keep their name, so
fn.__name__ and Python tracebacks are readable; lambdas have no useful name and appear as
pydotnet_callback.
Exceptions
An exception thrown inside the delegate becomes a Python exception, so a failure inside a callback looks to Python like any other error:
try:
fn()
except KeyError as err:
... # KeyNotFoundException: account not found
The mapping is deliberately coarse, and the .NET type name is kept in the message rather than discarded to fit a Python type:
| .NET | Python |
|---|---|
ArgumentOutOfRangeException, IndexOutOfRangeException |
IndexError |
KeyNotFoundException |
KeyError |
ArgumentException (including ArgumentNullException) |
ValueError |
FormatException |
ValueError |
InvalidCastException |
TypeError |
NotImplementedException, NotSupportedException |
NotImplementedError |
OverflowException |
OverflowError |
DivideByZeroException |
ZeroDivisionError |
TimeoutException |
TimeoutError |
OutOfMemoryException |
MemoryError |
IOException |
OSError |
| anything else | RuntimeError |
An exception that started in Python is raised again as the type it was, so a round trip does not degrade it:
using var callable = PyObject.FromDelegate(new Action(() =>
{
interp.Execute("raise ValueError('the original problem')"); // PyValueError in .NET
}));
Python sees a ValueError again, not a RuntimeError wrapping one. See
Exception handling for how Python exceptions arrive on the .NET side.
The GIL
The delegate runs with the GIL held, which is what Python guarantees any callable it invokes. Two consequences:
A delegate can use PyDotNet directly. Creating an interpreter, evaluating, importing — all of it works inside the callback, which is what a callback doing real work needs.
Long-running .NET work blocks Python. The GIL is not released around the delegate, and
releasing it would be wrong here: sorted(key=...) is midway through a list when it calls
back, and a callback that observed a half-mutated interpreter would be worse than a slow
one. Keep callbacks short, or hand the work to a Task the caller awaits outside Python.
Asynchronous callbacks
A delegate returning Task, Task<T>, ValueTask or ValueTask<T> becomes an awaitable:
using var fetch = PyObject.FromDelegate(new Func<string, Task<string>>(async url =>
{
using var response = await http.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}));
async def collect(urls, fetch):
return await asyncio.gather(*(fetch(u) for u in urls))
await suspends the calling coroutine rather than blocking it, so the three fetches
above overlap. That is the whole point: a callback that blocked the event loop while .NET
work ran would stall every other coroutine on it.
A Task or ValueTask with no result completes the await with None.
What it needs from the caller
The future is created on the loop the caller is running on, so an async callback has to be
called from somewhere its result can be awaited. Calling one outside a coroutine raises
RuntimeError naming the reason, rather than asyncio's bare "no running event loop".
Failure
A faulted task raises on the Python side through the same
mapping the synchronous path uses. The AggregateException a faulted Task
carries is unwrapped first, so what Python sees is the exception the delegate actually
threw. A cancelled task raises too rather than leaving the await pending forever.
Throwing before returning a task — argument validation, say — fails the call synchronously instead, because at that point there is no future to complete.
Not yet: cancelling from Python
Cancelling the future on the Python side stops the await, but does not cancel the .NET
task, which runs to completion with its result discarded. Propagating cancellation into a
CancellationToken parameter is tracked separately in
#98.
Limitations
No by-reference parameters. out and ref have no Python equivalent — there is
nothing for the caller to write back into — so a delegate using them is rejected, naming
the parameter.
Sample
dotnet run --project samples/PyDotNet.Sample.Callbacks
Covers sorted(key=...), a Python pipeline calling into .NET business logic with keyword
arguments and defaults, a .NET exception caught by Python, a Python exception round-tripped
through .NET, and the argument rules.