Compare commits

...
Author SHA1 Message Date
18f1c580f1
py-topdir.mk: Add test tree to default check roots
All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m45s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 4m30s
CI / Packaging test (pull_request) Successful in 0s
CI / Packaging - Kali Linux (push) Successful in 4m8s
CI / Packaging - OpenSUSE Tumbleweed (push) Successful in 4m23s
CI / Packaging test (push) Successful in 0s
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 <jan@janware.com>
Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1
2026-09-13 16:08:51 +02:00
83e77ca12e
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 <jan@janware.com>
2026-09-13 15:42:29 +02:00
64f1a73650
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 <jan@janware.com>
Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.85.1
2026-09-13 15:42:29 +02:00
6 changed files with 65 additions and 35 deletions

View file

@ -9,6 +9,9 @@ MYPY_PATH_DIRECTIVE = mypy_path = "$(MYPY_CONFIG_PATH)"
ifndef PY_CHECK_ROOTS ifndef PY_CHECK_ROOTS
PY_CHECK_ROOTS = $(wildcard $(TOPDIR)/src $(TOPDIR)/tools) PY_CHECK_ROOTS = $(wildcard $(TOPDIR)/src $(TOPDIR)/tools)
ifneq ($(wildcard dirs-all.done),)
PY_CHECK_ROOTS += $(wildcard $(TOPDIR)/test)
endif
endif endif
all: all:

View file

@ -35,17 +35,25 @@ module_path()
cmd_create_conftest_py() cmd_create_conftest_py()
{ {
cat <<-'EOT' cat <<-'EOT'
from __future__ import annotations
import os import os
import sys
import shutil import shutil
import sys
def pytest_configure(config): from typing import TYPE_CHECKING
import _pytest.terminal
import _pytest.timing as timing
# 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 width = shutil.get_terminal_size().columns
d = os.path.basename(os.getcwd()) d = os.path.basename(os.getcwd())
text = f"Running pytest for {d}" text = f"Running pytest for {d}"
@ -59,9 +67,9 @@ cmd_create_conftest_py()
banner = text banner = text
print(banner, file = sys.stderr) print(banner, file = sys.stderr)
self._session = session 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 EOT
} }

View file

@ -1,10 +1,16 @@
import asyncio from __future__ import annotations
import asyncio
import sys import sys
from typing import TYPE_CHECKING, override
from jw.pkg.lib.App import App from jw.pkg.lib.App import App
from jw.pkg.lib.Cmd import Cmd 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 # -- 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: # 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 # 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): class ChildCmd(Cmd):
def __init__(self, parent): def __init__(self, parent: App | Cmd) -> None:
super().__init__(parent, 'child', 'A child command') super().__init__(parent, 'child', 'A child command')
self.ran = False self.ran = False
self.got_opt = None self.got_opt = None
def add_arguments(self, parser): @override
def add_arguments(self, parser: ArgumentParser) -> None:
super().add_arguments(parser) super().add_arguments(parser)
parser.add_argument('--child-opt', default = 'child-default') 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.ran = True
self.got_opt = args.child_opt self.got_opt = args.child_opt
class RootCmd(Cmd): class RootCmd(Cmd):
made = [] made: list['RootCmd'] = []
def __init__(self, parent): def __init__(self, parent: App | Cmd) -> None:
super().__init__(parent, 'root', 'Root command') super().__init__(parent, 'root', 'Root command')
self.child = ChildCmd(self) self.child = ChildCmd(self)
self.add_subcommands(self.child) self.add_subcommands(self.child)
@ -38,11 +46,13 @@ class RootCmd(Cmd):
self.default = False self.default = False
RootCmd.made.append(self) RootCmd.made.append(self)
def add_arguments(self, parser): @override
def add_arguments(self, parser: ArgumentParser) -> None:
super().add_arguments(parser) super().add_arguments(parser)
parser.add_argument('--root-opt', default = 'root-default') parser.add_argument('--root-opt', default = 'root-default')
async def _run(self, args): @override
async def _run(self, args: Namespace) -> None:
self.setup = True self.setup = True
try: try:
if hasattr(args, 'func'): if hasattr(args, 'func'):

View file

@ -1,18 +1,27 @@
import asyncio from __future__ import annotations
import asyncio
import sys import sys
from typing import TYPE_CHECKING, override
from jw.pkg.lib.Cmd import Cmd from jw.pkg.lib.Cmd import Cmd
from jw.pkg.lib.ExecApp import ExecApp 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. # -- A minimal root command so the app can be built without a command tree.
class RootCmd(Cmd): class RootCmd(Cmd):
def __init__(self, parent): def __init__(self, parent: App | Cmd) -> None:
super().__init__(parent, 'root', 'Root command') super().__init__(parent, 'root', 'Root command')
async def _run(self, args): @override
async def _run(self, args: Namespace) -> None:
pass pass
# -- Counts the close() calls made by App.__aexit__() when the async context # -- Counts the close() calls made by App.__aexit__() when the async context
@ -22,7 +31,8 @@ class RecordingExecApp(ExecApp):
closed = 0 closed = 0
def close(self): @override
def close(self) -> None:
RecordingExecApp.closed += 1 RecordingExecApp.closed += 1
super().close() super().close()
@ -38,16 +48,14 @@ finally:
# -- ExecApp registers the exec-related options on the top-level parser. # -- ExecApp registers the exec-related options on the top-level parser.
opts = [ opts = [o for a in app.parser._actions for o in getattr(a, 'option_strings', ())]
o for a in app.parser._actions for o in getattr(a, 'option_strings', ())
]
for opt in ('--interactive', '--verbose', '--target'): for opt in ('--interactive', '--verbose', '--target'):
assert opt in opts, f'{opt} must be registered by ExecApp' assert opt in opts, f'{opt} must be registered by ExecApp'
# -- Exiting the async context must close the app, through the # -- Exiting the async context must close the app, through the
# App.__aexit__() that ExecApp.__aexit__() chains to. # App.__aexit__() that ExecApp.__aexit__() chains to.
async def exit_context(): async def exit_context() -> None:
async with app: async with app:
pass pass

View file

@ -1,5 +1,6 @@
import os import os
import tempfile import tempfile
from jw.pkg.lib.ProjectConf import ProjectConf from jw.pkg.lib.ProjectConf import ProjectConf
# -- Helper: create temp file and read it -- # -- Helper: create temp file and read it --
@ -13,7 +14,7 @@ def _load(text: str) -> ProjectConf:
_tmpfiles.append(f.name) _tmpfiles.append(f.name)
return ProjectConf.read(f.name) return ProjectConf.read(f.name)
def _cleanup(): def _cleanup() -> None:
for _p in _tmpfiles: for _p in _tmpfiles:
os.unlink(_p) os.unlink(_p)
del _p del _p