diff --git a/.github/workflows/github-action-test-python.yml b/.github/workflows/github-action-test-python.yml index 46b34cf..45f34de 100644 --- a/.github/workflows/github-action-test-python.yml +++ b/.github/workflows/github-action-test-python.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: # You need to change to branch protection rules if you change the versions here - python-version: [3.12.11, 3.13.7] + python-version: [3.12.11, 3.13.7, 3.14.0] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/python/allTests b/python/allTests index 1128200..7183b25 100755 --- a/python/allTests +++ b/python/allTests @@ -17,3 +17,4 @@ function run() PYENV_VERSION=3.12 run PYENV_VERSION=3.13 run +PYENV_VERSION=3.14 run diff --git a/python/code/wypp/instrument.py b/python/code/wypp/instrument.py index 68178e6..4015e83 100644 --- a/python/code/wypp/instrument.py +++ b/python/code/wypp/instrument.py @@ -4,7 +4,8 @@ import importlib import importlib.abc from importlib.machinery import ModuleSpec, SourceFileLoader -from importlib.util import decode_source +import importlib.machinery +from importlib.util import decode_source, spec_from_file_location from collections.abc import Buffer import types from os import PathLike @@ -126,8 +127,9 @@ def source_to_code( return code class InstrumentingFinder(importlib.abc.MetaPathFinder): - def __init__(self, finder, modDir: str, extraDirs: list[str]): + def __init__(self, finder, modDir: str, modName: str, extraDirs: list[str]): self._origFinder = finder + self.mainModName = modName self.modDir = os.path.realpath(modDir) + '/' self.extraDirs = [os.path.realpath(d) for d in extraDirs] @@ -137,9 +139,27 @@ def find_spec( path: Sequence[str] | None, target: types.ModuleType | None = None, ) -> ModuleSpec | None: + + # 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) + # 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 = [] + return spec + # 3) Fallback: use the original PathFinder spec = self._origFinder.find_spec(fullname, path, target) + debug(f'spec for {fullname}: {spec}') if spec is None: - return None + return spec + origin = os.path.realpath(spec.origin) dirs = [self.modDir] + self.extraDirs isLocalModule = False @@ -153,7 +173,7 @@ def find_spec( return spec @contextmanager -def setupFinder(modDir: str, extraDirs: list[str], typechecking: bool): +def setupFinder(modDir: str, modName: str, extraDirs: list[str], typechecking: bool): if not typechecking: yield else: @@ -169,7 +189,7 @@ def setupFinder(modDir: str, extraDirs: list[str], typechecking: bool): raise RuntimeError("Cannot find a PathFinder in sys.meta_path") # Create and install our custom finder - instrumenting_finder = InstrumentingFinder(finder, modDir, extraDirs) + instrumenting_finder = InstrumentingFinder(finder, modDir, modName, extraDirs) sys.meta_path.insert(0, instrumenting_finder) try: diff --git a/python/code/wypp/runCode.py b/python/code/wypp/runCode.py index e8014e4..bee6a9e 100644 --- a/python/code/wypp/runCode.py +++ b/python/code/wypp/runCode.py @@ -67,8 +67,8 @@ def prepareLib(onlyCheckRunnable, enableTypeChecking): def runCode(fileToRun, globals, doTypecheck=True, extraDirs=None) -> dict: if not extraDirs: extraDirs = [] - with instrument.setupFinder(os.path.dirname(fileToRun), extraDirs, doTypecheck): - modName = os.path.basename(os.path.splitext(fileToRun)[0]) + modName = os.path.basename(os.path.splitext(fileToRun)[0]) + with instrument.setupFinder(os.path.dirname(fileToRun), modName, extraDirs, doTypecheck): sys.dont_write_bytecode = True res = runpy.run_module(modName, init_globals=globals, run_name='__wypp__', alter_sys=False) return res diff --git a/pytrace-generator/main.py b/pytrace-generator/main.py index 7e59614..97e7f74 100644 --- a/pytrace-generator/main.py +++ b/pytrace-generator/main.py @@ -14,6 +14,12 @@ import types import typing +_DEBUG = False + +def debug(s): + if _DEBUG: + eprint(s) + def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) @@ -330,6 +336,36 @@ def push_frame(self): def pop_frame(self): self.frames.pop() +Skip = typing.Literal['SKIP', 'DONT_SKIP'] + +class Skipper: + def __init__(self): + # skipStartFile is a tuple (filename, counter) or None + # If None, then we are not in skipping mode + # Otherwise, we are in skipping mode. Each call event with the + # same filename increases the counter by 1. Each return event + # with the same filename decreases the counter by 0. + # The counter starts at 0 when entering skipping event. If it + # goes back to zero, we leave skipping mode + self.skipStartFile: tuple[str, int] | None = None + + def handleEvent(self, event: str, filename: str, isExternalMod: bool) -> Skip: + if event == 'call': + if self.skipStartFile is None and isExternalMod: + # start skipping + self.skipStartFile = (filename, 1) + elif self.skipStartFile is not None and filename == self.skipStartFile[0]: + self.skipStartFile = (self.skipStartFile[0], self.skipStartFile[1] + 1) + elif event == 'return': + if self.skipStartFile is not None and filename == self.skipStartFile[0]: + self.skipStartFile = (self.skipStartFile[0], self.skipStartFile[1] - 1) + if self.skipStartFile[1] == 0: + self.skipStartFile = None + return 'SKIP' + if self.skipStartFile is None: + return 'DONT_SKIP' + else: + return 'SKIP' class PyTraceGenerator(bdb.Bdb): def __init__(self, trace_socket): @@ -339,7 +375,7 @@ def __init__(self, trace_socket): self.stack_ignore = [] self.init = False self.filename = "" - self.skip_until = None + self.skipper = Skipper() self.import_following = False self.last_step_was_class = False self.prev_num_frames = 0 @@ -348,22 +384,14 @@ def __init__(self, trace_socket): self.captured_stdout = io.StringIO() self.last_event = "" - def trace_dispatch(self, frame, event, arg): + def trace_dispatch(self, frame, event: str, arg): filename = frame.f_code.co_filename - # Skip built-in modules - # This might not be the best solution. Adjust if required. - skip = False - if self.skip_until is not None: - skip = filename != self.skip_until - elif not filename.startswith(os.path.dirname(self.filename)): - skip = True - if frame.f_back: - self.skip_until = frame.f_back.f_code.co_filename - if skip: + skipRes = self.skipper.handleEvent( + event, filename, not filename.startswith(os.path.dirname(self.filename)) + ) + if skipRes == 'SKIP': return self.trace_dispatch - else: - self.skip_until = None line = frame.f_lineno if not self.init: