"""source_cache.py
Used to cache and retrieve source code.
This is especially useful when a custom REPL is used.
Note that we monkeypatch Python's linecache.getlines.
"""
import inspect
import linecache
import time
from typing import Any, Dict, Generator, List, Optional
import stack_data
old_getlines = linecache.getlines # To be monkeypatched.
class Cache:
"""Class used to store source of files and similar objects"""
def __init__(self) -> None:
self.local_cache: Dict[str, List[str]] = {}
self.context = 4
def add(self, filename: str, source: str) -> None:
"""Adds a source (received as a string) corresponding to a filename
in the cache.
The filename can be a true file name, or a fake one, like
<friendly-console:42>, used for saving an REPL entry.
These fake filenames might not be retrieved by Python's linecache
which is why we keep a duplicate of anything we add to linecache.cache
"""
# filename could be a Path object,
# which does not have a startswith() method used below
filename = str(filename)
self.remove(filename)
lines = [line + "\n" for line in source.splitlines()]
entry = (len(source), time.time(), lines, filename)
# mypy cannot get the type information from linecache in stdlib
linecache.cache[filename] = entry
self.local_cache[filename] = lines
def remove(self, filename: str) -> None:
"""Removes an entry from the cache if it can be found."""
if filename in self.local_cache:
del self.local_cache[filename]
if filename in linecache.cache:
del linecache.cache[filename]
# clear stack_data cache so it pulls fresh lines from linecache
stack_data.Source._class_local("__source_cache", {}).pop(filename, None)
def get_source_lines(
self, filename: str, module_globals: Optional[Dict[str, Any]] = None
) -> List[str]:
"""Given a filename, returns the corresponding source, either
from the cache or from actually opening the file.
If the filename corresponds to a true file, and the last time
it was modified differs from the recorded value, a fresh copy
is retrieved.
The contents is stored as a string and returned as a list of lines,
each line ending with a newline character.
"""
lines = old_getlines(filename, module_globals=module_globals)
if not lines and filename in self.local_cache:
lines = self.local_cache[filename]
if not lines: # can happen for f-strings and frozen modules
lines = []
# Adding ["\n"] is required when dealing with EOF errors
# Do not use append; see #174.
return lines + ["\n"]
cache = Cache()
# Monkeypatch linecache to make our own cached content available to Python.
linecache.getlines = cache.get_source_lines
def _counter() -> Generator[int, None, None]:
num = 0
while True:
yield num
num += 1
counter = _counter()
[docs]def friendly_exec(
source: Any,
globals_: Optional[Dict[str, None]] = None,
locals_: Optional[Dict[str, None]] = None,
) -> None:
"""A version of exec that uses a different filename each time
instead of the Python default '<string>', and caches the source.
This makes it possible to provide more help on code executed via 'exec'.
"""
# We use globals_ instead of globals as an argument name
# (and similarly for locals_)
# because friendly_traceback would give a warning about redefining
# the builtins globals and locals if an exception were to be raised.
# Note: if locals_ is None, we do not want to assign variables to
# locals() defined inside this function, or globals() defined in this
# module, but rather to that of the calling scope which is what exec does.
frame = inspect.getouterframes(inspect.currentframe())[1].frame
true_globals = frame.f_globals
true_locals = frame.f_locals
if globals_ is None:
globals_ = true_globals
if locals_ is None:
locals_ = true_locals
else:
if locals_ is None:
locals_ = true_globals
# Let any exception bubble up: they will be correctly handled
# by friendly-traceback
if not isinstance(source, str):
return exec(source, globals_, locals_)
filename = "<friendly-exec-%d>" % next(counter)
cache.add(filename, source)
code = compile(source, filename, "exec")
return exec(code, globals_, locals_)