core: improve mypy coverage

This commit is contained in:
Dima Gerasimov 2021-02-17 23:10:37 +00:00 committed by karlicoss
parent 56d5587c20
commit 4ad4f34cda
6 changed files with 60 additions and 43 deletions

View file

@ -1,9 +1,10 @@
import functools
import importlib
import os import os
from pathlib import Path from pathlib import Path
from subprocess import check_call, run, PIPE, CompletedProcess
import sys import sys
from subprocess import check_call, run, PIPE
from typing import Optional, Sequence, Iterable, List from typing import Optional, Sequence, Iterable, List
import importlib
import traceback import traceback
from . import LazyLogger from . import LazyLogger
@ -11,13 +12,12 @@ from . import LazyLogger
log = LazyLogger('HPI cli') log = LazyLogger('HPI cli')
import functools
@functools.lru_cache() @functools.lru_cache()
def mypy_cmd() -> Optional[Sequence[str]]: def mypy_cmd() -> Optional[Sequence[str]]:
try: try:
# preferably, use mypy from current python env # preferably, use mypy from current python env
import mypy import mypy
return ['python3', '-m', 'mypy'] return [sys.executable, '-m', 'mypy']
except ImportError: except ImportError:
pass pass
# ok, not ideal but try from PATH # ok, not ideal but try from PATH
@ -28,7 +28,8 @@ def mypy_cmd() -> Optional[Sequence[str]]:
return None return None
def run_mypy(pkg): from types import ModuleType
def run_mypy(pkg: ModuleType) -> Optional[CompletedProcess]:
from .preinit import get_mycfg_dir from .preinit import get_mycfg_dir
mycfg_dir = get_mycfg_dir() mycfg_dir = get_mycfg_dir()
# todo ugh. not sure how to extract it from pkg? # todo ugh. not sure how to extract it from pkg?
@ -57,7 +58,7 @@ def run_mypy(pkg):
return mres return mres
def eprint(x: str): def eprint(x: str) -> None:
print(x, file=sys.stderr) print(x, file=sys.stderr)
def indent(x: str) -> str: def indent(x: str) -> str:
@ -66,16 +67,16 @@ def indent(x: str) -> str:
OK = '' OK = ''
OFF = '🔲' OFF = '🔲'
def info(x: str): def info(x: str) -> None:
eprint(OK + ' ' + x) eprint(OK + ' ' + x)
def error(x: str): def error(x: str) -> None:
eprint('' + x) eprint('' + x)
def warning(x: str): def warning(x: str) -> None:
eprint('' + x) # todo yellow? eprint('' + x) # todo yellow?
def tb(e): def tb(e: Exception) -> None:
tb = ''.join(traceback.format_exception(Exception, e, e.__traceback__)) tb = ''.join(traceback.format_exception(Exception, e, e.__traceback__))
sys.stderr.write(indent(tb)) sys.stderr.write(indent(tb))
@ -94,7 +95,9 @@ class color:
RESET = '\033[0m' RESET = '\033[0m'
def config_create(args) -> None: from argparse import Namespace
def config_create(args: Namespace) -> None:
from .preinit import get_mycfg_dir from .preinit import get_mycfg_dir
mycfg_dir = get_mycfg_dir() mycfg_dir = get_mycfg_dir()
@ -138,18 +141,18 @@ class example:
sys.exit(1) sys.exit(1)
def config_check_cli(args) -> None: def config_check_cli(args: Namespace) -> None:
ok = config_ok(args) ok = config_ok(args)
sys.exit(0 if ok else False) sys.exit(0 if ok else False)
# TODO return the config as a result? # TODO return the config as a result?
def config_ok(args) -> bool: def config_ok(args: Namespace) -> bool:
errors: List[Exception] = [] errors: List[Exception] = []
import my import my
try: try:
paths = my.__path__._path # type: ignore[attr-defined] paths: Sequence[str] = my.__path__._path # type: ignore[attr-defined]
except Exception as e: except Exception as e:
errors.append(e) errors.append(e)
error('failed to determine module import path') error('failed to determine module import path')
@ -211,8 +214,8 @@ See https://github.com/karlicoss/HPI/blob/master/doc/SETUP.org#setting-up-module
return True return True
def _modules(all=False): from .util import HPIModule, modules
from .util import modules def _modules(*, all: bool=False) -> Iterable[HPIModule]:
skipped = [] skipped = []
for m in modules(): for m in modules():
if not all and m.skip_reason is not None: if not all and m.skip_reason is not None:
@ -223,7 +226,7 @@ def _modules(all=False):
warning(f'Skipped {len(skipped)} modules: {skipped}. Pass --all if you want to see them.') warning(f'Skipped {len(skipped)} modules: {skipped}. Pass --all if you want to see them.')
def modules_check(args) -> None: def modules_check(args: Namespace) -> None:
verbose: bool = args.verbose verbose: bool = args.verbose
quick: bool = args.quick quick: bool = args.quick
module: Optional[str] = args.module module: Optional[str] = args.module
@ -279,11 +282,12 @@ def modules_check(args) -> None:
info(f' - stats: {res}') info(f' - stats: {res}')
def list_modules(args) -> None: def list_modules(args: Namespace) -> None:
# todo add a --sort argument? # todo add a --sort argument?
tabulate_warnings() tabulate_warnings()
for mr in _modules(all=args.all): all: bool = args.all
for mr in _modules(all=all):
m = mr.name m = mr.name
sr = mr.skip_reason sr = mr.skip_reason
if sr is None: if sr is None:
@ -302,7 +306,7 @@ def tabulate_warnings() -> None:
''' '''
import warnings import warnings
orig = warnings.formatwarning orig = warnings.formatwarning
def override(*args, **kwargs): def override(*args, **kwargs) -> str:
res = orig(*args, **kwargs) res = orig(*args, **kwargs)
return ''.join(' ' + x for x in res.splitlines(keepends=True)) return ''.join(' ' + x for x in res.splitlines(keepends=True))
warnings.formatwarning = override warnings.formatwarning = override
@ -310,14 +314,14 @@ def tabulate_warnings() -> None:
# todo check that it finds private modules too? # todo check that it finds private modules too?
def doctor(args) -> None: def doctor(args: Namespace) -> None:
ok = config_ok(args) ok = config_ok(args)
# TODO propagate ok status up? # TODO propagate ok status up?
modules_check(args) modules_check(args)
def parser():
from argparse import ArgumentParser from argparse import ArgumentParser
def parser() -> ArgumentParser:
p = ArgumentParser('Human Programming Interface', epilog=''' p = ArgumentParser('Human Programming Interface', epilog='''
Tool for HPI. Tool for HPI.
@ -347,7 +351,7 @@ Work in progress, will be used for config management, troubleshooting & introspe
return p return p
def main(): def main() -> None:
p = parser() p = parser()
args = p.parse_args() args = p.parse_args()

View file

@ -377,6 +377,7 @@ QUICK_STATS = False
C = TypeVar('C') C = TypeVar('C')
Stats = Dict[str, Any] Stats = Dict[str, Any]
StatsFun = Callable[[], Stats]
# todo not sure about return type... # todo not sure about return type...
def stat(func: Union[Callable[[], Iterable[C]], Iterable[C]]) -> Stats: def stat(func: Union[Callable[[], Iterable[C]], Iterable[C]]) -> Stats:
if callable(func): if callable(func):

View file

@ -72,9 +72,10 @@ config = make_config(Config)
### tests start ### tests start
from typing import Iterator, Any
from contextlib import contextmanager as ctx from contextlib import contextmanager as ctx
@ctx @ctx
def _reset_config(): def _reset_config() -> Iterator[Config]:
# todo maybe have this decorator for the whole of my.config? # todo maybe have this decorator for the whole of my.config?
from .cfg import override_config from .cfg import override_config
with override_config(config) as cc: with override_config(config) as cc:

View file

@ -5,17 +5,20 @@ TODO name 'klogging' to avoid possible conflict with default 'logging' module
TODO shit. too late already? maybe use fallback & deprecate TODO shit. too late already? maybe use fallback & deprecate
''' '''
def test() -> None: def test() -> None:
from typing import Callable
import logging import logging
import sys import sys
M = lambda s: print(s, file=sys.stderr)
M: Callable[[str], None] = lambda s: print(s, file=sys.stderr)
M(" Logging module's deafults are not great...'") M(" Logging module's deafults are not great...'")
l = logging.getLogger('test_logger') l = logging.getLogger('test_logger')
# todo why is mypy unhappy about these???
l.error("For example, this should be logged as error. But it's not even formatted properly, doesn't have logger name or level") l.error("For example, this should be logged as error. But it's not even formatted properly, doesn't have logger name or level")
M(" The reason is that you need to remember to call basicConfig() first") M(" The reason is that you need to remember to call basicConfig() first")
logging.basicConfig()
l.error("OK, this is better. But the default format kinda sucks, I prefer having timestamps and the file/line number") l.error("OK, this is better. But the default format kinda sucks, I prefer having timestamps and the file/line number")
M("") M("")
@ -37,6 +40,7 @@ LevelIsh = Optional[Union[Level, str]]
def mklevel(level: LevelIsh) -> Level: def mklevel(level: LevelIsh) -> Level:
# todo put in some global file, like envvars.py
glevel = os.environ.get('HPI_LOGS', None) glevel = os.environ.get('HPI_LOGS', None)
if glevel is not None: if glevel is not None:
level = glevel level = glevel
@ -59,6 +63,7 @@ def setup_logger(logger: logging.Logger, level: LevelIsh) -> None:
import logzero # type: ignore[import] import logzero # type: ignore[import]
except ModuleNotFoundError: except ModuleNotFoundError:
import warnings import warnings
warnings.warn("You might want to install 'logzero' for nice colored logs!") warnings.warn("You might want to install 'logzero' for nice colored logs!")
logger.setLevel(lvl) logger.setLevel(lvl)
h = logging.StreamHandler() h = logging.StreamHandler()
@ -75,7 +80,7 @@ def setup_logger(logger: logging.Logger, level: LevelIsh) -> None:
class LazyLogger(logging.Logger): class LazyLogger(logging.Logger):
def __new__(cls, name, level: LevelIsh = 'INFO'): def __new__(cls, name: str, level: LevelIsh = 'INFO') -> 'LazyLogger':
logger = logging.getLogger(name) logger = logging.getLogger(name)
# this is called prior to all _log calls so makes sense to do it here? # this is called prior to all _log calls so makes sense to do it here?
def isEnabledFor_lazyinit(*args, logger=logger, orig=logger.isEnabledFor, **kwargs): def isEnabledFor_lazyinit(*args, logger=logger, orig=logger.isEnabledFor, **kwargs):
@ -86,7 +91,7 @@ class LazyLogger(logging.Logger):
return orig(*args, **kwargs) return orig(*args, **kwargs)
logger.isEnabledFor = isEnabledFor_lazyinit # type: ignore[assignment] logger.isEnabledFor = isEnabledFor_lazyinit # type: ignore[assignment]
return logger return logger # type: ignore[return-value]
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -31,7 +31,8 @@ def ignored(m: str) -> bool:
return re.match(f'^my.({exs})$', m) is not None return re.match(f'^my.({exs})$', m) is not None
def get_stats(module: str): from .common import StatsFun
def get_stats(module: str) -> Optional[StatsFun]:
# todo detect via ast? # todo detect via ast?
try: try:
mod = import_module(module) mod = import_module(module)
@ -63,19 +64,21 @@ def is_not_hpi_module(module: str) -> Optional[str]:
return "has no 'stats()' function" return "has no 'stats()' function"
return None return None
from types import ModuleType
# todo reuse in readme/blog post # todo reuse in readme/blog post
# borrowed from https://github.com/sanitizers/octomachinery/blob/24288774d6dcf977c5033ae11311dbff89394c89/tests/circular_imports_test.py#L22-L55 # borrowed from https://github.com/sanitizers/octomachinery/blob/24288774d6dcf977c5033ae11311dbff89394c89/tests/circular_imports_test.py#L22-L55
def _iter_all_importables(pkg) -> Iterable[HPIModule]: def _iter_all_importables(pkg: ModuleType) -> Iterable[HPIModule]:
# todo crap. why does it include some stuff three times?? # todo crap. why does it include some stuff three times??
yield from chain.from_iterable( yield from chain.from_iterable(
_discover_path_importables(Path(p), pkg.__name__) _discover_path_importables(Path(p), pkg.__name__)
# todo might need to handle __path__ for individual modules too? # todo might need to handle __path__ for individual modules too?
# not sure why __path__ was duplicated, but it did happen.. # not sure why __path__ was duplicated, but it did happen..
for p in set(pkg.__path__) for p in set(pkg.__path__) # type: ignore[attr-defined]
) )
def _discover_path_importables(pkg_pth, pkg_name) -> Iterable[HPIModule]: def _discover_path_importables(pkg_pth: Path, pkg_name: str) -> Iterable[HPIModule]:
from .core_config import config from .core_config import config
"""Yield all importables under a given path and package.""" """Yield all importables under a given path and package."""
@ -109,7 +112,7 @@ def _discover_path_importables(pkg_pth, pkg_name) -> Iterable[HPIModule]:
# TODO when do we need to recurse? # TODO when do we need to recurse?
def _walk_packages(path=None, prefix='', onerror=None) -> Iterable[HPIModule]: def _walk_packages(path: Iterable[str], prefix: str='', onerror=None) -> Iterable[HPIModule]:
''' '''
Modified version of https://github.com/python/cpython/blob/d50a0700265536a20bcce3fb108c954746d97625/Lib/pkgutil.py#L53, Modified version of https://github.com/python/cpython/blob/d50a0700265536a20bcce3fb108c954746d97625/Lib/pkgutil.py#L53,
to alvoid importing modules that are skipped to alvoid importing modules that are skipped
@ -123,6 +126,9 @@ def _walk_packages(path=None, prefix='', onerror=None) -> Iterable[HPIModule]:
for info in pkgutil.iter_modules(path, prefix): for info in pkgutil.iter_modules(path, prefix):
mname = info.name mname = info.name
if mname is None:
# why would it be? anyway makes mypy happier
continue
if ignored(mname): if ignored(mname):
# not sure if need to yield? # not sure if need to yield?

View file

@ -6,12 +6,13 @@ E.g. would be nice to propagate the warnings in the UI (it's even a subclass of
''' '''
import sys import sys
from typing import Optional
import warnings import warnings
# just bring in the scope of this module for convenience # just bring in the scope of this module for convenience
from warnings import warn from warnings import warn
def _colorize(x: str, color=None) -> str: def _colorize(x: str, color: Optional[str]=None) -> str:
if color is None: if color is None:
return x return x
@ -28,8 +29,7 @@ def _colorize(x: str, color=None) -> str:
# todo log something? # todo log something?
return x return x
def _warn(message: str, *args, color: Optional[str]=None, **kwargs) -> None:
def _warn(message: str, *args, color=None, **kwargs) -> None:
stacklevel = kwargs.get('stacklevel', 1) stacklevel = kwargs.get('stacklevel', 1)
kwargs['stacklevel'] = stacklevel + 2 # +1 for this function, +1 for medium/high wrapper kwargs['stacklevel'] = stacklevel + 2 # +1 for this function, +1 for medium/high wrapper
warnings.warn(_colorize(message, color=color), *args, **kwargs) warnings.warn(_colorize(message, color=color), *args, **kwargs)