diff --git a/README.rst b/README.rst index 19ce623..a8c06c0 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ Python3. API --- -Basically Beniget provides three analyse: +Basically Beniget provides three analyses: - ``beniget.Ancestors`` that maps each node to the list of enclosing nodes; - ``beniget.DefUseChains`` that maps each node to the list of definition points in that node; diff --git a/beniget/__init__.py b/beniget/__init__.py index 8e35334..aa2d3c4 100644 --- a/beniget/__init__.py +++ b/beniget/__init__.py @@ -1,3 +1,4 @@ from __future__ import absolute_import from beniget.version import __version__ -from beniget.beniget import Ancestors, DefUseChains, UseDefChains +from beniget.beniget import (Ancestors, DefUseChains, UseDefChains, Def, + parse_import, ImportInfo) diff --git a/beniget/beniget.py b/beniget/beniget.py index 9f45161..bb49578 100644 --- a/beniget/beniget.py +++ b/beniget/beniget.py @@ -1,6 +1,8 @@ -from collections import defaultdict +from collections import defaultdict, OrderedDict, deque from contextlib import contextmanager import sys +import platform +import os.path import gast as ast @@ -56,6 +58,106 @@ def parentFunction(self, node): def parentStmt(self, node): return self.parentInstance(node, ast.stmt) +class ImportInfo: + """ + Complement an `ast.alias` node with resolved + origin module and name of the locally bound name. + + :note: `orgname` will be ``*`` for wildcard imports. + """ + __slots__ = 'orgmodule', 'orgname' + + def __init__(self, orgmodule, orgname=None): + """ + :param orgmodule: str + :param orgname: str or None + """ + self.orgmodule = orgmodule + self.orgname = orgname + + def target(self): + """ + Returns the qualified name of the the imported symbol, str. + """ + if self.orgname: + return "{}.{}".format(self.orgmodule, self.orgname) + else: + return self.orgmodule + +_alias_needs_lineno = platform.python_implementation().lower() == 'cpython' and sys.version_info < (3,10) + +# The MIT License (MIT) +# Copyright (c) 2017 Jelle Zijlstra +# Adapted from the project typeshed_client. +def parse_import(node, modname, is_package=False): + """ + Parse the given import node into a mapping of aliases to `ImportInfo`. + + :param node: the import node. + :param str modname: the name of the module. + :param bool is_package: whether the module is a package. + :rtype: dict[ast.alias, ImportInfo] + """ + result = {} + + + # This seems to be the most resonable place to fix the ast.alias node not having + # proper line number information on python3.9 and before. + if _alias_needs_lineno: + for alias in node.names: + alias.lineno = node.lineno + + if isinstance(node, ast.Import): + for al in node.names: + if al.asname: + result[al] = ImportInfo(orgmodule=al.name) + else: + # Here, we're not including information + # regarding the submodules imported - if there is one. + # This is because this analysis map the names bounded by imports, + # not the dependencies. + result[al] = ImportInfo(orgmodule=al.name.split(".", 1)[0]) + + elif isinstance(node, ast.ImportFrom): + current_module = tuple(modname.split(".")) + + if node.module is None: + module = () + else: + module = tuple(node.module.split(".")) + + if not node.level: + source_module = module + else: + # parse relative imports + if node.level == 1: + if is_package: + relative_module = current_module + else: + relative_module = current_module[:-1] + else: + if is_package: + relative_module = current_module[: 1 - node.level] + else: + relative_module = current_module[: -node.level] + + if not relative_module: + # We don't raise errors when an relative import makes no sens, + # we simply pad the name with dots. + relative_module = ("",) * node.level + + source_module = relative_module + module + + for alias in node.names: + result[alias] = ImportInfo( + orgmodule=".".join(source_module), orgname=alias.name + ) + + else: + raise TypeError('unexpected node type: {}'.format(type(node))) + + return result + class Def(object): """ @@ -234,13 +336,73 @@ def collect_locals(node): visitor.generic_visit(node) return visitor.Locals +def posixpath_splitparts(path): + """ + Split a POSIX filename in parts. + + >>> posixpath_splitparts('typing.pyi') + ('typing.pyi',) + + >>> posixpath_splitparts('/var/lib/config.ini') + ('var', 'lib', 'config.ini') + + >>> posixpath_splitparts('/var/lib/config/') + ('var', 'lib', 'config') + + >>> posixpath_splitparts('c:/dir/config.ini') + ('c:', 'dir', 'config.ini') + """ + sep = '/' + r = deque(path.split(sep)) + # make sure the parts doesn't + # start or ends with a separator or empty string. + while r and r[0] in (sep, ''): + r.popleft() + while r and r[-1] in (sep, ''): + r.pop() + return tuple(r) + +def potential_module_names(filename): + """ + Returns a tuple of potential module + names deducted from the filename. + + >>> potential_module_names('/var/lib/config.py') + ('var.lib.config', 'lib.config', 'config') + >>> potential_module_names('git-repos/pydoctor/pydoctor/driver.py') + ('pydoctor.pydoctor.driver', 'pydoctor.driver', 'driver') + >>> potential_module_names('git-repos/pydoctor/pydoctor/__init__.py') + ('pydoctor.pydoctor', 'pydoctor') + """ + parts = posixpath_splitparts(filename) + mod = os.path.splitext(parts[-1])[0] + if mod == '__init__': + parts = parts[:-1] + else: + parts = parts[:-1] + (mod,) + + names = [] + len_parts = len(parts) + for i in range(len_parts): + p = parts[i:] + if not p or any(not all(sb.isidentifier() + for sb in s.split('.')) for s in p): + # the path cannot be converted to a module name + # because there are unallowed caracters. + continue + names.append('.'.join(p)) + + return tuple(names) or ('',) + class DefUseChains(ast.NodeVisitor): """ Module visitor that gathers two kinds of informations: - - locals: Dict[node, List[Def]], a mapping between a node and the list + - locals: dict[node, list[Def]], a mapping between a node and the list of variable defined in this node, - - chains: Dict[node, Def], a mapping between nodes and their chains. + - chains: dict[node, Def], a mapping between nodes and their chains. + - imports: dict[node, ImportInfo], a mapping between import aliases + and their resolved target. >>> import gast as ast >>> module = ast.parse("from b import c, d; c()") @@ -257,14 +419,61 @@ class DefUseChains(ast.NodeVisitor): One instance of DefUseChains is only suitable to analyse one AST Module in it's lifecycle. """ - def __init__(self, filename=None): + def __init__(self, + filename=None, + modname=None, + future_annotations=False, + is_stub=False): """ - - filename: str, included in error messages if specified + - filename: str, POSIX-like path pointing to the source file, + you can use `Path.as_posix` to ensure the value has proper format. + It's recommended to either provide the filename of the source + relative to the root of the package or provide both + a module name and a filename. + Included in error messages and used as part of the import resolving. + - modname: str, fully qualified name of the module we're analysing. + A module name may end with '.__init__' to indicate the module is a package. + - future_annotations: bool, PEP 563 mode. + It will auotmatically be enabled if the module has ``from __future__ import annotations``. + - is_stub: bool, stub module semantics mode, implies future_annotations=True. + It will auotmatically be enabled if the filename endswith '.pyi'. + When the module is a stub file, there is no need for quoting to do a forward reference + inside: + - annotations (like PEP 563 mode) + - `TypeAlias`` values + - ``TypeVar()`` call arguments + - classe base expressions, keywords and decorators + - function decorators """ self.chains = {} self.locals = defaultdict(list) + # mapping from ast.alias to their ImportInfo. + self.imports = {} self.filename = filename + self.is_stub = is_stub or filename is not None and filename.endswith('.pyi') + + # determine module name, we provide some flexibility: + # - The module name is not required to have correct parsing when the + # filename is a relative filename that starts at the package root. + # - We deduce whether the module is a package from module name or filename + # if they ends with __init__. + # - The module name doesn't have to be provided to use _is_qualname() + # if filename is provided. + is_package = False + if filename and posixpath_splitparts(filename)[-1].split('.')[0] == '__init__': + is_package = True + if modname: + if modname.endswith('.__init__'): + modname = modname[:-9] # strip __init__ + is_package = True + self._modnames = (modname, ) + elif filename: + self._modnames = potential_module_names(filename) + else: + self._modnames = ('', ) + self.modname = next(iter(self._modnames)) + self.is_package = is_package # deep copy of builtins, to remain reentrant self._builtins = {k: Def(v) for k, v in Builtins.items()} @@ -306,11 +515,53 @@ def __init__(self, filename=None): # attributes set in visit_Module self.module = None - self.future_annotations = False + self.future_annotations = self.is_stub or future_annotations # ## helpers # + + def _is_qualname(self, expr, qnames): + """ + Returns True if - one of - the expression's definition(s) matches + one of the given qualified names. + + The expression definition is looked up with + `lookup_annotation_name_defs`. + """ + + if isinstance(expr, ast.Name): + try: + defs = lookup_annotation_name_defs( + expr.id, self._scopes, self.locals) + except Exception: + return False + + for d in defs: + if isinstance(d.node, ast.alias): + # the symbol is an imported name + import_alias = self.imports[d.node].target() + if any(import_alias == n for n in qnames): + return True + elif any('{}.{}'.format(mod, d.name()) in qnames for mod in self._modnames): + # the symbol is a localy defined name + return True + else: + # localy defined name, but module name doesn't match + break + + elif isinstance(expr, ast.Attribute): + for n in qnames: + mod, _, _name = n.rpartition('.') + if mod and expr.attr == _name: + if self._is_qualname(expr.value, set((mod,))): + return True + return False + + def _is_typing_name(self, expr, name): + return self._is_qualname(expr, set(('typing.{}'.format(name), + 'typing_extensions.{}'.format(name)))) + def _dump_locals(self, node, only_live=False): """ Like `dump_definitions` but returns the result grouped by symbol name and it includes linenos. @@ -679,8 +930,13 @@ def visit_FunctionDef(self, node, step=DeclarationStep): self.visit(kw_default).add_user(dnode) for default in node.args.defaults: self.visit(default).add_user(dnode) - for decorator in node.decorator_list: - self.visit(decorator) + if self.is_stub: + for decorator in node.decorator_list: + self._defered_annotations[-1].append(( + decorator, currentscopes, None)) + else: + for decorator in node.decorator_list: + self.visit(decorator) if not self.future_annotations and node.returns: self.visit(node.returns) @@ -706,12 +962,26 @@ def visit_ClassDef(self, node): dnode = self.chains.setdefault(node, Def(node)) self.add_to_locals(node.name, dnode) - for base in node.bases: - self.visit(base).add_user(dnode) - for keyword in node.keywords: - self.visit(keyword.value).add_user(dnode) - for decorator in node.decorator_list: - self.visit(decorator).add_user(dnode) + if self.is_stub: + # special treatment for classes in stub modules + # so they can contain forward-references. + currentscopes = list(self._scopes) + for base in node.bases: + self._defered_annotations[-1].append(( + base, currentscopes, lambda dbase: dbase.add_user(dnode))) + for keyword in node.keywords: + self._defered_annotations[-1].append(( + keyword.value, currentscopes, lambda dkeyword: dkeyword.add_user(dnode))) + for decorator in node.decorator_list: + self._defered_annotations[-1].append(( + decorator, currentscopes, lambda ddecorator: ddecorator.add_user(dnode))) + else: + for base in node.bases: + self.visit(base).add_user(dnode) + for keyword in node.keywords: + self.visit(keyword.value).add_user(dnode) + for decorator in node.decorator_list: + self.visit(decorator).add_user(dnode) with self.ScopeContext(node): self.set_definition("__class__", Def("__class__")) @@ -743,15 +1013,22 @@ def visit_Assign(self, node): self.visit(node.value) for target in node.targets: self.visit(target) - + def visit_AnnAssign(self, node): - if node.value: - self.visit(node.value) + if (self.is_stub and node.value and self._is_typing_name( + node.annotation, 'TypeAlias')): + # support for PEP 613 - Explicit Type Aliases + # BUT an untyped global expression 'x=int' will NOT be considered a type alias. + self._defered_annotations[-1].append( + (node.value, list(self._scopes), None)) + elif node.value: + dvalue = self.visit(node.value) + if not self.future_annotations: self.visit(node.annotation) else: self._defered_annotations[-1].append( - (node.annotation, list(self._scopes), None)) + (node.annotation, list(self._scopes), None)) self.visit(node.target) def visit_AugAssign(self, node): @@ -930,6 +1207,7 @@ def visit_Import(self, node): base = alias.name.split(".", 1)[0] self.set_definition(alias.asname or base, dalias) self.add_to_locals(alias.asname or base, dalias) + self.imports.update(parse_import(node, self.modname, is_package=self.is_package)) def visit_ImportFrom(self, node): for alias in node.names: @@ -939,6 +1217,7 @@ def visit_ImportFrom(self, node): else: self.set_definition(alias.asname or alias.name, dalias) self.add_to_locals(alias.asname or alias.name, dalias) + self.imports.update(parse_import(node, self.modname, is_package=self.is_package)) def visit_Global(self, node): for name in node.names: @@ -1078,10 +1357,23 @@ def visit_Compare(self, node): def visit_Call(self, node): dnode = self.chains.setdefault(node, Def(node)) self.visit(node.func).add_user(dnode) - for arg in node.args: - self.visit(arg).add_user(dnode) - for kw in node.keywords: - self.visit(kw.value).add_user(dnode) + if self.is_stub and self._is_typing_name(node.func, 'TypeVar'): + # In stubs, constraints and bound argument + # of TypeVar() can be forward references. + current_scopes = list(self._scopes) + for arg in node.args: + self._defered_annotations[-1].append( + (arg, current_scopes, + lambda darg:darg.add_user(dnode))) + for kw in node.keywords: + self._defered_annotations[-1].append( + (kw.value, current_scopes, + lambda dkw:dkw.add_user(dnode))) + else: + for arg in node.args: + self.visit(arg).add_user(dnode) + for kw in node.keywords: + self.visit(kw.value).add_user(dnode) return dnode visit_Repr = visit_Await diff --git a/tests/test_chains.py b/tests/test_chains.py index a388867..66fc928 100644 --- a/tests/test_chains.py +++ b/tests/test_chains.py @@ -21,7 +21,8 @@ def captured_output(): class TestDefUseChains(TestCase): - def checkChains(self, code, ref, strict=True): + def checkChains(self, code, ref, strict=True, is_stub=False, + filename=None, modname=None): class StrictDefUseChains(beniget.DefUseChains): def warn(self, msg, node): raise RuntimeError( @@ -32,9 +33,12 @@ def warn(self, msg, node): node = ast.parse(code) if strict: - c = StrictDefUseChains() + c = StrictDefUseChains(is_stub=is_stub, + filename=filename, modname=modname) else: - c = beniget.DefUseChains() + c = beniget.DefUseChains(is_stub=is_stub, + filename=filename, modname=modname) + c.visit(node) self.assertEqual(c.dump_chains(node), ref) return node, c @@ -1237,7 +1241,165 @@ class A: strict=False ) - @skipIf(sys.version_info.major < 3, "Python 3 syntax") + def test_stubs_generic_base_forward_ref(self): + code = ''' +Thing = object +class _ScandirIterator(str, int, Thing[_ScandirIterator[F]], object): + ... +class C(F, k=H): + ... +F = H = object +''' + self.checkChains( + code, + ['Thing -> (Thing -> (Subscript -> (_ScandirIterator -> (_ScandirIterator -> (Subscript -> ((#2)))))))', + '_ScandirIterator -> (_ScandirIterator -> (Subscript -> (Subscript -> ((#0)))))', + 'C -> ()', + 'F -> (F -> (Subscript -> (Subscript -> (_ScandirIterator -> (_ScandirIterator -> ((#2)))))), F -> (C -> ()))', + 'H -> (H -> (C -> ()))'], + is_stub=True + ) + + self.checkChains( + code, + ['Thing -> (Thing -> (Subscript -> (_ScandirIterator -> ())))', + '_ScandirIterator -> ()', + 'C -> ()', + 'F -> ()', + 'H -> ()'], + strict=False, + ) + + def test_stubs_forward_ref(self): + code = ''' +from typing import TypeAlias +LiteralValue: TypeAlias = list[LiteralValue]|object +''' + self.checkChains( + code, + ['TypeAlias -> (TypeAlias -> ())', + 'LiteralValue -> (LiteralValue -> (Subscript -> (BinOp -> ())))'], + is_stub=True + ) + self.checkChains( + code.replace('typing', 'typing_extensions'), + ['TypeAlias -> (TypeAlias -> ())', + 'LiteralValue -> (LiteralValue -> (Subscript -> (BinOp -> ())))'], + is_stub=True + ) + self.checkChains( + code, + ['TypeAlias -> (TypeAlias -> ())', + 'LiteralValue -> ()'], + strict=False, + ) + + def test_stubs_typevar_forward_ref(self): + code = ''' +from typing import TypeVar +AnyStr = TypeVar('AnyStr', F, bound=ast.AST) +import ast +F = object +''' + self.checkChains( + code, + ['TypeVar -> (TypeVar -> (Call -> ()))', + 'AnyStr -> ()', + 'ast -> (ast -> (Attribute -> (Call -> ())))', + 'F -> (F -> (Call -> ()))'], + is_stub=True + ) + self.checkChains( + code, + ['TypeVar -> (TypeVar -> (Call -> ()))', 'AnyStr -> ()', 'ast -> ()', 'F -> ()'], + strict=False, + ) + + # c.TypeVar is not recognized as being typing.TypeVar. + self.checkChains( + code.replace('from typing import TypeVar', 'from c import TypeVar'), + ['TypeVar -> (TypeVar -> (Call -> ()))', 'AnyStr -> ()', 'ast -> ()', 'F -> ()'], + is_stub=True, + strict=False, + ) + + def test_stubs_typevar_typing_pyi(self): + code = ''' +class TypeVar: pass +AnyStr = TypeVar('AnyStr', F) +F = object +''' + self.checkChains( + code, + ['TypeVar -> (TypeVar -> (Call -> ()))', + 'AnyStr -> ()', + 'F -> (F -> (Call -> ()))'], + is_stub=True, + filename='typing.pyi', + ) + self.checkChains( + code, + ['TypeVar -> (TypeVar -> (Call -> ()))', + 'AnyStr -> ()', + 'F -> (F -> (Call -> ()))'], + is_stub=True, + modname='typing', + ) + self.checkChains( + code, + ['TypeVar -> (TypeVar -> (Call -> ()))', + 'AnyStr -> ()', + 'F -> (F -> (Call -> ()))'], + is_stub=True, + filename='/home/dev/projects/typeshed_client/typeshed/typing.pyi', + ) + + # When geniget doesn't know we're analysing the typing module, it cannot link + # TypeVar to typing.TypeVar, so this special stub semantics doesn't apply. + self.checkChains( + code, + ['TypeVar -> (TypeVar -> (Call -> ()))', 'AnyStr -> ()', 'F -> ()'], + is_stub=True, + strict=False, + ) + + def test_stubs_typealias_typing_pyi(self): + code = ''' +TypeAlias: object +LiteralValue: TypeAlias = list[LiteralValue]|object +''' + self.checkChains( + code, + ['TypeAlias -> (TypeAlias -> ())', + 'LiteralValue -> (LiteralValue -> (Subscript -> (BinOp -> ())))'], + is_stub=True, + filename='typing.pyi', + ) + + def test_stubs_class_decorators(self): + code = ''' +@dataclass_transform +class Thing: + x: int +def dataclass_transform(f):... +''' + self.checkChains( + code, + ['Thing -> ()', 'dataclass_transform -> (dataclass_transform -> (Thing -> ()))'], + filename='some.pyi') + + def test_stubs_function_decorators(self): + code = ''' +class Thing: + @property + def x(self) -> int:... +def property(f):... +''' + self.checkChains( + code, + ['Thing -> ()', 'property -> (property -> ())'], + filename='some.pyi') + def test_annotation_def_is_not_assign_target(self): code = 'from typing import Optional; var:Optional' self.checkChains(code, ['Optional -> (Optional -> ())', @@ -1267,3 +1429,43 @@ def test_simple_expression(self): def test_call(self): code = "from foo import bar; bar(1, 2)" self.checkChains(code, "Call <- {Constant, Constant, bar}, bar <- {bar}") + +class TestDefUseChainsUnderstandsFilename(TestCase): + + def test_potential_module_names(self): + from beniget.beniget import potential_module_names + self.assertEqual(potential_module_names('/var/lib/config.py'), + ('var.lib.config', 'lib.config', 'config')) + self.assertEqual(potential_module_names('git-repos/pydoctor/pydoctor/driver.py'), + ('pydoctor.pydoctor.driver', 'pydoctor.driver', 'driver')) + self.assertEqual(potential_module_names('git-repos/pydoctor/pydoctor/__init__.py'), + ('pydoctor.pydoctor', 'pydoctor')) + + def test_def_use_chains_init_modname(self): + self.assertEqual(beniget.DefUseChains( + 'typing.pyi').modname, 'typing') + self.assertEqual(beniget.DefUseChains( + 'beniget/beniget.py').modname, 'beniget.beniget') + self.assertEqual(beniget.DefUseChains( + '/root/repos/beniget/beniget/beniget.py', + modname='beniget.__init__').modname, 'beniget') + + def test_def_use_chains_init_is_stub(self): + self.assertEqual(beniget.DefUseChains( + 'typing.pyi').is_stub, True) + self.assertEqual(beniget.DefUseChains( + 'beniget/beniget.py').is_stub, False) + self.assertEqual(beniget.DefUseChains( + '/root/repos/beniget/beniget/beniget.pyi').is_stub, True) + self.assertEqual(beniget.DefUseChains( + 'beniget/beniget.py', is_stub=True).is_stub, True) + + def test_def_use_chains_init_is_package(self): + self.assertEqual(beniget.DefUseChains( + 'typing.pyi').is_package, False) + self.assertEqual(beniget.DefUseChains( + 'beniget/beniget.py').is_package, False) + self.assertEqual(beniget.DefUseChains( + '/root/repos/beniget/beniget/__init__.pyi').is_package, True) + self.assertEqual(beniget.DefUseChains( + 'beniget/beniget/', modname='beniget.__init__').is_package, True) \ No newline at end of file diff --git a/tests/test_definitions.py b/tests/test_definitions.py index e9f956a..13a81ee 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -468,10 +468,7 @@ def test_BothLive(self): else: _PY37PLUS = False ''' - if sys.version_info>=(3,10): - self.checkLiveLocals(code, ["sys:2", "_PY37PLUS:4,6"], ["sys:2", "_PY37PLUS:4,6"]) - else: - self.checkLiveLocals(code, ["sys:None", "_PY37PLUS:4,6"], ["sys:None", "_PY37PLUS:4,6"]) + self.checkLiveLocals(code, ["sys:2", "_PY37PLUS:4,6"], ["sys:2", "_PY37PLUS:4,6"]) def test_BuiltinNameRedefConditional(self): code = ''' @@ -484,12 +481,8 @@ class ExceptionGroup(Exception): def exceptions(self): pass ''' - if sys.version_info>=(3,10): - self.checkLiveLocals(code, ['sys:2', 'property:3', 'ExceptionGroup:6'], + self.checkLiveLocals(code, ['sys:2', 'property:3', 'ExceptionGroup:6'], ['sys:2', 'property:3', 'ExceptionGroup:6']) - else: - self.checkLiveLocals(code, ['sys:None', 'property:3', 'ExceptionGroup:6'], - ['sys:None', 'property:3', 'ExceptionGroup:6']) def test_loop_body_might_not_run(self): code = """ diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..611ea0a --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,55 @@ +import gast as ast +from unittest import TestCase +from textwrap import dedent + +from beniget import Def, parse_import + +class TestImportParser(TestCase): + + def test_import_parsing(self): + code = ''' + import mod2 + import pack.subpack + import pack.subpack as a + from mod2 import _k as k, _l as l, _m as m + from pack.subpack.stuff import C + ''' + expected = [{'mod2':('mod2', None)}, + {'pack':('pack', None)}, + {'a':('pack.subpack', None)}, + {'k':('mod2','_k'), + 'l':('mod2','_l'), + 'm':('mod2','_m')}, + {'C':('pack.subpack.stuff','C')},] + node = ast.parse(dedent(code)) + assert len(expected)==len(node.body) + for import_node, expected_names in zip(node.body, expected): + assert isinstance(import_node, (ast.Import, ast.ImportFrom)) + for al,i in parse_import(import_node, 'mod1', is_package=False).items(): + assert Def(al).name() in expected_names + expected_orgmodule, expected_orgname = expected_names[Def(al).name()] + assert i.orgmodule == expected_orgmodule + assert i.orgname == expected_orgname + ran=True + assert ran + + def test_import_parser_relative(self): + code = ''' + from ...mod2 import bar as b + from .pack import foo + from ......error import x + ''' + expected = [{'b':('top.mod2','bar')}, + {'foo':('top.subpack.other.pack','foo')}, + {'x': ('......error', 'x')}] + node = ast.parse(dedent(code)) + assert len(expected)==len(node.body) + for import_node, expected_names in zip(node.body, expected): + assert isinstance(import_node, (ast.Import, ast.ImportFrom)) + for al,i in parse_import(import_node, 'top.subpack.other', is_package=True).items(): + assert Def(al).name() in expected_names + expected_orgmodule, expected_orgname = expected_names[Def(al).name()] + assert i.orgmodule == expected_orgmodule + assert i.orgname == expected_orgname + ran=True + assert ran \ No newline at end of file