Skip to content

.NET Interop

Importing .NET Types

from system.collections.generic import List, Dictionary
from system.io import File, Path

# Use .NET types directly
# As of right now, this example is redundant because Sharpy
# uses the .NET collection types directly, e.g `list[T]`, so
# no explicit import is required.
items = List[int]()
items.add(42)

content = File.read_all_text("data.txt")

Import Name Fidelity

CLR type and namespace names bind verbatim from the reflected metadata. The Pythonic→PascalCase name mangler applies only to Sharpy-defined identifiers and member accesses — import targets use the exact spelling from System.Type. This holds for every spelling that denotes the type: an as alias binds the same type the un-aliased spelling binds and is emitted from the same reflected name (from system import Guid as G emits System.Guid, never the mangled System.GUID), and a bare CLR-only type name used as an annotation resolves through the well-known namespaces and is always emitted fully qualified.

from system import Guid          # System.Guid (not GUID)
from system import Guid as G     # the alias binds System.Guid, not System.GUID
from sharpy import ISized as IS  # a Sharpy-namespace type; the alias binds the same symbol


class Ruler:
    def __len__(self) -> int:
        return 12


def measure(x: IS) -> int:
    return len(x)


def take(sb: StringBuilder) -> int:  # bare CLR-only annotation, no import — resolves and is qualified
    return sb.length


def main() -> None:
    g = G.new_guid()
    print(len(str(g)))       # 36
    print(measure(Ruler()))  # 12

.NET Properties

.NET properties accessed like Sharpy properties:

from system.io import FileInfo

file = FileInfo("data.txt")
size = file.length
name = file.name

Name Mapping (snake_case to PascalCase)

Sharpy uses Python-style snake_case naming, while .NET uses PascalCase. The compiler automatically maps between these conventions when accessing .NET members:

from system import Console

# Sharpy snake_case maps to .NET PascalCase
Console.write_line("Hello")       # Calls System.Console.WriteLine("Hello")
Console.read_line()               # Calls System.Console.ReadLine()

from system.io import File
content = File.read_all_text("data.txt")  # Calls System.IO.File.ReadAllText(...)

This mapping applies to method names, property names, and static members. The compiler resolves snake_case identifiers to their PascalCase .NET equivalents at compile time.

Members a framework finds by name

A member you declare is emitted through the same mapping, so def on_gui(self) becomes OnGui. A snake_case name cannot spell an acronym, and a framework that finds a member by name through reflection — a Unity message such as OnGUI, or anything that calls Type.GetMethod("…") — finds only the exact spelling. Declare such a member with the backtick spelling, which is emitted verbatim: def `OnGUI`(self). The compiler does not warn about the near miss: a name a framework looks up is not an inherited member, so there is nothing for it to compare against.

class Mangled:
    def on_gui(self) -> None:      # emitted as OnGui
        pass

class Escaped:
    def `OnGUI`(self) -> None:     # emitted verbatim as OnGUI
        pass

def main() -> None:
    # What a reflective framework does: look the method up by its exact name.
    m: object = Mangled()
    e: object = Escaped()
    print(m.get_type().get_method("OnGUI") is None)   # True: there is only OnGui
    print(e.get_type().get_method("OnGUI") is None)   # False

Sharpy builtin receivers expose their Sharpy names only

A Sharpy builtin — list, dict, set, frozenset, frozendict, array, str, bytes — wraps a .NET type, but its surface is the Sharpy one. A PascalCase .NET spelling of a wrapper member is refused with a steer to the Sharpy spelling, rather than binding the wrapper's C# member and silently producing a System.Func or an internal view type:

def main() -> None:
    xs: list[int] = [1, 2, 3]
    print(xs.Count)     # error[SPY0203]: no member 'Count' — use len(xs)

Use the Sharpy spelling the diagnostic steers to: len(xs) for a length or count, d.keys() / d.values() for a dictionary's views, xs.append(...) for the Add verb.

The backtick escape deliberately reaches the wrapper's .NET member, and the reverse-mangled snake_case spelling of a .NET member stays typed:

def main() -> None:
    xs: list[int] = [1, 2, 3]
    n: int = xs.`Length`     # the escape binds the wrapper's .NET Length
    print(n)                 # 3
    print(xs.`IndexOf`(2))   # the escape CALLS the wrapper's .NET IndexOf -> 1

    s: str = "abc"
    print(s.to_upper())      # System.String.ToUpper reached by reverse-mangling -> ABC

The escape names a .NET member verbatim. An escape that names no member of the wrapper's .NET surface — instance or extension — is an absent member (SPY0203), steered to the Sharpy spelling, in every position: as a value and as a callee alike. count is the Sharpy name; the .NET member is Count, so the escape spells nothing:

def main() -> None:
    xs: list[int] = [1, 2, 2]
    print(xs.`count`(2))     # error[SPY0203]: no member 'count' — use xs.count

A method named on a Sharpy builtin receiver but not called — reached as a value rather than as a callee — is a method group nothing can spell, in every one of the three spellings (the Sharpy surface xs.count, the reverse-mangled xs.get_hash_code, and the backtick escape .``GetHashCode). It is refused (SPY0336) with a steer to call it, target-type it, or wrap it in a lambda — the same rule any .NET method group in value position gets:

def main() -> None:
    xs: list[int] = [1, 2, 3]
    f = xs.count       # error[SPY0336]: 'count' on a Sharpy builtin is a method group, not a value
    print(f(2))

A function target type at the reference site selects the method, so the reference runs:

def main() -> None:
    xs: list[int] = [1, 2, 3]
    counter: (int) -> int = xs.count    # the target type selects the overload
    print(counter(2))                    # 1

The tuple receiver is exempt: its .item1 / .Item1 element spellings are typed from the tuple's element types.

Subclassing .NET Types

A Sharpy class may extend a .NET class. Overriding one of its abstract or virtual methods follows the same rule as overriding a Sharpy base method: the override requires @override (see inheritance.md and dunder_invocation_rules.md), whether the base came from a .NET namespace or from a Sharpy module. The base method is found by the snake_case name, so compare overrides Comparer<T>.Compare. The only methods exempt are __str__, __eq__ and __hash__, which implicitly override System.Object members at any depth. Implementing a .NET interface is not an override.

import system.collections.generic as scg

class Descending(scg.Comparer[int]):
    @override
    def compare(self, x: int, y: int) -> int:
        return y - x

def main() -> None:
    c: scg.Comparer[int] = Descending()
    print(c.compare(1, 2))    # 1

Leaving out @override is an error (SPY0248: "Method 'compare' overrides an abstract method in base class 'Comparer' and requires the @override decorator").

Nested .NET types

A nested .NET type — a type declared inside another, such as System.Environment.SpecialFolder — is reached through its declaring type. The chain denotes a type, so it works in every position a type does: as a value (an enum member), as a type annotation, and as an isinstance operand.

from system import Environment


def main() -> None:
    # Value position: the enum member reached through the nested type.
    folder = Environment.SpecialFolder.Desktop
    print(folder)                                          # Desktop

    # Annotation position: the nested type names a variable's type.
    chosen: Environment.SpecialFolder = Environment.SpecialFolder.Desktop
    print(chosen)                                          # Desktop

    # isinstance position: the nested type is the type being tested against.
    value = 5
    print(isinstance(value, Environment.SpecialFolder))    # False

Because the chain resolves to the enum's own type, assigning it into an unrelated slot is a type error, not a silently-accepted value: flag: bool = Environment.SpecialFolder.Desktop is refused (SPY0220), the same as any other type mismatch.

Extension Methods

System.Linq.Enumerable's extension methods are available on any sequence, under their snake_case names. No import is needed — using System.Linq; is always emitted:

def main():
    numbers = [1, 2, 3, 4, 5]
    print(list(numbers.where(lambda x: x % 2 == 0)))   # [2, 4]
    print(list(numbers.select(lambda x: x * 2)))       # [2, 4, 6, 8, 10]

An extension method must be called. Referenced without calling it — as a value rather than a callee — an extension method group is refused (SPY0336), the same as any other .NET method group in value position; there is no single delegate type to give the reference. Call it, or wrap it in a lambda.

def main() -> None:
    numbers = [1, 2, 3]
    f = numbers.first_or_default    # error[SPY0336]: 'first_or_default' is a CLR method group
    print(f)

Type arguments are inferred

The type arguments are inferred from the receiver and the arguments, so the call has a Sharpy type and can be used wherever that type is expected — wrapped, annotated, or chained:

from system.collections.generic import List


def main():
    lst: List[int] = List[int]()
    lst.add(3)
    lst.add(4)

    print(list(lst.select(lambda x: str(x))))                        # ['3', '4']
    print(list(lst.select(lambda x: x * 2).where(lambda y: y > 6)))   # [8]

Inference proceeds in stages: the receiver fixes what it determines, each lambda is then checked with those types in place, and its return type fixes the rest. Three stages is the deepest any method on the surface needs.

Write the type arguments explicitly when inference cannot reach them — cast and of_type are the two whose result is determined by nothing else:

from system.collections.generic import List


def main():
    lst: List[int] = List[int]()
    lst.add(3)
    print(list(lst.cast[int]()))   # [3]

An instance member always wins

A name that exists as an instance member on the receiver resolves to that member, never to the extension method — C#'s own rule. Several names are on both surfaces, and they mean different things:

def main():
    xs: list[int] = [1, 2, 3]
    xs.reverse()          # list.reverse() — in place, returns None
    print(xs)             # [3, 2, 1]
    print(xs.count(2))    # list.count(value) — occurrences, not length
    print("-".join(["a", "b"]))   # str.join, not Enumerable.Join

reverse, count, index, contains, append, to_list, to_dictionary, to_hash_set, union and join all fall in this class. Which one binds depends on the receiver: a Sharpy list has reverse, a raw system.collections.generic.List has Reverse, and both keep their own meaning.

Where inference cannot close, nothing changes

A call whose type arguments cannot be determined is left exactly as it was: no type is recorded, no diagnostic is reported, and the emitted C# infers the vector itself. This is the normal outcome for an ambiguous overload and for a result type Sharpy cannot represent — group_by yields IGrouping<K, T> elements the bridge can only call object, so nothing is recorded and the emitted C# keeps the precise type. Two decidable cases are carved out of that silence: when the arguments make exactly one overload of an ambiguous-arity static call applicable, its result is typed like any single-overload call, and when an annotated destination is compatible with none of the candidates' return types, the assignment is refused up front (naming the candidate set) rather than deferred to a C# error:

from system.collections.generic import List


def main() -> None:
    lst: List[int] = List[int]()
    lst.add(3)
    lst.add(1)
    groups = lst.group_by(lambda v: v % 2)   # no Sharpy type, still iterates
    for g in groups:
        print(g.key)                          # 1

Such a call cannot be wrapped or annotated, because there is no type to wrap. Only the explicit spelling recovers that.

A receiver that is not a sequence is a different case, and it is refused rather than left alone: no overload of the name accepts it, so no argument list could make the call bind and deferring to codegen would only turn a type error into an internal error.

def main() -> None:
    n: int = 5
    for x in n.select(lambda v: v):
        print(x)
error[SPY0203]: Type 'int' has no member 'select'. 'select' is a .NET extension method on
'IEnumerable'; chain it onto an expression of that type

CLR Sequences and Sharpy Collections

ClrTypeBridge translates .NET signatures into Sharpy vocabulary, and that translation collapses several CLR types onto one spelling: List<T>, IList<T>, IReadOnlyList<T> and IEnumerable<T> all appear as list[T]. The collapse keeps signatures readable, but it means a list[T] you see in a .NET signature and a list[T] you write in Sharpy source are not the same thing. Three rules follow, and together they cover every position.

A .NET parameter accepts exactly what .NET accepts. A parameter that came from CLR metadata remembers the CLR type it was mapped from, so a CLR sequence satisfies it whenever the runtime says it does — which is the same set of calls C# would bind. Nothing is converted, because the real parameter is still the CLR type:

from system.collections.generic import List


def main() -> None:
    outer = List[int]()
    outer.add(1)
    inner = List[int]()
    inner.add(2)
    print(list(outer.concat(inner)))      # [1, 2] — a CLR List[int] against IEnumerable<int>

A Sharpy slot means a Sharpy collection, so a .NET sequence entering one is materialized. When a value whose runtime form is a CLR sequence is bound to a Sharpy variable, returned as a declared list[T], or passed to a parameter written list[T] in Sharpy source, it is converted into a real Sharpy collection. This is Python's list(...), applied implicitly — including its copy semantics:

from system.collections.generic import List


def main() -> None:
    lst: List[int] = List[int]()
    lst.add(3)
    ys = lst.select[str](lambda x: str(x))   # a Sharpy list from here on
    ys.append("z")
    print(ys)                                 # ['3', 'z']

Because the conversion copies, mutating the result does not affect the .NET collection it came from, exactly as b = list(a) in Python leaves a alone.

A mapping materializes the same way. A value whose runtime form is a CLR mapping — an IDictionary<K, V> or IReadOnlyDictionary<K, V> — materializes into a dict[K, V] exactly as a .NET sequence materializes into a list[T]:

from system.diagnostics import ProcessStartInfo


def main() -> None:
    p: ProcessStartInfo = ProcessStartInfo()
    env: dict[str, str] = p.environment
    env["SHARPY_HOME"] = "/opt/sharpy"
    print(env["SHARPY_HOME"])                 # /opt/sharpy

An ordered sequence is not collapsed, because a Sharpy list cannot stand in for one. Everything above holds for CLR types a Sharpy.List<T> can replace — it is an IEnumerable<T>, an IList<T>, an ICollection<T> — so materializing loses nothing. order_by returns IOrderedEnumerable<T>, which then_by extends and a list is not, so order_by's result keeps its own type and an inferred slot chains exactly as an unbroken chain does (#1390):

from system.collections.generic import List


def main() -> None:
    lst: List[int] = List[int]()
    lst.add(3)
    lst.add(1)
    xs = lst.order_by(lambda v: v)     # an ordered sequence, not a list
    for y in xs.then_by(lambda v: -v):
        print(y)                        # 1 then 3

The trade is real and the annotation is how you choose: an inferred slot holds an ordered sequence, so it chains but has no list surface (len(xs) draws SPY0320). Write list[T] and you get the materialization instead, and with it the loss of then_by — which is now reported rather than deferred to the C# compiler:

    xs: list[int] = lst.order_by(lambda v: v)   # fine: a Sharpy list[int]
    xs.then_by(lambda v: -v)                    # error[SPY0203]: list[int] has no member 'then_by'

Reading a CLR property is one of those positions. A property whose declared type is a CLR sequence materializes on read under the same rule, so what you get back is a Sharpy collection with a Sharpy collection's surface — indexable and len()-able — not the bare IEnumerable<T> the metadata names (#1294). A property whose CLR type is IEnumerable<T> therefore reads as list[T] — a copy, not a window onto the declaring object.

That rule is about the type of a read, and it does not make every .keys spelling a value. The stdlib mapping types deliberately expose keys(), values() and items() as methods, matching CPython, so c.keys on a Counter is the method itself and c.keys() is the sequence (#1391):

from collections import Counter


def main() -> None:
    c = Counter[str](["a", "b", "a"])
    ks = c.keys()        # call it — this is the sequence
    print(len(ks))       # 2

The returned sequence is a copy, not a live view: mutating the counter afterwards does not change ks, and appending to ks does not change the counter. CPython's dict_keys is a live view, so this is a documented divergence — see docs/deviations.yaml.

Assigning a .NET collection itself to a Sharpy annotation is refused. The conversion above happens where a sequence expression meets a Sharpy slot; naming a CLR collection and annotating it as a Sharpy one is a different request, and Sharpy will not silently copy for it:

from system.collections.generic import List


def main() -> None:
    clr = List[int]()
    xs: list[int] = clr        # error: cannot assign 'List[int]' to variable of type 'list[int]'
    ys: list[int] = list(clr)  # write the copy explicitly

The refusal is deliberate: accepting it would decide, invisibly, that mutations through clr are no longer visible through xs. Writing list(clr) says so.

Slicing a .NET array produces a list[T], since a slice is a new sequence and Sharpy's word for a growable sequence is list. Plain indexing still yields the element:

import sys


def main() -> None:
    for a in sys.argv[1:]:     # a list[str]
        print(a)

Overloaded Method Imports

When a .NET type has overloaded methods (multiple methods with the same name but different parameter signatures), importing the type makes all overloads available. The compiler resolves the correct overload at each call site based on the argument types:

from system import Convert

# Convert.ToInt32 has many overloads; compiler picks the right one
n1 = Convert.to_int32("42")        # ToInt32(string)
n2 = Convert.to_int32(3.14)        # ToInt32(double)
n3 = Convert.to_int32(True)        # ToInt32(bool)

If the compiler cannot unambiguously resolve an overload, it reports a compile-time error listing the candidate overloads.

Declared Nullability

Sharpy reads the nullable reference type (NRT) annotations that .NET members declare. A member returning string? is typed str | None; a member returning string is typed str. This applies to properties, fields, method returns, and parameters through one projection.

The annotation is read at EVERY position of the declared type, not only the outermost one: a member declared List<object?> is list[object | None], one declared IDictionary<string, string?> is dict[str, str | None], and string?[] is array[str | None]. Because a generic argument position is invariant, that is also the spelling a caller has to write.

import yaml

def main() -> None:
    # yaml.safe_load_all is declared List<object?> — one document per element,
    # and an empty document reads back as None.
    docs: list[object | None] = yaml.safe_load_all("a: 1\n---\nb: 2\n")
    print(len(docs))                        # 2
    print("---" in yaml.safe_dump_all(docs))  # True
import system.io

def main():
    # Path.get_directory_name returns string? — typed str | None
    d: str | None = system.io.Path.get_directory_name("/usr/bin/ls")
    print(d)  # /usr/bin

Passing None to a parameter declared non-nullable is refused:

import system.io

def main():
    # error SPY0229: Cannot pass 'None' to parameter 'path1' of
    # 'Path.combine' — it is declared non-nullable ('str')
    system.io.Path.combine(None, "b")

Parameters from assemblies without NRT annotations (NullabilityState.Unknown) accept None conservatively — the same behavior as before NRT support.

Testing a .NET reference for None

One rule covers every reference type, .NET and Sharpy alike: is never runs user code; == runs the type's operator ==.

is None / is not None are a reference null check. They lower to the C# null pattern v is null / v is not null, which never calls an overloaded ==:

from system import Version

def main() -> None:
    v: Version = Version(1, 2)
    print(v is None)       # False — lowered to `v is null`, a reference check
    print(v is not None)   # True

A type whose == makes a live object compare equal to null — a destroyed Unity Object — is therefore not None to is None: the variable still holds a reference. To ask the type's own ==, write == None / != None, which lowers to C# v == (T?)null and runs the type's same-type overload — the typed null keeps a second == overload (say, against another type) from making the comparison ambiguous, and a type with no same-type == compares references. That holds for every operand: a declared T, a T | None, a T | None that an assignment or an is not None test has narrowed to T, and a T? narrowed by is not None:

from system import Version

def is_gone(w: Version | None) -> bool:
    return w == None       # lowered to `w == (Version?)null`, through Version's operator ==

def main() -> None:
    print(is_gone(None))               # True
    print(is_gone(Version(1, 2)))      # False
    v: Version = Version(1, 2)
    print(v == None)                   # False — the same operator on a non-nullable operand
    print(None != v)                   # True

Inside a @test function, assert v == None asserts that same lowering (Assert.True(v == (Version?)null)), so a test and sharpyc run agree on what the comparison means.

IDisposable Pattern

.NET's IDisposable integrates with with:

from system.io import FileStream, FileMode

with FileStream("output.dat", FileMode.create) as stream:
    stream.write(data, 0, len(data))