Twelve single-file C# tutorials, one per feature area of
NumSharp.Interop.pythonnet — the zero-copy
bridge between NumSharp NDArray and the Python ecosystem (numpy, PEP 3118 buffers, PyTorch,
Pandas, custom adapters) through Python.NET. Together they touch every public member of the
package.
They are written to be read, top to bottom: each line does one thing and the comment beside it
states what that line leaves behind (// nd is now [11. 1. 2. 3.], // (48, 8),
// InvalidOperationException: ...). They print nothing and assert nothing. The proof lives next
door: every script has a twin under
test/NumSharp.Tests.Interop/Examples — the same
code, section by section, with every one of those comments turned into an assertion and run against
live CPython on every CI push.
Requirements: the .NET 10 SDK, and a python on PATH that can import numpy (or point
PYTHONNET_PYDLL at a python3xx.dll / libpython3.xx.so). Install the Python side once:
python -m pip install -r requirements.txt # numpy is required; torch/pandas/pyarrow/pillow are optionalThen, from this directory:
dotnet run 02-four-verbs.cs # one script (the first run builds NumSharp; later runs take ~2 s)
pwsh ./verify.ps1 # every script, in order; exit 1 if any fails to run to completion
pwsh ./verify.ps1 -Examples 06-lifetime, 09-pytorchA script that needs an optional package (09-pytorch.cs, 10-pandas.cs, and the Pillow / PyArrow
/ torch sections of 05-buffers-and-libraries.cs) skips that part quietly when the package is not
installed. Every file is a .NET 10
file-based app: its first line
references the in-repo project, and swapping that line for
#:package NumSharp.Interop.pythonnet@<version> makes it a standalone program you can copy
anywhere. Because a single-file app cannot share source with its siblings, the ~80-line scaffolding
at the bottom of each file (PythonHost: CPython discovery, engine startup, codec registration, a
namespace with import numpy as np, crash-free shutdown; Throws: the exception a line is expected
to raise) is repeated verbatim; the tutorial is everything above it.
Three things shape every script, and they are the three things worth taking away:
using var host = PythonHost.Start(); // CPython + numpy, the engine, the codec
using var scope = NDScope.Open(); // every NDArray built below is released when the scope closes
dynamic py = host.Namespace; // a Python module: talk to it under Py.GIL()
NDArray nd = np.arange(4).astype(np.float64);
using (Py.GIL())
{
py.v = nd; // NDArray -> numpy: a zero-copy view (the codec ran ToNumpy)
py.Exec("v[0] = 11.0"); // Python writes; nd is now [11. 1. 2. 3.]
double v2 = py.v[2]; // read back with the natural C# type
bool shared = py.np.shares_memory(py.v, py.v);
NDArray back = py.np.sin(py.v / 3.0); // numpy -> NDArray: a view of the fresh result (AsNDArray, implicitly)
(long, long) shape = py.np.zeros((2, 3)).shape; // C# tuples cross as Python tuples and back
}dynamicfor the Python side. The namespace is aPyModuleheld asdynamic: attributes bind by name,NDArrayarguments and results convert through the registered codec, kwargs are C# named arguments (numpy.arange(6, dtype: "f8")), and a Pythontuplereads into a C# one. The explicit verbs (nd.ToNumpy(),pyObj.AsNDArray(), ...) appear where a verb is the subject. Two spellings stay Python strings on purpose: an element write (py.Exec("v[0] = 11.0")— adynamicindexer setter needs aPyObject) and a multi-index read (py.Eval("src[1, 2]"), ora.item(1, 2)); a numpy integer scalar converts throughint(...)/.item()(it is not a Pythonint).- One
NDScopeper script, opened right after the engine. EveryNDArrayconstructed on the thread while it is open — slices, transposes, import views — is released when it closes, so no script disposes an array by hand; a loop opens a nested scope per iteration, and a helper that hands an array out yields it withscope.Returns(...).06-lifetime.csis where this meets the interop's own pins and leases. Py.GIL()around the Python parts. The conversion verbs take the GIL themselves; your ownPyObject/dynamicwork runs inside ausing (Py.GIL())block, and NumSharp work needs none.
One trap the scripts point out as they go: a dynamic read of a numpy array (py.win) hands out a
CLR wrapper that keeps its own Python reference until the garbage collector reaches it — harmless
in general, but where a reference count is the subject (06-lifetime.cs) the scripts read the
object as using PyObject instead, and a tight loop reads a statistic through py.Eval("win.std()")
rather than through the array. (Encodes are covered: the codec releases the wrapper it hands
pythonnet at the next conversion, so py.win = window in a loop keeps exactly one export live.)
| Script | Feature area | What it walks through |
|---|---|---|
01-bootstrap.cs |
Engine lifecycle | discovering CPython, Initialize / BeginAllowThreads, the pythonnet-vs-Python version guard, RegisterCodec() ordering, the first shared buffer, converting from a thread that never touched Python, Shutdown() with NoopFormatter |
02-four-verbs.cs |
The four verbs | ToNumpy (view) / ToNumpyCopy / ToNDArray (copy) / AsNDArray (view), the aliases ToPython, ToNumpy(copy:true), ToNDArrayView; the codec's implicit spellings py.x = nd / NDArray a = py.x; write-through both ways; read-only sources and allowReadonly; import views own nothing (resize refusal, np.require("O")); FromArrayLike |
03-layouts.cs |
Layouts | export keeps strides/flags for C, row/column slices, transpose, reversed (negative strides), Fortran, broadcast (read-only), 0-d, empty — as one table; the ctypes-window / as_strided base chain; the three zero-copy import routes (contiguous exporters, strided numpy via __array_interface__, strided memoryviews via PyBUF.STRIDED); the only layouts that copy |
04-dtypes.cs |
Dtypes | the 14 mappable dtypes both ways; ToNumpyDtypeStr / FromNumpyDtypeStr / ToBufferFormat / FromBufferFormat; bit-exact specials (NaN payloads, -0.0, uint64 maxima); Decimal → float64, complex64 widening, UCS-4 narrowing, big-endian byte-swapping, refused dtypes |
05-buffers-and-libraries.cs |
Buffers & libraries | numpy-agnostic import of bytes/bytearray/memoryview/array.array (12 typecodes)/ctypes/BytesIO/mmap/shared memory; ToMemoryView for struct, hashlib, zlib, sockets, np.frombuffer; Pillow, PyArrow, torch.frombuffer |
06-lifetime.cs |
Lifetime | LiveExports / LiveImports; an export outlives every managed reference (derived numpy views included); an import lease outlives every Python reference (derived slices included, yielded out of a nested NDScope); resize locks on both sides; 120 rounds of churn under per-round scopes; engine shutdown draining imports and sweeping orphaned exports |
07-codec.cs |
The codec | RegisterCodec() and the cached-miss ordering trap; implicit encode at py.x = nd, call arguments and dynamic numpy; C# tuples as shapes and multi-indices; As<NDArray>() / AsManagedObject / NDArray a = py.x; what CanDecode accepts; NumpyCodecMode Auto / View / Copy via standalone NumpyCodec instances; DecodeAnyBuffer, DecodeArrayLike, DecodeArrayAdapters, ConvertTuples |
08-gil.cs |
The GIL | verbs acquire the GIL themselves; the requireGIL:false hot loop; the process-wide RequireGIL opt-out; a contention proof; worker threads with their own scopes; the Python → .NET callback trap (PyGILState_Check() == 0); the background drain being immune to the policy |
09-pytorch.cs |
PyTorch | ToTorch (view / copy:true), torch.Size and strides as C# tuples, all 15 dtypes, unsafe layouts; AsTorchNDArray / ToTorchNDArray(force); autograd round trip; requires_grad / conj / CUDA / bfloat16 / sparse / meta boundaries; expand() as a read-only broadcast; torch.from_numpy(nd) through the codec; resize refusal |
10-pandas.cs |
Pandas | Series / DataFrame / Index / RangeIndex / nullable arrays through the adapter; read-only Copy-on-Write projections and allowReadonly; what materializes (mixed blocks, pd.NA, categorical, text); labels as metadata; pd.Series(nd, copy=False); lifetime; DecodeArrayAdapters |
11-custom-adapter.cs |
Extensibility | an IPythonArrayAdapter for a buffer-less library object; RegisterArrayAdapter / PythonArrayAdapterRegistry.Register idempotence; declining with null; the adapter serving the direct verbs and the codec; a throwing adapter cannot break the pipeline |
12-scenarios.cs |
Applications | ML inference hand-off, an in-place image pipeline, a telemetry ring, a Python dataset outliving its references under NumSharp kernels, a mixed-ownership co-simulation, a codec service loop, generators, exceptions, JSON, faulty inputs |
test/NumSharp.Tests.Interop/Examples/Example01_Bootstrap.cs … Example12_Scenarios.cs are the
twins: one test class per script, one test method per section, the same code with the comments
asserted (dotnet test test/NumSharp.Tests.Interop -f net10.0 --filter "FullyQualifiedName~Examples").
They inherit the suite's per-test leak gate, so each section is also a no-leak proof. The rest of
test/NumSharp.Tests.Interop (630+ tests against live CPython) is the package's contract, and the
website pages Python & numpy,
PyTorch,
Pandas and
np.frombuffer are its reference.