Introduce QdkContext API for isolated interpreter sessions#3029
Introduce QdkContext API for isolated interpreter sessions#3029minestarks wants to merge 1 commit into
QdkContext API for isolated interpreter sessions#3029Conversation
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.
| 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
| 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
| 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
| 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
| """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
| """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
| """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
| """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
| """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
| 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
QdkContext API for isolated interpreter sessions
There was a problem hiding this comment.
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
QdkContextplusnew_context(),get_context(), andcontext_of()APIs; updatesinit()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
QSharpContextis introduced as a backward-compatible alias, but it isn’t included in__all__. If users rely onfrom qsharp import *(or tooling that uses__all__) this breaks the backward-compatibility story. Consider adding\"QSharpContext\"to__all__.
# Copyright (c) Microsoft Corporation.
| 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 |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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).
| try: | ||
| display(output) | ||
| return | ||
| except: |
There was a problem hiding this comment.
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.
| except: | |
| except Exception: |
| result1 = ctx1.eval("Foo()") | ||
| assert result1 == 42 | ||
| # ctx2 should not have Foo defined | ||
| with pytest.raises(Exception): |
There was a problem hiding this comment.
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.
| with pytest.raises(Exception): | |
| with pytest.raises(qsharp.QSharpError): |
| # Backward-compatible alias | ||
| QSharpContext = QdkContext |
There was a problem hiding this comment.
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__.
* 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.
Closes #2998
Summary
This PR introduces the
QdkContextclass, which provides isolated Q# interpreter contexts with their own configuration, compiled code, and state. This directly addresses the architectural problem described in #2998: theqsharppackage 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
QdkContext.eval(),.run(),.compile(),.circuit(),.estimate(),.logical_counts(),.set_quantum_seed(),.set_classical_seed(),.dump_machine(),.dump_circuit(),.import_openqasm(), and a.configproperty.qsharp.new_context(...)QdkContext.qsharp.get_context()qsharp.context_of(callable)QdkContextthat compiled a given callable.Behavioral changes
init()now returnsQdkContextinstead ofConfig. The context proxies__repr__and_repr_mimebundle_from its config, so Jupyter notebook display is unchanged._qdk_get_contextattribute. Passing a callable to a different context's method (e.g.,ctx_b.run(ctx_a.code.Foo)) raisesQSharpErrorwith a clear message.init()is called, callables from the prior context raiseQSharpError("disposed") when invoked.qsharp.eval(),qsharp.run(), etc. delegate to the global default context exactly as before.