From 1790168d97f372b667b8de4fb88c08fc39ca5ceb Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Mon, 1 Jun 2026 11:21:08 -0700 Subject: [PATCH 1/9] re-export things via "internal" --- PRIVATE_API_LEAKAGE_REPORT.md | 333 +++++++++++++++++++++ source/qdk_package/check_api_surface.py | 378 ++++++++++++++++++++++++ source/qdk_package/qdk/internal.py | 44 +++ source/qdk_package/qdk/qre/__init__.py | 3 +- source/qdk_package/qdk/qre/internal.py | 46 +++ 5 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 PRIVATE_API_LEAKAGE_REPORT.md create mode 100644 source/qdk_package/check_api_surface.py create mode 100644 source/qdk_package/qdk/internal.py create mode 100644 source/qdk_package/qdk/qre/internal.py diff --git a/PRIVATE_API_LEAKAGE_REPORT.md b/PRIVATE_API_LEAKAGE_REPORT.md new file mode 100644 index 0000000000..126ab58377 --- /dev/null +++ b/PRIVATE_API_LEAKAGE_REPORT.md @@ -0,0 +1,333 @@ +# Private API leakage in `qdk` — analysis & proposed plan + +## Background + +Auditing docstrings and Sphinx cross-references in the `qdk` package surfaced +a systemic issue: a number of types defined in private modules +(`qdk._native`, `qdk._types`, `qdk._interpreter`, and several `qdk.qre.*` +submodules) appear in the public API surface — as return types, parameter +types, or types referenced in docstrings of public functions and methods. + +This causes three concrete problems: + +1. **Broken cross-references in the generated docs.** Tools like py2docfx and + Sphinx can't emit working links to a type that lives at a non-public path, + so the generated reference pages contain dead `` markers. +2. **Inconsistent / undiscoverable API.** Users who follow a return type + annotation to import it cannot, because the type isn't reachable from any + stable `qdk.*` path (e.g. `from qdk._native import Circuit` is technically + importable but is explicitly private). +3. **Static typing breaks.** Type checkers (pyright, mypy) flag references + to private modules as private-symbol violations when used from user code, + even when the type is unavoidable because it's the return type of a public + function. + +This document inventories the leakage and proposes a categorized plan to +resolve it. + +## Leakage outside `qdk.qre` + +### 1. Native types appearing in `qdk.qsharp` and `qdk.openqasm` signatures + +The following types are imported from `qdk._native` (the Rust extension) or +`qdk._types` and appear in parameter or return positions of public +functions, but are **not** currently re-exported on any public path: + +| Type | Defined in | Used by | Position | +| ---------------- | ------------- | -------------------------------------------------------------------- | ----------- | +| `Circuit` | `qdk._native` | return type of `qdk.qsharp.circuit()` | return | +| `QirInputData` | `qdk._types` | return type of `qdk.qsharp.compile()` and `qdk.openqasm.compile()` | return | +| `Config` | `qdk._types` | return type of `qdk.qsharp.init()` | return | +| `GlobalCallable` | `qdk._native` | `qdk.qsharp.run`, `compile`, `circuit`, `estimate`, `logical_counts` | parameter | +| `Closure` | `qdk._native` | `qdk.qsharp.run`, `compile`, `circuit`, `estimate`, `logical_counts` | parameter | +| `NoiseConfig` | `qdk._native` | `qdk.qsharp.run`, `qdk.openqasm.run` | parameter | +| `Output` | `qdk._native` | callback signatures used by `run` event-saving paths | callback | +| `StateDumpData` | `qdk._native` | inputs to user-facing `StateDump` construction | constructor | +| `CircuitConfig` | `qdk._native` | configuration object passed internally by `circuit()` | internal | +| `Interpreter` | `qdk._native` | return type of internal `get_interpreter()` helper | internal | + +Notes: + +- `Circuit`, `QirInputData`, and `Config` are concrete return types of + public top-level functions. Without a public path, users cannot annotate + variables that hold these values, and the doc pages that describe + `circuit()`, `compile()`, and `init()` cannot link their return types. +- `NoiseConfig` is **already** re-exported as `qdk.simulation.NoiseConfig`, + so it has a public home; the issue is only that `qdk.qsharp.run`'s union + type annotation references the bare `NoiseConfig` rather than + `qdk.simulation.NoiseConfig`. +- `GlobalCallable` and `Closure` represent Q# callables and closures + produced by the interpreter and stored on user-facing callable objects + (via `__global_callable`). They are part of the contract users see when + passing a Q#-generated callable back into `run` / `compile` / `circuit`. +- `Output` and `StateDumpData` are wrappers that the user receives in + callback contexts; they appear in event-saving code paths. +- `CircuitConfig` and `Interpreter` are arguably internal-only and could + stay private, with the recommendation being to remove them from + documented signatures rather than promote them. + +### 2. Internal types confirmed not to leak + +The following types are imported from `qdk._native` or defined in private +modules and are used in internal helper signatures only. They have been +verified not to appear in any user-visible docstring or public signature +and should remain private. + +- `TypeIR`, `TypeKind`, `PrimitiveKind`, `UdtValue` (used by [`_context.py`](source/qdk_package/qdk/_context.py) in the dynamic Q#-class generation machinery). +- `CircuitConfig`, `Interpreter` (used by internal helpers `get_interpreter()` and `circuit()`). + +## Leakage inside `qdk.qre` + +The `qre` module has the most extensive leakage and warrants treatment as a +cohesive design refresh rather than piecemeal patches. + +### Categorized by severity + +#### Public methods returning or exposing private types + +| Public surface | Private type | +| ------------------------------------------------- | --------------------------------- | +| `qdk.qre.InstructionSource.__getitem__(id)` | `_InstructionSourceNodeReference` | +| `qdk.qre.InstructionSource.get(id, default=None)` | `_InstructionSourceNodeReference` | +| `qdk.qre.InstructionSource.nodes` | `list[_InstructionSourceNode]` | +| `qdk.qre.ISATransform.bind(name, node)` | `_BindingNode` | +| `qdk.qre.ISAQuery.__add__(other)` | `_SumNode` | +| `qdk.qre.ISAQuery.__mul__(other)` | `_ProductNode` | +| `qdk.qre.TraceQuery` (class) | inherits from private `_Node` | +| `qdk.qre.Application.context()` | `_Context` | + +The `_InstructionSourceNodeReference` case is the clearest leak: it's the +**only** way to reach the child-node traversal API +(`_InstructionSourceNodeReference.__getitem__`, +`_InstructionSourceNodeReference.get`, +`_InstructionSourceNodeReference.instruction`, +`_InstructionSourceNodeReference.transform`), but the type itself isn't +public, so users have to type-erase to `Any` or import from +`qdk.qre._instruction` directly. + +#### Private types in public parameter positions + +| Method | Parameter type | +| ---------------------------------------- | ------------------- | +| `qdk.qre.TraceQuery.enumerate(ctx, ...)` | `_Context` | +| Many `*_to_trace` Cirq adapters | `_CirqTraceBuilder` | + +`_CirqTraceBuilder` lives in [qdk/qre/interop/\_cirq.py](source/qdk_package/qdk/qre/interop/_cirq.py) +and is referenced by ~12 module-level `*_to_trace` functions that are +visible to users registering custom gate translations. It also exposes a +`q_to_id` property typed as the private `_QidToTraceId`. + +#### TypeVars not re-exported + +| Symbol | Defined in | Used in | +| ----------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `TraceParameters` | [qdk/qre/\_application.py](source/qdk_package/qdk/qre/_application.py) | `Application[TraceParameters]`, `Application.get_trace(parameters: TraceParameters)`, `Application.post_process(parameters: TraceParameters, ...)`, `Application.enumerate_traces_with_parameters(...)` (yields `tuple[TraceParameters, Trace]`) | + +Users subclassing `Application` need this TypeVar to write their own +generic specializations, but it's not exported from `qdk.qre`. + +#### Public types not re-exported in submodules + +These exist publicly at `qdk.qre.X` but are referenced by classes that live +in deeper submodules like `qdk.qre.models` and `qdk.qre.models.factories`. +py2docfx renders xrefs relative to the type's module, so links break: + +| Type from `qdk.qre` | Used in | +| ---------------------- | -------------------------------------------------------------------------------------------- | +| `Instruction` | `InstructionSource.add_node(instruction: Instruction, ...)` | +| `ISA`, `ISAContext` | `Litinski19Factory.provided_isa(self, impl_isa: ISA, ctx: ISAContext)` and similar factories | +| `EstimationTableEntry` | `EstimationTable.add_column(function: Callable[[EstimationTableEntry], Any])` | +| `ConstraintBound` | `qdk.qre.constraint(error_rate: Optional[ConstraintBound] = None)` | + +Note that `Instruction` is **not even in `qdk.qre.__all__`**, so it's +effectively unreachable except via `qdk.qre._qre.Instruction` (private). + +#### Internal types confirmed not to leak + +The following private types are used in internal positions only and have +been verified not to appear in any public signature. They should remain +private. + +- `_Entry`, `_Protocol` in [qdk/qre/models/factories/\_litinski.py](source/qdk_package/qdk/qre/models/factories/_litinski.py) and [qdk/qre/models/factories/\_cultivation.py](source/qdk_package/qdk/qre/models/factories/_cultivation.py) (distillation-table representations). +- `_ComponentQuery` (used internally by `ISATransform.q()`, which returns the public `ISAQuery` base type). +- `_PSSPC`, `_LatticeSurgery` (private aliases of Rust types wrapped by the public `PSSPC` and `LatticeSurgery` classes). + +## Proposed plan + +We recommend routing all currently-private leaked types through a pair of +new "internal but visible" namespaces: + +- **`qdk.internal`** for non-qre types that are unavoidably exposed in the + public surface but are not part of the supported API. +- **`qdk.qre.internal`** for the analogous qre-specific types. `qre` has + enough internal surface area that a separate namespace keeps the + top-level `qdk.internal` from being dominated by qre concerns. + +These namespaces are explicitly internal — their module docstrings make +clear that types defined or re-exported there are not part of the supported +public API and may change in any release without notice — but they are +reachable from a stable import path. This fixes all three problems in +[Background](#background): doc-gen tools can emit working xrefs, users who +follow type annotations land on a clearly-labeled page, and type checkers +no longer flag references as private-module accesses. + +Types that users genuinely instantiate and configure (e.g. `PauliNoise`, +`NoiseConfig`, `EstimatorParams`) remain at their existing public paths. +The `internal` namespaces are reserved for types users only ever receive +from the API or pass through as opaque values. + +### Placement strategy + +For each leaked type, choose one of: + +- **Promote to a fully public path** (`qdk.X`, `qdk.qsharp.X`, `qdk.qre.X`, etc.) if users will construct or configure instances of the type directly. +- **Re-export under `qdk.internal` or `qdk.qre.internal`** if users will only encounter the type through annotations or as a return value, but the type still needs to be reachable for documentation and type-checking purposes. +- **Leave private** if the type does not appear in any user-facing signature or docstring. + +The two `internal` modules should: + +- Have a top-of-file docstring that explicitly identifies them as internal and warns against direct use. +- Be re-export shims only — canonical definitions stay in their existing private modules. +- Not be advertised in the top-level `qdk` package overview docstring. + +### Tier 1 — User-blocking. Do as part of next minor release. + +Each item here represents a case where the user **cannot** reach or +correctly annotate a type that appears in a public signature. + +Non-qre (re-export under `qdk.internal`): + +1. **Re-export `Circuit`** from `qdk._native` under `qdk.internal`. Return type of `qdk.qsharp.circuit()`. +2. **Re-export `QirInputData`** from `qdk._types` under `qdk.internal`. Return type of `qdk.qsharp.compile()` and `qdk.openqasm.compile()`. +3. **Re-export `Config`** from `qdk._types` under `qdk.internal`. Return type of `qdk.qsharp.init()`. +4. **Re-export `GlobalCallable` and `Closure`** under `qdk.internal`. These appear in the union type of `entry_expr` for every public `run`/`compile`/`circuit`/`estimate`/`logical_counts` function. +5. **Re-export `Output` and `StateDumpData`** under `qdk.internal`. These appear in user-facing callback signatures (the `on_save_events` path for `run`). + +qre (re-export under `qdk.qre.internal`): + +6. **Re-export `_InstructionSourceNodeReference` as `qdk.qre.internal.InstructionSourceNodeReference`.** Only way to traverse instruction-source children, returned from public methods. +7. **Re-export `_InstructionSourceNode` as `qdk.qre.internal.InstructionSourceNode`.** Exposed as the element type of the public `InstructionSource.nodes` attribute. +8. **Re-export `_Context` from `qdk.qre._application` as `qdk.qre.internal.ApplicationContext`.** Return type of `Application.context()` and an input to `TraceQuery.enumerate`. +9. **Re-export `_BindingNode` from `qdk.qre._isa_enumeration` as `qdk.qre.internal.BindingNode`.** Return type of `ISATransform.bind`. +10. **Re-export `_SumNode` and `_ProductNode` as `qdk.qre.internal.ISASumNode` and `qdk.qre.internal.ISAProductNode`.** Return types of `ISAQuery.__add__` and `ISAQuery.__mul__`. Alternative: widen the return-type annotations to the public `ISAQuery` base, since callers rarely need the concrete subtype. +11. **Address the `_Node` base of public `TraceQuery`.** Either re-export as `qdk.qre.internal.TraceNode`, or if `TraceQuery` is the only public consumer, drop the inheritance and inline the abstract `enumerate` method on `TraceQuery`. +12. **Re-export `Instruction` as `qdk.qre.internal.Instruction`.** Currently leaks via `InstructionSource.add_node(instruction: Instruction, ...)` and is not in any `__all__`. + +Public surface (no internal namespace needed): + +13. **Add `TraceParameters` (TypeVar) to `qdk.qre.__all__`.** Users subclassing `Application` legitimately use this in their own annotations, so it belongs on the public surface, not in `internal`. + +After Tier 1, every type appearing in a public function signature has a +reachable, doc-linkable home. + +#### Opacity — required before merge + +The initial Tier 1 implementation uses bare re-exports (the `internal` +module exposes the same class objects as the private modules). This means +users importing from `qdk.internal` get full access to every method and +attribute on these types, which undermines the intent of keeping them +internal. + +Before merging this feature, replace the bare re-exports with **Protocol +types that expose only the stable method subset**. For each type: + +- Define a `typing.Protocol` in the `internal` module that describes only + the methods we commit to supporting (e.g. `Circuit` exposes `.json()` + only; `QirInputData` exposes `._repr_qir_()` and `__str__()` only). +- Use `typing.TYPE_CHECKING` guards so the Protocol is what type checkers + and doc generators see, while at runtime the real class is still + returned by the public API functions. +- Verify that `isinstance` checks are not performed on these types + anywhere in the public surface (they aren't today). + +This approach gives users autocomplete and doc links for the stable +surface, while making it clear that other methods are implementation +details. Internal code continues to import directly from `_native` / +`_types` and is unaffected. + +#### Jupyter / notebook integration — testing notes + +`Config`, `Output`, and `StateDumpData` have special roles in the Jupyter +notebook experience. While the proposed re-exports should not change +runtime behavior (the Jupyter display protocol is duck-typed via +`_repr_mimebundle_`, `_repr_markdown_`, etc., and re-exporting does not +change class identity), the following scenarios should be verified after +the Tier 1 changes land: + +- **`Config` MIME bundle round-trip.** Calling `qdk.qsharp.init()` in a + notebook cell must still emit an `application/x.qsharp-config` MIME + output item that the VS Code extension can parse. The extension reads + the raw JSON bytes from cell output — it does not import or + `isinstance`-check `Config` — but we should confirm the data still + flows correctly. +- **`Output` display in `%%qsharp` cells.** The `%%qsharp` cell magic + calls `IPython.display.display(output)` on `Output` objects. IPython + renders them via `Output._repr_markdown_()`. Verify that state dumps, + messages, and matrix outputs still render correctly after the change. +- **`StateDumpData` in `save_events` path.** When `save_events=True`, + `Context.eval()` and `Context.run()` extract `StateDumpData` via + `output.state_dump()` and wrap it in `StateDump`. Confirm that + `StateDump._repr_markdown_()` still works and that the `check_eq` / + `as_dense_state` methods are unaffected. + +Note: `qsharp_widgets` does **not** import any of these types. Its +`Circuit` widget accepts any object with a `.json()` method (duck-typed), +and its only `qdk` import is a lazy `from qdk import qsharp` inside +`Histogram.run()`. The widgets package will not be affected by these +changes. + +### Tier 2 — Discoverability. Schedule for the same release if budget allows. + +These items eliminate broken xrefs and improve doc-gen output. The +underlying functionality is already reachable. + +1. **Re-export `ISA` and `ISAContext` from `qdk.qre.models` and `qdk.qre.models.factories`** so that signature renderings inside those submodules resolve. These types are already public at `qdk.qre.X`; the submodule re-exports are a doc-gen accommodation, not a new API. +2. **Re-export `_CirqTraceBuilder` as `qdk.qre.internal.CirqTraceBuilder` and `_QidToTraceId` as `qdk.qre.internal.QidToTraceId`.** `_CirqTraceBuilder` is the parameter type of ~12 user-facing trace-builder functions; `_QidToTraceId` is the return type of its public `q_to_id` property. +3. **Make `qdk.qsharp.run`'s `noise:` parameter annotation use `qdk.simulation.NoiseConfig` instead of the bare `NoiseConfig`.** `NoiseConfig` is already publicly exposed, just under a different submodule name. +4. **Audit `qdk.qsharp` and `qdk.openqasm` `__all__` for missing entries** that appear in signature renderings. + +## Process recommendations + +### Enforce the existing public/private convention + +The codebase follows the standard Python convention: names (and modules) +starting with an underscore are private and not part of the supported API +surface. This convention is purely advisory — Python itself enforces +nothing, and nothing in the type system or our current build prevents a +private-named type from appearing in the signature or docstring of a +public function or method. Every leak documented in this report is an +instance of that pattern: the private types are correctly named, but they +reach the public surface via return types, parameter types, and docstring +references. + +The fix is to mechanically enforce the existing convention at build time. + +### Add a CI lint + +A lightweight check that walks every `__all__` symbol, inspects its +signature annotations and docstring `:type:` / `:rtype:` / `:param:` +references, and fails if any of them name an underscore-prefixed symbol +(or a symbol whose nearest enclosing module starts with underscore). + +This would have caught all the leakage in this report at code-review time. + +Sample heuristic (pseudocode): + +```python +for module in walk_qdk_modules(): + for name in module.__all__: + sym = getattr(module, name) + for annotation in collect_annotations(sym): + if annotation_names_private_symbol(annotation): + report_violation(module, name, annotation) +``` + +Run as part of `./build.py` so violations land in the same gate that +prevents the PR from merging. + +## Summary + +- Placement strategy: route currently-private leaked types through new `qdk.internal` and `qdk.qre.internal` namespaces, clearly labeled internal but reachable for docs and type-checking. Promote to fully public paths only the types users genuinely instantiate. +- Tier 1 list (user-blocking): 13 changes (5 non-qre, 7 qre, 1 public). +- Tier 2 list (discoverability): 4 changes. +- Recommended process: enforce the existing public/private boundary with a CI lint in `./build.py`. diff --git a/source/qdk_package/check_api_surface.py b/source/qdk_package/check_api_surface.py new file mode 100644 index 0000000000..0aa364db4c --- /dev/null +++ b/source/qdk_package/check_api_surface.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Lint: detect private-symbol leakage in the qdk public API surface. + +For every symbol listed in a module's ``__all__``, this script inspects +function / method type annotations and class base classes. If any +annotation or base references an underscore-prefixed name (e.g. +``_native.Circuit``) or a type whose ``__module__`` contains an +underscore-prefixed segment (e.g. ``qdk._types.Config``), a violation +is reported. + +Exit code 0 – no violations found. +Exit code 1 – one or more violations found (details printed to stderr). + +Usage:: + + python check_api_surface.py # check everything + python check_api_surface.py --json # machine-readable output +""" + +from __future__ import annotations + +import argparse +import importlib +import inspect +import json +import pkgutil +import sys +import types +import typing +from dataclasses import dataclass, field +from typing import get_type_hints + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +# Root package to scan. +ROOT_PACKAGE = "qdk" + +# Modules to skip entirely (they are internal and not expected to have +# a clean public surface). +SKIP_MODULES: set[str] = { + "qdk._native", + "qdk._types", + "qdk._context", + "qdk._interpreter", + "qdk._ipython", + "qdk._fs", + "qdk._http", + "qdk._adaptive_bytecode", + "qdk._adaptive_pass", + "qdk._device", + "qdk.telemetry", + "qdk.telemetry_events", +} + +# Only report violations for types whose __module__ starts with one of +# these prefixes. This filters out false positives from standard-library +# and third-party packages whose internal types happen to have private +# __module__ paths (e.g. pathlib._local.Path, concurrent.futures._base.Future, +# qiskit._accelerate.target.QubitProperties). +OWNED_PREFIXES: tuple[str, ...] = ("qdk.",) + +# Symbols that are themselves known-OK despite having a private-looking +# module path (e.g. the Rust extension types that we *intend* to re-export). +# Format: frozenset of fully qualified "." strings. +KNOWN_EXCEPTIONS: frozenset[str] = frozenset() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _is_private_name(name: str) -> bool: + """Return True if *name* starts with underscore (Python private convention).""" + return name.startswith("_") and not name.startswith("__") + + +def _module_has_private_segment(mod_name: str) -> bool: + """Return True if any segment in a dotted module path is private. + + >>> _module_has_private_segment("qdk._native") + True + >>> _module_has_private_segment("qdk.qsharp") + False + """ + return any(_is_private_name(part) for part in mod_name.split(".")) + + +def _type_fqn(tp: type) -> str: + """Fully-qualified name of a type (best effort).""" + mod = getattr(tp, "__module__", "") or "" + qual = getattr(tp, "__qualname__", "") or getattr(tp, "__name__", str(tp)) + if mod: + return f"{mod}.{qual}" + return qual + + +def _extract_leaf_types(annotation) -> list: + """Recursively unwrap generic aliases and return leaf types.""" + origin = getattr(annotation, "__origin__", None) + args = getattr(annotation, "__args__", None) + + # typing special forms (Union, Optional, etc.) + if origin is typing.Union or origin is types.UnionType: + result = [] + for arg in args or (): + result.extend(_extract_leaf_types(arg)) + return result + + if args: + result = [] + if isinstance(origin, type): + result.append(origin) + for arg in args: + result.extend(_extract_leaf_types(arg)) + return result + + if isinstance(annotation, type): + return [annotation] + + # TypeVar — check bound and constraints + if isinstance(annotation, typing.TypeVar): + result = [] + if annotation.__bound__: + result.extend(_extract_leaf_types(annotation.__bound__)) + for c in annotation.__constraints__: + result.extend(_extract_leaf_types(c)) + return result + + # Forward reference (string annotation) – we can't resolve these + # without the full module context, but check if the string looks private. + if isinstance(annotation, (str, typing.ForwardRef)): + return [annotation] + + return [] + + +@dataclass +class Violation: + """A single API-surface violation.""" + + module: str + public_symbol: str + context: str # e.g. "return type", "parameter 'foo'", "base class" + private_ref: str # the private type or module reference + + def __str__(self) -> str: + return ( + f"{self.module}.{self.public_symbol}: " + f"{self.context} references private {self.private_ref}" + ) + + +def _check_annotation( + annotation, + module_name: str, + symbol_name: str, + context: str, + violations: list[Violation], +) -> None: + """Check a single annotation for private-symbol leakage.""" + for leaf in _extract_leaf_types(annotation): + if isinstance(leaf, (str, typing.ForwardRef)): + ref_str = ( + leaf + if isinstance(leaf, str) + else ( + leaf.__forward_arg__ + if hasattr(leaf, "__forward_arg__") + else str(leaf) + ) + ) + if _is_private_name(ref_str) or any( + _is_private_name(p) for p in ref_str.split(".") + ): + fqn = f"{module_name}.{symbol_name}" + if fqn not in KNOWN_EXCEPTIONS: + violations.append( + Violation(module_name, symbol_name, context, ref_str) + ) + elif isinstance(leaf, type): + leaf_mod = getattr(leaf, "__module__", "") or "" + leaf_name = getattr(leaf, "__qualname__", "") or getattr( + leaf, "__name__", "" + ) + # Only flag types owned by this project + if not any(leaf_mod.startswith(p) for p in OWNED_PREFIXES): + continue + if _is_private_name(leaf_name) or _module_has_private_segment(leaf_mod): + ref = _type_fqn(leaf) + fqn = f"{module_name}.{symbol_name}" + if fqn not in KNOWN_EXCEPTIONS: + violations.append(Violation(module_name, symbol_name, context, ref)) + + +def _check_callable( + obj, + module_name: str, + symbol_name: str, + violations: list[Violation], +) -> None: + """Inspect a function or method's annotations for private types.""" + try: + hints = get_type_hints(obj) + except Exception: + # If we can't resolve hints (e.g. forward refs to unavailable types), + # fall back to raw __annotations__. + hints = getattr(obj, "__annotations__", {}) + + for param_name, annotation in hints.items(): + if param_name == "return": + context = "return type" + else: + context = f"parameter '{param_name}'" + _check_annotation(annotation, module_name, symbol_name, context, violations) + + +def _check_class( + cls: type, + module_name: str, + symbol_name: str, + violations: list[Violation], +) -> None: + """Check a class's bases, and its public methods' annotations.""" + # Check base classes + for base in cls.__mro__[1:]: # skip the class itself + if base is object: + continue + base_mod = getattr(base, "__module__", "") or "" + base_name = getattr(base, "__qualname__", "") or "" + # Only flag types owned by this project + if not any(base_mod.startswith(p) for p in OWNED_PREFIXES): + continue + if _is_private_name(base_name) or _module_has_private_segment(base_mod): + ref = _type_fqn(base) + violations.append(Violation(module_name, symbol_name, "base class", ref)) + + # Check public methods — only those defined in qdk-owned modules. + for attr_name in dir(cls): + if attr_name.startswith("_") and not attr_name.startswith("__"): + continue # skip private methods + try: + attr = getattr(cls, attr_name) + except Exception: + continue + if not callable(attr): + continue + if not (inspect.isfunction(attr) or inspect.ismethod(attr)): + continue + # Skip methods inherited from third-party / stdlib classes. + defining_mod = getattr(attr, "__module__", None) or "" + if ( + not any(defining_mod.startswith(p) for p in OWNED_PREFIXES) + and defining_mod != ROOT_PACKAGE + ): + continue + _check_callable( + attr, + module_name, + f"{symbol_name}.{attr_name}", + violations, + ) + + +# --------------------------------------------------------------------------- +# Module discovery and scanning +# --------------------------------------------------------------------------- + + +def _iter_qdk_modules() -> list[tuple[str, types.ModuleType]]: + """Import and yield all public qdk submodules.""" + root = importlib.import_module(ROOT_PACKAGE) + result: list[tuple[str, types.ModuleType]] = [(ROOT_PACKAGE, root)] + + for importer, modname, ispkg in pkgutil.walk_packages( + root.__path__, prefix=ROOT_PACKAGE + "." + ): + # Skip private modules entirely + if any(_is_private_name(part) for part in modname.split(".")): + continue + if modname in SKIP_MODULES: + continue + try: + mod = importlib.import_module(modname) + result.append((modname, mod)) + except Exception as exc: + print(f"WARNING: could not import {modname}: {exc}", file=sys.stderr) + return result + + +def scan() -> list[Violation]: + """Scan the qdk package and return all violations.""" + violations: list[Violation] = [] + + for mod_name, mod in _iter_qdk_modules(): + all_symbols = getattr(mod, "__all__", None) + if all_symbols is None: + continue # only check modules that declare __all__ + + for sym_name in all_symbols: + obj = getattr(mod, sym_name, None) + if obj is None: + continue + + if isinstance(obj, type): + _check_class(obj, mod_name, sym_name, violations) + elif callable(obj): + _check_callable(obj, mod_name, sym_name, violations) + # For non-callable, non-class objects (e.g. TypeVar, constants), + # we skip — they don't have annotations to check. + + return violations + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="Output violations as JSON") + args = parser.parse_args() + + violations = scan() + + if not violations: + print("No private API leakage detected.", file=sys.stderr) + return 0 + + if args.json: + print( + json.dumps( + [ + { + "module": v.module, + "symbol": v.public_symbol, + "context": v.context, + "private_ref": v.private_ref, + } + for v in violations + ], + indent=2, + ) + ) + else: + print( + f"\n{'='*70}\n" + f" Private API leakage: {len(violations)} violation(s) found\n" + f"{'='*70}\n", + file=sys.stderr, + ) + # Group by module for readability + by_module: dict[str, list[Violation]] = {} + for v in violations: + by_module.setdefault(v.module, []).append(v) + + for mod, vs in sorted(by_module.items()): + print(f"\n {mod}:", file=sys.stderr) + for v in vs: + print( + f" - {v.public_symbol}: {v.context} -> {v.private_ref}", + file=sys.stderr, + ) + + print(f"\n{'='*70}\n", file=sys.stderr) + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/source/qdk_package/qdk/internal.py b/source/qdk_package/qdk/internal.py new file mode 100644 index 0000000000..ec4bb725d5 --- /dev/null +++ b/source/qdk_package/qdk/internal.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Internal types that appear in the public API surface. + +.. warning:: + The types re-exported here are **not** part of the supported public API + and may change in any release without notice. They are made reachable + from this module solely so that: + + 1. Documentation generators (py2docfx, Sphinx) can emit working + cross-reference links for return types and parameter types. + 2. Type checkers (pyright, mypy) do not flag references as + private-module accesses when users annotate variables that hold + values returned by public functions. + 3. Users who follow a type annotation can land on a clearly-labeled + page rather than a ``ModuleNotFoundError``. + + Do **not** depend on the presence or shape of any symbol in this + module. If you need to construct or configure one of these types + directly, use the corresponding public API instead. +""" + +from ._native import ( # type: ignore + Circuit, + Closure, + GlobalCallable, + Output, + StateDumpData, +) +from ._types import ( + Config, + QirInputData, +) + +__all__ = [ + "Circuit", + "Closure", + "Config", + "GlobalCallable", + "Output", + "QirInputData", + "StateDumpData", +] diff --git a/source/qdk_package/qdk/qre/__init__.py b/source/qdk_package/qdk/qre/__init__.py index 0dbe8d4a9d..90ce7e28e2 100644 --- a/source/qdk_package/qdk/qre/__init__.py +++ b/source/qdk_package/qdk/qre/__init__.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from ._application import Application +from ._application import Application, TraceParameters from ._architecture import Architecture, ISAContext from ._estimation import estimate from ._instruction import ( @@ -79,6 +79,7 @@ "property_name", "property_name_to_key", "Trace", + "TraceParameters", "TraceQuery", "TraceTransform", "LOGICAL", diff --git a/source/qdk_package/qdk/qre/internal.py b/source/qdk_package/qdk/qre/internal.py new file mode 100644 index 0000000000..4d7df78042 --- /dev/null +++ b/source/qdk_package/qdk/qre/internal.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Internal types that appear in the ``qdk.qre`` public API surface. + +.. warning:: + The types re-exported here are **not** part of the supported public API + and may change in any release without notice. They are made reachable + from this module solely so that: + + 1. Documentation generators (py2docfx, Sphinx) can emit working + cross-reference links for return types and parameter types. + 2. Type checkers (pyright, mypy) do not flag references as + private-module accesses when users annotate variables that hold + values returned by public functions. + 3. Users who follow a type annotation can land on a clearly-labeled + page rather than a ``ModuleNotFoundError``. + + Do **not** depend on the presence or shape of any symbol in this + module. If you need to construct or configure one of these types + directly, use the corresponding public API instead. +""" + +from ._application import _Context as ApplicationContext +from ._instruction import ( + _InstructionSourceNode as InstructionSourceNode, + _InstructionSourceNodeReference as InstructionSourceNodeReference, +) +from ._isa_enumeration import ( + _BindingNode as BindingNode, + _ProductNode as ISAProductNode, + _SumNode as ISASumNode, +) +from ._qre import Instruction +from ._trace import _Node as TraceNode + +__all__ = [ + "ApplicationContext", + "BindingNode", + "ISAProductNode", + "ISASumNode", + "Instruction", + "InstructionSourceNode", + "InstructionSourceNodeReference", + "TraceNode", +] From b0a52a8e49e6cf001bdef6e79307f7982f7e0d24 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Mon, 1 Jun 2026 11:29:42 -0700 Subject: [PATCH 2/9] fix qre signature resolution --- source/qdk_package/qdk/_context.py | 4 +++- source/qdk_package/qdk/_interpreter.py | 4 +++- source/qdk_package/qdk/qre/internal.py | 12 ++++++++++++ source/qdk_package/qdk/qre/models/__init__.py | 9 +++++++++ .../qdk_package/qdk/qre/models/factories/__init__.py | 8 ++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/source/qdk_package/qdk/_context.py b/source/qdk_package/qdk/_context.py index a0508de548..07d6757ec9 100644 --- a/source/qdk_package/qdk/_context.py +++ b/source/qdk_package/qdk/_context.py @@ -558,7 +558,9 @@ def run( :param *args: The arguments to pass to the callable, if one is provided. :param on_result: A callback function that will be called with each result. :param save_events: If true, the output of each shot will be saved. If false, they will be printed. - :param noise: The noise to use in simulation. + :param noise: The noise to use in simulation. Can be a tuple of ``(x, y, z)`` + Pauli error probabilities, a :class:`~qdk.qsharp.PauliNoise` subclass, or a + :class:`~qdk.simulation.NoiseConfig` for per-gate noise configuration. :param qubit_loss: The probability of qubit loss in simulation. :param seed: The seed to use for the random number generator in simulation, if any. :param type: The type of simulator to use. If not specified, the default sparse state vector simulation will be used. diff --git a/source/qdk_package/qdk/_interpreter.py b/source/qdk_package/qdk/_interpreter.py index 01561f5065..debdd1dc75 100644 --- a/source/qdk_package/qdk/_interpreter.py +++ b/source/qdk_package/qdk/_interpreter.py @@ -235,7 +235,9 @@ def run( :param *args: The arguments to pass to the callable, if one is provided. :param on_result: A callback function that will be called with each result. :param save_events: If true, the output of each shot will be saved. If false, they will be printed. - :param noise: The noise to use in simulation. + :param noise: The noise to use in simulation. Can be a tuple of ``(x, y, z)`` + Pauli error probabilities, a :class:`~qdk.qsharp.PauliNoise` subclass, or a + :class:`~qdk.simulation.NoiseConfig` for per-gate noise configuration. :param qubit_loss: The probability of qubit loss in simulation. :param seed: The seed to use for the random number generator in simulation, if any. :param type: The type of simulator to use. If not specified, the default sparse state vector simulation will be used. diff --git a/source/qdk_package/qdk/qre/internal.py b/source/qdk_package/qdk/qre/internal.py index 4d7df78042..b09559e988 100644 --- a/source/qdk_package/qdk/qre/internal.py +++ b/source/qdk_package/qdk/qre/internal.py @@ -34,13 +34,25 @@ from ._qre import Instruction from ._trace import _Node as TraceNode +try: + from .interop._cirq import ( + _CirqTraceBuilder as CirqTraceBuilder, + _QidToTraceId as QidToTraceId, + ) +except ImportError: + # cirq is an optional dependency; these re-exports are only available + # when the cirq extra is installed. + pass + __all__ = [ "ApplicationContext", "BindingNode", + "CirqTraceBuilder", "ISAProductNode", "ISASumNode", "Instruction", "InstructionSourceNode", "InstructionSourceNodeReference", + "QidToTraceId", "TraceNode", ] diff --git a/source/qdk_package/qdk/qre/models/__init__.py b/source/qdk_package/qdk/qre/models/__init__.py index 8d29b33c66..3a678731b2 100644 --- a/source/qdk_package/qdk/qre/models/__init__.py +++ b/source/qdk_package/qdk/qre/models/__init__.py @@ -17,7 +17,16 @@ ) from .qubits import GateBased, Majorana, NeutralAtom +# Re-export types from qdk.qre that appear in signatures of classes +# defined in this submodule (e.g. Architecture.provided_isa) so that +# doc-gen tools can resolve cross-references within this namespace. +from .._qre import ISA, ISARequirements # noqa: F401 +from .._architecture import ISAContext # noqa: F401 + __all__ = [ + "ISA", + "ISAContext", + "ISARequirements", "GateBased", "GSJ24Factory", "GSJ24CCXFactory", diff --git a/source/qdk_package/qdk/qre/models/factories/__init__.py b/source/qdk_package/qdk/qre/models/factories/__init__.py index 0bc633083b..688a3a26d8 100644 --- a/source/qdk_package/qdk/qre/models/factories/__init__.py +++ b/source/qdk_package/qdk/qre/models/factories/__init__.py @@ -7,7 +7,15 @@ from ._t_to_ccz import GSJ24CCXFactory from ._utils import MagicUpToClifford +# Re-export types from qdk.qre that appear in signatures of classes +# defined in this submodule so that doc-gen tools can resolve cross-references. +from ..._qre import ISA, ISARequirements # noqa: F401 +from ..._architecture import ISAContext # noqa: F401 + __all__ = [ + "ISA", + "ISAContext", + "ISARequirements", "GSJ24Factory", "GSJ24CCXFactory", "Litinski19Factory", From 2e7c79d612f238618e0e92c19a9d79f5f0f7249f Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Mon, 1 Jun 2026 13:20:09 -0700 Subject: [PATCH 3/9] type checking is used to create opacity in the internal types --- PRIVATE_API_LEAKAGE_REPORT.md | 44 ++++++------- source/qdk_package/qdk/internal.py | 89 ++++++++++++++++++++++---- source/qdk_package/qdk/qre/internal.py | 75 +++++++++++++--------- 3 files changed, 142 insertions(+), 66 deletions(-) diff --git a/PRIVATE_API_LEAKAGE_REPORT.md b/PRIVATE_API_LEAKAGE_REPORT.md index 126ab58377..d013bd6607 100644 --- a/PRIVATE_API_LEAKAGE_REPORT.md +++ b/PRIVATE_API_LEAKAGE_REPORT.md @@ -220,30 +220,26 @@ Public surface (no internal namespace needed): After Tier 1, every type appearing in a public function signature has a reachable, doc-linkable home. -#### Opacity — required before merge - -The initial Tier 1 implementation uses bare re-exports (the `internal` -module exposes the same class objects as the private modules). This means -users importing from `qdk.internal` get full access to every method and -attribute on these types, which undermines the intent of keeping them -internal. - -Before merging this feature, replace the bare re-exports with **Protocol -types that expose only the stable method subset**. For each type: - -- Define a `typing.Protocol` in the `internal` module that describes only - the methods we commit to supporting (e.g. `Circuit` exposes `.json()` - only; `QirInputData` exposes `._repr_qir_()` and `__str__()` only). -- Use `typing.TYPE_CHECKING` guards so the Protocol is what type checkers - and doc generators see, while at runtime the real class is still - returned by the public API functions. -- Verify that `isinstance` checks are not performed on these types - anywhere in the public surface (they aren't today). - -This approach gives users autocomplete and doc links for the stable -surface, while making it clear that other methods are implementation -details. Internal code continues to import directly from `_native` / -`_types` and is unaffected. +#### Opacity — implemented + +Both `qdk.internal` and `qdk.qre.internal` now use a +`typing.TYPE_CHECKING` guard to implement the opacity model: + +- **Type-checking time:** Each exported name resolves to a + `typing.Protocol` that exposes only the stable method subset (e.g. + `Circuit` exposes `.json()`, `__repr__`, `__str__`; `QirInputData` + exposes `._repr_qir_()`, `._name`, `__str__()` only; `Instruction` + exposes read-only properties and query methods but not mutation + methods like `set_source` / `set_property`). +- **Runtime:** The `else` branch re-exports the real class, so existing + code continues to work unchanged. +- **No `isinstance` checks** are performed on these types anywhere in + the public surface (verified). + +This means users get autocomplete and doc links for the stable surface +only, while other methods are clearly implementation details. Internal +code continues to import directly from `_native` / `_types` / private +submodules and is unaffected. #### Jupyter / notebook integration — testing notes diff --git a/source/qdk_package/qdk/internal.py b/source/qdk_package/qdk/internal.py index ec4bb725d5..48d935d94f 100644 --- a/source/qdk_package/qdk/internal.py +++ b/source/qdk_package/qdk/internal.py @@ -4,7 +4,7 @@ """Internal types that appear in the public API surface. .. warning:: - The types re-exported here are **not** part of the supported public API + The types exposed here are **not** part of the supported public API and may change in any release without notice. They are made reachable from this module solely so that: @@ -21,17 +21,82 @@ directly, use the corresponding public API instead. """ -from ._native import ( # type: ignore - Circuit, - Closure, - GlobalCallable, - Output, - StateDumpData, -) -from ._types import ( - Config, - QirInputData, -) +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Dict, Optional, Protocol, Union + + class StateDumpData(Protocol): + """A state dump returned from the Q# interpreter.""" + + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + def _repr_markdown_(self) -> str: ... + def _repr_latex_(self) -> Optional[str]: ... + + class Circuit(Protocol): + """A quantum circuit diagram generated from a Q# or OpenQASM program.""" + + def json(self) -> str: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + + class Closure(Protocol): + """An opaque closure reference that can be passed back into Q#.""" + + ... + + class GlobalCallable(Protocol): + """An opaque callable reference that can be passed back into Q#.""" + + ... + + class Output(Protocol): + """An output returned from the Q# interpreter. + + Outputs can be state dumps, matrices, or messages. + """ + + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + def _repr_markdown_(self) -> Optional[str]: ... + + class Config(Protocol): + """Configuration hints for the language service.""" + + def __repr__(self) -> str: ... + def _repr_mimebundle_( + self, + include: Union[Any, None] = None, + exclude: Union[Any, None] = None, + ) -> Dict[str, Dict[str, Any]]: ... + + class QirInputData(Protocol): + """Wraps a compiled QIR program for submission to a quantum target. + + Implements the ``QirRepresentable`` protocol expected by the + ``azure-quantum`` package. + """ + + _name: str + + def _repr_qir_(self, **kwargs) -> bytes: ... + def __str__(self) -> str: ... + +else: + from ._native import ( # type: ignore + Circuit, + Closure, + GlobalCallable, + Output, + StateDumpData, + ) + from ._types import ( + Config, + QirInputData, + ) __all__ = [ "Circuit", diff --git a/source/qdk_package/qdk/qre/internal.py b/source/qdk_package/qdk/qre/internal.py index b09559e988..d0425e8a9e 100644 --- a/source/qdk_package/qdk/qre/internal.py +++ b/source/qdk_package/qdk/qre/internal.py @@ -4,7 +4,7 @@ """Internal types that appear in the ``qdk.qre`` public API surface. .. warning:: - The types re-exported here are **not** part of the supported public API + The types exposed here are **not** part of the supported public API and may change in any release without notice. They are made reachable from this module solely so that: @@ -21,38 +21,53 @@ directly, use the corresponding public API instead. """ -from ._application import _Context as ApplicationContext -from ._instruction import ( - _InstructionSourceNode as InstructionSourceNode, - _InstructionSourceNodeReference as InstructionSourceNodeReference, -) -from ._isa_enumeration import ( - _BindingNode as BindingNode, - _ProductNode as ISAProductNode, - _SumNode as ISASumNode, -) -from ._qre import Instruction -from ._trace import _Node as TraceNode - -try: - from .interop._cirq import ( - _CirqTraceBuilder as CirqTraceBuilder, - _QidToTraceId as QidToTraceId, +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional, Protocol, Union + + from ._architecture import Architecture + from ._instruction import ISATransform + + # ------------------------------------------------------------------ + # InstructionSourceNodeReference + # (runtime: _instruction._InstructionSourceNodeReference) + # ------------------------------------------------------------------ + class InstructionSourceNodeReference(Protocol): + """Reference to a node in an ``InstructionSource`` graph.""" + + @property + def instruction(self) -> Instruction: ... + @property + def transform(self) -> Optional[Union[ISATransform, Architecture]]: ... + + # ------------------------------------------------------------------ + # Instruction (runtime: _qre.Instruction — Rust native) + # ------------------------------------------------------------------ + class Instruction(Protocol): + """A quantum instruction with resource properties.""" + + @property + def id(self) -> int: ... + @property + def encoding(self) -> int: ... + @property + def arity(self) -> Optional[int]: ... + def space(self, arity: Optional[int] = None) -> Optional[int]: ... + def time(self, arity: Optional[int] = None) -> Optional[int]: ... + def error_rate(self, arity: Optional[int] = None) -> Optional[float]: ... + def expect_time(self, arity: Optional[int] = None) -> int: ... + def expect_error_rate(self, arity: Optional[int] = None) -> float: ... + +else: + from ._instruction import ( + _InstructionSourceNodeReference as InstructionSourceNodeReference, ) -except ImportError: - # cirq is an optional dependency; these re-exports are only available - # when the cirq extra is installed. - pass + from ._qre import Instruction __all__ = [ - "ApplicationContext", - "BindingNode", - "CirqTraceBuilder", - "ISAProductNode", - "ISASumNode", "Instruction", - "InstructionSourceNode", "InstructionSourceNodeReference", - "QidToTraceId", - "TraceNode", ] From 77a2083f6f24575ba3d7b738321d3a5483f49fc5 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Mon, 1 Jun 2026 15:10:56 -0700 Subject: [PATCH 4/9] Fix remaining leaks --- source/qdk_package/check_api_surface.py | 156 ++++++++++++++---- source/qdk_package/qdk/internal.py | 21 +++ source/qdk_package/qdk/qre/_application.py | 6 +- source/qdk_package/qdk/qre/_instruction.py | 4 +- .../qdk_package/qdk/qre/_isa_enumeration.py | 12 +- source/qdk_package/qdk/qre/_trace.py | 8 +- source/qdk_package/qdk/qre/internal.py | 16 ++ 7 files changed, 178 insertions(+), 45 deletions(-) diff --git a/source/qdk_package/check_api_surface.py b/source/qdk_package/check_api_surface.py index 0aa364db4c..4f8bdd2227 100644 --- a/source/qdk_package/check_api_surface.py +++ b/source/qdk_package/check_api_surface.py @@ -6,11 +6,17 @@ """Lint: detect private-symbol leakage in the qdk public API surface. For every symbol listed in a module's ``__all__``, this script inspects -function / method type annotations and class base classes. If any -annotation or base references an underscore-prefixed name (e.g. -``_native.Circuit``) or a type whose ``__module__`` contains an -underscore-prefixed segment (e.g. ``qdk._types.Config``), a violation -is reported. +function / method type annotations. If any annotation references a type +that is **only** reachable through an underscore-prefixed (private) +module path, a violation is reported. + +Base classes are intentionally *not* checked — a private base (mixin, +ABC, protocol) is an implementation detail that users never need to +reference directly, so it does not constitute actionable leakage. + +Types that are defined in a private module but re-exported through a +public module's ``__all__`` are **not** flagged — they are considered +public. Exit code 0 – no violations found. Exit code 1 – one or more violations found (details printed to stderr). @@ -66,10 +72,23 @@ OWNED_PREFIXES: tuple[str, ...] = ("qdk.",) # Symbols that are themselves known-OK despite having a private-looking -# module path (e.g. the Rust extension types that we *intend* to re-export). +# module path (e.g. TypeVar constraints that users never reference directly). # Format: frozenset of fully qualified "." strings. KNOWN_EXCEPTIONS: frozenset[str] = frozenset() +# Private types that are tolerated in the public API surface. Unlike +# KNOWN_EXCEPTIONS (which match by public-symbol FQN), these match by the +# private type's own FQN. Use this for typing-only artefacts that users +# never import or reference directly. +KNOWN_PRIVATE_TYPES: frozenset[str] = frozenset( + { + # DataclassProtocol is a TypeVar constraint on TraceParameters. Users + # satisfy it implicitly by using @dataclass; they never import or + # reference the protocol itself. + "qdk.qre._application.DataclassProtocol", + } +) + # --------------------------------------------------------------------------- # Helpers @@ -157,12 +176,44 @@ def __str__(self) -> str: ) +def _build_public_types( + modules: list[tuple[str, types.ModuleType]], +) -> tuple[set[int], set[str]]: + """Build the set of types that are publicly re-exported. + + Returns: + A tuple of (public_type_ids, public_type_names) where: + - public_type_ids is a set of ``id()`` values for type objects + found in any public module's ``__all__``. + - public_type_names is a set of unqualified names (e.g. "Config") + for resolving forward-reference strings. + """ + public_type_ids: set[int] = set() + public_type_names: set[str] = set() + + for mod_name, mod in modules: + all_symbols = getattr(mod, "__all__", None) + if all_symbols is None: + continue + for sym_name in all_symbols: + obj = getattr(mod, sym_name, None) + if obj is None: + continue + if isinstance(obj, type): + public_type_ids.add(id(obj)) + public_type_names.add(sym_name) + + return public_type_ids, public_type_names + + def _check_annotation( annotation, module_name: str, symbol_name: str, context: str, violations: list[Violation], + public_type_ids: set[int], + public_type_names: set[str], ) -> None: """Check a single annotation for private-symbol leakage.""" for leaf in _extract_leaf_types(annotation): @@ -179,12 +230,22 @@ def _check_annotation( if _is_private_name(ref_str) or any( _is_private_name(p) for p in ref_str.split(".") ): + # Check if the bare name matches a publicly-exported type + bare_name = ref_str.rsplit(".", 1)[-1] + if bare_name in public_type_names: + continue + # Check if this is a known-OK private type (by forward-ref string) + if ref_str in KNOWN_PRIVATE_TYPES: + continue fqn = f"{module_name}.{symbol_name}" if fqn not in KNOWN_EXCEPTIONS: violations.append( Violation(module_name, symbol_name, context, ref_str) ) elif isinstance(leaf, type): + # Skip types that are publicly re-exported + if id(leaf) in public_type_ids: + continue leaf_mod = getattr(leaf, "__module__", "") or "" leaf_name = getattr(leaf, "__qualname__", "") or getattr( leaf, "__name__", "" @@ -194,6 +255,9 @@ def _check_annotation( continue if _is_private_name(leaf_name) or _module_has_private_segment(leaf_mod): ref = _type_fqn(leaf) + # Check if this is a known-OK private type (by FQN) + if ref in KNOWN_PRIVATE_TYPES: + continue fqn = f"{module_name}.{symbol_name}" if fqn not in KNOWN_EXCEPTIONS: violations.append(Violation(module_name, symbol_name, context, ref)) @@ -204,6 +268,8 @@ def _check_callable( module_name: str, symbol_name: str, violations: list[Violation], + public_type_ids: set[int], + public_type_names: set[str], ) -> None: """Inspect a function or method's annotations for private types.""" try: @@ -218,7 +284,15 @@ def _check_callable( context = "return type" else: context = f"parameter '{param_name}'" - _check_annotation(annotation, module_name, symbol_name, context, violations) + _check_annotation( + annotation, + module_name, + symbol_name, + context, + violations, + public_type_ids, + public_type_names, + ) def _check_class( @@ -226,21 +300,15 @@ def _check_class( module_name: str, symbol_name: str, violations: list[Violation], + public_type_ids: set[int], + public_type_names: set[str], ) -> None: - """Check a class's bases, and its public methods' annotations.""" - # Check base classes - for base in cls.__mro__[1:]: # skip the class itself - if base is object: - continue - base_mod = getattr(base, "__module__", "") or "" - base_name = getattr(base, "__qualname__", "") or "" - # Only flag types owned by this project - if not any(base_mod.startswith(p) for p in OWNED_PREFIXES): - continue - if _is_private_name(base_name) or _module_has_private_segment(base_mod): - ref = _type_fqn(base) - violations.append(Violation(module_name, symbol_name, "base class", ref)) + """Check a class's public methods' annotations for private types. + Note: base classes are intentionally *not* checked. A private base + (mixin, ABC, protocol) is an implementation detail that users never + reference directly, so it does not constitute actionable leakage. + """ # Check public methods — only those defined in qdk-owned modules. for attr_name in dir(cls): if attr_name.startswith("_") and not attr_name.startswith("__"): @@ -265,6 +333,8 @@ def _check_class( module_name, f"{symbol_name}.{attr_name}", violations, + public_type_ids, + public_type_names, ) @@ -298,7 +368,10 @@ def scan() -> list[Violation]: """Scan the qdk package and return all violations.""" violations: list[Violation] = [] - for mod_name, mod in _iter_qdk_modules(): + modules = _iter_qdk_modules() + public_type_ids, public_type_names = _build_public_types(modules) + + for mod_name, mod in modules: all_symbols = getattr(mod, "__all__", None) if all_symbols is None: continue # only check modules that declare __all__ @@ -309,9 +382,23 @@ def scan() -> list[Violation]: continue if isinstance(obj, type): - _check_class(obj, mod_name, sym_name, violations) + _check_class( + obj, + mod_name, + sym_name, + violations, + public_type_ids, + public_type_names, + ) elif callable(obj): - _check_callable(obj, mod_name, sym_name, violations) + _check_callable( + obj, + mod_name, + sym_name, + violations, + public_type_ids, + public_type_names, + ) # For non-callable, non-class objects (e.g. TypeVar, constants), # we skip — they don't have annotations to check. @@ -350,22 +437,31 @@ def main() -> int: ) ) else: + # Compute unique private types for the summary line. + unique_types = {v.private_ref for v in violations} + print( f"\n{'='*70}\n" - f" Private API leakage: {len(violations)} violation(s) found\n" + f" Private API leakage detected\n" + f" {len(unique_types)} private type(s), " + f"{len(violations)} reference(s) across public API\n" f"{'='*70}\n", file=sys.stderr, ) - # Group by module for readability - by_module: dict[str, list[Violation]] = {} + + # Primary grouping: by private type (the actionable unit). + by_type: dict[str, list[Violation]] = {} for v in violations: - by_module.setdefault(v.module, []).append(v) + by_type.setdefault(v.private_ref, []).append(v) - for mod, vs in sorted(by_module.items()): - print(f"\n {mod}:", file=sys.stderr) + for priv_type, vs in sorted(by_type.items()): + print( + f"\n {priv_type} ({len(vs)} reference(s))", + file=sys.stderr, + ) for v in vs: print( - f" - {v.public_symbol}: {v.context} -> {v.private_ref}", + f" - {v.module}.{v.public_symbol}: {v.context}", file=sys.stderr, ) diff --git a/source/qdk_package/qdk/internal.py b/source/qdk_package/qdk/internal.py index 48d935d94f..3a2f94acbc 100644 --- a/source/qdk_package/qdk/internal.py +++ b/source/qdk_package/qdk/internal.py @@ -26,6 +26,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from enum import Enum from typing import Any, Dict, Optional, Protocol, Union class StateDumpData(Protocol): @@ -85,6 +86,23 @@ class QirInputData(Protocol): def _repr_qir_(self, **kwargs) -> bytes: ... def __str__(self) -> str: ... + class ZoneType(Enum): + """Type of zone in a neutral-atom device layout.""" + + REG = "register" + INTER = "interaction" + MEAS = "measurement" + + class Zone(Protocol): + """A zone in a neutral-atom device layout.""" + + name: str + row_count: int + type: ZoneType + offset: int + + def set_offset(self, offset: int) -> None: ... + else: from ._native import ( # type: ignore Circuit, @@ -97,6 +115,7 @@ def __str__(self) -> str: ... Config, QirInputData, ) + from ._device._device import Zone, ZoneType __all__ = [ "Circuit", @@ -106,4 +125,6 @@ def __str__(self) -> str: ... "Output", "QirInputData", "StateDumpData", + "Zone", + "ZoneType", ] diff --git a/source/qdk_package/qdk/qre/_application.py b/source/qdk_package/qdk/qre/_application.py index 6c20621b2b..05a5357977 100644 --- a/source/qdk_package/qdk/qre/_application.py +++ b/source/qdk_package/qdk/qre/_application.py @@ -70,9 +70,9 @@ def q(**kwargs) -> TraceQuery: """ return TraceQuery(NoneType, **kwargs) - def context(self) -> _Context: + def context(self) -> ApplicationContext: """Create a new enumeration context for this application.""" - return _Context(self) + return ApplicationContext(self) def post_process( self, parameters: TraceParameters, estimation: EstimationResult @@ -157,7 +157,7 @@ def disable_parallel_traces(self): self._parallel_traces = False -class _Context: +class ApplicationContext: """Enumeration context wrapping an application instance.""" application: Application diff --git a/source/qdk_package/qdk/qre/_instruction.py b/source/qdk_package/qdk/qre/_instruction.py index 4669a86d4c..a665bf1bc3 100644 --- a/source/qdk_package/qdk/qre/_instruction.py +++ b/source/qdk_package/qdk/qre/_instruction.py @@ -171,7 +171,7 @@ def q(cls, *, source: ISAQuery | None = None, **kwargs) -> ISAQuery: ) @classmethod - def bind(cls, name: str, node: ISAQuery) -> _BindingNode: + def bind(cls, name: str, node: ISAQuery) -> ISAQuery: """ Create a BindingNode for this transform. @@ -182,7 +182,7 @@ def bind(cls, name: str, node: ISAQuery) -> _BindingNode: node (Node): The child node that can reference this binding. Returns: - BindingNode: A binding node enclosing this transform. + ISAQuery: A binding node enclosing this transform. """ return cls.q().bind(name, node) diff --git a/source/qdk_package/qdk/qre/_isa_enumeration.py b/source/qdk_package/qdk/qre/_isa_enumeration.py index 7543c071ed..9c93f2fff2 100644 --- a/source/qdk_package/qdk/qre/_isa_enumeration.py +++ b/source/qdk_package/qdk/qre/_isa_enumeration.py @@ -61,7 +61,7 @@ def populate(self, ctx: ISAContext) -> int: pass return start - def __add__(self, other: ISAQuery) -> _SumNode: + def __add__(self, other: ISAQuery) -> ISAQuery: """ Perform a union of two enumeration nodes. @@ -73,7 +73,7 @@ def __add__(self, other: ISAQuery) -> _SumNode: other (Node): The other enumeration node. Returns: - SumNode: A node representing the union of both enumerations. + ISAQuery: A node representing the union of both enumerations. Example: @@ -95,7 +95,7 @@ def __add__(self, other: ISAQuery) -> _SumNode: else: return _SumNode([self, other]) - def __mul__(self, other: ISAQuery) -> _ProductNode: + def __mul__(self, other: ISAQuery) -> ISAQuery: """ Perform the cross product of two enumeration nodes. @@ -109,7 +109,7 @@ def __mul__(self, other: ISAQuery) -> _ProductNode: other (Node): The other enumeration node. Returns: - ProductNode: A node representing the product of both enumerations. + ISAQuery: A node representing the product of both enumerations. Example: @@ -133,7 +133,7 @@ def __mul__(self, other: ISAQuery) -> _ProductNode: else: return _ProductNode([self, other]) - def bind(self, name: str, node: ISAQuery) -> "_BindingNode": + def bind(self, name: str, node: ISAQuery) -> ISAQuery: """Create a BindingNode with this node as the component. Args: @@ -141,7 +141,7 @@ def bind(self, name: str, node: ISAQuery) -> "_BindingNode": node: The child enumeration node that may contain ISARefNodes. Returns: - A BindingNode with self as the component. + ISAQuery: A binding node with self as the component. Example: diff --git a/source/qdk_package/qdk/qre/_trace.py b/source/qdk_package/qdk/qre/_trace.py index 49974e80d9..3ecd52cc95 100644 --- a/source/qdk_package/qdk/qre/_trace.py +++ b/source/qdk_package/qdk/qre/_trace.py @@ -9,7 +9,7 @@ from typing import Any, Optional, Generator, Type, TYPE_CHECKING if TYPE_CHECKING: - from ._application import _Context + from ._application import ApplicationContext from ._enumeration import _enumerate_instances from ._qre import PSSPC as _PSSPC, LatticeSurgery as _LatticeSurgery, Trace @@ -113,7 +113,7 @@ class _Node(ABC): """Abstract base class for trace enumeration nodes.""" @abstractmethod - def enumerate(self, ctx: _Context) -> Generator[Trace, None, None]: ... + def enumerate(self, ctx: ApplicationContext) -> Generator[Trace, None, None]: ... class TraceQuery(_Node): @@ -131,12 +131,12 @@ def __init__(self, t: Type, **kwargs): self.sequence = [(t, kwargs)] def enumerate( - self, ctx: _Context, track_parameters: bool = False + self, ctx: ApplicationContext, track_parameters: bool = False ) -> Generator[Trace | tuple[Any, Trace], None, None]: """Enumerate transformed traces from the application context. Args: - ctx (_Context): The application enumeration context. + ctx (ApplicationContext): The application enumeration context. track_parameters (bool): If True, yield ``(parameters, trace)`` tuples instead of plain traces. Default is False. diff --git a/source/qdk_package/qdk/qre/internal.py b/source/qdk_package/qdk/qre/internal.py index d0425e8a9e..41e76d5b33 100644 --- a/source/qdk_package/qdk/qre/internal.py +++ b/source/qdk_package/qdk/qre/internal.py @@ -31,6 +31,20 @@ from ._architecture import Architecture from ._instruction import ISATransform + # ------------------------------------------------------------------ + # ApplicationContext + # (runtime: _application.ApplicationContext) + # ------------------------------------------------------------------ + class ApplicationContext(Protocol): + """Enumeration context wrapping an application instance. + + Obtained via :meth:`Application.context` and passed to + :meth:`TraceQuery.enumerate`. + """ + + @property + def application(self) -> "Application": ... + # ------------------------------------------------------------------ # InstructionSourceNodeReference # (runtime: _instruction._InstructionSourceNodeReference) @@ -62,12 +76,14 @@ def expect_time(self, arity: Optional[int] = None) -> int: ... def expect_error_rate(self, arity: Optional[int] = None) -> float: ... else: + from ._application import ApplicationContext from ._instruction import ( _InstructionSourceNodeReference as InstructionSourceNodeReference, ) from ._qre import Instruction __all__ = [ + "ApplicationContext", "Instruction", "InstructionSourceNodeReference", ] From 613da8f18be55020da94b8fdc41b0cec822d799b Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Mon, 1 Jun 2026 15:37:13 -0700 Subject: [PATCH 5/9] Updated the build script so that the lint runs with other checks (needs `--check` and `--qdk` flags) --- build.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.py b/build.py index a721f0ad36..158888c04a 100755 --- a/build.py +++ b/build.py @@ -458,6 +458,14 @@ def run_ci_historic_benchmark(): build_wheel(python_bin, qdk_python_src, env=pip_env, maturin=True) step_end() + if args.check: + step_start("Checking qdk public API surface for private type leakage") + run( + [python_bin, os.path.join(qdk_python_src, "check_api_surface.py")], + cwd=qdk_python_src, + ) + step_end() + if run_tests: step_start("Running tests for the qdk python package") # Install per-package test requirements (pytest, etc.) From 85d3e9d7d75882bf740d38cd75169c6d7d911f4c Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Tue, 2 Jun 2026 09:39:35 -0700 Subject: [PATCH 6/9] no exceptions --- source/qdk_package/check_api_surface.py | 38 +++---------------------- source/qdk_package/qdk/qre/internal.py | 17 +++++++++-- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/source/qdk_package/check_api_surface.py b/source/qdk_package/check_api_surface.py index 4f8bdd2227..3442ebed89 100644 --- a/source/qdk_package/check_api_surface.py +++ b/source/qdk_package/check_api_surface.py @@ -18,8 +18,8 @@ public module's ``__all__`` are **not** flagged — they are considered public. -Exit code 0 – no violations found. -Exit code 1 – one or more violations found (details printed to stderr). +Exit code 0 - no violations found. +Exit code 1 - one or more violations found (details printed to stderr). Usage:: @@ -71,24 +71,6 @@ # qiskit._accelerate.target.QubitProperties). OWNED_PREFIXES: tuple[str, ...] = ("qdk.",) -# Symbols that are themselves known-OK despite having a private-looking -# module path (e.g. TypeVar constraints that users never reference directly). -# Format: frozenset of fully qualified "." strings. -KNOWN_EXCEPTIONS: frozenset[str] = frozenset() - -# Private types that are tolerated in the public API surface. Unlike -# KNOWN_EXCEPTIONS (which match by public-symbol FQN), these match by the -# private type's own FQN. Use this for typing-only artefacts that users -# never import or reference directly. -KNOWN_PRIVATE_TYPES: frozenset[str] = frozenset( - { - # DataclassProtocol is a TypeVar constraint on TraceParameters. Users - # satisfy it implicitly by using @dataclass; they never import or - # reference the protocol itself. - "qdk.qre._application.DataclassProtocol", - } -) - # --------------------------------------------------------------------------- # Helpers @@ -234,14 +216,7 @@ def _check_annotation( bare_name = ref_str.rsplit(".", 1)[-1] if bare_name in public_type_names: continue - # Check if this is a known-OK private type (by forward-ref string) - if ref_str in KNOWN_PRIVATE_TYPES: - continue - fqn = f"{module_name}.{symbol_name}" - if fqn not in KNOWN_EXCEPTIONS: - violations.append( - Violation(module_name, symbol_name, context, ref_str) - ) + violations.append(Violation(module_name, symbol_name, context, ref_str)) elif isinstance(leaf, type): # Skip types that are publicly re-exported if id(leaf) in public_type_ids: @@ -255,12 +230,7 @@ def _check_annotation( continue if _is_private_name(leaf_name) or _module_has_private_segment(leaf_mod): ref = _type_fqn(leaf) - # Check if this is a known-OK private type (by FQN) - if ref in KNOWN_PRIVATE_TYPES: - continue - fqn = f"{module_name}.{symbol_name}" - if fqn not in KNOWN_EXCEPTIONS: - violations.append(Violation(module_name, symbol_name, context, ref)) + violations.append(Violation(module_name, symbol_name, context, ref)) def _check_callable( diff --git a/source/qdk_package/qdk/qre/internal.py b/source/qdk_package/qdk/qre/internal.py index 41e76d5b33..c1b2615d7e 100644 --- a/source/qdk_package/qdk/qre/internal.py +++ b/source/qdk_package/qdk/qre/internal.py @@ -26,7 +26,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional, Protocol, Union + from typing import ClassVar, Optional, Protocol, Union from ._architecture import Architecture from ._instruction import ISATransform @@ -45,6 +45,18 @@ class ApplicationContext(Protocol): @property def application(self) -> "Application": ... + # ------------------------------------------------------------------ + # DataclassProtocol + # (runtime: _application.DataclassProtocol) + # ------------------------------------------------------------------ + class DataclassProtocol(Protocol): + """Structural type satisfied by any ``@dataclass`` class. + + Used as a constraint on :data:`TraceParameters`. + """ + + __dataclass_fields__: ClassVar[dict] + # ------------------------------------------------------------------ # InstructionSourceNodeReference # (runtime: _instruction._InstructionSourceNodeReference) @@ -76,7 +88,7 @@ def expect_time(self, arity: Optional[int] = None) -> int: ... def expect_error_rate(self, arity: Optional[int] = None) -> float: ... else: - from ._application import ApplicationContext + from ._application import ApplicationContext, DataclassProtocol from ._instruction import ( _InstructionSourceNodeReference as InstructionSourceNodeReference, ) @@ -84,6 +96,7 @@ def expect_error_rate(self, arity: Optional[int] = None) -> float: ... __all__ = [ "ApplicationContext", + "DataclassProtocol", "Instruction", "InstructionSourceNodeReference", ] From a9a4f00059bfff4c6a1c71b02cd551ef3cc43849 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Tue, 2 Jun 2026 09:49:07 -0700 Subject: [PATCH 7/9] remove unecessary re-exports --- source/qdk_package/qdk/qre/models/__init__.py | 9 --------- source/qdk_package/qdk/qre/models/factories/__init__.py | 8 -------- 2 files changed, 17 deletions(-) diff --git a/source/qdk_package/qdk/qre/models/__init__.py b/source/qdk_package/qdk/qre/models/__init__.py index 3a678731b2..8d29b33c66 100644 --- a/source/qdk_package/qdk/qre/models/__init__.py +++ b/source/qdk_package/qdk/qre/models/__init__.py @@ -17,16 +17,7 @@ ) from .qubits import GateBased, Majorana, NeutralAtom -# Re-export types from qdk.qre that appear in signatures of classes -# defined in this submodule (e.g. Architecture.provided_isa) so that -# doc-gen tools can resolve cross-references within this namespace. -from .._qre import ISA, ISARequirements # noqa: F401 -from .._architecture import ISAContext # noqa: F401 - __all__ = [ - "ISA", - "ISAContext", - "ISARequirements", "GateBased", "GSJ24Factory", "GSJ24CCXFactory", diff --git a/source/qdk_package/qdk/qre/models/factories/__init__.py b/source/qdk_package/qdk/qre/models/factories/__init__.py index 688a3a26d8..0bc633083b 100644 --- a/source/qdk_package/qdk/qre/models/factories/__init__.py +++ b/source/qdk_package/qdk/qre/models/factories/__init__.py @@ -7,15 +7,7 @@ from ._t_to_ccz import GSJ24CCXFactory from ._utils import MagicUpToClifford -# Re-export types from qdk.qre that appear in signatures of classes -# defined in this submodule so that doc-gen tools can resolve cross-references. -from ..._qre import ISA, ISARequirements # noqa: F401 -from ..._architecture import ISAContext # noqa: F401 - __all__ = [ - "ISA", - "ISAContext", - "ISARequirements", "GSJ24Factory", "GSJ24CCXFactory", "Litinski19Factory", From 0e84c17ca902d8822e273e49d649bf0d7ce75aa4 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Tue, 2 Jun 2026 10:04:20 -0700 Subject: [PATCH 8/9] remove report --- PRIVATE_API_LEAKAGE_REPORT.md | 329 ---------------------------------- 1 file changed, 329 deletions(-) delete mode 100644 PRIVATE_API_LEAKAGE_REPORT.md diff --git a/PRIVATE_API_LEAKAGE_REPORT.md b/PRIVATE_API_LEAKAGE_REPORT.md deleted file mode 100644 index d013bd6607..0000000000 --- a/PRIVATE_API_LEAKAGE_REPORT.md +++ /dev/null @@ -1,329 +0,0 @@ -# Private API leakage in `qdk` — analysis & proposed plan - -## Background - -Auditing docstrings and Sphinx cross-references in the `qdk` package surfaced -a systemic issue: a number of types defined in private modules -(`qdk._native`, `qdk._types`, `qdk._interpreter`, and several `qdk.qre.*` -submodules) appear in the public API surface — as return types, parameter -types, or types referenced in docstrings of public functions and methods. - -This causes three concrete problems: - -1. **Broken cross-references in the generated docs.** Tools like py2docfx and - Sphinx can't emit working links to a type that lives at a non-public path, - so the generated reference pages contain dead `` markers. -2. **Inconsistent / undiscoverable API.** Users who follow a return type - annotation to import it cannot, because the type isn't reachable from any - stable `qdk.*` path (e.g. `from qdk._native import Circuit` is technically - importable but is explicitly private). -3. **Static typing breaks.** Type checkers (pyright, mypy) flag references - to private modules as private-symbol violations when used from user code, - even when the type is unavoidable because it's the return type of a public - function. - -This document inventories the leakage and proposes a categorized plan to -resolve it. - -## Leakage outside `qdk.qre` - -### 1. Native types appearing in `qdk.qsharp` and `qdk.openqasm` signatures - -The following types are imported from `qdk._native` (the Rust extension) or -`qdk._types` and appear in parameter or return positions of public -functions, but are **not** currently re-exported on any public path: - -| Type | Defined in | Used by | Position | -| ---------------- | ------------- | -------------------------------------------------------------------- | ----------- | -| `Circuit` | `qdk._native` | return type of `qdk.qsharp.circuit()` | return | -| `QirInputData` | `qdk._types` | return type of `qdk.qsharp.compile()` and `qdk.openqasm.compile()` | return | -| `Config` | `qdk._types` | return type of `qdk.qsharp.init()` | return | -| `GlobalCallable` | `qdk._native` | `qdk.qsharp.run`, `compile`, `circuit`, `estimate`, `logical_counts` | parameter | -| `Closure` | `qdk._native` | `qdk.qsharp.run`, `compile`, `circuit`, `estimate`, `logical_counts` | parameter | -| `NoiseConfig` | `qdk._native` | `qdk.qsharp.run`, `qdk.openqasm.run` | parameter | -| `Output` | `qdk._native` | callback signatures used by `run` event-saving paths | callback | -| `StateDumpData` | `qdk._native` | inputs to user-facing `StateDump` construction | constructor | -| `CircuitConfig` | `qdk._native` | configuration object passed internally by `circuit()` | internal | -| `Interpreter` | `qdk._native` | return type of internal `get_interpreter()` helper | internal | - -Notes: - -- `Circuit`, `QirInputData`, and `Config` are concrete return types of - public top-level functions. Without a public path, users cannot annotate - variables that hold these values, and the doc pages that describe - `circuit()`, `compile()`, and `init()` cannot link their return types. -- `NoiseConfig` is **already** re-exported as `qdk.simulation.NoiseConfig`, - so it has a public home; the issue is only that `qdk.qsharp.run`'s union - type annotation references the bare `NoiseConfig` rather than - `qdk.simulation.NoiseConfig`. -- `GlobalCallable` and `Closure` represent Q# callables and closures - produced by the interpreter and stored on user-facing callable objects - (via `__global_callable`). They are part of the contract users see when - passing a Q#-generated callable back into `run` / `compile` / `circuit`. -- `Output` and `StateDumpData` are wrappers that the user receives in - callback contexts; they appear in event-saving code paths. -- `CircuitConfig` and `Interpreter` are arguably internal-only and could - stay private, with the recommendation being to remove them from - documented signatures rather than promote them. - -### 2. Internal types confirmed not to leak - -The following types are imported from `qdk._native` or defined in private -modules and are used in internal helper signatures only. They have been -verified not to appear in any user-visible docstring or public signature -and should remain private. - -- `TypeIR`, `TypeKind`, `PrimitiveKind`, `UdtValue` (used by [`_context.py`](source/qdk_package/qdk/_context.py) in the dynamic Q#-class generation machinery). -- `CircuitConfig`, `Interpreter` (used by internal helpers `get_interpreter()` and `circuit()`). - -## Leakage inside `qdk.qre` - -The `qre` module has the most extensive leakage and warrants treatment as a -cohesive design refresh rather than piecemeal patches. - -### Categorized by severity - -#### Public methods returning or exposing private types - -| Public surface | Private type | -| ------------------------------------------------- | --------------------------------- | -| `qdk.qre.InstructionSource.__getitem__(id)` | `_InstructionSourceNodeReference` | -| `qdk.qre.InstructionSource.get(id, default=None)` | `_InstructionSourceNodeReference` | -| `qdk.qre.InstructionSource.nodes` | `list[_InstructionSourceNode]` | -| `qdk.qre.ISATransform.bind(name, node)` | `_BindingNode` | -| `qdk.qre.ISAQuery.__add__(other)` | `_SumNode` | -| `qdk.qre.ISAQuery.__mul__(other)` | `_ProductNode` | -| `qdk.qre.TraceQuery` (class) | inherits from private `_Node` | -| `qdk.qre.Application.context()` | `_Context` | - -The `_InstructionSourceNodeReference` case is the clearest leak: it's the -**only** way to reach the child-node traversal API -(`_InstructionSourceNodeReference.__getitem__`, -`_InstructionSourceNodeReference.get`, -`_InstructionSourceNodeReference.instruction`, -`_InstructionSourceNodeReference.transform`), but the type itself isn't -public, so users have to type-erase to `Any` or import from -`qdk.qre._instruction` directly. - -#### Private types in public parameter positions - -| Method | Parameter type | -| ---------------------------------------- | ------------------- | -| `qdk.qre.TraceQuery.enumerate(ctx, ...)` | `_Context` | -| Many `*_to_trace` Cirq adapters | `_CirqTraceBuilder` | - -`_CirqTraceBuilder` lives in [qdk/qre/interop/\_cirq.py](source/qdk_package/qdk/qre/interop/_cirq.py) -and is referenced by ~12 module-level `*_to_trace` functions that are -visible to users registering custom gate translations. It also exposes a -`q_to_id` property typed as the private `_QidToTraceId`. - -#### TypeVars not re-exported - -| Symbol | Defined in | Used in | -| ----------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `TraceParameters` | [qdk/qre/\_application.py](source/qdk_package/qdk/qre/_application.py) | `Application[TraceParameters]`, `Application.get_trace(parameters: TraceParameters)`, `Application.post_process(parameters: TraceParameters, ...)`, `Application.enumerate_traces_with_parameters(...)` (yields `tuple[TraceParameters, Trace]`) | - -Users subclassing `Application` need this TypeVar to write their own -generic specializations, but it's not exported from `qdk.qre`. - -#### Public types not re-exported in submodules - -These exist publicly at `qdk.qre.X` but are referenced by classes that live -in deeper submodules like `qdk.qre.models` and `qdk.qre.models.factories`. -py2docfx renders xrefs relative to the type's module, so links break: - -| Type from `qdk.qre` | Used in | -| ---------------------- | -------------------------------------------------------------------------------------------- | -| `Instruction` | `InstructionSource.add_node(instruction: Instruction, ...)` | -| `ISA`, `ISAContext` | `Litinski19Factory.provided_isa(self, impl_isa: ISA, ctx: ISAContext)` and similar factories | -| `EstimationTableEntry` | `EstimationTable.add_column(function: Callable[[EstimationTableEntry], Any])` | -| `ConstraintBound` | `qdk.qre.constraint(error_rate: Optional[ConstraintBound] = None)` | - -Note that `Instruction` is **not even in `qdk.qre.__all__`**, so it's -effectively unreachable except via `qdk.qre._qre.Instruction` (private). - -#### Internal types confirmed not to leak - -The following private types are used in internal positions only and have -been verified not to appear in any public signature. They should remain -private. - -- `_Entry`, `_Protocol` in [qdk/qre/models/factories/\_litinski.py](source/qdk_package/qdk/qre/models/factories/_litinski.py) and [qdk/qre/models/factories/\_cultivation.py](source/qdk_package/qdk/qre/models/factories/_cultivation.py) (distillation-table representations). -- `_ComponentQuery` (used internally by `ISATransform.q()`, which returns the public `ISAQuery` base type). -- `_PSSPC`, `_LatticeSurgery` (private aliases of Rust types wrapped by the public `PSSPC` and `LatticeSurgery` classes). - -## Proposed plan - -We recommend routing all currently-private leaked types through a pair of -new "internal but visible" namespaces: - -- **`qdk.internal`** for non-qre types that are unavoidably exposed in the - public surface but are not part of the supported API. -- **`qdk.qre.internal`** for the analogous qre-specific types. `qre` has - enough internal surface area that a separate namespace keeps the - top-level `qdk.internal` from being dominated by qre concerns. - -These namespaces are explicitly internal — their module docstrings make -clear that types defined or re-exported there are not part of the supported -public API and may change in any release without notice — but they are -reachable from a stable import path. This fixes all three problems in -[Background](#background): doc-gen tools can emit working xrefs, users who -follow type annotations land on a clearly-labeled page, and type checkers -no longer flag references as private-module accesses. - -Types that users genuinely instantiate and configure (e.g. `PauliNoise`, -`NoiseConfig`, `EstimatorParams`) remain at their existing public paths. -The `internal` namespaces are reserved for types users only ever receive -from the API or pass through as opaque values. - -### Placement strategy - -For each leaked type, choose one of: - -- **Promote to a fully public path** (`qdk.X`, `qdk.qsharp.X`, `qdk.qre.X`, etc.) if users will construct or configure instances of the type directly. -- **Re-export under `qdk.internal` or `qdk.qre.internal`** if users will only encounter the type through annotations or as a return value, but the type still needs to be reachable for documentation and type-checking purposes. -- **Leave private** if the type does not appear in any user-facing signature or docstring. - -The two `internal` modules should: - -- Have a top-of-file docstring that explicitly identifies them as internal and warns against direct use. -- Be re-export shims only — canonical definitions stay in their existing private modules. -- Not be advertised in the top-level `qdk` package overview docstring. - -### Tier 1 — User-blocking. Do as part of next minor release. - -Each item here represents a case where the user **cannot** reach or -correctly annotate a type that appears in a public signature. - -Non-qre (re-export under `qdk.internal`): - -1. **Re-export `Circuit`** from `qdk._native` under `qdk.internal`. Return type of `qdk.qsharp.circuit()`. -2. **Re-export `QirInputData`** from `qdk._types` under `qdk.internal`. Return type of `qdk.qsharp.compile()` and `qdk.openqasm.compile()`. -3. **Re-export `Config`** from `qdk._types` under `qdk.internal`. Return type of `qdk.qsharp.init()`. -4. **Re-export `GlobalCallable` and `Closure`** under `qdk.internal`. These appear in the union type of `entry_expr` for every public `run`/`compile`/`circuit`/`estimate`/`logical_counts` function. -5. **Re-export `Output` and `StateDumpData`** under `qdk.internal`. These appear in user-facing callback signatures (the `on_save_events` path for `run`). - -qre (re-export under `qdk.qre.internal`): - -6. **Re-export `_InstructionSourceNodeReference` as `qdk.qre.internal.InstructionSourceNodeReference`.** Only way to traverse instruction-source children, returned from public methods. -7. **Re-export `_InstructionSourceNode` as `qdk.qre.internal.InstructionSourceNode`.** Exposed as the element type of the public `InstructionSource.nodes` attribute. -8. **Re-export `_Context` from `qdk.qre._application` as `qdk.qre.internal.ApplicationContext`.** Return type of `Application.context()` and an input to `TraceQuery.enumerate`. -9. **Re-export `_BindingNode` from `qdk.qre._isa_enumeration` as `qdk.qre.internal.BindingNode`.** Return type of `ISATransform.bind`. -10. **Re-export `_SumNode` and `_ProductNode` as `qdk.qre.internal.ISASumNode` and `qdk.qre.internal.ISAProductNode`.** Return types of `ISAQuery.__add__` and `ISAQuery.__mul__`. Alternative: widen the return-type annotations to the public `ISAQuery` base, since callers rarely need the concrete subtype. -11. **Address the `_Node` base of public `TraceQuery`.** Either re-export as `qdk.qre.internal.TraceNode`, or if `TraceQuery` is the only public consumer, drop the inheritance and inline the abstract `enumerate` method on `TraceQuery`. -12. **Re-export `Instruction` as `qdk.qre.internal.Instruction`.** Currently leaks via `InstructionSource.add_node(instruction: Instruction, ...)` and is not in any `__all__`. - -Public surface (no internal namespace needed): - -13. **Add `TraceParameters` (TypeVar) to `qdk.qre.__all__`.** Users subclassing `Application` legitimately use this in their own annotations, so it belongs on the public surface, not in `internal`. - -After Tier 1, every type appearing in a public function signature has a -reachable, doc-linkable home. - -#### Opacity — implemented - -Both `qdk.internal` and `qdk.qre.internal` now use a -`typing.TYPE_CHECKING` guard to implement the opacity model: - -- **Type-checking time:** Each exported name resolves to a - `typing.Protocol` that exposes only the stable method subset (e.g. - `Circuit` exposes `.json()`, `__repr__`, `__str__`; `QirInputData` - exposes `._repr_qir_()`, `._name`, `__str__()` only; `Instruction` - exposes read-only properties and query methods but not mutation - methods like `set_source` / `set_property`). -- **Runtime:** The `else` branch re-exports the real class, so existing - code continues to work unchanged. -- **No `isinstance` checks** are performed on these types anywhere in - the public surface (verified). - -This means users get autocomplete and doc links for the stable surface -only, while other methods are clearly implementation details. Internal -code continues to import directly from `_native` / `_types` / private -submodules and is unaffected. - -#### Jupyter / notebook integration — testing notes - -`Config`, `Output`, and `StateDumpData` have special roles in the Jupyter -notebook experience. While the proposed re-exports should not change -runtime behavior (the Jupyter display protocol is duck-typed via -`_repr_mimebundle_`, `_repr_markdown_`, etc., and re-exporting does not -change class identity), the following scenarios should be verified after -the Tier 1 changes land: - -- **`Config` MIME bundle round-trip.** Calling `qdk.qsharp.init()` in a - notebook cell must still emit an `application/x.qsharp-config` MIME - output item that the VS Code extension can parse. The extension reads - the raw JSON bytes from cell output — it does not import or - `isinstance`-check `Config` — but we should confirm the data still - flows correctly. -- **`Output` display in `%%qsharp` cells.** The `%%qsharp` cell magic - calls `IPython.display.display(output)` on `Output` objects. IPython - renders them via `Output._repr_markdown_()`. Verify that state dumps, - messages, and matrix outputs still render correctly after the change. -- **`StateDumpData` in `save_events` path.** When `save_events=True`, - `Context.eval()` and `Context.run()` extract `StateDumpData` via - `output.state_dump()` and wrap it in `StateDump`. Confirm that - `StateDump._repr_markdown_()` still works and that the `check_eq` / - `as_dense_state` methods are unaffected. - -Note: `qsharp_widgets` does **not** import any of these types. Its -`Circuit` widget accepts any object with a `.json()` method (duck-typed), -and its only `qdk` import is a lazy `from qdk import qsharp` inside -`Histogram.run()`. The widgets package will not be affected by these -changes. - -### Tier 2 — Discoverability. Schedule for the same release if budget allows. - -These items eliminate broken xrefs and improve doc-gen output. The -underlying functionality is already reachable. - -1. **Re-export `ISA` and `ISAContext` from `qdk.qre.models` and `qdk.qre.models.factories`** so that signature renderings inside those submodules resolve. These types are already public at `qdk.qre.X`; the submodule re-exports are a doc-gen accommodation, not a new API. -2. **Re-export `_CirqTraceBuilder` as `qdk.qre.internal.CirqTraceBuilder` and `_QidToTraceId` as `qdk.qre.internal.QidToTraceId`.** `_CirqTraceBuilder` is the parameter type of ~12 user-facing trace-builder functions; `_QidToTraceId` is the return type of its public `q_to_id` property. -3. **Make `qdk.qsharp.run`'s `noise:` parameter annotation use `qdk.simulation.NoiseConfig` instead of the bare `NoiseConfig`.** `NoiseConfig` is already publicly exposed, just under a different submodule name. -4. **Audit `qdk.qsharp` and `qdk.openqasm` `__all__` for missing entries** that appear in signature renderings. - -## Process recommendations - -### Enforce the existing public/private convention - -The codebase follows the standard Python convention: names (and modules) -starting with an underscore are private and not part of the supported API -surface. This convention is purely advisory — Python itself enforces -nothing, and nothing in the type system or our current build prevents a -private-named type from appearing in the signature or docstring of a -public function or method. Every leak documented in this report is an -instance of that pattern: the private types are correctly named, but they -reach the public surface via return types, parameter types, and docstring -references. - -The fix is to mechanically enforce the existing convention at build time. - -### Add a CI lint - -A lightweight check that walks every `__all__` symbol, inspects its -signature annotations and docstring `:type:` / `:rtype:` / `:param:` -references, and fails if any of them name an underscore-prefixed symbol -(or a symbol whose nearest enclosing module starts with underscore). - -This would have caught all the leakage in this report at code-review time. - -Sample heuristic (pseudocode): - -```python -for module in walk_qdk_modules(): - for name in module.__all__: - sym = getattr(module, name) - for annotation in collect_annotations(sym): - if annotation_names_private_symbol(annotation): - report_violation(module, name, annotation) -``` - -Run as part of `./build.py` so violations land in the same gate that -prevents the PR from merging. - -## Summary - -- Placement strategy: route currently-private leaked types through new `qdk.internal` and `qdk.qre.internal` namespaces, clearly labeled internal but reachable for docs and type-checking. Promote to fully public paths only the types users genuinely instantiate. -- Tier 1 list (user-blocking): 13 changes (5 non-qre, 7 qre, 1 public). -- Tier 2 list (discoverability): 4 changes. -- Recommended process: enforce the existing public/private boundary with a CI lint in `./build.py`. From 36cc1526f29a80decb0bff05b52201590481f421 Mon Sep 17 00:00:00 2001 From: Scott Carda Date: Tue, 2 Jun 2026 10:29:14 -0700 Subject: [PATCH 9/9] remove redundant private skips --- source/qdk_package/check_api_surface.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/source/qdk_package/check_api_surface.py b/source/qdk_package/check_api_surface.py index 3442ebed89..cc76006083 100644 --- a/source/qdk_package/check_api_surface.py +++ b/source/qdk_package/check_api_surface.py @@ -50,16 +50,6 @@ # Modules to skip entirely (they are internal and not expected to have # a clean public surface). SKIP_MODULES: set[str] = { - "qdk._native", - "qdk._types", - "qdk._context", - "qdk._interpreter", - "qdk._ipython", - "qdk._fs", - "qdk._http", - "qdk._adaptive_bytecode", - "qdk._adaptive_pass", - "qdk._device", "qdk.telemetry", "qdk.telemetry_events", }