Context Managers¶
The with statement manages resources:
with open("file.txt", "r") as f:
content = f.read()
# f.close() called automatically
# Multiple resources
with open("in.txt") as input, open("out.txt", "w") as output:
output.write(input.read())
as Targets¶
The as clause accepts any store target, not only a name — the same set the for statement
accepts: a name, an attribute, an index, or a tuple of targets.
class CMPair:
def __enter__(self) -> tuple[int, str]:
return (1, "two")
def __exit__(self) -> None:
pass
def main() -> None:
with CMPair() as (a, b): # tuple target: binds a = 1, b = "two"
print(a)
print(b)
A tuple target requires __enter__ (or __aenter__) to return a tuple of the same arity;
anything else is SPY0239 (invalid tuple unpacking). A starred target (as *rest) is not
a target at all — Python rejects it as a SyntaxError and so does Sharpy.
Supported Protocols¶
Sharpy supports two context manager protocols:
1. Dunder Protocol (__enter__/__exit__)¶
Classes can implement __enter__ and __exit__ to define context manager behavior:
class Resource:
def __enter__(self) -> Resource:
print("entering")
return self
def __exit__(self):
print("exiting")
def main():
with Resource() as r:
print("using resource")
# prints: entering, using resource, exiting
Protocol methods:
- __enter__(self) -> T — Called on block entry. The return value is bound to the as variable.
- __exit__(self) — Called on block exit (in a finally clause), handles cleanup.
C# emission:
var __ctx_0 = new Resource();
var r = __ctx_0.Enter();
try {
System.Console.WriteLine("using resource");
} finally {
__ctx_0.Exit();
}
2. IDisposable Protocol¶
Objects implementing .NET's IDisposable interface can be used directly in with statements:
C# emission:
Async Context Managers¶
The async with statement supports async resource management:
1. Async Dunder Protocol (__aenter__/__aexit__)¶
class AsyncResource:
async def __aenter__(self) -> AsyncResource:
print("entering")
return self
async def __aexit__(self):
print("exiting")
async def main():
async with AsyncResource() as r:
print("inside")
Protocol methods:
- async def __aenter__(self) -> T — Async enter, return value bound to as variable.
- async def __aexit__(self) — Async cleanup.
C# emission:
var __ctx_0 = new AsyncResource();
var r = await __ctx_0.AenterAsync();
try {
System.Console.WriteLine("inside");
} finally {
await __ctx_0.AexitAsync();
}
2. IAsyncDisposable Protocol¶
Objects implementing .NET's IAsyncDisposable are emitted as await using:
Protocol Priority¶
When a type implements both protocols, dunders take priority:
| Statement | Priority | Fallback |
|---|---|---|
with |
__enter__/__exit__ |
IDisposable |
async with |
__aenter__/__aexit__ |
IAsyncDisposable |
If neither protocol is implemented, a compile-time error is reported (SPY0324).
Implementation
- ✅ with statement: Dunder protocol → try/finally with Enter()/Exit(); IDisposable → C# using
- ✅ async with statement: Async dunder protocol → try/finally with await AenterAsync()/AexitAsync(); IAsyncDisposable → C# await using
- ✅ Multiple resources in a single with statement are supported
Exit Shapes¶
The __exit__ / __aexit__ parameter count determines the exit shape — how the compiler
models the with block's control flow and what the emitter generates.
| Parameters | Exit shape | C# lowering | Can suppress? |
|---|---|---|---|
self only |
Simple | try / finally { Exit() } |
No |
self + 3 (exc_type, exc_val, exc_tb) |
Suppression-capable | try / catch { suppress = Exit(…); if (!suppress) throw; } / finally { … } |
Yes |
IDisposable |
Simple | using (…) { … } |
No |
IAsyncDisposable |
Simple | await using (…) { … } |
No |
The exit shape is a materialized semantic fact — it is decided during type checking and
carried to code generation on the IR. The emitter never inspects __exit__ itself.
Suppression-capable with and reachability¶
When a with body's only exit is a return, raise, or ? early return, the code after the
with is still reachable if the manager is suppression-capable: __exit__ returning True
suppresses the exception and falls through. For a simple manager, the code after the with is
unreachable and must not contain a return path.
class Suppressor:
def __enter__(self) -> Suppressor:
return self
def __exit__(self, exc_type: object?, exc_val: Exception?, exc_tb: object?) -> bool:
return True
def f() -> int:
with Suppressor() as s:
return 9
# error SPY0266: function 'f' must return a value of type 'int32' in all code paths
A function returning None whose body raises inside a suppressing with falls through —
__exit__ suppresses the exception:
class Suppressor:
def __enter__(self) -> Suppressor:
return self
def __exit__(self, exc_type: object?, exc_val: Exception?, exc_tb: object?) -> bool:
return True
def g() -> None:
with Suppressor() as s:
raise ValueError("test")
print("after") # reachable — __exit__ suppresses the exception
def main() -> None:
g()
Definite assignment across a suppression-capable with¶
Because __exit__ may return True and swallow an exception raised anywhere in the body, the
statement after the with is reachable with the body only partly executed — a bare local assigned
inside the body may or may not have been assigned when control reaches the read. Sharpy resolves this
at runtime, like Python (#1839): such a read
is runtime-checked, not refused. When the body completes normally the read finds the value; when a
suppressed exception skipped the assignment the read raises UnboundLocalError — nothing that runs
in Python is refused, and nothing prints a silent default.
class Suppressor:
def __enter__(self) -> int:
return 1
def __exit__(self, exc_type: object?, exc_val: Exception?, exc_tb: object?) -> bool:
return True
def main() -> None:
n: int
with Suppressor():
n = 5
print(n)
If the body raises before the assignment and __exit__ swallows it, the read after the with finds
n unset and raises UnboundLocalError (Python 3.12's exact wording), caught here to show it:
class Suppressor:
def __enter__(self) -> int:
return 1
def __exit__(self, exc_type: object?, exc_val: Exception?, exc_tb: object?) -> bool:
return True
def maybe_raise(flag: bool) -> None:
if flag:
raise ValueError("boom")
def main() -> None:
n: int
with Suppressor():
maybe_raise(True)
n = 5
try:
print(n)
except UnboundLocalError as e:
print(str(e))
The cost is one bool per runtime-checked local (its assigned-flag). A manager that cannot
suppress has no suppression edge, so a body assignment is unconditional and its read after the
with runs with no flag at all. A read that is unassigned even ignoring suppression — a
conditional assignment, say — is a plain definite-assignment hole and is still refused (SPY0600),
under every manager:
class Suppressor:
def __enter__(self) -> int:
return 1
def __exit__(self, exc_type: object?, exc_val: Exception?, exc_tb: object?) -> bool:
return True
def main() -> None:
c: bool = True
n: int
with Suppressor():
if c:
n = 5
print(n) # error SPY0600: variable 'n' is used before being assigned
astargets are block-scoped. Unlike Python, awith … as nametarget does not persist after the block (a freshastarget read outside is SPY0200). A pre-declared local rebound bywith … as ntherefore is not assigned after the block, and reading it is an ordinary use-before-assign — the runtime check above applies to BODY assignments, not to theastarget.
yield inside a suppression-capable with (SPY0703)¶
A yield inside a with whose __exit__ can suppress is refused. The C# iterator state machine
cannot resume inside a try/catch, and the suppression edge makes the successor reachable:
class Suppressor:
def __enter__(self) -> Suppressor:
return self
def __exit__(self, exc_type: object?, exc_val: Exception?, exc_tb: object?) -> bool:
return True
def gen() -> int:
with Suppressor() as s:
yield 1 # error SPY0703: 'yield' cannot be used inside a 'with' block
# whose '__exit__' is suppression-capable
A simple (1-parameter) __exit__ poses no issue — the with lowers to try/finally,
and C# allows yield return inside a finally-free try body:
class Simple:
def __enter__(self) -> Simple:
return self
def __exit__(self):
pass
def gen() -> int:
with Simple() as s:
yield 1 # OK — simple exit shape
yield 2
__exit__ Signature Variants¶
Status: Implemented
Supported Signatures¶
Sharpy supports two forms for __exit__ and __aexit__:
- No-arg form:
def __exit__(self):— cleanup only, no exception awareness (simple exit shape) - 3-arg form:
def __exit__(self, exc_type, exc_val, exc_tb):— receives exception context, can suppress exceptions by returningTrue(suppression-capable exit shape)
The same applies to the async variants (__aexit__).
class Resource:
def __enter__(self) -> Resource:
return self
# No-arg form — cleanup only
def __exit__(self):
self.cleanup()
class SuppressingResource:
def __enter__(self) -> SuppressingResource:
return self
# 3-arg form — exception-aware
def __exit__(self, exc_type: object?, exc_val: Exception?, exc_tb: object?) -> bool:
if exc_val is not None:
print(f"Suppressing {exc_type}: {exc_val}")
return True # suppress the exception
return False # propagate
The ProtocolRegistry enforces ExpectedParamCount: 1 (just self) for both __exit__ and __aexit__, with AlternateParamCount: 4 for the 3-arg exception-aware form. The no-arg __exit__ method maps directly to IDisposable.Dispose() via ClrMethodName: "Dispose", and __aexit__ maps to IAsyncDisposable.DisposeAsync().
Codegen¶
For the no-arg form, the emitter generates a simple try/finally:
For the 3-arg form, the emitter generates a try/catch/finally pattern that captures exception information and passes it to Exit():
var __ctx_0 = new Resource();
var r = __ctx_0.Enter();
Exception? __exc_0 = null;
try {
// body
} catch (Exception __e_0) {
__exc_0 = __e_0;
var __suppress = __ctx_0.Exit(__e_0.GetType(), __e_0, null);
if (!__suppress) throw;
} finally {
if (__exc_0 == null) __ctx_0.Exit(null, null, null);
}
Note: The exc_tb parameter is always null since .NET has no direct equivalent of Python's traceback object. Stack trace information is available via the Exception object itself.