Skip to content

Introduce QdkContext API for isolated interpreter sessions#3029

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

Introduce QdkContext API for isolated interpreter sessions#3029
minestarks wants to merge 1 commit into
mainfrom
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.
@fedimser fedimser mentioned this pull request May 9, 2026
pull Bot pushed a commit to Mattlk13/qsharp that referenced this pull request May 12, 2026
* This PR introduces a new concept - `Context`, which is a wrapper
around what currently was a global state - `Interpreter` instance,
`Config` instance and `code` namespace with defined Q# symbols.
* Most public module-level methods (e.g. `qdk.eval`, `qdk.run`) have
been migrated to Context. The module-level methods call methods with the
same name on default global context.
* `qdk.init` now creates a new Context (passing init arguments to
constructor) and stores it in global `_default_context` variable.
* `interpreter.get_default_context()` now can be used to access global
context and lazily initialize it if needed. However, it's not public
API.
* Also, added method `Context.import_openqasm` which is migrated from
`qsharp.openqasm.import_openqasm`.
* Two deprecated methods, `estimate` and `dump_circuit` were not
migrated. Instead, their implementation is kept in place, but it refers
to global context.
* There are multiple places using global interpreter. For them to keep
working, added 3 functions `get_interpreter`,
`python_args_to_interpreter_args`, `qsharp_value_to_python_value` that
access interpreter and converters from the global context.
* Fixed a bug where circular check in Python-Q# object conversion was
not happening, because `set.add` returns None
[here](https://github.com/microsoft/qdk/blob/35707ac58b8b0cac6a20ee6eac5af7e355e4cd5b/source/pip/qsharp/_qsharp.py#L59).
* Code for conversion from Python to Q# now has to be in context of a
`Context` (because it needs to forbid cross-context object passing), so
I moved it to be methods of `Context`. class.
* This PR is based onhttps://github.com/microsoft/pull/3029 with
some changes. In particular, added isolation checks (that callables and
structs from one context cannot be passed to another).
* This PR addresses microsoft#2998

**Example**
```
import qdk

ctx = qdk.Context()
ctx.eval("operation Main() : Result { use q = Qubit(); X(q); MResetZ(q) }")
assert ctx.run("Main()", 2) == [qdk.Result.One, qdk.Result.One]
assert ctx.code.Main() == qdk.Result.One
```


**Recommended usage**
* For notebooks and small projects, module-level API (`qsharp.init`,
`qsharp.eval`, `qsharp.code`) is okay to use and we will not deprecate
it.
* It is also okay to use `Context` anywhere you want, but there should
be no need to use 2 contexts in the same notebook.
* For writing Python libraries which wrap around Q# code, you must use a
single Context per library to make sure you have isolated interpreter
and its state is not changed by any other libraries or user code that
might exist in the same Python context.
* 2 styles (global vs Context) should not be mixed. In particular, we do
not expose a method to get access to global Context. If you want to use
Context, it must be created with constructor.

**API changes**
* Added a **single** new exported symbol - a class `qsharp.Context` with
9 public methods: `eval`, `run`, `compile`, `circuit`,
`logical_couints`, `set_quantum_seed`, `set_classical_seed`,
`dump_machine`, `import_openqasm`.
* Each context also has dynamic namespace `code` which contains all
defined Q# symbols, which can be used like `qsharp.code` was used
before.
* All existing method-level APIs (including deprecated `estimate` and
`dump_circuit`) are there and have exactly the same observed behaviur.
`qsharp.init` internally creates new global context and returns config
assosiated with that context.
* While we added function `get_default_context()` which accesses default
global context (with lazy initialization), it is intended only for usage
within the library, and is not exported as public API.

**Documentation**
* Now have 9 methods (function in qsharp module vs method in `Context`
class), plus `qsharp.init` vs `Context.__init__`, which do basically the
same and take the same arguments. Because none of them is deprecated,
and both are public API, both needs to be documented. Therefore, I had
to duplicate exactly lengthy pydocs and argument descriptions, and they
will have to be kept in sync in the future.
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