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