"""Load and link .pyi files."""

import collections
import logging
import os
import pickle

from typing import Dict, Iterable, Optional, Tuple

from pytype import module_utils
from pytype import utils
from pytype.pyi import parser
from pytype.pytd import builtin_stubs
from pytype.pytd import pytd
from pytype.pytd import pytd_utils
from pytype.pytd import serialize_ast
from pytype.pytd import typeshed
from pytype.pytd import visitors

log = logging.getLogger(__name__)

# Allow a file to be used as the designated default pyi for blacklisted files
DEFAULT_PYI_PATH_SUFFIX = None

# Always load this module from typeshed, even if we have it in the imports map
_ALWAYS_PREFER_TYPESHED = frozenset({"typing_extensions"})

# Type alias
_AST = pytd.TypeDeclUnit


def _is_default_pyi(path):
  return DEFAULT_PYI_PATH_SUFFIX and path.endswith(DEFAULT_PYI_PATH_SUFFIX)


def create_loader(options):
  """Create a pytd loader."""
  if options.precompiled_builtins:
    return PickledPyiLoader.load_from_pickle(
        options.precompiled_builtins, options)
  elif options.use_pickled_files:
    return PickledPyiLoader(options)
  else:
    return Loader(options)


ResolvedModule = collections.namedtuple(
    "ResolvedModule", ("module_name", "filename", "ast"))


class Module:
  """Represents a parsed module.

  Attributes:
    module_name: The module name, e.g. "numpy.fft.fftpack".
    filename: The filename of the pytd that describes the module. Needs to be
      unique. Will be in one of the following formats:
      - "pytd:{module_name}" for pytd files that ship with pytype.
      - "pytd:{filename}" for pyi files that ship with typeshed.
      - "{filename}" for other pyi files.
    ast: The parsed PyTD. Internal references will be resolved, but
      NamedType nodes referencing other modules might still be unresolved.
    pickle: The AST as a pickled string. As long as this field is not None, the
      ast will be None.
    has_unresolved_pointers: Whether all ClassType pointers have been filled in
  """

  # pylint: disable=redefined-outer-name
  def __init__(self, module_name, filename, ast,
               pickle=None, has_unresolved_pointers=True):
    self.module_name = module_name
    self.filename = filename
    self.ast = ast
    self.pickle = pickle
    self.has_unresolved_pointers = has_unresolved_pointers
  # pylint: enable=redefined-outer-name

  def needs_unpickling(self):
    return bool(self.pickle)

  def is_package(self):
    if self.filename == os.devnull:
      # imports_map_loader adds os.devnull entries for __init__.py files in
      # intermediate directories.
      return True
    return self.filename and os.path.basename(self.filename) == "__init__.pyi"


class BadDependencyError(Exception):
  """If we can't resolve a module referenced by the one we're trying to load."""

  def __init__(self, module_error, src=None):
    referenced = ", referenced from %r" % src if src else ""
    super().__init__(module_error + referenced)

  def __str__(self):
    return utils.message(self)


class _ModuleMap:
  """A map of fully qualified module name -> Module."""

  PREFIX = "pytd:"  # for pytd files that ship with pytype

  def __init__(self, options, modules):
    self.options = options
    self._modules: Dict[str, Module] = modules or self._base_modules()
    if self._modules["builtins"].needs_unpickling():
      self._unpickle_module(self._modules["builtins"])
    if self._modules["typing"].needs_unpickling():
      self._unpickle_module(self._modules["typing"])
    self._concatenated = None

  def __getitem__(self, key):
    return self._modules[key]

  def __setitem__(self, key, val):
    self._modules[key] = val

  def __delitem__(self, key):
    del self._modules[key]

  def __contains__(self, key):
    return key in self._modules

  def items(self):
    return self._modules.items()

  def values(self):
    return self._modules.values()

  def get(self, key):
    return self._modules.get(key)

  def get_existing_ast(self, module_name: str) -> Optional[_AST]:
    existing = self._modules.get(module_name)
    if existing:
      if existing.needs_unpickling():
        self._unpickle_module(existing)
      return existing.ast
    return None

  def defined_asts(self) -> Iterable[_AST]:
    """All module ASTs that are not None."""
    return (module.ast for module in self._modules.values() if module.ast)

  def get_module_map(self) -> Dict[str, _AST]:
    """Get a {name: ast} map of all modules with a filled-in ast."""
    return {name: module.ast for name, module in self._modules.items()
            if module.ast}

  def get_resolved_modules(self) -> Dict[str, ResolvedModule]:
    """Get a {name: ResolvedModule} map of all resolved modules."""
    resolved_modules = {}
    for name, mod in self._modules.items():
      if not mod.has_unresolved_pointers:
        resolved_modules[name] = ResolvedModule(
            mod.module_name, mod.filename, mod.ast)
    return resolved_modules

  def _base_modules(self):
    bltins, typing = builtin_stubs.GetBuiltinsAndTyping(
        parser.PyiOptions.from_toplevel_options(self.options))
    return {
        "builtins":
        Module("builtins", self.PREFIX + "builtins", bltins,
               has_unresolved_pointers=False),
        "typing":
        Module("typing", self.PREFIX + "typing", typing,
               has_unresolved_pointers=False)
    }

  def _unpickle_module(self, module):
    """Unpickle a pickled ast and its dependencies."""
    if not module.pickle:
      return
    todo = [module]
    seen = set()
    newly_loaded_asts = []
    while todo:
      m = todo.pop()
      if m in seen:
        continue
      else:
        seen.add(m)
      if not m.pickle:
        continue
      loaded_ast = pickle.loads(m.pickle)
      deps = [d for d, _ in loaded_ast.dependencies if d != loaded_ast.ast.name]
      loaded_ast = serialize_ast.EnsureAstName(loaded_ast, m.module_name)
      assert m.module_name in self._modules
      todo.extend(self._modules[dependency] for dependency in deps)
      newly_loaded_asts.append(loaded_ast)
      m.ast = loaded_ast.ast
      m.pickle = None
    module_map = self.get_module_map()
    for loaded_ast in newly_loaded_asts:
      serialize_ast.FillLocalReferences(loaded_ast, module_map)
    assert module.ast

  def concat_all(self):
    if not self._concatenated:
      self._concatenated = pytd_utils.Concat(*self.defined_asts(), name="<all>")
    return self._concatenated

  def invalidate_concatenated(self):
    self._concatenated = None


class _PathFinder:
  """Find a filepath for a module."""

  def __init__(self, options):
    self.options = options

  def find_import(self, module_name: str) -> Tuple[Optional[str], bool]:
    """Search through pythonpath for a module.

    Args:
      module_name: module name

    Returns:
      - (path, file_exists) if we find a path (file_exists will be false if we
        have found a directory where we need to create an __init__.pyi)
      - None if we cannot find a full path
    """
    module_name_split = module_name.split(".")
    for searchdir in self.options.pythonpath:
      path = os.path.join(searchdir, *module_name_split)
      # See if this is a directory with a "__init__.py" defined.
      # (These also get automatically created in imports_map_loader.py)
      init_path = os.path.join(path, "__init__")
      full_path = self.get_pyi_path(init_path)
      if full_path is not None:
        log.debug("Found module %r with path %r", module_name, init_path)
        return full_path, True
      elif self.options.imports_map is None and os.path.isdir(path):
        # We allow directories to not have an __init__ file.
        # The module's empty, but you can still load submodules.
        log.debug("Created empty module %r with path %r",
                  module_name, init_path)
        full_path = os.path.join(path, "__init__.pyi")
        return full_path, False
      else:  # Not a directory
        full_path = self.get_pyi_path(path)
        if full_path is not None:
          log.debug("Found module %r in path %r", module_name, path)
          return full_path, True
    return None, None

  def get_pyi_path(self, path: str) -> Optional[str]:
    """Get a pyi file from path if it exists."""
    if self.options.imports_map is not None:
      if path in self.options.imports_map:
        full_path = self.options.imports_map[path]
      else:
        return None
    else:
      full_path = path + ".pyi"

    # We have /dev/null entries in the import_map - os.path.isfile() returns
    # False for those. However, we *do* want to load them. Hence exists / isdir.
    if os.path.exists(full_path) and not os.path.isdir(full_path):
      return full_path
    else:
      return None

  def log_module_not_found(self, module_name):
    log.warning("Couldn't import module %s %r in (path=%r) imports_map: %s",
                module_name, module_name, self.options.pythonpath,
                "%d items" % len(self.options.imports_map) if
                self.options.imports_map is not None else "none")
    if log.isEnabledFor(logging.DEBUG) and self.options.imports_map:
      for module, path in self.options.imports_map.items():
        log.debug("%s -> %s", module, path)


class _Resolver:
  """Resolve symbols in a pytd tree."""

  def __init__(self, builtins_ast):
    self.builtins_ast = builtins_ast
    self.allow_singletons = False

  def _lookup(self, visitor, mod_ast, lookup_ast):
    if lookup_ast:
      visitor.EnterTypeDeclUnit(lookup_ast)
    mod_ast = mod_ast.Visit(visitor)
    return mod_ast

  def resolve_local_types(self, mod_ast, *, lookup_ast=None):
    local_lookup = visitors.LookupLocalTypes(self.allow_singletons)
    return self._lookup(local_lookup, mod_ast, lookup_ast)

  def resolve_builtin_types(self, mod_ast, *, lookup_ast=None):
    bltn_lookup = visitors.LookupBuiltins(
        self.builtins_ast, full_names=False,
        allow_singletons=self.allow_singletons)
    mod_ast = self._lookup(bltn_lookup, mod_ast, lookup_ast)
    mod_ast = mod_ast.Visit(
        visitors.ExpandCompatibleBuiltins(self.builtins_ast))
    return mod_ast

  def resolve_external_types(self, mod_ast, module_map, aliases, *,
                             mod_name=None):
    name = mod_name or mod_ast.name
    try:
      mod_ast = mod_ast.Visit(visitors.LookupExternalTypes(
          module_map, self_name=name, module_alias_map=aliases))
    except KeyError as e:
      raise BadDependencyError(utils.message(e), name) from e
    return mod_ast

  def resolve_module_alias(self, name, *, lookup_ast=None,
                           lookup_ast_name=None):
    """Check if a given name is an alias and resolve it if so."""
    # name is bare, but aliases are stored as "ast_name.alias".
    if lookup_ast is None:
      return name
    ast_name = lookup_ast_name or lookup_ast.name
    key = f"{ast_name}.{name}"
    for alias, value in lookup_ast.aliases:
      if alias == key and isinstance(value, pytd.Module):
        return value.module_name
    return name

  def verify(self, mod_ast, *, mod_name=None):
    try:
      mod_ast.Visit(visitors.VerifyLookup(ignore_late_types=True))
    except ValueError as e:
      name = mod_name or mod_ast.name
      raise BadDependencyError(utils.message(e), name) from e
    mod_ast.Visit(visitors.VerifyContainers())

  @classmethod
  def collect_dependencies(cls, mod_ast):
    """Goes over an ast and returns all references module names."""
    deps = visitors.CollectDependencies()
    mod_ast.Visit(deps)
    return deps.dependencies


class Loader:
  """A cache for loaded PyTD files.

  Typically, you'll have one instance of this class, per module.

  Attributes:
    options: A config.Options object.
    builtins: The builtins ast.
    typing: The typing ast.
  """

  PREFIX = "pytd:"  # for pytd files that ship with pytype

  def __init__(self, options, modules=None):
    self.options = options
    self._modules = _ModuleMap(options, modules)
    self.builtins = self._modules["builtins"].ast
    self.typing = self._modules["typing"].ast
    self._path_finder = _PathFinder(options)
    self._builtin_loader = builtin_stubs.BuiltinLoader(
        parser.PyiOptions.from_toplevel_options(options))
    self._resolver = _Resolver(self.builtins)
    self._import_name_cache = {}  # performance cache
    self._aliases = {}
    self._prefixes = set()
    # Paranoid verification that pytype.main properly checked the flags:
    if options.imports_map is not None:
      assert options.pythonpath == [""], options.pythonpath

  def get_default_ast(self):
    return builtin_stubs.GetDefaultAst(
        parser.PyiOptions.from_toplevel_options(self.options))

  def save_to_pickle(self, filename):
    """Save to a pickle. See PickledPyiLoader.load_from_pickle for reverse."""
    # We assume that the Loader is in a consistent state here. In particular, we
    # assume that for every module in _modules, all the transitive dependencies
    # have been loaded.
    items = tuple(
        (name, serialize_ast.StoreAst(
            module.ast, open_function=self.options.open_function))
        for name, module in sorted(self._modules.items()))
    # Preparing an ast for pickling clears its class pointers, making it
    # unsuitable for reuse, so we have to discard the builtins cache.
    builtin_stubs.InvalidateCache()
    # Now pickle the pickles. We keep the "inner" modules as pickles as a
    # performance optimization - unpickling is slow.
    pytd_utils.SavePickle(items, filename, compress=True,
                          open_function=self.options.open_function)

  def _resolve_external_and_local_types(self, mod_ast, lookup_ast=None):
    dependencies = self._resolver.collect_dependencies(mod_ast)
    if dependencies:
      lookup_ast = lookup_ast or mod_ast
      self._load_ast_dependencies(dependencies, lookup_ast)
      mod_ast = self._resolve_external_types(
          mod_ast, lookup_ast and lookup_ast.name)
    mod_ast = self._resolver.resolve_local_types(mod_ast, lookup_ast=lookup_ast)
    return mod_ast

  def _create_empty(self, module_name, filename):
    return self.load_file(module_name, filename,
                          pytd_utils.CreateModule(module_name))

  def load_file(self, module_name, filename, mod_ast=None):
    """Load (or retrieve from cache) a module and resolve its dependencies."""
    # TODO(mdemello): Should we do this in _ModuleMap.__setitem__? Also, should
    # we only invalidate concatenated if existing = None?
    self._modules.invalidate_concatenated()
    # Check for an existing ast first
    existing = self._modules.get_existing_ast(module_name)
    if existing:
      return existing
    if not mod_ast:
      with self.options.open_function(filename, "r") as f:
        mod_ast = parser.parse_string(
            f.read(), filename=filename, name=module_name,
            options=parser.PyiOptions.from_toplevel_options(self.options))
    return self._process_module(module_name, filename, mod_ast)

  def _process_module(self, module_name, filename, mod_ast):
    """Create a module from a loaded ast and save it to the loader cache.

    Args:
      module_name: The fully qualified name of the module being imported.
        May be None.
      filename: The file the ast was generated from. May be None.
      mod_ast: The pytd.TypeDeclUnit representing the module.

    Returns:
      The ast (pytd.TypeDeclUnit) as represented in this loader.
    """
    module = Module(module_name, filename, mod_ast)
    # Builtins need to be resolved before the module is cached so that they are
    # not mistaken for local types. External types can be left unresolved
    # because they are unambiguous.
    self._resolver.allow_singletons = False
    module.ast = self._resolver.resolve_builtin_types(module.ast)
    self._modules[module_name] = module
    try:
      self._resolver.allow_singletons = True
      module.ast = self._resolve_external_and_local_types(module.ast)
      # We need to resolve builtin singletons after we have made sure they are
      # not shadowed by a local or a star import.
      module.ast = self._resolver.resolve_builtin_types(module.ast)
      self._resolver.allow_singletons = False
      # Now that any imported TypeVar instances have been resolved, adjust type
      # parameters in classes and functions.
      module.ast = module.ast.Visit(visitors.AdjustTypeParameters())
      # Now we can fill in internal cls pointers to ClassType nodes in the
      # module. This code executes when the module is first loaded, which
      # happens before any others use it to resolve dependencies, so there are
      # no external pointers into the module at this point.
      module_map = {"": module.ast, module_name: module.ast}
      module.ast.Visit(visitors.FillInLocalPointers(module_map))
    except:
      # don't leave half-resolved modules around
      del self._modules[module_name]
      raise
    if module_name:
      self.add_module_prefixes(module_name)
    return module.ast

  def _try_import_prefix(self, name: str) -> Optional[_AST]:
    """Try importing all prefixes of name, returning the first valid module."""
    prefix = name
    while "." in prefix:
      prefix, _ = prefix.rsplit(".", 1)
      ast = self._import_module_by_name(prefix)
      if ast:
        return ast
    return None

  def _load_ast_dependencies(self, dependencies, lookup_ast,
                             lookup_ast_name=None):
    """Fill in all ClassType.cls pointers and load reexported modules."""
    ast_name = lookup_ast_name or lookup_ast.name
    for dep_name in dependencies:
      name = self._resolver.resolve_module_alias(
          dep_name, lookup_ast=lookup_ast, lookup_ast_name=lookup_ast_name)
      if dep_name != name:
        # We have an alias. Store it in the aliases map.
        self._aliases[dep_name] = name
      if name in self._modules and self._modules[name].ast:
        dep_ast = self._modules[name].ast
      else:
        dep_ast = self._import_module_by_name(name)
        if dep_ast is None:
          dep_ast = self._try_import_prefix(name)
          if dep_ast or f"{ast_name}.{name}" in lookup_ast:
            # If any prefix is a valid module, then we'll assume that we're
            # importing a nested class. If name is in lookup_ast, then it is a
            # local reference and not an import at all.
            continue
          else:
            self._path_finder.log_module_not_found(name)
            raise BadDependencyError("Can't find pyi for %r" % name, ast_name)
      # If `name` is a package, try to load any base names not defined in
      # __init__ as submodules.
      if not self._modules[name].is_package() or "__getattr__" in dep_ast:
        continue
      for base_name in dependencies[dep_name]:
        if base_name == "*":
          continue
        full_name = "%s.%s" % (name, base_name)
        # Check whether full_name is a submodule based on whether it is
        # defined in the __init__ file.
        try:
          attr = dep_ast.Lookup(full_name)
        except KeyError:
          attr = None
        # 'from . import submodule as submodule' produces
        # Alias(submodule, NamedType(submodule)).
        if attr is None or (
            isinstance(attr, pytd.Alias) and attr.name == attr.type.name):
          if not self._import_module_by_name(full_name):
            # Add logging to make debugging easier but otherwise ignore the
            # result - resolve_external_types will raise a better error.
            self._path_finder.log_module_not_found(full_name)

  def _resolve_external_types(self, mod_ast, mod_name=None):
    module_map = self._modules.get_module_map()
    mod_ast = self._resolver.resolve_external_types(
        mod_ast, module_map, self._aliases, mod_name=mod_name)
    return mod_ast

  def _resolve_classtype_pointers(self, mod_ast, *, lookup_ast=None):
    module_map = self._modules.get_module_map()
    module_map[""] = lookup_ast or mod_ast  # The module itself (local lookup)
    mod_ast.Visit(visitors.FillInLocalPointers(module_map))

  def resolve_pytd(self, pytd_node, lookup_ast):
    """Resolve and verify pytd value, using the given ast for local lookup."""
    # NOTE: Modules of dependencies will be loaded into the cache
    pytd_node = self._resolver.resolve_builtin_types(
        pytd_node, lookup_ast=lookup_ast)
    pytd_node = self._resolve_external_and_local_types(
        pytd_node, lookup_ast=lookup_ast)
    self._resolve_classtype_pointers_for_all_modules()
    self._resolve_classtype_pointers(pytd_node, lookup_ast=lookup_ast)
    self._resolver.verify(pytd_node, mod_name=lookup_ast.name)
    return pytd_node

  def resolve_ast(self, ast):
    """Resolve the dependencies of an AST, without adding it to our modules."""
    # NOTE: Modules of dependencies will be loaded into the cache
    return self.resolve_pytd(ast, ast)

  def _resolve_classtype_pointers_for_all_modules(self):
    for module in self._modules.values():
      if module.has_unresolved_pointers:
        self._resolve_classtype_pointers(module.ast)
        module.has_unresolved_pointers = False

  def import_relative_name(self, name: str) -> _AST:
    """IMPORT_NAME with level=-1. A name relative to the current directory."""
    if self.options.module_name is None:
      raise ValueError("Attempting relative import in non-package.")
    path = self.options.module_name.split(".")[:-1]
    path.append(name)
    return self.import_name(".".join(path))

  def import_relative(self, level: int) -> _AST:
    """Import a module relative to our base module.

    Args:
      level: Relative level:
        https://docs.python.org/2/library/functions.html#__import__
        E.g.
          1: "from . import abc"
          2: "from .. import abc"
          etc.
        Since you'll use import_name() for -1 and 0, this function expects the
        level to be >= 1.
    Returns:
      The parsed pytd. Instance of pytd.TypeDeclUnit. None if we can't find the
      module.
    Raises:
      ValueError: If we don't know the name of the base module.
    """
    assert level >= 1
    if self.options.module_name is None:
      raise ValueError("Attempting relative import in non-package.")
    components = self.options.module_name.split(".")
    sub_module = ".".join(components[0:-level])
    return self.import_name(sub_module)

  def import_name(self, module_name: str) -> _AST:
    if module_name in self._import_name_cache:
      return self._import_name_cache[module_name]
    mod_ast = self._import_module_by_name(module_name)
    if not mod_ast:
      self._path_finder.log_module_not_found(module_name)
    self._resolve_classtype_pointers_for_all_modules()
    mod_ast = self.finish_and_verify_ast(mod_ast)
    self._import_name_cache[module_name] = mod_ast
    return mod_ast

  def finish_and_verify_ast(self, mod_ast):
    """Verify the ast, doing external type resolution first if necessary."""
    if mod_ast:
      try:
        self._resolver.verify(mod_ast)
      except BadDependencyError:
        # In the case of a circular import, an external type may be left
        # unresolved. As long as the module containing the unresolved type does
        # not also contain a circular import, an extra lookup should resolve it.
        mod_ast = self._resolve_external_types(mod_ast)
        self._resolver.verify(mod_ast)
    return mod_ast

  def add_module_prefixes(self, module_name):
    for prefix in module_utils.get_all_prefixes(module_name):
      self._prefixes.add(prefix)

  def has_module_prefix(self, prefix):
    return prefix in self._prefixes

  def _load_builtin(self, subdir, module_name, third_party_only=False):
    """Load a pytd/pyi that ships with pytype or typeshed."""
    # Try our own type definitions first.
    if not third_party_only:
      filename, mod_ast = self._builtin_loader.get_builtin(subdir, module_name)
      if mod_ast:
        return self.load_file(filename=self.PREFIX + filename,
                              module_name=module_name, mod_ast=mod_ast)
    if self.options.typeshed:
      return self._load_typeshed_builtin(subdir, module_name)
    return None

  def _load_typeshed_builtin(self, subdir, module_name):
    """Load a pyi from typeshed."""
    loaded = typeshed.parse_type_definition(
        subdir, module_name,
        parser.PyiOptions.from_toplevel_options(self.options))
    if loaded:
      filename, mod_ast = loaded
      return self.load_file(filename=self.PREFIX + filename,
                            module_name=module_name, mod_ast=mod_ast)
    return None

  def _import_module_by_name(self, module_name):
    """Load a name like 'sys' or 'foo.bar.baz'.

    Args:
      module_name: The name of the module. May contain dots.

    Returns:
      The parsed file, instance of pytd.TypeDeclUnit, or None if we
      the module wasn't found.
    """
    existing = self._modules.get_existing_ast(module_name)
    if existing:
      return existing

    assert os.sep not in module_name, (os.sep, module_name)
    log.debug("Trying to import %r", module_name)
    # Builtin modules (but not standard library modules!) take precedence
    # over modules in PYTHONPATH.
    # Note: while typeshed no longer has a builtins subdir, the pytd
    # tree still does, and order is important here.
    mod = self._load_builtin("builtins", module_name)
    if mod:
      return mod

    file_ast, path = self._import_file(module_name)
    if file_ast:
      if _is_default_pyi(path) or path == os.devnull:
        # Remove the default module from the cache; we will return it later if
        # nothing else supplies the module AST.
        default = self._modules.get(module_name)
        del self._modules[module_name]
      elif module_name in _ALWAYS_PREFER_TYPESHED:
        del self._modules[module_name]
      else:
        return file_ast

    # The standard library is (typically) towards the end of PYTHONPATH.
    mod = self._load_builtin("stdlib", module_name)
    if mod:
      return mod

    # Third party modules from typeshed (typically site-packages) come last.
    mod = self._load_builtin("third_party", module_name, third_party_only=True)
    if mod:
      return mod

    # Now return the default module if we have found nothing better.
    if file_ast:
      assert default
      self._modules[module_name] = default
      return file_ast

    return None

  def _import_file(self, module_name):
    """Helper for import_relative: try to load an AST, using pythonpath.

    Loops over self.options.pythonpath, taking care of the semantics for
    __init__, and pretending there's an empty __init__ if the path (derived from
    module_name) is a directory.

    Args:
      module_name: The name of the module. May contain dots.
    Returns:
      The parsed file (AST) and file path if found, otherwise None.
    """
    full_path, file_exists = self._path_finder.find_import(module_name)
    if full_path is None:
      return None, None
    if file_exists:
      mod_ast = self.load_file(filename=full_path, module_name=module_name)
    else:
      mod_ast = self._create_empty(filename=full_path, module_name=module_name)
    assert mod_ast is not None, full_path
    return mod_ast, full_path

  def concat_all(self):
    return self._modules.concat_all()

  def get_resolved_modules(self):
    """Gets a name -> ResolvedModule map of the loader's resolved modules."""
    return self._modules.get_resolved_modules()

  def lookup_builtin(self, name):
    try:
      return self.builtins.Lookup(name)
    except KeyError:
      return self.typing.Lookup(name)


class PickledPyiLoader(Loader):
  """A Loader which always loads pickle instead of PYI, for speed."""

  @classmethod
  def load_from_pickle(cls, filename, options):
    """Load a pytd module from a pickle file."""
    items = pytd_utils.LoadPickle(filename, compress=True,
                                  open_function=options.open_function)
    modules = {
        name: Module(name, filename=None, ast=None, pickle=pickle,
                     has_unresolved_pointers=False)
        for name, pickle in items
    }
    return cls(options, modules=modules)

  def load_file(self, module_name, filename, mod_ast=None):
    """Load (or retrieve from cache) a module and resolve its dependencies."""
    if not pytd_utils.IsPickle(filename):
      return super().load_file(module_name, filename, mod_ast)
    existing = self._modules.get_existing_ast(module_name)
    if existing:
      return existing
    loaded_ast = pytd_utils.LoadPickle(
        filename, open_function=self.options.open_function)
    # At this point ast.name and module_name could be different.
    # They are later synced in ProcessAst.
    dependencies = {d: names for d, names in loaded_ast.dependencies
                    if d != loaded_ast.ast.name}
    loaded_ast = serialize_ast.EnsureAstName(loaded_ast, module_name, fix=True)
    self._modules[module_name] = Module(module_name, filename, loaded_ast.ast)
    self._load_ast_dependencies(dependencies, lookup_ast=mod_ast,
                                lookup_ast_name=module_name)
    try:
      ast = serialize_ast.ProcessAst(loaded_ast, self._modules.get_module_map())
    except serialize_ast.UnrestorableDependencyError as e:
      del self._modules[module_name]
      raise BadDependencyError(utils.message(e), module_name) from e
    # Mark all the module's late dependencies as explicitly imported.
    for d, _ in loaded_ast.late_dependencies:
      if d != loaded_ast.ast.name:
        self.add_module_prefixes(d)

    self._modules[module_name].ast = ast
    self._modules[module_name].pickle = None
    self._modules[module_name].has_unresolved_pointers = False
    return ast
