Format: text/x-python
⬇ Download File (16.2 KB)
   1  """
   2  Projects are a way to handle Python projects within Jedi. For simpler plugins
   3  you might not want to deal with projects, but if you want to give the user more
   4  flexibility to define sys paths and Python interpreters for a project,
   5  :class:`.Project` is the perfect way to allow for that.
   6  
   7  Projects can be saved to disk and loaded again, to allow project definitions to
   8  be used across repositories.
   9  """
  10  import json
  11  from pathlib import Path
  12  from itertools import chain
  13  
  14  from jedi import debug
  15  from jedi.api.environment import get_cached_default_environment, create_environment
  16  from jedi.api.exceptions import WrongVersion
  17  from jedi.api.completion import search_in_module
  18  from jedi.api.helpers import split_search_string, get_module_names
  19  from jedi.inference.imports import load_module_from_path, \
  20      load_namespace_from_path, iter_module_names
  21  from jedi.inference.sys_path import discover_buildout_paths
  22  from jedi.inference.cache import inference_state_as_method_param_cache
  23  from jedi.inference.references import recurse_find_python_folders_and_files, search_in_file_ios
  24  from jedi.file_io import FolderIO
  25  
  26  _CONFIG_FOLDER = '.jedi'
  27  _CONTAINS_POTENTIAL_PROJECT = \
  28      'setup.py', '.git', '.hg', 'requirements.txt', 'MANIFEST.in', 'pyproject.toml'
  29  
  30  _SERIALIZER_VERSION = 1
  31  
  32  
  33  def _try_to_skip_duplicates(func):
  34      def wrapper(*args, **kwargs):
  35          found_tree_nodes = []
  36          found_modules = []
  37          for definition in func(*args, **kwargs):
  38              tree_node = definition._name.tree_name
  39              if tree_node is not None and tree_node in found_tree_nodes:
  40                  continue
  41              if definition.type == 'module' and definition.module_path is not None:
  42                  if definition.module_path in found_modules:
  43                      continue
  44                  found_modules.append(definition.module_path)
  45              yield definition
  46              found_tree_nodes.append(tree_node)
  47      return wrapper
  48  
  49  
  50  def _remove_duplicates_from_path(path):
  51      used = set()
  52      for p in path:
  53          if p in used:
  54              continue
  55          used.add(p)
  56          yield p
  57  
  58  
  59  class Project:
  60      """
  61      Projects are a simple way to manage Python folders and define how Jedi does
  62      import resolution. It is mostly used as a parameter to :class:`.Script`.
  63      Additionally there are functions to search a whole project.
  64      """
  65      _environment = None
  66  
  67      @staticmethod
  68      def _get_config_folder_path(base_path):
  69          return base_path.joinpath(_CONFIG_FOLDER)
  70  
  71      @staticmethod
  72      def _get_json_path(base_path):
  73          return Project._get_config_folder_path(base_path).joinpath('project.json')
  74  
  75      @classmethod
  76      def load(cls, path):
  77          """
  78          Loads a project from a specific path. You should not provide the path
  79          to ``.jedi/project.json``, but rather the path to the project folder.
  80  
  81          :param path: The path of the directory you want to use as a project.
  82          """
  83          if isinstance(path, str):
  84              path = Path(path)
  85          with open(cls._get_json_path(path)) as f:
  86              version, data = json.load(f)
  87  
  88          if version == 1:
  89              return cls(**data)
  90          else:
  91              raise WrongVersion(
  92                  "The Jedi version of this project seems newer than what we can handle."
  93              )
  94  
  95      def save(self):
  96          """
  97          Saves the project configuration in the project in ``.jedi/project.json``.
  98          """
  99          data = dict(self.__dict__)
 100          data.pop('_environment', None)
 101          data.pop('_django', None)  # TODO make django setting public?
 102          data = {k.lstrip('_'): v for k, v in data.items()}
 103          data['path'] = str(data['path'])
 104  
 105          self._get_config_folder_path(self._path).mkdir(parents=True, exist_ok=True)
 106          with open(self._get_json_path(self._path), 'w') as f:
 107              return json.dump((_SERIALIZER_VERSION, data), f)
 108  
 109      def __init__(
 110          self,
 111          path,
 112          *,
 113          environment_path=None,
 114          load_unsafe_extensions=False,
 115          sys_path=None,
 116          added_sys_path=(),
 117          smart_sys_path=True,
 118      ) -> None:
 119          """
 120          :param path: The base path for this project.
 121          :param environment_path: The Python executable path, typically the path
 122              of a virtual environment.
 123          :param load_unsafe_extensions: Default False, Loads extensions that are not in the
 124              sys path and in the local directories. With this option enabled,
 125              this is potentially unsafe if you clone a git repository and
 126              analyze it's code, because those compiled extensions will be
 127              important and therefore have execution privileges.
 128          :param sys_path: list of str. You can override the sys path if you
 129              want. By default the ``sys.path.`` is generated by the
 130              environment (virtualenvs, etc).
 131          :param added_sys_path: list of str. Adds these paths at the end of the
 132              sys path.
 133          :param smart_sys_path: If this is enabled (default), adds paths from
 134              local directories. Otherwise you will have to rely on your packages
 135              being properly configured on the ``sys.path``.
 136          """
 137  
 138          if isinstance(path, str):
 139              path = Path(path).absolute()
 140          self._path = path
 141  
 142          self._environment_path = environment_path
 143          if sys_path is not None:
 144              # Remap potential pathlib.Path entries
 145              sys_path = list(map(str, sys_path))
 146          self._sys_path = sys_path
 147          self._smart_sys_path = smart_sys_path
 148          self._load_unsafe_extensions = load_unsafe_extensions
 149          self._django = False
 150          # Remap potential pathlib.Path entries
 151          self.added_sys_path = list(map(str, added_sys_path))
 152          """The sys path that is going to be added at the end of the """
 153  
 154      @property
 155      def path(self):
 156          """
 157          The base path for this project.
 158          """
 159          return self._path
 160  
 161      @property
 162      def sys_path(self):
 163          """
 164          The sys path provided to this project. This can be None and in that
 165          case will be auto generated.
 166          """
 167          return self._sys_path
 168  
 169      @property
 170      def smart_sys_path(self):
 171          """
 172          If the sys path is going to be calculated in a smart way, where
 173          additional paths are added.
 174          """
 175          return self._smart_sys_path
 176  
 177      @property
 178      def load_unsafe_extensions(self):
 179          """
 180          Wheter the project loads unsafe extensions.
 181          """
 182          return self._load_unsafe_extensions
 183  
 184      @inference_state_as_method_param_cache()
 185      def _get_base_sys_path(self, inference_state):
 186          # The sys path has not been set explicitly.
 187          sys_path = list(inference_state.environment.get_sys_path())
 188          try:
 189              sys_path.remove('')
 190          except ValueError:
 191              pass
 192          return sys_path
 193  
 194      @inference_state_as_method_param_cache()
 195      def _get_sys_path(self, inference_state, add_parent_paths=True, add_init_paths=False):
 196          """
 197          Keep this method private for all users of jedi. However internally this
 198          one is used like a public method.
 199          """
 200          suffixed = list(self.added_sys_path)
 201          prefixed = []
 202  
 203          if self._sys_path is None:
 204              sys_path = list(self._get_base_sys_path(inference_state))
 205          else:
 206              sys_path = list(self._sys_path)
 207  
 208          if self._smart_sys_path:
 209              prefixed.append(str(self._path))
 210  
 211              if inference_state.script_path is not None:
 212                  suffixed += map(str, discover_buildout_paths(
 213                      inference_state,
 214                      inference_state.script_path
 215                  ))
 216  
 217                  if add_parent_paths:
 218                      # Collect directories in upward search by:
 219                      #   1. Skipping directories with __init__.py
 220                      #   2. Stopping immediately when above self._path
 221                      traversed = []
 222                      for parent_path in inference_state.script_path.parents:
 223                          if parent_path == self._path \
 224                                  or self._path not in parent_path.parents:
 225                              break
 226                          if not add_init_paths \
 227                                  and parent_path.joinpath("__init__.py").is_file():
 228                              continue
 229                          traversed.append(str(parent_path))
 230  
 231                      # AFAIK some libraries have imports like `foo.foo.bar`, which
 232                      # leads to the conclusion to by default prefer longer paths
 233                      # rather than shorter ones by default.
 234                      suffixed += reversed(traversed)
 235  
 236          if self._django:
 237              prefixed.append(str(self._path))
 238  
 239          path = prefixed + sys_path + suffixed
 240          return list(_remove_duplicates_from_path(path))
 241  
 242      def get_environment(self):
 243          if self._environment is None:
 244              if self._environment_path is not None:
 245                  self._environment = create_environment(self._environment_path, safe=False)
 246              else:
 247                  self._environment = get_cached_default_environment()
 248          return self._environment
 249  
 250      def search(self, string, *, all_scopes=False):
 251          """
 252          Searches a name in the whole project. If the project is very big,
 253          at some point Jedi will stop searching. However it's also very much
 254          recommended to not exhaust the generator. Just display the first ten
 255          results to the user.
 256  
 257          There are currently three different search patterns:
 258  
 259          - ``foo`` to search for a definition foo in any file or a file called
 260            ``foo.py`` or ``foo.pyi``.
 261          - ``foo.bar`` to search for the ``foo`` and then an attribute ``bar``
 262            in it.
 263          - ``class foo.bar.Bar`` or ``def foo.bar.baz`` to search for a specific
 264            API type.
 265  
 266          :param bool all_scopes: Default False; searches not only for
 267              definitions on the top level of a module level, but also in
 268              functions and classes.
 269          :yields: :class:`.Name`
 270          """
 271          return self._search_func(string, all_scopes=all_scopes)
 272  
 273      def complete_search(self, string, **kwargs):
 274          """
 275          Like :meth:`.Script.search`, but completes that string. An empty string
 276          lists all definitions in a project, so be careful with that.
 277  
 278          :param bool all_scopes: Default False; searches not only for
 279              definitions on the top level of a module level, but also in
 280              functions and classes.
 281          :yields: :class:`.Completion`
 282          """
 283          return self._search_func(string, complete=True, **kwargs)
 284  
 285      @_try_to_skip_duplicates
 286      def _search_func(self, string, complete=False, all_scopes=False):
 287          # Using a Script is they easiest way to get an empty module context.
 288          from jedi import Script
 289          s = Script('', project=self)
 290          inference_state = s._inference_state
 291          empty_module_context = s._get_module_context()
 292  
 293          debug.dbg('Search for string %s, complete=%s', string, complete)
 294          wanted_type, wanted_names = split_search_string(string)
 295          name = wanted_names[0]
 296          stub_folder_name = name + '-stubs'
 297  
 298          ios = recurse_find_python_folders_and_files(FolderIO(str(self._path)))
 299          file_ios = []
 300  
 301          # 1. Search for modules in the current project
 302          for folder_io, file_io in ios:
 303              if file_io is None:
 304                  file_name = folder_io.get_base_name()
 305                  if file_name == name or file_name == stub_folder_name:
 306                      f = folder_io.get_file_io('__init__.py')
 307                      try:
 308                          m = load_module_from_path(inference_state, f).as_context()
 309                      except FileNotFoundError:
 310                          f = folder_io.get_file_io('__init__.pyi')
 311                          try:
 312                              m = load_module_from_path(inference_state, f).as_context()
 313                          except FileNotFoundError:
 314                              m = load_namespace_from_path(inference_state, folder_io).as_context()
 315                  else:
 316                      continue
 317              else:
 318                  file_ios.append(file_io)
 319                  if Path(file_io.path).name in (name + '.py', name + '.pyi'):
 320                      m = load_module_from_path(inference_state, file_io).as_context()
 321                  else:
 322                      continue
 323  
 324              debug.dbg('Search of a specific module %s', m)
 325              yield from search_in_module(
 326                  inference_state,
 327                  m,
 328                  names=[m.name],
 329                  wanted_type=wanted_type,
 330                  wanted_names=wanted_names,
 331                  complete=complete,
 332                  convert=True,
 333                  ignore_imports=True,
 334              )
 335  
 336          # 2. Search for identifiers in the project.
 337          for module_context in search_in_file_ios(inference_state, file_ios,
 338                                                   name, complete=complete):
 339              names = get_module_names(module_context.tree_node, all_scopes=all_scopes)
 340              names = [module_context.create_name(n) for n in names]
 341              names = _remove_imports(names)
 342              yield from search_in_module(
 343                  inference_state,
 344                  module_context,
 345                  names=names,
 346                  wanted_type=wanted_type,
 347                  wanted_names=wanted_names,
 348                  complete=complete,
 349                  ignore_imports=True,
 350              )
 351  
 352          # 3. Search for modules on sys.path
 353          sys_path = [
 354              p for p in self._get_sys_path(inference_state)
 355              # Exclude the current folder which is handled by recursing the folders.
 356              if p != self._path
 357          ]
 358          names = list(iter_module_names(inference_state, empty_module_context, sys_path))
 359          yield from search_in_module(
 360              inference_state,
 361              empty_module_context,
 362              names=names,
 363              wanted_type=wanted_type,
 364              wanted_names=wanted_names,
 365              complete=complete,
 366              convert=True,
 367          )
 368  
 369      def __repr__(self):
 370          return '<%s: %s>' % (self.__class__.__name__, self._path)
 371  
 372  
 373  def _is_potential_project(path):
 374      for name in _CONTAINS_POTENTIAL_PROJECT:
 375          try:
 376              if path.joinpath(name).exists():
 377                  return True
 378          except OSError:
 379              continue
 380      return False
 381  
 382  
 383  def _is_django_path(directory):
 384      """ Detects the path of the very well known Django library (if used) """
 385      try:
 386          with open(directory.joinpath('manage.py'), 'rb') as f:
 387              return b"DJANGO_SETTINGS_MODULE" in f.read()
 388      except (FileNotFoundError, IsADirectoryError, PermissionError):
 389          return False
 390  
 391  
 392  def get_default_project(path=None):
 393      """
 394      If a project is not defined by the user, Jedi tries to define a project by
 395      itself as well as possible. Jedi traverses folders until it finds one of
 396      the following:
 397  
 398      1. A ``.jedi/config.json``
 399      2. One of the following files: ``setup.py``, ``.git``, ``.hg``,
 400         ``requirements.txt`` and ``MANIFEST.in``.
 401      """
 402      if path is None:
 403          path = Path.cwd()
 404      elif isinstance(path, str):
 405          path = Path(path)
 406  
 407      check = path.absolute()
 408      probable_path = None
 409      first_no_init_file = None
 410      for dir in chain([check], check.parents):
 411          try:
 412              return Project.load(dir)
 413          except (FileNotFoundError, IsADirectoryError, PermissionError):
 414              pass
 415          except NotADirectoryError:
 416              continue
 417  
 418          if first_no_init_file is None:
 419              if dir.joinpath('__init__.py').exists():
 420                  # In the case that a __init__.py exists, it's in 99% just a
 421                  # Python package and the project sits at least one level above.
 422                  continue
 423              elif not dir.is_file():
 424                  first_no_init_file = dir
 425  
 426          if _is_django_path(dir):
 427              project = Project(dir)
 428              project._django = True
 429              return project
 430  
 431          if probable_path is None and _is_potential_project(dir):
 432              probable_path = dir
 433  
 434      if probable_path is not None:
 435          return Project(probable_path)
 436  
 437      if first_no_init_file is not None:
 438          return Project(first_no_init_file)
 439  
 440      curdir = path if path.is_dir() else path.parent
 441      return Project(curdir)
 442  
 443  
 444  def _remove_imports(names):
 445      return [
 446          n for n in names
 447          if n.tree_name is None or n.api_type not in ('module', 'namespace')
 448      ]
 449