Writing extensions

Python code can call compiled code for speed, or to reach a library that is not written in Python. SteelPython supports two kinds of extension:

C extensions C# extensions
Written in C, C++, Cython, Rust (PyO3) ... - anything that targets CPython's C API C# (or any .NET language)
Built with A C compiler (MSVC on Windows), through setuptools and pip The .NET SDK (dotnet build)
Loaded with import name, like any module import name too: the .dll on sys.path, like an extension module
Runs on CPython too Yes: the same source builds for both No (CPython would need pythonnet)
Best for Wrapping C libraries; code that must also run on CPython; existing extension packages .NET libraries and services; fast code without a C toolchain; sharing types with a C# application

The extensions other people publish need no work at all: wheels with C extensions install from PyPI as they do for CPython (Packages and environments).

Extensions in C

SteelPython runs extension modules written against CPython 3.12's C API, so the official guide applies as written: Extending and Embedding the Python Interpreter, with the C API reference. The limited API and stable ABI (Py_LIMITED_API, abi3 wheels) work too. Code generated by Cython, pybind11, PyO3 and mypyc uses the same API, and packages built with each of them run on SteelPython.

Tutorial: a module in C

You need the Microsoft C++ Build Tools (the Desktop development with C++ workload), as you would for CPython. SteelPython provides the headers (Include\) and import libraries (libs\); you do not need to point anything at them.

Make a folder fastgeo with three files.

src/fastgeo.c:

#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <math.h>

/* distance(x1, y1, x2, y2) -> float */
static PyObject *
distance(PyObject *self, PyObject *args)
{
    double x1, y1, x2, y2;
    if (!PyArg_ParseTuple(args, "dddd", &x1, &y1, &x2, &y2))
        return NULL;
    return PyFloat_FromDouble(hypot(x2 - x1, y2 - y1));
}

static PyMethodDef methods[] = {
    {"distance", distance, METH_VARARGS, "Distance between two points."},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef module = {
    PyModuleDef_HEAD_INIT, "fastgeo", "Geometry helpers written in C.", -1, methods
};

PyMODINIT_FUNC
PyInit_fastgeo(void)
{
    return PyModule_Create(&module);
}

setup.py:

from setuptools import setup, Extension

setup(ext_modules=[Extension("fastgeo", ["src/fastgeo.c"])])

pyproject.toml:

[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

[project]
name = "fastgeo"
version = "0.1.0"

Build and install it into a virtual environment, then use it:

spy -m venv .venv
.venv\Scripts\activate
spy -m pip install ./fastgeo
spy -c "import fastgeo; print(fastgeo.distance(0, 0, 3, 4))"
5.0

pip compiled fastgeo.cp312-win_amd64.pyd, the same file name CPython 3.12 uses. To share it, build a wheel with spy -m pip wheel ./fastgeo -w dist.

Things to keep in mind

  • Reference counting works as the C API documents: keep your Py_INCREF/Py_DECREF balanced, as you would for CPython.
  • The GIL: SteelPython has none, but the C API's calls for it (PyGILState_Ensure, Py_BEGIN_ALLOW_THREADS, ...) work, and an extension that uses them correctly is correct on SteelPython. Python threads do run in parallel, so C code that keeps global state and relied on the GIL to serialise calls into it should protect it with a lock of its own, as for the free-threaded builds of CPython.
  • CPython internals: use the C API rather than reading interpreter structures directly. Code that includes CPython's internal/ headers, or reads fields of frames and the interpreter state, is not supported.
  • Speed: each call from Python into C, and each C API call back, costs somewhat more than on CPython. Do a reasonable amount of work per call (a loop over an array in C rather than one call per element) and the difference disappears.

Extensions in C#

A .NET class library is an extension for SteelPython. There are two levels.

Any class library

Every public type of a .NET assembly can be used from Python, with no special preparation. A class library:

// Units.cs, in a project made with: dotnet new classlib -n Units
namespace Units;

public enum Unit { Metre, Foot, Inch }

public static class Converter
{
    public static double ToMetres(double value, Unit unit) => unit switch
    {
        Unit.Foot => value * 0.3048,
        Unit.Inch => value * 0.0254,
        _ => value,
    };
}

Build it (dotnet build -c Release), put Units.dll next to your script, and:

from Units import Converter, Unit          # finds Units.dll on sys.path: here, the script's folder

print(Converter.ToMetres(10, Unit.Foot))   # 3.048

Its types import from their C# namespace, and behave as described in Using .NET from Python.

A library made for Python

With the SteelPython NuGet package (part of the SteelPython SDK), a library can choose the Python module its types live in, get bindings generated at compile time (faster calls, and ready for Native AOT), and offer classes that Python code subclasses. Reference the package and mark the types:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- a library does not need its own copy of the standard library -->
    <SteelPythonCopyStdlib>false</SteelPythonCopyStdlib>
    <SteelPythonCopyCApiShim>false</SteelPythonCopyCApiShim>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="SteelPython" Version="0.1.0" />
  </ItemGroup>
</Project>
using SteelPython.Interop;

namespace Geometry;

[PythonExport(Module = "geometry")]           // import geometry; geometry.Vector
public class Vector
{
    public Vector(double x, double y) { X = x; Y = y; }

    public double X { get; set; }
    public double Y { get; set; }
    public double Length => Math.Sqrt(X * X + Y * Y);

    public Vector Scale(double factor = 2) => new(X * factor, Y * factor);
    public static Vector operator +(Vector a, Vector b) => new(a.X + b.X, a.Y + b.Y);
    public override string ToString() => $"Vector({X}, {Y})";
}

[PythonSubclassable(Module = "geometry")]     // Python classes may derive from it
public abstract class Shape
{
    public abstract double Area();
    public virtual string Describe() => $"a shape of area {Area():0.##}";

    public static double TotalArea(IEnumerable<Shape> shapes) => shapes.Sum(s => s.Area());
}

[PythonExport(Module = "geometry")]
public static class Survey
{
    public static async Task<int> CountAsync(int n)
    {
        await Task.Delay(10);
        return n;
    }
}

From Python:

import asyncio
from geometry import Vector, Shape, Survey  # Geometry.dll, next to the script; the module its attributes name

v = Vector(3, 4)
print(v.Length, v + Vector(1, 1), v.Scale(), v.Scale(factor=0.5))
# 5.0 Vector(4, 5) Vector(6, 8) Vector(1.5, 2)

class Square(Shape):                 # a Python class deriving from the C# class
    def __init__(self, side):
        super().__init__()
        self.side = side
    def Area(self):                  # overrides the abstract method
        return self.side ** 2
    def Describe(self):
        return "a square, " + super().Describe()

print(Shape.TotalArea([Square(2), Square(3)]))   # C# calls the Python Area(): 13.0
print(Square(3).Describe())                      # a square, a shape of area 9

async def main():
    return await Survey.CountAsync(7)            # a C# Task, awaited by asyncio
print(asyncio.run(main()))                       # 7

The attributes:

Attribute On Does
[PythonExport] classes, structs, static classes, enums Generates the type's binding; Module and Name choose where it imports from and what it is called
[PythonSubclassable] classes Also lets Python classes derive from it, overriding its virtual and abstract members
[PythonInterface] interfaces Lets any Python object with the right methods be passed where C# expects the interface
[PythonName("...")] members Gives a method, property or event another name in Python (greet_all for GreetAll)
[PythonIgnore] members Hides a member from Python

Methods may have default and params parameters and are callable with keyword arguments; overloads are chosen by argument types; properties, indexers, events and operators map to their Python equivalents; Task results are awaitable. Generic types and methods, and members with ref or out parameters, are left out of a generated binding.

Shipping a C# extension as a Python package

To install a C# extension with pip like any other package, put the assembly inside a Python package and load it from the package's __init__.py, by its path:

# geometry_tools/__init__.py
import os
import clr

clr.AddReference(os.path.join(os.path.dirname(__file__), "Geometry.dll"))
from geometry import Vector, Shape, Survey

Include Geometry.dll as package data in the package's pyproject.toml, and import geometry_tools then works wherever the package is installed.