139 lines
5.9 KiB
Python
139 lines
5.9 KiB
Python
# -*- coding: utf-8; -*-
|
|
'''Utilities related to writing macro expanders and similar meta-metaprogramming tasks.'''
|
|
|
|
__all__ = ['resolve_package', 'relativize', 'match_syspath',
|
|
'ismacroimport', 'get_macros']
|
|
|
|
from ast import ImportFrom
|
|
import importlib
|
|
import importlib.util
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
from .unparser import unparse_with_fallbacks
|
|
from .utils import format_location
|
|
|
|
def resolve_package(filename): # TODO: for now, `guess_package`, really. Check the docs again.
|
|
"""Resolve absolute Python package name for .py source file `filename`.
|
|
|
|
If `filename` is at the top level of the matching entry in `sys.path`, raises `ImportError`.
|
|
If `filename` is not under any directory in `sys.path`, raises `ValueError`.
|
|
"""
|
|
containing_directory = pathlib.Path(filename).expanduser().resolve().parent
|
|
root_path, relative_path = relativize(containing_directory)
|
|
if not relative_path: # at the root_path - not inside a package
|
|
absolute_filename = str(pathlib.Path(filename).expanduser().resolve())
|
|
resolved = f" (resolved to {absolute_filename})" if absolute_filename != str(filename) else ""
|
|
raise ImportError(f"{filename}{resolved} is not in a package, but at the root level of syspath {str(root_path)}")
|
|
package_dotted_name = relative_path.replace(os.path.sep, '.')
|
|
return package_dotted_name
|
|
|
|
|
|
def relativize(filename):
|
|
"""Convert `filename` into a relative one under the matching `sys.path`.
|
|
|
|
Return value is `(root_path, relative_path)`, where `root_path` is the
|
|
return value of `match_syspath` (a `pathlib.Path`), and `relative_path`
|
|
is a string containing the relative path of `filename` under `root_path`.
|
|
|
|
`filename` can be a .py source file or a package directory.
|
|
"""
|
|
absolute_filename = str(pathlib.Path(filename).expanduser().resolve())
|
|
root_path = match_syspath(absolute_filename)
|
|
relative_path = absolute_filename[len(str(root_path)):]
|
|
if relative_path.startswith(os.path.sep):
|
|
relative_path = relative_path[len(os.path.sep):]
|
|
return root_path, relative_path
|
|
|
|
|
|
def match_syspath(filename):
|
|
"""Return the entry in `sys.path` the `filename` is found under.
|
|
|
|
Return value is a `pathlib.Path` for the matching `sys.path` directory,
|
|
resolved to an absolute directory.
|
|
|
|
If `filename` is not under any directory in `sys.path`, raises `ValueError`.
|
|
"""
|
|
absolute_filename = str(pathlib.Path(filename).expanduser().resolve())
|
|
# We sort the paths in reverse order of length so a longer path matches
|
|
# before a shorter one.
|
|
for root_path in sorted(sys.path, key=len, reverse=True):
|
|
root_path = pathlib.Path(root_path).expanduser().resolve()
|
|
if absolute_filename.startswith(str(root_path)):
|
|
return root_path
|
|
resolved = f" (resolved to {absolute_filename})" if absolute_filename != str(filename) else ""
|
|
raise ValueError(f"{filename}{resolved} not under any directory in `sys.path`")
|
|
|
|
# --------------------------------------------------------------------------------
|
|
|
|
def ismacroimport(statement, magicname='macros'):
|
|
'''Return whether `statement` is a macro-import.
|
|
|
|
A macro-import is a statement of the form::
|
|
|
|
from ... import macros, ...
|
|
|
|
where "macros" is the literal string given as `magicname`.
|
|
'''
|
|
if isinstance(statement, ImportFrom):
|
|
firstimport = statement.names[0]
|
|
if firstimport.name == magicname and firstimport.asname is None:
|
|
return True
|
|
return False
|
|
|
|
|
|
def get_macros(macroimport, *, filename, reload=False, allow_asname=True):
|
|
'''Get absolute module name, macro names and macro functions from a macro-import.
|
|
|
|
As a side effect, import the macro definition module.
|
|
|
|
Return value is `module_absname, {macroname0: macrofunction0, ...}`.
|
|
|
|
Use the `reload` flag only when implementing a REPL, because it'll refresh modules,
|
|
causing different uses of the same macros to point to different function objects.
|
|
|
|
Use `allow_asname` to control whether your expander supports renaming macros
|
|
at the use site. Usually it's a good idea to support it; but e.g. renaming a
|
|
dialect makes no sense.
|
|
|
|
This function is meant for implementing actual macro expanders.
|
|
'''
|
|
package_absname = None
|
|
if macroimport.level and filename.endswith(".py"):
|
|
try:
|
|
package_absname = resolve_package(filename)
|
|
except (ValueError, ImportError) as err:
|
|
raise ImportError(f"while resolving absolute package name of {filename}, which uses relative macro-imports") from err
|
|
|
|
if macroimport.module is None:
|
|
# fallbacks may trigger if the macro-import is programmatically generated.
|
|
approx_sourcecode = unparse_with_fallbacks(macroimport)
|
|
loc = format_location(filename, 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)
|
|
|
|
try:
|
|
module = importlib.import_module(module_absname)
|
|
except ModuleNotFoundError as err:
|
|
approx_sourcecode = unparse_with_fallbacks(macroimport)
|
|
loc = format_location(filename, macroimport, approx_sourcecode)
|
|
raise ModuleNotFoundError(f"{loc}\nNo module named {module_absname}") from err
|
|
|
|
if reload:
|
|
module = importlib.reload(module)
|
|
|
|
bindings = {}
|
|
for name in macroimport.names[1:]:
|
|
if not allow_asname and name.asname is not None:
|
|
raise ImportError("This expander (see call stack) does not support as-naming macro-imports.")
|
|
|
|
try:
|
|
bindings[name.asname or name.name] = getattr(module, name.name)
|
|
except AttributeError as err:
|
|
approx_sourcecode = unparse_with_fallbacks(macroimport)
|
|
loc = format_location(filename, macroimport, approx_sourcecode)
|
|
raise ImportError(f"{loc}\ncannot import name '{name.name}' from module {module_absname}") from err
|
|
|
|
return module_absname, bindings
|