Introduce QdkContext API for isolated interpreter sessions#3029
Introduce QdkContext API for isolated interpreter sessions#3029minestarks wants to merge 1 commit intomainfrom
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__.
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.