Skip to content

Commit 6bd2a0a

Browse files
xieofxieHualiang Xie
andauthored
type: clean mypy in optracing, quant, serve, session, sysinfo, telemetry, utils and enforce in CI (#916)
@ ## Summary Brings seven more `winml.modelkit` packages to **zero mypy errors** under the strict `[tool.mypy]` config and wires them into the required CI type-check gate (16 → 23 packages). Continues the incremental cleanup from #896. Cleaned: **optracing, quant, serve, session, sysinfo, telemetry, utils** (sysinfo was already clean). Fixes follow the repos typing playbook — fix at the source first; `cast()` only at genuine untyped third-party boundaries; `Any` only for true dynamic pass-throughs. ## Real bugs surfaced (not just typing) - **`utils/hub_utils.py` version import** — `from ..version import __version__` referenced a non-existent module; guarded by `try/except`, so the export version was silently always `"unknown"`. Fixed to `from .. import __version__`. (The containing helper `inject_hub_metadata` is dead code — tracked in #913.) - **`utils/hub_utils.py`** — `model_info.modelId` → `model_info.id` (huggingface_hub renamed the attribute). ## Notable fixes - **session** — bound a non-None local after auto-compile so narrowing survives into the lambda/comprehension; explicit guards for `resolved_ep is None` and single-mode `model_path`; widened PDH counter containers to `float`; class-level `_initialized` annotation; distinct ctypes struct locals. - **telemetry** — annotated the `LoggerProvider`/`Logger` instance attributes (were inferred as `None`, cascading into unreachable/attr-defined); OTel/JSON boundary casts; typed the `@contextmanager`/`@asynccontextmanager` generators. - **serve** — `cast()` at `json.loads`/`app.state` boundaries; `import binascii` to use `binascii.Error` (typeshed doesnt expose `base64.binascii`); explicit single-mode `model_path` guard. - **utils** — removed stale `# type: ignore`s; `DataclassInstance` cast for generic-`TypeVar` dataclass reflection; `EPAlias` key cast for the alias lookup; loop-variable rename to fix `InputTensorSpec`/`OutputTensorSpec` shadowing. ## Config changes (`pyproject.toml`) - **`qairt.*`** → `ignore_missing_imports` (external Qualcomm AI Runtime SDK; imported only inside `compile_qairt_bin.py`, which runs in a separate `venv-winml` subprocess — absent from the CI env). - **`windowsml`** → `follow_untyped_imports = true` (installed but ships no `py.typed`; analyzing its source is clean and keeps its inline annotations instead of collapsing to `Any`). ## Verification - Combined required-gate invocation (all 23 packages): `Success: no issues found in 300 source files`. - ruff clean; unit tests green for the touched packages (2 pre-existing OpenVINO-EP-unavailable failures in this env, confirmed unrelated). - Branch-vs-main diff is content-only (line endings normalized to LF to match main). @ --------- Co-authored-by: Hualiang Xie <hualxie@microsoft.com>
1 parent 23c10cf commit 6bd2a0a

26 files changed

Lines changed: 195 additions & 83 deletions

.github/workflows/lint.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ concurrency:
1313
jobs:
1414
lint:
1515
runs-on: windows-latest
16-
# Bumped from 5: combined mypy on 16 packages cold-starts at ~3-4 min on
16+
# Bumped from 5: combined mypy on 23 packages cold-starts at ~3-4 min on
1717
# Windows runners; the original 5-min ceiling cancelled mid-run.
1818
timeout-minutes: 10
1919

@@ -64,3 +64,10 @@ jobs:
6464
-p winml.modelkit.loader
6565
-p winml.modelkit.onnx
6666
-p winml.modelkit.optim
67+
-p winml.modelkit.optracing
68+
-p winml.modelkit.quant
69+
-p winml.modelkit.serve
70+
-p winml.modelkit.session
71+
-p winml.modelkit.sysinfo
72+
-p winml.modelkit.telemetry
73+
-p winml.modelkit.utils

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,9 +506,21 @@ module = [
506506
"sklearn.*", # used in eval/metrics; no community stubs
507507
"evaluate", # HF evaluate, used in eval/; no community stubs
508508
"evaluate.*",
509+
# QAIRT (Qualcomm AI Runtime) SDK — imported only inside compile_qairt_bin.py,
510+
# which runs in a separate venv-winml subprocess where the SDK is installed.
511+
# Not a dependency of the main/CI environment, so it has no stubs here.
512+
"qairt",
513+
"qairt.*",
509514
]
510515
ignore_missing_imports = true
511516

517+
# windowsml ships no py.typed marker, but its source is installed and usable —
518+
# analyze it directly (PEP 561 opt-in) so its inline annotations are honored
519+
# instead of collapsing every symbol to Any.
520+
[[tool.mypy.overrides]]
521+
module = [ "windowsml", "windowsml.*" ]
522+
follow_untyped_imports = true
523+
512524
# Relaxed modules: tests and WIP code
513525

514526
[[tool.mypy.overrides]]

src/winml/modelkit/optracing/qnn/profiler.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import logging
2020
import os
2121
from pathlib import Path
22-
from typing import Any
22+
from typing import TYPE_CHECKING, Any
2323

2424
import numpy as np
2525

@@ -29,6 +29,10 @@
2929
from .viewer import find_qnn_sdk, run_qhas_viewer
3030

3131

32+
if TYPE_CHECKING:
33+
from collections.abc import Iterator
34+
35+
3236
logger = logging.getLogger(__name__)
3337

3438

@@ -53,7 +57,7 @@ def _resolve_shape(shape: list, default_dim: int = 1) -> list[int]:
5357

5458

5559
@contextlib.contextmanager
56-
def _working_directory(path: Path):
60+
def _working_directory(path: Path) -> Iterator[None]:
5761
"""Temporarily change CWD and restore on exit.
5862
5963
QNN EP writes ``*_schematic.bin`` into the process CWD, so we

src/winml/modelkit/optracing/qnn/qhas_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,4 @@ def _vtcm_ratio(op: dict) -> float | None:
110110
total = vtcm_read + dram_read
111111
if total == 0:
112112
return None
113-
return vtcm_read / total
113+
return float(vtcm_read / total)

src/winml/modelkit/quant/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
result = quantize_onnx("model.onnx", WinMLQuantizationConfig(samples=100))
1717
"""
1818

19+
from typing import Any
20+
1921
from .config import QuantizeResult, WinMLQuantizationConfig
2022

2123

@@ -31,7 +33,7 @@
3133
}
3234

3335

34-
def __getattr__(name: str):
36+
def __getattr__(name: str) -> Any:
3537
"""Lazy-load quantizer (imports onnxruntime.quantization)."""
3638
if name in _LAZY_IMPORTS:
3739
module_path, attr_name = _LAZY_IMPORTS[name]

src/winml/modelkit/serve/app.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@
2323

2424
import asyncio
2525
import base64
26+
import binascii
2627
import importlib.resources
2728
import json
2829
import logging
2930
import time
3031
from collections import deque
3132
from contextlib import asynccontextmanager
3233
from pathlib import Path
33-
from typing import TYPE_CHECKING, Any
34+
from typing import TYPE_CHECKING, Any, cast
3435

3536
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
3637
from fastapi.middleware.cors import CORSMiddleware
@@ -57,6 +58,8 @@
5758

5859

5960
if TYPE_CHECKING:
61+
from collections.abc import AsyncIterator
62+
6063
from ..utils.constants import EPNameOrAlias
6164

6265
logger = logging.getLogger(__name__)
@@ -166,7 +169,7 @@ def create_app(
166169
"""
167170

168171
@asynccontextmanager
169-
async def lifespan(app: FastAPI):
172+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
170173
app.state.start_time = time.time()
171174
# Raise the modelkit logger to INFO so the ring handler receives
172175
# operational records during `winml serve`. Tests that build the app
@@ -187,6 +190,8 @@ async def lifespan(app: FastAPI):
187190
logger.info("Multi-model server started (empty — load via POST /v1/models)")
188191
app.state.manager = mgr
189192
else:
193+
if model_path is None:
194+
raise ValueError("single-model mode requires a model_path")
190195
engine = InferenceEngine()
191196
engine.load(model_path, task=task, device=device, ep=ep)
192197
app.state.manager = SingleModelManager(engine, idle_timeout_sec=idle_timeout_sec)
@@ -240,7 +245,7 @@ def _get_mgr() -> SingleModelManager | ModelSlotManager:
240245
mgr = getattr(app.state, "manager", None)
241246
if mgr is None:
242247
raise HTTPException(status_code=503, detail="Model not loaded yet")
243-
return mgr
248+
return cast("SingleModelManager | ModelSlotManager", mgr)
244249

245250
def _get_start_time() -> float:
246251
return getattr(app.state, "start_time", time.time())
@@ -733,13 +738,13 @@ async def cli_command(command: str, request: CliRequest) -> CliResponse:
733738
# ---------------------------------------------------------------------------
734739

735740

736-
def _manifest_from_engine(engine: InferenceEngine) -> dict:
741+
def _manifest_from_engine(engine: InferenceEngine) -> dict[str, Any]:
737742
"""Build manifest dict from engine, trying build_manifest.json first."""
738743
if engine.model_path:
739744
manifest_file = Path(engine.model_path) / "build_manifest.json"
740745
if manifest_file.exists():
741746
try:
742-
return json.loads(manifest_file.read_text())
747+
return cast("dict[str, Any]", json.loads(manifest_file.read_text()))
743748
except (json.JSONDecodeError, OSError) as e:
744749
logger.warning("Failed to load manifest: %s", e)
745750

@@ -753,7 +758,7 @@ def _manifest_from_engine(engine: InferenceEngine) -> dict:
753758

754759

755760
def _build_model_schema(
756-
manifest: dict,
761+
manifest: dict[str, Any],
757762
engine: InferenceEngine | None = None,
758763
task_override: str | None = None,
759764
) -> dict[str, Any]:
@@ -828,7 +833,7 @@ def _decode_rest_inputs(
828833
if field and field.type in BINARY_TYPES and isinstance(value, str):
829834
try:
830835
result[name] = base64.b64decode(value)
831-
except (ValueError, base64.binascii.Error) as exc:
836+
except (ValueError, binascii.Error) as exc:
832837
raise ValueError(f"Invalid base64 for input '{name}': {exc}") from exc
833838
return result
834839

src/winml/modelkit/serve/cli_api.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import tempfile
2828
import time
2929
from pathlib import Path
30-
from typing import Any
30+
from typing import Any, cast
3131

3232
from fastapi import FastAPI, HTTPException
3333
from fastapi.middleware.cors import CORSMiddleware
@@ -212,7 +212,12 @@ def _extract_json_from_stdout(stdout: str) -> dict[str, Any] | list[Any] | None:
212212
if start == -1:
213213
break
214214
try:
215-
return json.loads(stdout[start : end + 1])
215+
# Reached only after matching a {...} / [...] pair, so the
216+
# parse yields a dict or list — never None.
217+
return cast(
218+
"dict[str, Any] | list[Any]",
219+
json.loads(stdout[start : end + 1]),
220+
)
216221
except json.JSONDecodeError:
217222
continue
218223
pos = end # try next (earlier) end_char

src/winml/modelkit/session/ep_registry.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@ class WinMLEPRegistry:
228228
available = registry.get_available_eps()
229229
"""
230230

231+
# Set in __new__ before __init__ runs; declared here so mypy can resolve its
232+
# type at the __init__ read site.
233+
_initialized: bool
234+
231235
def __new__(cls) -> WinMLEPRegistry:
232236
"""Singleton pattern."""
233237
global _winml_ep_registry
@@ -269,7 +273,8 @@ def _load_ep_catalog(self) -> None:
269273
"""Load EP catalog from WinML."""
270274
from windowsml import EpCatalog
271275

272-
checked_eps: set[EPName] = set()
276+
# Dedup of raw windowsml provider-name strings (pre-validation).
277+
checked_eps: set[str] = set()
273278
with EpCatalog() as catalog:
274279
for provider in catalog.find_all_providers():
275280
if provider.name in checked_eps:
@@ -402,4 +407,4 @@ def get_ort_available_providers(use_winml: bool = True) -> list[str]:
402407
except Exception as e:
403408
logger.debug("WinML discovery skipped: %s", e)
404409

405-
return ort.get_available_providers()
410+
return cast("list[str]", ort.get_available_providers())

src/winml/modelkit/session/monitor/_pdh.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -178,22 +178,24 @@ def _collect_once(self) -> dict[str, float | int | None]:
178178
continue
179179

180180
if entry.fmt == _PDH_FMT_DOUBLE:
181-
val = _PdhFmtDouble()
181+
dval = _PdhFmtDouble()
182182
s = _pdh.PdhGetFormattedCounterValue(
183183
entry.handle,
184184
_PDH_FMT_DOUBLE | _PDH_FMT_NOCAP100,
185185
ctypes.byref(ct),
186-
ctypes.byref(val),
186+
ctypes.byref(dval),
187187
)
188188
values[entry.name] = (
189-
val.doubleValue if _pdh_ok(s) and _pdh_ok(val.CStatus) else None
189+
dval.doubleValue if _pdh_ok(s) and _pdh_ok(dval.CStatus) else None
190190
)
191191
else:
192-
val = _PdhFmtLarge()
192+
lval = _PdhFmtLarge()
193193
s = _pdh.PdhGetFormattedCounterValue(
194-
entry.handle, _PDH_FMT_LARGE, ctypes.byref(ct), ctypes.byref(val)
194+
entry.handle, _PDH_FMT_LARGE, ctypes.byref(ct), ctypes.byref(lval)
195+
)
196+
values[entry.name] = (
197+
lval.largeValue if _pdh_ok(s) and _pdh_ok(lval.CStatus) else None
195198
)
196-
values[entry.name] = val.largeValue if _pdh_ok(s) and _pdh_ok(val.CStatus) else None
197199

198200
return values
199201

@@ -403,14 +405,17 @@ def __init__(
403405
self._stop_event = threading.Event()
404406
self._lock = threading.Lock()
405407
self._util_samples: list[float] = []
406-
self._memory_local_bytes: list[int] = []
407-
self._memory_shared_bytes: list[int] = []
408+
# PDH counters arrive as float|int (double vs large format), so store as float.
409+
self._memory_local_bytes: list[float] = []
410+
self._memory_shared_bytes: list[float] = []
408411
self._cpu_samples: list[float] = []
409-
self._ram_used_bytes: list[int] = []
412+
self._ram_used_bytes: list[float] = []
410413
# Per-engtype snapshots of the monotonic Running Time counter. Stored
411414
# as dicts because an adapter exposes multiple engines (e.g. several
412415
# Compute_* on an NPU; 3D + Compute_* on a GPU) and the total adapter
413416
# time is the sum of per-engine deltas.
417+
# Running-time counters are 64-bit ns integers; ns-since-epoch (~1.7e18)
418+
# exceeds float64's exact range (2**53), so keep them int.
414419
self._running_time_start_ns: dict[str, int] = {}
415420
self._running_time_end_ns: dict[str, int] = {}
416421

@@ -479,7 +484,7 @@ def start(self) -> None:
479484

480485
initial = self._query.collect(interval=0.05)
481486
self._running_time_start_ns = {
482-
k: v
487+
k: int(v)
483488
for k, v in initial.items()
484489
if k.startswith("running_time_") and v is not None
485490
}
@@ -509,7 +514,7 @@ def stop(self) -> None:
509514
try:
510515
final = self._query._collect_once()
511516
self._running_time_end_ns = {
512-
k: v
517+
k: int(v)
513518
for k, v in final.items()
514519
if k.startswith("running_time_") and v is not None
515520
}
@@ -532,8 +537,11 @@ def _poll_loop(self) -> None:
532537
# normalize so cpu_pct stays 0..100 across machines.
533538
cpu_divisor = float(os.cpu_count() or 1)
534539
while not self._stop_event.is_set():
540+
query = self._query
541+
if query is None:
542+
break
535543
try:
536-
values = self._query._collect_once()
544+
values = query._collect_once()
537545
# util_* counters are per-engine ratios over the same sample
538546
# window, so max reports the most-loaded engine on the adapter.
539547
# Don't sum — that would exceed 100% and duplicate what the

src/winml/modelkit/session/monitor/_xrt_smi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import tempfile
2222
from dataclasses import dataclass
2323
from pathlib import Path
24-
from typing import Any
24+
from typing import Any, cast
2525

2626

2727
logger = logging.getLogger(__name__)
@@ -109,7 +109,7 @@ def snapshot(self) -> dict[str, Any]:
109109
return {}
110110

111111
with Path(tmp_path).open(encoding="utf-8") as f:
112-
return json.load(f)
112+
return cast("dict[str, Any]", json.load(f))
113113

114114
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
115115
logger.debug("xrt-smi snapshot failed: %s", exc)

0 commit comments

Comments
 (0)