1"""Cache lines from Python source files. 2 3This is intended to read lines from modules imported -- hence if a filename 4is not found, it will look down the module search path for a file by 5that name. 6""" 7 8import functools 9import sys 10import os 11import tokenize 12 13__all__ = ["getline", "clearcache", "checkcache", "lazycache"] 14 15 16# The cache. Maps filenames to either a thunk which will provide source code, 17# or a tuple (size, mtime, lines, fullname) once loaded. 18cache = {} 19 20 21def clearcache(): 22 """Clear the cache entirely.""" 23 cache.clear() 24 25 26def getline(filename, lineno, module_globals=None): 27 """Get a line for a Python source file from the cache. 28 Update the cache if it doesn't contain an entry for this file already.""" 29 30 lines = getlines(filename, module_globals) 31 if 1 <= lineno <= len(lines): 32 return lines[lineno - 1] 33 return '' 34 35 36def getlines(filename, module_globals=None): 37 """Get the lines for a Python source file from the cache. 38 Update the cache if it doesn't contain an entry for this file already.""" 39 40 if filename in cache: 41 entry = cache[filename] 42 if len(entry) != 1: 43 return cache[filename][2] 44 45 try: 46 return updatecache(filename, module_globals) 47 except MemoryError: 48 clearcache() 49 return [] 50 51 52def checkcache(filename=None): 53 """Discard cache entries that are out of date. 54 (This is not checked upon each call!)""" 55 56 if filename is None: 57 filenames = list(cache.keys()) 58 elif filename in cache: 59 filenames = [filename] 60 else: 61 return 62 63 for filename in filenames: 64 entry = cache[filename] 65 if len(entry) == 1: 66 # lazy cache entry, leave it lazy. 67 continue 68 size, mtime, lines, fullname = entry 69 if mtime is None: 70 continue # no-op for files loaded via a __loader__ 71 try: 72 stat = os.stat(fullname) 73 except OSError: 74 cache.pop(filename, None) 75 continue 76 if size != stat.st_size or mtime != stat.st_mtime: 77 cache.pop(filename, None) 78 79 80def updatecache(filename, module_globals=None): 81 """Update a cache entry and return its list of lines. 82 If something's wrong, print a message, discard the cache entry, 83 and return an empty list.""" 84 85 if filename in cache: 86 if len(cache[filename]) != 1: 87 cache.pop(filename, None) 88 if not filename or (filename.startswith('<') and filename.endswith('>')): 89 return [] 90 91 fullname = filename 92 try: 93 stat = os.stat(fullname) 94 except OSError: 95 basename = filename 96 97 # Realise a lazy loader based lookup if there is one 98 # otherwise try to lookup right now. 99 if lazycache(filename, module_globals): 100 try: 101 data = cache[filename][0]() 102 except (ImportError, OSError): 103 pass 104 else: 105 if data is None: 106 # No luck, the PEP302 loader cannot find the source 107 # for this module. 108 return [] 109 cache[filename] = ( 110 len(data), 111 None, 112 [line + '\n' for line in data.splitlines()], 113 fullname 114 ) 115 return cache[filename][2] 116 117 # Try looking through the module search path, which is only useful 118 # when handling a relative filename. 119 if os.path.isabs(filename): 120 return [] 121 122 for dirname in sys.path: 123 try: 124 fullname = os.path.join(dirname, basename) 125 except (TypeError, AttributeError): 126 # Not sufficiently string-like to do anything useful with. 127 continue 128 try: 129 stat = os.stat(fullname) 130 break 131 except OSError: 132 pass 133 else: 134 return [] 135 try: 136 with tokenize.open(fullname) as fp: 137 lines = fp.readlines() 138 except OSError: 139 return [] 140 if lines and not lines[-1].endswith('\n'): 141 lines[-1] += '\n' 142 size, mtime = stat.st_size, stat.st_mtime 143 cache[filename] = size, mtime, lines, fullname 144 return lines 145 146 147def lazycache(filename, module_globals): 148 """Seed the cache for filename with module_globals. 149 150 The module loader will be asked for the source only when getlines is 151 called, not immediately. 152 153 If there is an entry in the cache already, it is not altered. 154 155 :return: True if a lazy load is registered in the cache, 156 otherwise False. To register such a load a module loader with a 157 get_source method must be found, the filename must be a cacheable 158 filename, and the filename must not be already cached. 159 """ 160 if filename in cache: 161 if len(cache[filename]) == 1: 162 return True 163 else: 164 return False 165 if not filename or (filename.startswith('<') and filename.endswith('>')): 166 return False 167 # Try for a __loader__, if available 168 if module_globals and '__name__' in module_globals: 169 name = module_globals['__name__'] 170 if (loader := module_globals.get('__loader__')) is None: 171 if spec := module_globals.get('__spec__'): 172 try: 173 loader = spec.loader 174 except AttributeError: 175 pass 176 get_source = getattr(loader, 'get_source', None) 177 178 if name and get_source: 179 get_lines = functools.partial(get_source, name) 180 cache[filename] = (get_lines,) 181 return True 182 return False 183