Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions python/code/wypp/exceptionHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ def handleCurrentException(exit=True, removeFirstTb=False, file=sys.stderr):
frameList = (stacktrace.tbToFrameList(tb) if tb is not None else [])
if frameList and removeFirstTb:
frameList = frameList[1:]
isBug = not isWyppError and not isinstance(val, SyntaxError) and \
len(frameList) > 0 and stacktrace.isWyppFrame(frameList[-1])
isSyntaxError = isinstance(val, SyntaxError)
lastFrameIsWypp = len(frameList) > 0 and stacktrace.isWyppFrame(frameList[-1])
isBug = not isWyppError and not isSyntaxError and lastFrameIsWypp
stackSummary = stacktrace.limitTraceback(frameList, extra, not isBug and not isDebug())
header = False
for x in stackSummary.format():
Expand All @@ -62,6 +63,7 @@ def handleCurrentException(exit=True, removeFirstTb=False, file=sys.stderr):
for x in traceback.format_exception_only(etype, val):
file.write(x)
if isBug:
debug(f'isWyppError={isWyppError}, isSyntaxError={isSyntaxError}, lastFrameIsWypp={lastFrameIsWypp}')
file.write(f'BUG: the error above is most likely a bug in WYPP!')
if exit:
utils.die(1)
15 changes: 14 additions & 1 deletion python/code/wypp/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,19 +140,23 @@ def find_spec(
target: types.ModuleType | None = None,
) -> ModuleSpec | None:

debug(f'Consulting InstrumentingFinder.find_spec for fullname={fullname}')
# 1) The fullname is the name of the main module. This might be a dotted name such as x.y.z.py
# so we have special logic here
fp = os.path.join(self.modDir, f"{fullname}.py")
if self.mainModName == fullname and os.path.isfile(fp):
loader = InstrumentingLoader(fullname, fp)
return spec_from_file_location(fullname, fp, loader=loader)
spec = spec_from_file_location(fullname, fp, loader=loader)
debug(f'spec for {fullname}: {spec}')
return spec
# 2) The fullname is a prefix of the main module. We want to load main modules with
# dotted names such as x.y.z.py, hence we synthesize a namespace pkg
# e.g. if 'x.y.z.py' exists and we're asked for 'x', return a package spec.
elif self.mainModName.startswith(fullname + '.'):
spec = importlib.machinery.ModuleSpec(fullname, loader=None, is_package=True)
# Namespace package marker (PEP 451)
spec.submodule_search_locations = []
debug(f'spec for {fullname}: {spec}')
return spec
# 3) Fallback: use the original PathFinder
spec = self._origFinder.find_spec(fullname, path, target)
Expand Down Expand Up @@ -191,10 +195,19 @@ def setupFinder(modDir: str, modName: str, extraDirs: list[str], typechecking: b
# Create and install our custom finder
instrumenting_finder = InstrumentingFinder(finder, modDir, modName, extraDirs)
sys.meta_path.insert(0, instrumenting_finder)
debug(f'Installed instrument finder {instrumenting_finder}')

alreadyLoaded = sys.modules.get(modName)
if alreadyLoaded:
sys.modules.pop(modName, None)
importlib.invalidate_caches()

try:
yield
finally:
if alreadyLoaded:
sys.modules[modName] = alreadyLoaded

# Remove our custom finder when exiting the context
if instrumenting_finder in sys.meta_path:
sys.meta_path.remove(instrumenting_finder)
11 changes: 11 additions & 0 deletions python/code/wypp/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ def getResultTypeLocation(self) -> Optional[Loc]:
@abc.abstractmethod
def getParamSourceLocation(self, paramName: str) -> Optional[Loc]:
pass
@property
@abc.abstractmethod
def isAsync(self) -> bool:
pass

class StdCallableInfo(CallableInfo):
"""
Expand Down Expand Up @@ -276,6 +280,10 @@ def getParamSourceLocation(self, paramName: str) -> Optional[Loc]:
res.end_lineno,
res.end_col_offset)

@property
def isAsync(self) -> bool:
node = self._findDef()
return isinstance(node, ast.AsyncFunctionDef)

def classFilename(cls) -> str | None:
"""Best-effort path to the file that defined `cls`."""
Expand Down Expand Up @@ -315,6 +323,9 @@ def getParamSourceLocation(self, paramName: str) -> Optional[Loc]:
return Loc(file, node.lineno, node.col_offset, node.end_lineno, node.end_col_offset)
else:
return None
@property
def isAsync(self) -> bool:
return False

def locationOfArgument(fi: inspect.FrameInfo, idxOrName: int | str) -> Optional[Loc]:
"""
Expand Down
20 changes: 19 additions & 1 deletion python/code/wypp/runCode.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def __init__(self, mod, properlyImported):
@dataclass
class RunSetup:
def __init__(self, pathDir: str, args: list[str]):
self.pathDir = pathDir
self.pathDir = os.path.abspath(pathDir)
self.args = args
self.sysPathInserted = False
self.oldArgs = sys.argv
Expand Down Expand Up @@ -64,12 +64,30 @@ def prepareLib(onlyCheckRunnable, enableTypeChecking):
quiet=onlyCheckRunnable)
return libDefs

def debugModule(name):
if name in sys.modules:
m = sys.modules["copy"]
print(f"Module {name} already loaded from:", getattr(m, "__file__", None))
print("CWD:", os.getcwd())
print("sys.path[0]:", sys.path[0])
print("First few sys.path entries:")
for p in sys.path[:5]:
print(" ", p)

spec = importlib.util.find_spec(name)
print("Resolved spec:", spec)
if spec:
print("Origin:", spec.origin)
print("Loader:", type(spec.loader).__name__)

def runCode(fileToRun, globals, doTypecheck=True, extraDirs=None) -> dict:
if not extraDirs:
extraDirs = []
modName = os.path.basename(os.path.splitext(fileToRun)[0])
with instrument.setupFinder(os.path.dirname(fileToRun), modName, extraDirs, doTypecheck):
sys.dont_write_bytecode = True
if DEBUG:
debugModule(modName)
res = runpy.run_module(modName, init_globals=globals, run_name='__wypp__', alter_sys=False)
return res

Expand Down
11 changes: 9 additions & 2 deletions python/code/wypp/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,14 @@ def main(globals, argList=None):
sys.exit(1)

fileToRun: str = args.file
if not os.path.exists(fileToRun):
printStderr(f'File {fileToRun} does not exist')
sys.exit(1)
if args.changeDir:
os.chdir(os.path.dirname(fileToRun))
d = os.path.dirname(fileToRun)
os.chdir(d)
fileToRun = os.path.basename(fileToRun)
debug(f'Changed directory to {d}, fileToRun={fileToRun}')

isInteractive = args.interactive
version = versionMod.readVersion()
Expand All @@ -81,13 +86,15 @@ def main(globals, argList=None):

libDefs = runCode.prepareLib(onlyCheckRunnable=args.checkRunnable, enableTypeChecking=args.checkTypes)

with (runCode.RunSetup(os.path.dirname(fileToRun), [fileToRun] + restArgs),
runDir = os.path.dirname(fileToRun)
with (runCode.RunSetup(runDir, [fileToRun] + restArgs),
paths.projectDir(os.path.abspath(os.getcwd()))):
globals['__name__'] = '__wypp__'
sys.modules['__wypp__'] = sys.modules['__main__']
loadingFailed = False
try:
verbose(f'running code in {fileToRun}')
debug(f'sys.path: {sys.path}')
globals['__file__'] = fileToRun
globals = runCode.runStudentCode(fileToRun, globals, args.checkRunnable,
doTypecheck=args.checkTypes, extraDirs=args.extraDirs)
Expand Down
2 changes: 2 additions & 0 deletions python/code/wypp/typecheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ def raiseArgMismatch():

def checkReturn(sig: inspect.Signature, returnFrame: Optional[inspect.FrameInfo],
result: Any, info: location.CallableInfo, cfg: CheckCfg) -> None:
if info.isAsync:
return
t = sig.return_annotation
if isEmptyAnnotation(t):
t = None
Expand Down
4 changes: 4 additions & 0 deletions python/file-test-data/basics/async2_ok.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
coroutine object
At start of meaningOfLife: a
At end of meaningOfLife: a
42
24 changes: 24 additions & 0 deletions python/file-test-data/basics/async2_ok.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import *
import types
import asyncio
from datetime import datetime

def debug(msg):
print(msg)

async def meaningOfLife(x: str) -> int:
debug(f'At start of meaningOfLife: {x}')
await asyncio.sleep(0.0001)
debug(f'At end of meaningOfLife: {x}')
return 42

async def run() -> None:
a = meaningOfLife('a')
debug('coroutine object')
n = await a
debug(n)

def main():
asyncio.run(run())

main()
1 change: 1 addition & 0 deletions python/file-test-data/basics/copy.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello copy
4 changes: 4 additions & 0 deletions python/file-test-data/basics/copy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# WYPP_TEST_CONFIG: {"args": ["--change-directory"], "exitCode": 0}

# This file intentionally has the same name as a file from the stdlib
print('Hello copy')
1 change: 1 addition & 0 deletions python/file-test-data/basics/typing.err
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
File file-test-data/basics/typing.py does not exist
9 changes: 9 additions & 0 deletions python/fileTests.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pathlib import Path
from fileTestsLib import *
import sys
import os

def pythonMinVersion(major: int, minor: int) -> bool:
return sys.version_info >= (major, minor)
Expand Down Expand Up @@ -33,7 +34,15 @@ def main():

globalCtx.results.finish()

def extraChecks():
# The localTyping file does not exist, running should thus fail
localTyping = 'file-test-data/basics/typing.py'
if os.path.exists(localTyping):
raise ValueError(f'File {localTyping} should not exist')
check(localTyping, exitCode=1)

try:
main()
extraChecks()
except KeyboardInterrupt:
pass
30 changes: 24 additions & 6 deletions python/fileTestsLib.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
import re
import fnmatch

DEBUG = False

def debug(s):
if DEBUG:
print(f'[TESTDEBUG] {s}')

GLOBAL_CHECK_OUTPUTS = True

GLOBAL_RECORD_ALL = False # Should be False, write actual output to all expected output files
Expand All @@ -34,6 +40,7 @@ def parseArgs() -> TestOpts:
)

# Define the command-line arguments
parser.add_argument("--debug", action="store_true", help="Output debug messages")
parser.add_argument("--start-at", type=str, help="Start with test in FILE")
parser.add_argument("--only", type=str, help="Run only the test in FILE")
parser.add_argument("--continue", action="store_true",
Expand All @@ -48,7 +55,9 @@ def parseArgs() -> TestOpts:
nargs='*')
# Parse the arguments
args = parser.parse_args()

if args.debug:
global DEBUG
DEBUG = True
scriptDir = os.path.dirname(__file__)
return TestOpts(
cmd=f'{scriptDir}/code/wypp/runYourProgram.py',
Expand Down Expand Up @@ -244,6 +253,7 @@ def _runTest(testFile: str,
env = os.environ.copy()
env['PYTHONPATH'] = os.pathsep.join([os.path.join(ctx.opts.baseDir, 'code')] + pythonPath)
env['WYPP_UNDER_TEST'] = 'True'
debug(' '.join(cmd))
with open(actualStdoutFile, 'w') as stdoutFile, \
open(actualStderrFile, 'w') as stderrFile:
# Run the command
Expand Down Expand Up @@ -332,17 +342,20 @@ class WyppTestConfig:
typecheck: Literal[True, False, "both"]
args: list[str]
pythonPath: Optional[str]
exitCode: Optional[int]
@staticmethod
def default() -> WyppTestConfig:
return WyppTestConfig(typecheck=True, args=[], pythonPath=None)
return WyppTestConfig(typecheck=True, args=[], pythonPath=None, exitCode=None)

def readWyppTestConfig(path: str, *, max_lines: int = 5) -> WyppTestConfig:
"""
Read a line like `# WYPP_TEST_CONFIG: {"typecheck": false}` from the first
`max_lines` lines of the file at `path` and return it as a dict.
Returns {} if not present.
"""
validKeys = ['typecheck', 'args', 'pythonPath']
validKeys = ['typecheck', 'args', 'pythonPath', 'exitCode']
if not os.path.exists(path):
return WyppTestConfig.default()
with open(path, "r", encoding="utf-8") as f:
for lineno in range(1, max_lines + 1):
line = f.readline()
Expand All @@ -358,7 +371,10 @@ def readWyppTestConfig(path: str, *, max_lines: int = 5) -> WyppTestConfig:
typecheck = j.get('typecheck', True)
args = j.get('args', [])
pythonPath = j.get('pythonPath')
return WyppTestConfig(typecheck=typecheck, args=args, pythonPath=pythonPath)
exitCode = j.get('exitCode')
cfg = WyppTestConfig(typecheck=typecheck, args=args, pythonPath=pythonPath, exitCode=exitCode)
debug(f'Config for {path}: {cfg}')
return cfg
return WyppTestConfig.default()

def checkNoConfig(testFile: str,
Expand All @@ -370,8 +386,6 @@ def checkNoConfig(testFile: str,
checkOutputs: bool = True,
ctx: TestContext = globalCtx,
what: str = ''):
if guessExitCode(testFile) == 0:
exitCode = 0
status = _check(testFile, exitCode, typecheck, args, pythonPath, minVersion, checkOutputs, ctx, what)
ctx.results.storeTestResult(testFile, status)
if status == 'failed':
Expand All @@ -396,6 +410,10 @@ def check(testFile: str,
pythonPath = []
if cfg.pythonPath:
pythonPath = cfg.pythonPath.split(':')
if cfg.exitCode is not None:
exitCode = cfg.exitCode
elif guessExitCode(testFile) == 0:
exitCode = 0
if cfg.typecheck == 'both':
checkNoConfig(testFile, exitCode, typecheck=True, args=args,
pythonPath=pythonPath, minVersion=minVersion, checkOutputs=checkOutputs,
Expand Down
Loading