173 lines
7.2 KiB
Python
173 lines
7.2 KiB
Python
# -*- coding: utf-8; -*-
|
|
'''Importer (finder/loader) customizations, to inject the macro expander.'''
|
|
|
|
__all__ = ['source_to_xcode', 'path_xstats', 'invalidate_xcaches']
|
|
|
|
import ast
|
|
import importlib.util
|
|
from importlib.machinery import FileFinder, SourceFileLoader
|
|
import tokenize
|
|
import os
|
|
import pickle
|
|
import sys
|
|
|
|
from .core import MacroExpansionError
|
|
from .dialects import expand_dialects
|
|
from .expander import find_macros, expand_macros
|
|
from .coreutils import resolve_package, ismacroimport
|
|
from .markers import get_markers
|
|
from .unparser import unparse_with_fallbacks
|
|
from .utils import format_location
|
|
|
|
|
|
def source_to_xcode(self, data, path, *, _optimize=-1):
|
|
'''[mcpyrate] Expand dialects, then expand macros, then compile.
|
|
|
|
Intercepts the source to bytecode transformation.
|
|
'''
|
|
tree = expand_dialects(data, filename=path)
|
|
|
|
module_macro_bindings = find_macros(tree, filename=path)
|
|
expansion = expand_macros(tree, bindings=module_macro_bindings, filename=path)
|
|
|
|
remaining_markers = get_markers(expansion)
|
|
if remaining_markers:
|
|
raise MacroExpansionError("{path}: AST markers remaining after expansion: {remaining_markers}")
|
|
|
|
return compile(expansion, path, 'exec', dont_inherit=True, optimize=_optimize)
|
|
|
|
|
|
# TODO: Support PEP552 (Deterministic pycs). Need to intercept source file hashing, too.
|
|
# TODO: https://www.python.org/dev/peps/pep-0552/
|
|
_path_stats = SourceFileLoader.path_stats
|
|
_xstats_cache = {}
|
|
def path_xstats(self, path):
|
|
'''[mcpyrate] Compute a `.py` source file's mtime, accounting for macro-imports.
|
|
|
|
Beside the source file `path` itself, we look at any macro definition files
|
|
the source file imports macros from, recursively, in a `make`-like fashion.
|
|
|
|
The mtime is the latest of those of `path` and its macro-dependencies,
|
|
considered recursively, so that if any macro definition anywhere in the
|
|
macro-dependency tree of `path` is changed, Python will treat the source
|
|
file `path` as "changed", thus re-expanding and recompiling `path` (hence,
|
|
updating the corresponding `.pyc`).
|
|
|
|
If `path` does not end in `.py`, delegate to the standard implementation
|
|
of `SourceFileLoader.path_stats`.
|
|
'''
|
|
if not path.endswith(".py"):
|
|
return _path_stats(path)
|
|
if path in _xstats_cache:
|
|
return _xstats_cache[path]
|
|
|
|
stat_result = os.stat(path)
|
|
|
|
# Try for cached macro-import statements for `path` to avoid the parse cost.
|
|
#
|
|
# This is a single node in the dependency graph; the result depends only
|
|
# on the content of the source file `path` itself. So we invalidate the
|
|
# macro-import statement cache for `path` based on the mtime of `path` only.
|
|
#
|
|
# For a given source file `path`, the `.pyc` sometimes becomes newer than
|
|
# the macro-dependency cache. This is normal. Unlike the bytecode, the
|
|
# macro-dependency cache only needs to be refreshed when the text of the
|
|
# source file `path` changes.
|
|
#
|
|
# So if some of the macro-dependency source files have changed (so `path`
|
|
# must be re-expanded and recompiled), but `path` itself hasn't, the text
|
|
# of the source file `path` will still have the same macro-imports it did
|
|
# last time.
|
|
#
|
|
pycpath = importlib.util.cache_from_source(path)
|
|
if pycpath.endswith(".pyc"):
|
|
pycpath = pycpath[:-4]
|
|
importcachepath = pycpath + ".mcpyrate.pickle"
|
|
try:
|
|
cache_valid = False
|
|
with open(importcachepath, "rb") as importcachefile:
|
|
data = pickle.load(importcachefile)
|
|
if data["st_mtime_ns"] == stat_result.st_mtime_ns:
|
|
cache_valid = True
|
|
except Exception:
|
|
pass
|
|
|
|
if cache_valid:
|
|
macro_and_dialect_imports = data["macroimports"] + data["dialectimports"]
|
|
has_relative_macroimports = data["has_relative_macroimports"]
|
|
else:
|
|
# This can be slow, the point of `.pyc` is to avoid the parse-and-compile cost.
|
|
# We do save the macro-expansion cost, though, and that's likely much more expensive.
|
|
#
|
|
# TODO: Dialects may inject imports in the template that the dialect transformer itself
|
|
# TODO: doesn't need. How to detect those? Regex-search the source text?
|
|
with tokenize.open(path) as sourcefile:
|
|
tree = ast.parse(sourcefile.read())
|
|
macroimports = [stmt for stmt in tree.body if ismacroimport(stmt)]
|
|
dialectimports = [stmt for stmt in tree.body if ismacroimport(stmt, magicname="dialects")]
|
|
|
|
macro_and_dialect_imports = macroimports + dialectimports
|
|
has_relative_macroimports = any(macroimport.level for macroimport in macro_and_dialect_imports)
|
|
|
|
# macro-import statement cache goes with the .pyc
|
|
if not sys.dont_write_bytecode:
|
|
data = {"st_mtime_ns": stat_result.st_mtime_ns,
|
|
"macroimports": macroimports,
|
|
"dialectimports": dialectimports,
|
|
"has_relative_macroimports": has_relative_macroimports}
|
|
try:
|
|
with open(importcachepath, "wb") as importcachefile:
|
|
pickle.dump(data, importcachefile)
|
|
except Exception:
|
|
pass
|
|
|
|
# The rest of the lookup process depends on the configuration of the currently
|
|
# running Python, particularly its `sys.path`, so we do it dynamically.
|
|
#
|
|
# TODO: some duplication with code in mcpyrate.coreutils.get_macros, including the error messages.
|
|
package_absname = None
|
|
if has_relative_macroimports:
|
|
try:
|
|
package_absname = resolve_package(path)
|
|
except (ValueError, ImportError) as err:
|
|
raise ImportError(f"while resolving absolute package name of {path}, which uses relative macro-imports") from err
|
|
|
|
mtimes = []
|
|
for macroimport in macro_and_dialect_imports:
|
|
if macroimport.module is None:
|
|
approx_sourcecode = unparse_with_fallbacks(macroimport)
|
|
loc = format_location(path, macroimport, approx_sourcecode)
|
|
raise SyntaxError(f"{loc}\nmissing module name in macro-import")
|
|
module_absname = importlib.util.resolve_name('.' * macroimport.level + macroimport.module, package_absname)
|
|
|
|
spec = importlib.util.find_spec(module_absname)
|
|
origin = spec.origin
|
|
stats = path_xstats(self, origin)
|
|
mtimes.append(stats['mtime'])
|
|
|
|
mtime = stat_result.st_mtime_ns * 1e-9
|
|
# size = stat_result.st_size
|
|
mtimes.append(mtime)
|
|
|
|
result = {'mtime': max(mtimes)} # and sum(sizes)? OTOH, as of Python 3.8, only 'mtime' is mandatory.
|
|
if sys.version_info >= (3, 7, 0):
|
|
# Docs say `size` is optional, and this is correct in 3.6 (and in PyPy3 7.3.0):
|
|
# https://docs.python.org/3/library/importlib.html#importlib.abc.SourceLoader.path_stats
|
|
#
|
|
# but in 3.7 and later, the implementation is expecting at least a `None` there,
|
|
# if the `size` is not used. See `get_code` in:
|
|
# https://github.com/python/cpython/blob/master/Lib/importlib/_bootstrap_external.py
|
|
result['size'] = None
|
|
_xstats_cache[path] = result
|
|
return result
|
|
|
|
|
|
_invalidate_caches = FileFinder.invalidate_caches
|
|
def invalidate_xcaches(self):
|
|
'''[mcpyrate] Clear the macro dependency tree cache.
|
|
|
|
Then delegate to the standard implementation of `FileFinder.invalidate_caches`.
|
|
'''
|
|
_xstats_cache.clear()
|
|
return _invalidate_caches(self)
|