diff --git a/python/code/typeguard/_checkers.py b/python/code/typeguard/_checkers.py index d3875f7a..70ee854c 100644 --- a/python/code/typeguard/_checkers.py +++ b/python/code/typeguard/_checkers.py @@ -914,6 +914,10 @@ def check_type_internal( :param memo: a memo object containing configuration and information necessary for looking up forward references """ + ty = type(value) + if ty == annotation or (value is None and annotation is type(None)): + # some early exits for better performance + return annotation = resolve_alias_chains(annotation) if isinstance(annotation, ForwardRef): diff --git a/python/code/wypp/errors.py b/python/code/wypp/errors.py index 90cdde07..332488c7 100644 --- a/python/code/wypp/errors.py +++ b/python/code/wypp/errors.py @@ -78,6 +78,30 @@ def invalidType(ty: Any, loc: Optional[location.Loc]) -> WyppTypeError: lines.append(renderLoc(loc)) raise WyppTypeError('\n'.join(lines)) + @staticmethod + def invalidRestArgType(ty: Any, loc: Optional[location.Loc]) -> WyppTypeError: + lines = [] + tyStr = renderTy(ty) + lines.append(i18n.invalidRestArgTy(tyStr)) + lines.append('') + if loc is not None: + lines.append(f'## {i18n.tr("File")} {loc.filename}') + lines.append(f'## {i18n.tr("Type declared in line")} {loc.startLine}:\n') + lines.append(renderLoc(loc)) + raise WyppTypeError('\n'.join(lines)) + + @staticmethod + def invalidKwArgType(ty: Any, loc: Optional[location.Loc]) -> WyppTypeError: + lines = [] + tyStr = renderTy(ty) + lines.append(i18n.invalidKwArgTy(tyStr)) + lines.append('') + if loc is not None: + lines.append(f'## {i18n.tr("File")} {loc.filename}') + lines.append(f'## {i18n.tr("Type declared in line")} {loc.startLine}:\n') + lines.append(renderLoc(loc)) + raise WyppTypeError('\n'.join(lines)) + @staticmethod def unknownKeywordArgument(callableName: location.CallableName, callLoc: Optional[location.Loc], name: str) -> WyppTypeError: lines = [] diff --git a/python/code/wypp/i18n.py b/python/code/wypp/i18n.py index 032e03e8..8cf295e9 100644 --- a/python/code/wypp/i18n.py +++ b/python/code/wypp/i18n.py @@ -102,6 +102,10 @@ def tr(key: str, **kws) -> str: 'invalid type `{ty}`': 'ungültiger Typ `{ty}`', + 'invalid type for rest argument: `{ty}`': + 'ungültiger Typ für Restargument: `{ty}`', + 'invalid type for keyword argument: `{ty}`': + 'ungültiger Typ für Schlüsselwort-Argument: `{ty}`', 'Cannot set attribute to value of type `{ty}`.': 'Das Attribut kann nicht auf einen Wert vom Typ `{ty}` gesetzt werden.', 'Problematic assignment in line': 'Fehlerhafte Zuweisung in Zeile', @@ -342,6 +346,12 @@ def noTypeAnnotationForAttribute(attrName: str, recordName: str) -> str: def invalidTy(ty: Any) -> str: return tr('invalid type `{ty}`', ty=ty) +def invalidRestArgTy(ty: Any) -> str: + return tr('invalid type for rest argument: `{ty}`', ty=ty) + +def invalidKwArgTy(ty: Any) -> str: + return tr('invalid type for keyword argument: `{ty}`', ty=ty) + def didYouMean(ty: str) -> str: return tr('Did you mean `{ty}`?', ty=ty) diff --git a/python/code/wypp/location.py b/python/code/wypp/location.py index 9b0a6f46..0cdf99ad 100644 --- a/python/code/wypp/location.py +++ b/python/code/wypp/location.py @@ -217,6 +217,7 @@ def __init__(self, f: Callable, kind: CallableKind): self.__lineno = f.__code__.co_firstlineno self.__name = f.__name__ self.__ast = parsecache.getAST(self.file) + self.__async = None def __repr__(self): return f'StdCallableInfo({self.name}, {self.kind})' @@ -282,8 +283,10 @@ def getParamSourceLocation(self, paramName: str) -> Optional[Loc]: @property def isAsync(self) -> bool: - node = self._findDef() - return isinstance(node, ast.AsyncFunctionDef) + if self.__async is None: + node = self._findDef() + self.__async = isinstance(node, ast.AsyncFunctionDef) + return self.__async def classFilename(cls) -> str | None: """Best-effort path to the file that defined `cls`.""" diff --git a/python/code/wypp/stacktrace.py b/python/code/wypp/stacktrace.py index eec485c4..fcec06e7 100644 --- a/python/code/wypp/stacktrace.py +++ b/python/code/wypp/stacktrace.py @@ -89,6 +89,11 @@ def __call__(self, frame: types.FrameType, event: str, arg: Any): pass case 'c_exception': pass + def getReturnFrameType(self, idx: int) -> Optional[types.FrameType]: + try: + return self.__returnFrames[idx] + except IndexError: + return None def getReturnFrame(self, idx: int) -> Optional[inspect.FrameInfo]: try: f = self.__returnFrames[idx] @@ -102,6 +107,15 @@ def getReturnFrame(self, idx: int) -> Optional[inspect.FrameInfo]: else: return None +def frameTypeToFrameInfo(f: Optional[types.FrameType]) -> Optional[inspect.FrameInfo]: + if f: + tb = inspect.getframeinfo(f, context=1) + fi = inspect.FrameInfo(f, tb.filename, tb.lineno, tb.function, tb.code_context, tb.index) + del f + return fi + else: + return None + # when using _call_with_next_frame_removed, we have to take the second-to-last # return. Hence, we keep the two most recent returns byn setting entriesToKeep = 2. def installProfileHook(entriesToKeep: int=2) -> ReturnTracker: diff --git a/python/code/wypp/typecheck.py b/python/code/wypp/typecheck.py index e4c2f2ae..b1431d3a 100644 --- a/python/code/wypp/typecheck.py +++ b/python/code/wypp/typecheck.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable from typing import ParamSpec, TypeVar, Any, Optional, Literal +import types import inspect from dataclasses import dataclass import utils @@ -24,14 +25,14 @@ def isEmptySignature(sig: inspect.Signature) -> bool: return False return isEmptyAnnotation(sig.return_annotation) -def handleMatchesTyResult(res: MatchesTyResult, tyLoc: Optional[location.Loc]) -> bool: +def handleMatchesTyResult(res: MatchesTyResult, getTyLoc: Callable[[], Optional[location.Loc]]) -> bool: match res: case MatchesTyFailure(exc, ty): if isDebug(): debug(f'Exception occurred while calling matchesTy with type {ty}, re-raising') raise exc else: - raise errors.WyppTypeError.invalidType(ty, tyLoc) + raise errors.WyppTypeError.invalidType(ty, getTyLoc()) case b: return b @@ -63,9 +64,11 @@ def checkSignature(sig: inspect.Signature, info: location.CallableInfo, cfg: Che locDecl = info.getParamSourceLocation(name) raise errors.WyppTypeError.partialAnnotationError(location.CallableName.mk(info), name, locDecl) if p.default is not inspect.Parameter.empty: - locDecl = info.getParamSourceLocation(name) + locDecl = lambda: info.getParamSourceLocation(name) if not handleMatchesTyResult(matchesTy(p.default, ty, cfg.ns), locDecl): - raise errors.WyppTypeError.defaultError(location.CallableName.mk(info), name, locDecl, ty, p.default) + raise errors.WyppTypeError.defaultError(location.CallableName.mk(info), name, + locDecl(), ty, p.default) + def mandatoryArgCount(sig: inspect.Signature) -> int: required_kinds = { @@ -80,24 +83,34 @@ def mandatoryArgCount(sig: inspect.Signature) -> int: return res def checkArgument(p: inspect.Parameter, name: str, idx: Optional[int], a: Any, - locArg: Optional[location.Loc], info: location.CallableInfo, cfg: CheckCfg): + getLocArg: Callable[[], Optional[location.Loc]], + info: location.CallableInfo, cfg: CheckCfg): t = p.annotation if not isEmptyAnnotation(t): + locDecl = lambda: info.getParamSourceLocation(name) if p.kind == inspect.Parameter.VAR_POSITIONAL: + if type(t) == str: + t = eval(t) argT = None # For *args annotated as tuple[X, ...], extract the element type X origin = getattr(t, '__origin__', None) if origin is tuple: args = getattr(t, '__args__', None) - if args: + if args and len(args) == 2 and args[1] is Ellipsis: + # tuple[X, ...] — homogeneous variadic argT = args[0] + elif args: + # tuple[X, Y, ...] — fixed-length tuple, no single element type to extract + raise errors.WyppTypeError.invalidRestArgType(t, locDecl()) elif t is tuple: # bare `tuple` without type parameters, nothing to check return else: - raise ValueError(f'Invalid type for rest argument: {t}') + raise errors.WyppTypeError.invalidRestArgType(t, locDecl()) t = argT elif p.kind == inspect.Parameter.VAR_KEYWORD: + if type(t) == str: + t = eval(t) valT = None # For **kwargs annotated as dict[str, X], extract the value type X origin = getattr(t, '__origin__', None) @@ -108,31 +121,39 @@ def checkArgument(p: inspect.Parameter, name: str, idx: Optional[int], a: Any, elif t is dict: return else: - raise ValueError(f'Invalid type for keyword argument: {t}') + raise errors.WyppTypeError.invalidKwArgType(t, info.getParamSourceLocation(p.name)) t = valT - locDecl = info.getParamSourceLocation(name) if not handleMatchesTyResult(matchesTy(a, t, cfg.ns), locDecl): cn = location.CallableName.mk(info) raise errors.WyppTypeError.argumentError(cn, name, idx, - locDecl, + locDecl(), t, a, - locArg) + getLocArg()) + def checkArguments(sig: inspect.Signature, args: tuple, kwargs: dict, info: location.CallableInfo, cfg: CheckCfg) -> None: - debug(f'Checking arguments when calling {info}') + if isDebug(): + debug(f'Checking arguments when calling {info}') paramNames = list(sig.parameters) mandatory = mandatoryArgCount(sig) kind = getKind(cfg, paramNames) offset = 1 if kind == 'method' else 0 - fi = stacktrace.callerOutsideWypp() - callLoc = None if not fi else location.Loc.fromFrameInfo(fi) cn = location.CallableName.mk(info) + # stacktrace.callerOutsideWypp() is expensive, only access it lazily + def getCallLoc() -> Optional[location.Loc]: + fi = stacktrace.callerOutsideWypp() + return None if not fi else location.Loc.fromFrameInfo(fi) + def getLocArg(idxOrName: int | str) -> Callable[[], Optional[location.Loc]]: + def f(): + fi = stacktrace.callerOutsideWypp() + return None if fi is None else location.locationOfArgument(fi, i) + return f def raiseArgMismatch(): raise errors.WyppTypeError.argCountMismatch(cn, - callLoc, + getCallLoc(), len(paramNames) - offset, mandatory - offset, len(args) - offset) @@ -152,46 +173,46 @@ def raiseArgMismatch(): raiseArgMismatch() # Check positional args for i in range(len(args)): - locArg = None if fi is None else location.locationOfArgument(fi, i) if i < len(positionalNames): name = positionalNames[i] p = sig.parameters[name] - checkArgument(p, name, i - offset, args[i], locArg, info, cfg) + checkArgument(p, name, i - offset, args[i], getLocArg(i), info, cfg) elif varPositionalParam is not None: - checkArgument(varPositionalParam, varPositionalParam.name, i - offset, args[i], locArg, info, cfg) + checkArgument(varPositionalParam, varPositionalParam.name, i - offset, args[i], getLocArg(i), info, cfg) else: raiseArgMismatch() # Check keyword args for name in kwargs: - locArg = None if fi is None else location.locationOfArgument(fi, name) if name in sig.parameters and sig.parameters[name].kind not in ( inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD ): - checkArgument(sig.parameters[name], name, None, kwargs[name], locArg, info, cfg) + checkArgument(sig.parameters[name], name, None, kwargs[name], getLocArg(name), info, cfg) elif varKeywordParam is not None: - checkArgument(varKeywordParam, name, None, kwargs[name], locArg, info, cfg) + checkArgument(varKeywordParam, name, None, kwargs[name], getLocArg(name), info, cfg) else: - raise errors.WyppTypeError.unknownKeywordArgument(cn, callLoc, name) + raise errors.WyppTypeError.unknownKeywordArgument(cn, getCallLoc(), name) -def checkReturn(sig: inspect.Signature, returnFrame: Optional[inspect.FrameInfo], +def checkReturn(sig: inspect.Signature, returnFrameType: Optional[types.FrameType], result: Any, info: location.CallableInfo, cfg: CheckCfg) -> None: if info.isAsync: return t = sig.return_annotation if isEmptyAnnotation(t): t = None - debug(f'Checking return value when calling {info}, return type: {t}') - locDecl = info.getResultTypeLocation() + if isDebug(): + debug(f'Checking return value when calling {info}, return type: {t}') + locDecl = lambda: info.getResultTypeLocation() if not handleMatchesTyResult(matchesTy(result, t, cfg.ns), locDecl): fi = stacktrace.callerOutsideWypp() if fi is not None: locRes = location.Loc.fromFrameInfo(fi) returnLoc = None extraFrames = [] + returnFrame = stacktrace.frameTypeToFrameInfo(returnFrameType) if returnFrame: returnLoc = location.Loc.fromFrameInfo(returnFrame) extraFrames = [returnFrame] - raise errors.WyppTypeError.resultError(location.CallableName.mk(info), locDecl, t, returnLoc, result, + raise errors.WyppTypeError.resultError(location.CallableName.mk(info), locDecl(), t, returnLoc, result, locRes, extraFrames) @@ -232,9 +253,9 @@ def wrapped(*args, **kwargs) -> T: utils._call_with_frames_removed(checkArguments, sig, args, kwargs, info, checkCfg) returnTracker = stacktrace.getReturnTracker() result = utils._call_with_next_frame_removed(f, *args, **kwargs) - retFrame = returnTracker.getReturnFrame(0) + ft = returnTracker.getReturnFrameType(0) utils._call_with_frames_removed( - checkReturn, sig, retFrame, result, info, checkCfg + checkReturn, sig, ft, result, info, checkCfg ) return result return wrapped diff --git a/python/file-test-data/extras/args3_ok.py b/python/file-test-data/extras/args3_ok.py index eb3ccfd5..7f34284e 100644 --- a/python/file-test-data/extras/args3_ok.py +++ b/python/file-test-data/extras/args3_ok.py @@ -1,6 +1,6 @@ from wypp import * -def f(x: int, *rest: tuple[int], **kw: dict[str, int]): +def f(x: int, *rest: tuple[int, ...], **kw: dict[str, int]): print(f'x={x}, kw={kw}') f(1) diff --git a/python/file-test-data/extras/args5.err b/python/file-test-data/extras/args5.err index 588e3488..02b871a8 100644 --- a/python/file-test-data/extras/args5.err +++ b/python/file-test-data/extras/args5.err @@ -14,4 +14,4 @@ f(1, 10, '11', y=2, z=3) ## Typ deklariert in Zeile 3: -def f(x: int, *rest: tuple[int], **kw: dict[str, int]): +def f(x: int, *rest: tuple[int, ...], **kw: dict[str, int]): diff --git a/python/file-test-data/extras/args5.py b/python/file-test-data/extras/args5.py index 70aa57ac..a66820a6 100644 --- a/python/file-test-data/extras/args5.py +++ b/python/file-test-data/extras/args5.py @@ -1,6 +1,6 @@ from wypp import * -def f(x: int, *rest: tuple[int], **kw: dict[str, int]): +def f(x: int, *rest: tuple[int, ...], **kw: dict[str, int]): print(f'x={x}, kw={kw}') f(1) diff --git a/python/file-test-data/extras/args6.py b/python/file-test-data/extras/args6.py index f05c7a3b..7a8654e3 100644 --- a/python/file-test-data/extras/args6.py +++ b/python/file-test-data/extras/args6.py @@ -1,6 +1,6 @@ from wypp import * -def f(x: int, *rest: tuple[int], **kw: dict[str, int]): +def f(x: int, *rest: tuple[int, ...], **kw: dict[str, int]): print(f'x={x}, kw={kw}') f(1) diff --git a/python/file-test-data/extras/args7.err b/python/file-test-data/extras/args7.err new file mode 100644 index 00000000..4a5ea674 --- /dev/null +++ b/python/file-test-data/extras/args7.err @@ -0,0 +1,10 @@ +Traceback (most recent call last): + File "file-test-data/extras/args7.py", line 6, in + f(1, 2) + +WyppTypeError: ungültiger Typ für Restargument: `tuple[int]` + +## Datei file-test-data/extras/args7.py +## Typ deklariert in Zeile 3: + +def f(x: int, *rest: tuple[int]): diff --git a/python/file-test-data/extras/args7.out b/python/file-test-data/extras/args7.out new file mode 100644 index 00000000..e69de29b diff --git a/python/file-test-data/extras/args7.py b/python/file-test-data/extras/args7.py new file mode 100644 index 00000000..f26e6f13 --- /dev/null +++ b/python/file-test-data/extras/args7.py @@ -0,0 +1,6 @@ +from wypp import * + +def f(x: int, *rest: tuple[int]): + pass + +f(1, 2) diff --git a/python/file-test-data/extras/args8.err b/python/file-test-data/extras/args8.err new file mode 100644 index 00000000..0275c548 --- /dev/null +++ b/python/file-test-data/extras/args8.err @@ -0,0 +1,10 @@ +Traceback (most recent call last): + File "file-test-data/extras/args8.py", line 6, in + f(1, y=2) + +WyppTypeError: ungültiger Typ für Schlüsselwort-Argument: `tuple[int]` + +## Datei file-test-data/extras/args8.py +## Typ deklariert in Zeile 3: + +def f(x: int, **kw: tuple[int]): diff --git a/python/file-test-data/extras/args8.out b/python/file-test-data/extras/args8.out new file mode 100644 index 00000000..e69de29b diff --git a/python/file-test-data/extras/args8.py b/python/file-test-data/extras/args8.py new file mode 100644 index 00000000..08737d3e --- /dev/null +++ b/python/file-test-data/extras/args8.py @@ -0,0 +1,6 @@ +from wypp import * + +def f(x: int, **kw: tuple[int]): + pass + +f(1, y=2) diff --git a/python/file-test-data/extras/testForwardRef5.err b/python/file-test-data/extras/testForwardRef5.err index d8eefa7f..346b2d07 100644 --- a/python/file-test-data/extras/testForwardRef5.err +++ b/python/file-test-data/extras/testForwardRef5.err @@ -9,8 +9,8 @@ Der Aufruf des Konstruktors des Records `Garage` erwartet einen Wert vom Typ `li ## Datei file-test-data/extras/testForwardRef5.py ## Fehlerhafter Aufruf in Zeile 22: -garage = Garage(cars=[Car(color='red'), "Not A Car"]) +garage = Garage(cars=[Car(color='red'), "Not A Car"]) ## Typ deklariert in Zeile 11: - cars: list['Car'] \ No newline at end of file + cars: list['Car'] diff --git a/python/run b/python/run index fa7ad80d..faf34d84 100755 --- a/python/run +++ b/python/run @@ -8,6 +8,9 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" OPTS="--quiet" # OPTS="--verbose" -PYTHONPATH="$SCRIPT_DIR"/code:"$PYTHONPATH" $PY "$SCRIPT_DIR"/code/wypp/runYourProgram.py \ +# For profiling, uncomment the line below +# PROF="-m cProfile -o profile.out" + +PYTHONPATH="$SCRIPT_DIR"/code:"$PYTHONPATH" $PY $PROF "$SCRIPT_DIR"/code/wypp/runYourProgram.py \ --no-clear $OPTS "$@" exit $?