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")

.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.

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]

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.

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.

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))