From 64f1a73650eb3e40c2d060576f7686cb13d5b49e Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 11 Sep 2026 14:22:26 +0200 Subject: [PATCH 1/3] test: Fix mypy strict errors in unit tests The unit test files below test/unit/python/jw/pkg/lib/ define Cmd and App subclasses with unannotated methods: __init__(), add_arguments(), _run() and close() lack signatures, the made class attribute lacks a type, and the _cleanup() and exit_context() helpers are untyped, so a strict mypy run over the test tree fails on them. Likewise, the ExecApp and version tests carry list formatting that yapf rejects. Annotate the test classes following the library conventions: parent is App | Cmd, the parser is ArgumentParser, and args is Namespace, and mark every overridden method with @override. Guard the imports that are only used for annotations behind TYPE_CHECKING, and add the future annotations import so they stay out of the runtime path. Then reformat the test tree with yapf. That folds the opts list of the ExecApp test and re-indents the rejected operator list of the version test. Signed-off-by: Jan Lindemann Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 --- test/unit/python/jw/pkg/lib/App/test.py | 26 +++++++++++++------ test/unit/python/jw/pkg/lib/ExecApp/test.py | 24 +++++++++++------ .../python/jw/pkg/lib/ProjectConf/test.py | 3 ++- test/unit/python/jw/pkg/lib/version/test.py | 20 +++++++------- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/test/unit/python/jw/pkg/lib/App/test.py b/test/unit/python/jw/pkg/lib/App/test.py index 2b899d4f..402f11c5 100644 --- a/test/unit/python/jw/pkg/lib/App/test.py +++ b/test/unit/python/jw/pkg/lib/App/test.py @@ -1,10 +1,16 @@ -import asyncio +from __future__ import annotations +import asyncio import sys +from typing import TYPE_CHECKING, override + from jw.pkg.lib.App import App from jw.pkg.lib.Cmd import Cmd +if TYPE_CHECKING: + from argparse import ArgumentParser, Namespace + # -- A minimal command tree: a root command hosting one child command. The # root mimics the "run + opts" unit the App can now mount at the top level: # it adds options, sets up, dispatches to the selected child (or runs its own @@ -12,24 +18,26 @@ from jw.pkg.lib.Cmd import Cmd class ChildCmd(Cmd): - def __init__(self, parent): + def __init__(self, parent: App | Cmd) -> None: super().__init__(parent, 'child', 'A child command') self.ran = False self.got_opt = None - def add_arguments(self, parser): + @override + def add_arguments(self, parser: ArgumentParser) -> None: super().add_arguments(parser) parser.add_argument('--child-opt', default = 'child-default') - async def _run(self, args): + @override + async def _run(self, args: Namespace) -> None: self.ran = True self.got_opt = args.child_opt class RootCmd(Cmd): - made = [] + made: list['RootCmd'] = [] - def __init__(self, parent): + def __init__(self, parent: App | Cmd) -> None: super().__init__(parent, 'root', 'Root command') self.child = ChildCmd(self) self.add_subcommands(self.child) @@ -38,11 +46,13 @@ class RootCmd(Cmd): self.default = False RootCmd.made.append(self) - def add_arguments(self, parser): + @override + def add_arguments(self, parser: ArgumentParser) -> None: super().add_arguments(parser) parser.add_argument('--root-opt', default = 'root-default') - async def _run(self, args): + @override + async def _run(self, args: Namespace) -> None: self.setup = True try: if hasattr(args, 'func'): diff --git a/test/unit/python/jw/pkg/lib/ExecApp/test.py b/test/unit/python/jw/pkg/lib/ExecApp/test.py index 3a558946..b0071e56 100644 --- a/test/unit/python/jw/pkg/lib/ExecApp/test.py +++ b/test/unit/python/jw/pkg/lib/ExecApp/test.py @@ -1,18 +1,27 @@ -import asyncio +from __future__ import annotations +import asyncio import sys +from typing import TYPE_CHECKING, override + from jw.pkg.lib.Cmd import Cmd from jw.pkg.lib.ExecApp import ExecApp +if TYPE_CHECKING: + from argparse import Namespace + + from jw.pkg.lib.App import App + # -- A minimal root command so the app can be built without a command tree. class RootCmd(Cmd): - def __init__(self, parent): + def __init__(self, parent: App | Cmd) -> None: super().__init__(parent, 'root', 'Root command') - async def _run(self, args): + @override + async def _run(self, args: Namespace) -> None: pass # -- Counts the close() calls made by App.__aexit__() when the async context @@ -22,7 +31,8 @@ class RecordingExecApp(ExecApp): closed = 0 - def close(self): + @override + def close(self) -> None: RecordingExecApp.closed += 1 super().close() @@ -38,16 +48,14 @@ finally: # -- ExecApp registers the exec-related options on the top-level parser. -opts = [ - o for a in app.parser._actions for o in getattr(a, 'option_strings', ()) -] +opts = [o for a in app.parser._actions for o in getattr(a, 'option_strings', ())] for opt in ('--interactive', '--verbose', '--target'): assert opt in opts, f'{opt} must be registered by ExecApp' # -- Exiting the async context must close the app, through the # App.__aexit__() that ExecApp.__aexit__() chains to. -async def exit_context(): +async def exit_context() -> None: async with app: pass diff --git a/test/unit/python/jw/pkg/lib/ProjectConf/test.py b/test/unit/python/jw/pkg/lib/ProjectConf/test.py index b503a2ec..e2a55cc3 100644 --- a/test/unit/python/jw/pkg/lib/ProjectConf/test.py +++ b/test/unit/python/jw/pkg/lib/ProjectConf/test.py @@ -1,5 +1,6 @@ import os import tempfile + from jw.pkg.lib.ProjectConf import ProjectConf # -- Helper: create temp file and read it -- @@ -13,7 +14,7 @@ def _load(text: str) -> ProjectConf: _tmpfiles.append(f.name) return ProjectConf.read(f.name) -def _cleanup(): +def _cleanup() -> None: for _p in _tmpfiles: os.unlink(_p) del _p diff --git a/test/unit/python/jw/pkg/lib/version/test.py b/test/unit/python/jw/pkg/lib/version/test.py index 07817f87..337219a6 100644 --- a/test/unit/python/jw/pkg/lib/version/test.py +++ b/test/unit/python/jw/pkg/lib/version/test.py @@ -233,16 +233,16 @@ for bad in ['', 'foo =', ' = 1.0']: # Operators outside the supported set are rejected when parsed, # not folded into the package name or deferred to expansion for bad in [ - 'foo ~= 1.0', - 'foo~=1.0', - 'foo ~ 1.0', - 'foo != 1.0', - 'foo!=1.0', - 'foo == 1.0', - 'foo==1.0', - 'foo === 1.0', - 'foo << 1.0', - 'foo >> 1.0', + 'foo ~= 1.0', + 'foo~=1.0', + 'foo ~ 1.0', + 'foo != 1.0', + 'foo!=1.0', + 'foo == 1.0', + 'foo==1.0', + 'foo === 1.0', + 'foo << 1.0', + 'foo >> 1.0', ]: try: Dependency(bad).full_name From 83e77ca12e08284b6c75b25bd15867b516f76919 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 11 Sep 2026 17:41:30 +0200 Subject: [PATCH 2/3] python-tools.sh: Annotate generated conftest.py create-conftest-py generates the conftest.py that pytest test directories pick up at build time, and make clean removes it. The generated file lacks type annotations, though: pytest_configure() and the nested patched() take untyped parameters, and the replacement of .pytest_sessionstart() on the class is rejected by strict mypy. Emit a type-annotated version instead: add a future annotations import, type both functions, move the types that are only used in annotations behind a TYPE_CHECKING guard, and silence the method replacement with a targeted type ignore. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 Signed-off-by: Jan Lindemann --- scripts/python-tools.sh | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/scripts/python-tools.sh b/scripts/python-tools.sh index 0b189d7b..a981be88 100644 --- a/scripts/python-tools.sh +++ b/scripts/python-tools.sh @@ -35,17 +35,25 @@ module_path() cmd_create_conftest_py() { cat <<-'EOT' + from __future__ import annotations + import os - import sys import shutil + import sys - def pytest_configure(config): - import _pytest.terminal - import _pytest.timing as timing + from typing import TYPE_CHECKING - # orig = _pytest.terminal.TerminalReporter.pytest_sessionstart + from _pytest.terminal import TerminalReporter + from _pytest.timing import Instant - def patched(self, session): + if TYPE_CHECKING: + from _pytest.config import Config + from _pytest.main import Session + + def pytest_configure(config: Config) -> None: + """Print a banner centered in the terminal when the session starts.""" + + def patched(self: TerminalReporter, session: Session) -> None: width = shutil.get_terminal_size().columns d = os.path.basename(os.getcwd()) text = f"Running pytest for {d}" @@ -59,9 +67,9 @@ cmd_create_conftest_py() banner = text print(banner, file = sys.stderr) self._session = session - self._session_start = timing.Instant() + self._session_start = Instant() - _pytest.terminal.TerminalReporter.pytest_sessionstart = patched + TerminalReporter.pytest_sessionstart = patched # type: ignore[method-assign] EOT } From 18f1c580f1ee34dfe29f5945aab45a2d96b75b91 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 11 Sep 2026 14:38:51 +0200 Subject: [PATCH 3/3] py-topdir.mk: Add test tree to default check roots PY_CHECK_ROOTS defaults to the src and tools directories, so the ruff, mypy, pyright, and yapf checks and the bad-pattern scan silently skip test trees. Python tests below test/ therefore escape linting, type checking, and formatting enforcement, even though py-check.mk itself defaults to the whole directory when no roots are given at all. Add $(TOPDIR)/test to the default PY_CHECK_ROOTS after the build is through, so that the test tree receives the same checks and formatting as src and tools. The previous commit prepared this repo's unit tests for the strict type check and the formatter, so the extended roots pass cleanly here. Signed-off-by: Jan Lindemann Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1 --- make/py-topdir.mk | 3 +++ 1 file changed, 3 insertions(+) diff --git a/make/py-topdir.mk b/make/py-topdir.mk index c6b0f3ac..75d95b50 100644 --- a/make/py-topdir.mk +++ b/make/py-topdir.mk @@ -9,6 +9,9 @@ MYPY_PATH_DIRECTIVE = mypy_path = "$(MYPY_CONFIG_PATH)" ifndef PY_CHECK_ROOTS PY_CHECK_ROOTS = $(wildcard $(TOPDIR)/src $(TOPDIR)/tools) + ifneq ($(wildcard dirs-all.done),) + PY_CHECK_ROOTS += $(wildcard $(TOPDIR)/test) + endif endif all: