Skip to content

Introduce QdkContext API for isolated interpreter sessions#3029

Draft
minestarks wants to merge 1 commit intomainfrom
minestarks/qdk-context-api
Draft

Introduce QdkContext API for isolated interpreter sessions#3029
minestarks wants to merge 1 commit intomainfrom
minestarks/qdk-context-api

Conversation

@minestarks
Copy link
Copy Markdown
Member

@minestarks minestarks commented Mar 18, 2026

Closes #2998

Summary

This PR introduces the QdkContext class, which provides isolated Q# interpreter contexts with their own configuration, compiled code, and state. This directly addresses the architectural problem described in #2998: the qsharp package previously stored a single global interpreter, making it impossible for two libraries (or a library and end-user code) to coexist without silently clobbering each other's state.

See #2998 (comment) for full context.

What changed

New public API

Function / Class Purpose
QdkContext An isolated interpreter context with .eval(), .run(), .compile(), .circuit(), .estimate(), .logical_counts(), .set_quantum_seed(), .set_classical_seed(), .dump_machine(), .dump_circuit(), .import_openqasm(), and a .config property.
qsharp.new_context(...) Creates a new isolated QdkContext.
qsharp.get_context() Returns the current global context (lazily initialized).
qsharp.context_of(callable) Returns the QdkContext that compiled a given callable.

Behavioral changes

  • init() now returns QdkContext instead of Config. The context proxies __repr__ and _repr_mimebundle_ from its config, so Jupyter notebook display is unchanged.
  • Callables are bound to their context. Each callable carries a _qdk_get_context attribute. Passing a callable to a different context's method (e.g., ctx_b.run(ctx_a.code.Foo)) raises QSharpError with a clear message.
  • Stale callable protection. After init() is called, callables from the prior context raise QSharpError ("disposed") when invoked.
  • Module-level functions are unchanged. qsharp.eval(), qsharp.run(), etc. delegate to the global default context exactly as before.

Add QdkContext class with instance methods (.eval(), .run(), .compile(),
.circuit(), .estimate(), .logical_counts(), etc.) that mirror module-level
functions. Module-level functions delegate to a global default context.

New public API:
- qsharp.new_context(...) creates an isolated context
- qsharp.get_context() returns the global context (lazy init)
- qsharp.context_of(callable) returns the context that compiled it
- init() now returns QdkContext (backward-compatible)

Cross-context safety: passing a callable from one context to another's
method raises QSharpError. Stale callables (from a prior init) raise
QSharpError when invoked.

Includes 20 test cases covering isolation, cross-context validation,
stale callable detection, backward compatibility, and config access.
Comment on lines +1211 to +1229
result = ctx.eval("1 + 2")
assert result == 3


def test_context_isolation() -> None:
ctx1 = qsharp.new_context()
ctx2 = qsharp.new_context()
ctx1.eval("function Foo() : Int { 42 }")
result1 = ctx1.eval("Foo()")
assert result1 == 42
# ctx2 should not have Foo defined
with pytest.raises(Exception):
ctx2.eval("Foo()")


def test_context_run() -> None:
ctx = qsharp.new_context()
ctx.eval('operation Foo() : Result { Message("hi"); Zero }')
results = ctx.run("Foo()", 3)

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
Comment on lines +1235 to +1240
result = qsharp.eval("1 + 1")
assert result == 2


def test_init_returns_context() -> None:
ctx = qsharp.init()

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
Comment on lines +1243 to +1252
result = ctx.eval("3 + 4")
assert result == 7
# Module-level eval should use the same context
result2 = qsharp.eval("3 + 4")
assert result2 == 7


def test_context_callable_has_interpreter_ref() -> None:
"""Callables created via eval carry a _qdk_get_interpreter attribute."""
ctx = qsharp.new_context()

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
Comment on lines +1298 to +1312
ctx.eval("function Hello() : Int { 1 }")
fn = ctx.code.Hello
assert qsharp.context_of(fn) is ctx


def test_context_of_global_callable() -> None:
"""context_of() works for callables in the global context."""
ctx = qsharp.init()
qsharp.eval("function Hi() : Int { 2 }")
fn = qsharp.code.Hi
assert qsharp.context_of(fn) is ctx


def test_context_of_rejects_non_callable() -> None:
"""context_of() raises TypeError for non-QDK objects."""

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
"""Passing a callable from one context to another's run() raises."""
ctx_a = qsharp.new_context()
ctx_b = qsharp.new_context()
ctx_a.eval("operation Foo() : Result { use q = Qubit(); M(q) }")

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
"""Passing a callable from one context to another's compile() raises."""
ctx_a = qsharp.new_context(target_profile=qsharp.TargetProfile.Base)
ctx_b = qsharp.new_context(target_profile=qsharp.TargetProfile.Base)
ctx_a.eval("operation Bar() : Result { use q = Qubit(); M(q) }")

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
"""Passing a callable from one context to another's circuit() raises."""
ctx_a = qsharp.new_context()
ctx_b = qsharp.new_context()
ctx_a.eval("operation Baz() : Unit { use q = Qubit(); H(q); }")

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
"""Passing a callable from one context to another's estimate() raises."""
ctx_a = qsharp.new_context()
ctx_b = qsharp.new_context()
ctx_a.eval("operation Qux() : Unit { use q = Qubit(); H(q); }")

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
"""Passing a callable from one context to another's logical_counts() raises."""
ctx_a = qsharp.new_context()
ctx_b = qsharp.new_context()
ctx_a.eval("operation Corge() : Unit { use q = Qubit(); H(q); }")

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
Comment on lines +1370 to +1373
qsharp.eval("function Stale() : Int { 99 }")
old_fn = qsharp.code.Stale
# Reinitialize — old callable should now be stale
qsharp.init()

Check notice

Code scanning / devskim

If untrusted data (data from HTTP requests, user submitted files, etc.) is included in an eval statement it can allow an attacker to inject their own code. Note test

Review eval for untrusted data
@minestarks minestarks changed the title Introduce QdkContext API for isolated interpreter sessions Introduce QdkContext API for isolated interpreter sessions Mar 18, 2026
@minestarks minestarks requested a review from Copilot April 15, 2026 20:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Introduces a QdkContext API to support isolated Q# interpreter sessions, addressing prior global-interpreter state clobbering (Closes #2998).

Changes:

  • Adds QdkContext plus new_context(), get_context(), and context_of() APIs; updates init() to return a context while keeping module-level APIs delegating to the default context.
  • Updates OpenQASM utilities to use the interpreter associated with a callable/context when available.
  • Expands Python test coverage for context isolation, stale callable protection, and backward compatibility.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
source/qdk_package/tests/mocks.py Extends test stub exports for the new context API.
source/qdk_package/src/qdk/init.py Re-exports new context API in the qdk package surface.
source/pip/tests/test_qsharp.py Updates existing init tests and adds dedicated QdkContext behavior tests.
source/pip/qsharp/utils/_utils.py Enables dump_operation(..., ctx=...) to run against a specific context.
source/pip/qsharp/openqasm/_run.py Runs callables using the callable’s owning interpreter when present.
source/pip/qsharp/openqasm/_estimate.py Estimates using the callable’s owning interpreter when present.
source/pip/qsharp/openqasm/_compile.py Compiles using the callable’s owning interpreter when present; formats error string.
source/pip/qsharp/openqasm/_circuit.py Generates circuits using the callable’s owning interpreter when present.
source/pip/qsharp/_qsharp.py Implements QdkContext, default-context delegation, callable binding, and stale-callable disposal.
source/pip/qsharp/_ipython.py Routes cell magic execution through the default context interpreter.
source/pip/qsharp/init.py Exposes new context API and adds QSharpContext backward-compatible alias.
Comments suppressed due to low confidence (1)

source/pip/qsharp/init.py:1

  • QSharpContext is introduced as a backward-compatible alias, but it isn’t included in __all__. If users rely on from qsharp import * (or tooling that uses __all__) this breaks the backward-compatibility story. Consider adding \"QSharpContext\" to __all__.
# Copyright (c) Microsoft Corporation.

Comment on lines +625 to +633
for name in namespace:
accumulated_namespace += name
if hasattr(module, name):
module = module.__getattribute__(name)
else:
new_module = types.ModuleType(accumulated_namespace)
module.__setattr__(name, new_module)
sys.modules[accumulated_namespace] = new_module
module = new_module
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QdkContext._make_class doesn’t mirror _make_callable’s behavior of ensuring existing namespace modules are registered in sys.modules. If an attribute already exists (e.g., due to a name collision between a callable and a namespace), imports/lookup via sys.modules can fail or behave inconsistently. Consider adding the same if sys.modules.get(accumulated_namespace) is None: sys.modules[accumulated_namespace] = module logic used in _make_callable when hasattr(module, name) is true.

Copilot uses AI. Check for mistakes.
Comment on lines +552 to +557
if _code_module is not None:
self.code = _code_module
self._code_prefix = _code_prefix or "qsharp.code"
else:
self._code_prefix = f"qsharp._ctx_{id(self)}"
self.code = types.ModuleType(self._code_prefix)
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New (non-default) contexts create entries in sys.modules for nested namespaces (e.g., qsharp._ctx_<id>.*) but the root module (self._code_prefix) is not registered, and there’s no cleanup path for removing these entries when the context is no longer needed. This can cause unbounded sys.modules growth across many contexts and may also introduce confusing orphaned modules. Suggested fix: register self.code in sys.modules[self._code_prefix] when creating a new context, and add an explicit dispose() (or similar) that calls _clear_code_module(self.code, self._code_prefix) and removes the root module entry; then call it when contexts are intentionally discarded (and in init() for the old default context).

Copilot uses AI. Check for mistakes.
Comment on lines +579 to +584
if sys.modules.get(accumulated_namespace) is None:
sys.modules[accumulated_namespace] = module
else:
new_module = types.ModuleType(accumulated_namespace)
module.__setattr__(name, new_module)
sys.modules[accumulated_namespace] = new_module
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New (non-default) contexts create entries in sys.modules for nested namespaces (e.g., qsharp._ctx_<id>.*) but the root module (self._code_prefix) is not registered, and there’s no cleanup path for removing these entries when the context is no longer needed. This can cause unbounded sys.modules growth across many contexts and may also introduce confusing orphaned modules. Suggested fix: register self.code in sys.modules[self._code_prefix] when creating a new context, and add an explicit dispose() (or similar) that calls _clear_code_module(self.code, self._code_prefix) and removes the root module entry; then call it when contexts are intentionally discarded (and in init() for the old default context).

Copilot uses AI. Check for mistakes.
try:
display(output)
return
except:
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using a bare except: here, as it also catches KeyboardInterrupt/SystemExit and can make shutdown/interrupt behavior unreliable (especially in notebooks). Prefer except Exception: (or a narrower exception) and keep the same fallback behavior.

Suggested change
except:
except Exception:

Copilot uses AI. Check for mistakes.
result1 = ctx1.eval("Foo()")
assert result1 == 42
# ctx2 should not have Foo defined
with pytest.raises(Exception):
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new context API promises specific error behavior (e.g., raising QSharpError for interpreter failures). Using pytest.raises(Exception) is overly broad and can mask unrelated failures (like regressions that raise the wrong exception type). Prefer asserting pytest.raises(qsharp.QSharpError, match=...) (or a more specific type/message) so the test validates the intended contract.

Suggested change
with pytest.raises(Exception):
with pytest.raises(qsharp.QSharpError):

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +31
# Backward-compatible alias
QSharpContext = QdkContext
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QSharpContext is introduced as a backward-compatible alias, but it isn’t included in __all__. If users rely on from qsharp import * (or tooling that uses __all__) this breaks the backward-compatibility story. Consider adding \"QSharpContext\" to __all__.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The QDK Python layer should have knowledge of its initialization state

3 participants