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
2 changes: 1 addition & 1 deletion .github/workflows/github-action-test-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions python/allTests
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ function run()

PYENV_VERSION=3.12 run
PYENV_VERSION=3.13 run
PYENV_VERSION=3.14 run
30 changes: 25 additions & 5 deletions python/code/wypp/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions python/code/wypp/runCode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 42 additions & 14 deletions pytrace-generator/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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:
Expand Down