# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """ Projects Plugin It handles closing, opening and switching among projetcs and also updating the file tree explorer associated with a project """ # Standard library imports import configparser import os import os.path as osp import shutil import functools from collections import OrderedDict # Third party imports from qtpy.compat import getexistingdirectory from qtpy.QtCore import Signal, Slot from qtpy.QtWidgets import QInputDialog, QMessageBox # Local imports from spyder.api.exceptions import SpyderAPIError from spyder.api.plugin_registration.decorators import on_plugin_available from spyder.api.translations import get_translation from spyder.api.plugins import Plugins, SpyderDockablePlugin from spyder.config.base import (get_home_dir, get_project_config_folder, running_under_pytest) from spyder.py3compat import is_text_string, to_text_string from spyder.utils import encoding from spyder.utils.icon_manager import ima from spyder.utils.misc import getcwd_or_home from spyder.plugins.mainmenu.api import ApplicationMenus, ProjectsMenuSections from spyder.plugins.projects.api import (BaseProjectType, EmptyProject, WORKSPACE) from spyder.plugins.projects.utils.watcher import WorkspaceWatcher from spyder.plugins.projects.widgets.main_widget import ProjectExplorerWidget from spyder.plugins.projects.widgets.projectdialog import ProjectDialog from spyder.plugins.completion.api import ( CompletionRequestTypes, FileChangeType, WorkspaceUpdateKind) from spyder.plugins.completion.decorators import ( request, handles, class_register) # Localization _ = get_translation("spyder") class ProjectsMenuSubmenus: RecentProjects = 'recent_projects' class ProjectsActions: NewProject = 'new_project_action' OpenProject = 'open_project_action' CloseProject = 'close_project_action' DeleteProject = 'delete_project_action' ClearRecentProjects = 'clear_recent_projects_action' MaxRecent = 'max_recent_action' class RecentProjectsMenuSections: Recent = 'recent_section' Extras = 'extras_section' @class_register class Projects(SpyderDockablePlugin): """Projects plugin.""" NAME = 'project_explorer' CONF_SECTION = NAME CONF_FILE = False REQUIRES = [] OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Editor, Plugins.MainMenu] WIDGET_CLASS = ProjectExplorerWidget # Signals sig_project_created = Signal(str, str, object) """ This signal is emitted to request the Projects plugin the creation of a project. Parameters ---------- project_path: str Location of project. project_type: str Type of project as defined by project types. project_packages: object Package to install. Currently not in use. """ sig_project_loaded = Signal(object) """ This signal is emitted when a project is loaded. Parameters ---------- project_path: object Loaded project path. """ sig_project_closed = Signal((object,), (bool,)) """ This signal is emitted when a project is closed. Parameters ---------- project_path: object Closed project path (signature 1). close_project: bool This is emitted only when closing a project but not when switching between projects (signature 2). """ sig_pythonpath_changed = Signal() """ This signal is emitted when the Python path has changed. """ def __init__(self, parent=None, configuration=None): """Initialization.""" super().__init__(parent, configuration) self.recent_projects = self.get_conf('recent_projects', []) self.current_active_project = None self.latest_project = None self.watcher = WorkspaceWatcher(self) self.completions_available = False self.get_widget().setup_project(self.get_active_project_path()) self.watcher.connect_signals(self) self._project_types = OrderedDict() # ---- SpyderDockablePlugin API # ------------------------------------------------------------------------ def get_name(self): return _("Project") def get_description(self): return _("Create Spyder projects and manage their files.") def get_icon(self): return self.create_icon('project') def on_initialize(self): """Register plugin in Spyder's main window""" widget = self.get_widget() treewidget = widget.treewidget self.ipyconsole = None self.editor = None self.completions = None treewidget.sig_delete_project.connect(self.delete_project) treewidget.sig_redirect_stdio_requested.connect( self.sig_redirect_stdio_requested) self.sig_switch_to_plugin_requested.connect( lambda plugin, check: self.show_explorer()) self.sig_project_loaded.connect(self.update_explorer) if self.main: widget.sig_open_file_requested.connect(self.main.open_file) self.main.project_path = self.get_pythonpath(at_start=True) self.sig_project_loaded.connect( lambda v: self.main.set_window_title()) self.sig_project_closed.connect( lambda v: self.main.set_window_title()) self.main.restore_scrollbar_position.connect( self.restore_scrollbar_position) self.sig_pythonpath_changed.connect(self.main.pythonpath_changed) self.register_project_type(self, EmptyProject) self.setup() @on_plugin_available(plugin=Plugins.Editor) def on_editor_available(self): self.editor = self.get_plugin(Plugins.Editor) widget = self.get_widget() treewidget = widget.treewidget treewidget.sig_open_file_requested.connect(self.editor.load) treewidget.sig_removed.connect(self.editor.removed) treewidget.sig_tree_removed.connect(self.editor.removed_tree) treewidget.sig_renamed.connect(self.editor.renamed) treewidget.sig_tree_renamed.connect(self.editor.renamed_tree) treewidget.sig_module_created.connect(self.editor.new) treewidget.sig_file_created.connect( lambda t: self.editor.new(text=t)) self.sig_project_loaded.connect( lambda v: self.editor.setup_open_files()) self.sig_project_closed[bool].connect( lambda v: self.editor.setup_open_files()) self.editor.set_projects(self) self.sig_project_loaded.connect( lambda v: self.editor.set_current_project_path(v)) self.sig_project_closed.connect( lambda v: self.editor.set_current_project_path()) @on_plugin_available(plugin=Plugins.Completions) def on_completions_available(self): self.completions = self.get_plugin(Plugins.Completions) # TODO: This is not necessary anymore due to us starting workspace # services in the editor. However, we could restore it in the future. # completions.sig_language_completions_available.connect( # lambda settings, language: # self.start_workspace_services()) self.completions.sig_stop_completions.connect( self.stop_workspace_services) self.sig_project_loaded.connect( functools.partial(self.completions.project_path_update, update_kind=WorkspaceUpdateKind.ADDITION, instance=self)) self.sig_project_closed.connect( functools.partial(self.completions.project_path_update, update_kind=WorkspaceUpdateKind.DELETION, instance=self)) @on_plugin_available(plugin=Plugins.IPythonConsole) def on_ipython_console_available(self): self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) widget = self.get_widget() treewidget = widget.treewidget treewidget.sig_open_interpreter_requested.connect( self.ipyconsole.create_client_from_path) treewidget.sig_run_requested.connect( lambda fname: self.ipyconsole.run_script( fname, osp.dirname(fname), '', False, False, False, True, False) ) @on_plugin_available(plugin=Plugins.MainMenu) def on_main_menu_available(self): main_menu = self.get_plugin(Plugins.MainMenu) new_project_action = self.get_action(ProjectsActions.NewProject) open_project_action = self.get_action(ProjectsActions.OpenProject) projects_menu = main_menu.get_application_menu( ApplicationMenus.Projects) projects_menu.aboutToShow.connect(self.is_invalid_active_project) main_menu.add_item_to_application_menu( new_project_action, menu_id=ApplicationMenus.Projects, section=ProjectsMenuSections.New) for item in [open_project_action, self.close_project_action, self.delete_project_action]: main_menu.add_item_to_application_menu( item, menu_id=ApplicationMenus.Projects, section=ProjectsMenuSections.Open) main_menu.add_item_to_application_menu( self.recent_project_menu, menu_id=ApplicationMenus.Projects, section=ProjectsMenuSections.Extras) def setup(self): """Setup the plugin actions.""" self.create_action( ProjectsActions.NewProject, text=_("New Project..."), triggered=self.create_new_project) self.create_action( ProjectsActions.OpenProject, text=_("Open Project..."), triggered=lambda v: self.open_project()) self.close_project_action = self.create_action( ProjectsActions.CloseProject, text=_("Close Project"), triggered=self.close_project) self.delete_project_action = self.create_action( ProjectsActions.DeleteProject, text=_("Delete Project"), triggered=self.delete_project) self.clear_recent_projects_action = self.create_action( ProjectsActions.ClearRecentProjects, text=_("Clear this list"), triggered=self.clear_recent_projects) self.max_recent_action = self.create_action( ProjectsActions.MaxRecent, text=_("Maximum number of recent projects..."), triggered=self.change_max_recent_projects) self.recent_project_menu = self.get_widget().create_menu( ProjectsMenuSubmenus.RecentProjects, _("Recent Projects") ) self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions) self.setup_menu_actions() def setup_menu_actions(self): """Setup and update the menu actions.""" if self.recent_projects: for project in self.recent_projects: if self.is_valid_project(project): if os.name == 'nt': name = project else: name = project.replace(get_home_dir(), '~') try: action = self.get_action(name) except KeyError: action = self.create_action( name, text=name, icon=ima.icon('project'), triggered=self.build_opener(project), ) self.get_widget().add_item_to_menu( action, menu=self.recent_project_menu, section=RecentProjectsMenuSections.Recent) for item in [self.clear_recent_projects_action, self.max_recent_action]: self.get_widget().add_item_to_menu( item, menu=self.recent_project_menu, section=RecentProjectsMenuSections.Extras) self.update_project_actions() def update_project_actions(self): """Update actions of the Projects menu""" if self.recent_projects: self.clear_recent_projects_action.setEnabled(True) else: self.clear_recent_projects_action.setEnabled(False) active = bool(self.get_active_project_path()) self.close_project_action.setEnabled(active) self.delete_project_action.setEnabled(active) def on_close(self, cancelable=False): """Perform actions before parent main window is closed""" self.save_config() return True def unmaximize(self): """Unmaximize the currently maximized plugin, if not self.""" if self.main: if (self.main.last_plugin is not None and self.main.last_plugin._ismaximized and self.main.last_plugin is not self): self.main.maximize_dockwidget() def build_opener(self, project): """Build function opening passed project""" def opener(*args, **kwargs): self.open_project(path=project) return opener # ------ Public API ------------------------------------------------------- @Slot() def create_new_project(self): """Create new project.""" self.unmaximize() dlg = ProjectDialog(self.get_widget(), project_types=self.get_project_types()) result = dlg.exec_() data = dlg.project_data root_path = data.get("root_path", None) project_type = data.get("project_type", EmptyProject.ID) if result: self._create_project(root_path, project_type_id=project_type) dlg.close() def _create_project(self, root_path, project_type_id=EmptyProject.ID, packages=None): """Create a new project.""" project_types = self.get_project_types() if project_type_id in project_types: project_type_class = project_types[project_type_id] project = project_type_class( root_path=root_path, parent_plugin=project_type_class._PARENT_PLUGIN, ) created_succesfully, message = project.create_project() if not created_succesfully: QMessageBox.warning( self.get_widget(), "Project creation", message) shutil.rmtree(root_path, ignore_errors=True) return # TODO: In a subsequent PR return a value and emit based on that self.sig_project_created.emit(root_path, project_type_id, packages) self.open_project(path=root_path, project=project) else: if not running_under_pytest(): QMessageBox.critical( self.get_widget(), _('Error'), _("{} is not a registered Spyder project " "type!").format(project_type_id) ) def open_project(self, path=None, project=None, restart_consoles=True, save_previous_files=True, workdir=None): """Open the project located in `path`.""" self.unmaximize() if path is None: basedir = get_home_dir() path = getexistingdirectory(parent=self.get_widget(), caption=_("Open project"), basedir=basedir) path = encoding.to_unicode_from_fs(path) if not self.is_valid_project(path): if path: QMessageBox.critical( self.get_widget(), _('Error'), _("%s is not a Spyder project!") % path, ) return else: path = encoding.to_unicode_from_fs(path) if project is None: project_type_class = self._load_project_type_class(path) project = project_type_class( root_path=path, parent_plugin=project_type_class._PARENT_PLUGIN, ) # A project was not open before if self.current_active_project is None: if save_previous_files and self.editor is not None: self.editor.save_open_files() if self.editor is not None: self.set_conf('last_working_dir', getcwd_or_home(), section='editor') if self.get_conf('visible_if_project_open'): self.show_explorer() else: # We are switching projects if self.editor is not None: self.set_project_filenames(self.editor.get_open_filenames()) # TODO: Don't emit sig_project_closed when we support # multiple workspaces. self.sig_project_closed.emit( self.current_active_project.root_path) self.watcher.stop() self.current_active_project = project self.latest_project = project self.add_to_recent(path) self.set_conf('current_project_path', self.get_active_project_path()) self.setup_menu_actions() if workdir and osp.isdir(workdir): self.sig_project_loaded.emit(workdir) else: self.sig_project_loaded.emit(path) self.sig_pythonpath_changed.emit() self.watcher.start(path) if restart_consoles: self.restart_consoles() open_successfully, message = project.open_project() if not open_successfully: QMessageBox.warning(self.get_widget(), "Project open", message) def close_project(self): """ Close current project and return to a window without an active project """ if self.current_active_project: self.unmaximize() if self.editor is not None: self.set_project_filenames( self.editor.get_open_filenames()) path = self.current_active_project.root_path closed_sucessfully, message = ( self.current_active_project.close_project()) if not closed_sucessfully: QMessageBox.warning( self.get_widget(), "Project close", message) self.current_active_project = None self.set_conf('current_project_path', None) self.setup_menu_actions() self.sig_project_closed.emit(path) self.sig_project_closed[bool].emit(True) self.sig_pythonpath_changed.emit() # Hide pane. self.set_conf('visible_if_project_open', self.get_widget().isVisible()) self.toggle_view(False) self.get_widget().clear() self.restart_consoles() self.watcher.stop() def delete_project(self): """ Delete the current project without deleting the files in the directory. """ if self.current_active_project: self.unmaximize() path = self.current_active_project.root_path buttons = QMessageBox.Yes | QMessageBox.No answer = QMessageBox.warning( self.get_widget(), _("Delete"), _("Do you really want to delete {filename}?

" "Note: This action will only delete the project. " "Its files are going to be preserved on disk." ).format(filename=osp.basename(path)), buttons) if answer == QMessageBox.Yes: try: self.close_project() shutil.rmtree(osp.join(path, '.spyproject')) except EnvironmentError as error: QMessageBox.critical( self.get_widget(), _("Project Explorer"), _("Unable to delete {varpath}" "

The error message was:
{error}" ).format(varpath=path, error=to_text_string(error))) def clear_recent_projects(self): """Clear the list of recent projects""" self.recent_projects = [] self.set_conf('recent_projects', self.recent_projects) self.setup_menu_actions() def change_max_recent_projects(self): """Change max recent projects entries.""" mrf, valid = QInputDialog.getInt( self.get_widget(), _('Projects'), _('Maximum number of recent projects'), self.get_conf('max_recent_projects'), 1, 35) if valid: self.set_conf('max_recent_projects', mrf) def get_active_project(self): """Get the active project""" return self.current_active_project def reopen_last_project(self): """ Reopen the active project when Spyder was closed last time, if any """ current_project_path = self.get_conf('current_project_path', default=None) # Needs a safer test of project existence! if (current_project_path and self.is_valid_project(current_project_path)): self.open_project(path=current_project_path, restart_consoles=False, save_previous_files=False) self.load_config() def get_project_filenames(self): """Get the list of recent filenames of a project""" recent_files = [] if self.current_active_project: recent_files = self.current_active_project.get_recent_files() elif self.latest_project: recent_files = self.latest_project.get_recent_files() return recent_files def set_project_filenames(self, recent_files): """Set the list of open file names in a project""" if (self.current_active_project and self.is_valid_project( self.current_active_project.root_path)): self.current_active_project.set_recent_files(recent_files) def get_active_project_path(self): """Get path of the active project""" active_project_path = None if self.current_active_project: active_project_path = self.current_active_project.root_path return active_project_path def get_pythonpath(self, at_start=False): """Get project path as a list to be added to PYTHONPATH""" if at_start: current_path = self.get_conf('current_project_path', default=None) else: current_path = self.get_active_project_path() if current_path is None: return [] else: return [current_path] def get_last_working_dir(self): """Get the path of the last working directory""" return self.get_conf( 'last_working_dir', section='editor', default=getcwd_or_home()) def save_config(self): """ Save configuration: opened projects & tree widget state. Also save whether dock widget is visible if a project is open. """ self.set_conf('recent_projects', self.recent_projects) self.set_conf('expanded_state', self.get_widget().treewidget.get_expanded_state()) self.set_conf('scrollbar_position', self.get_widget().treewidget.get_scrollbar_position()) if self.current_active_project: self.set_conf('visible_if_project_open', self.get_widget().isVisible()) def load_config(self): """Load configuration: opened projects & tree widget state""" expanded_state = self.get_conf('expanded_state', None) # Sometimes the expanded state option may be truncated in .ini file # (for an unknown reason), in this case it would be converted to a # string by 'userconfig': if is_text_string(expanded_state): expanded_state = None if expanded_state is not None: self.get_widget().treewidget.set_expanded_state(expanded_state) def restore_scrollbar_position(self): """Restoring scrollbar position after main window is visible""" scrollbar_pos = self.get_conf('scrollbar_position', None) if scrollbar_pos is not None: self.get_widget().treewidget.set_scrollbar_position(scrollbar_pos) def update_explorer(self): """Update explorer tree""" self.get_widget().setup_project(self.get_active_project_path()) def show_explorer(self): """Show the explorer""" if self.get_widget() is not None: self.toggle_view(True) self.get_widget().setVisible(True) self.get_widget().raise_() self.get_widget().update() def restart_consoles(self): """Restart consoles when closing, opening and switching projects""" if self.ipyconsole is not None: self.ipyconsole.restart() def is_valid_project(self, path): """Check if a directory is a valid Spyder project""" spy_project_dir = osp.join(path, '.spyproject') return osp.isdir(path) and osp.isdir(spy_project_dir) def is_invalid_active_project(self): """Handle an invalid active project.""" try: path = self.get_active_project_path() except AttributeError: return if bool(path): if not self.is_valid_project(path): if path: QMessageBox.critical( self.get_widget(), _('Error'), _("{} is no longer a valid Spyder project! " "Since it is the current active project, it will " "be closed automatically.").format(path) ) self.close_project() def add_to_recent(self, project): """ Add an entry to recent projetcs We only maintain the list of the 10 most recent projects """ if project not in self.recent_projects: self.recent_projects.insert(0, project) if len(self.recent_projects) > self.get_conf('max_recent_projects'): self.recent_projects.pop(-1) def start_workspace_services(self): """Enable LSP workspace functionality.""" self.completions_available = True if self.current_active_project: path = self.get_active_project_path() self.notify_project_open(path) def stop_workspace_services(self, _language): """Disable LSP workspace functionality.""" self.completions_available = False def emit_request(self, method, params, requires_response): """Send request/notification/response to all LSP servers.""" params['requires_response'] = requires_response params['response_instance'] = self if self.completions: self.completions.broadcast_notification(method, params) @Slot(str, dict) def handle_response(self, method, params): """Method dispatcher for LSP requests.""" if method in self.handler_registry: handler_name = self.handler_registry[method] handler = getattr(self, handler_name) handler(params) @Slot(str, str, bool) @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, requires_response=False) def file_moved(self, src_file, dest_file, is_dir): """Notify LSP server about a file that is moved.""" # LSP specification only considers file updates if is_dir: return deletion_entry = { 'file': src_file, 'kind': FileChangeType.DELETED } addition_entry = { 'file': dest_file, 'kind': FileChangeType.CREATED } entries = [addition_entry, deletion_entry] params = { 'params': entries } return params @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, requires_response=False) @Slot(str, bool) def file_created(self, src_file, is_dir): """Notify LSP server about file creation.""" if is_dir: return params = { 'params': [{ 'file': src_file, 'kind': FileChangeType.CREATED }] } return params @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, requires_response=False) @Slot(str, bool) def file_deleted(self, src_file, is_dir): """Notify LSP server about file deletion.""" if is_dir: return params = { 'params': [{ 'file': src_file, 'kind': FileChangeType.DELETED }] } return params @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, requires_response=False) @Slot(str, bool) def file_modified(self, src_file, is_dir): """Notify LSP server about file modification.""" if is_dir: return params = { 'params': [{ 'file': src_file, 'kind': FileChangeType.CHANGED }] } return params @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, requires_response=False) def notify_project_open(self, path): """Notify LSP server about project path availability.""" params = { 'folder': path, 'instance': self, 'kind': 'addition' } return params @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, requires_response=False) def notify_project_close(self, path): """Notify LSP server to unregister project path.""" params = { 'folder': path, 'instance': self, 'kind': 'deletion' } return params @handles(CompletionRequestTypes.WORKSPACE_APPLY_EDIT) @request(method=CompletionRequestTypes.WORKSPACE_APPLY_EDIT, requires_response=False) def handle_workspace_edit(self, params): """Apply edits to multiple files and notify server about success.""" edits = params['params'] response = { 'applied': False, 'error': 'Not implemented', 'language': edits['language'] } return response # --- New API: # ------------------------------------------------------------------------ def _load_project_type_class(self, path): """ Load a project type class from the config project folder directly. Notes ----- This is done directly, since using the EmptyProject would rewrite the value in the constructor. If the project found has not been registered as a valid project type, the EmptyProject type will be returned. Returns ------- spyder.plugins.projects.api.BaseProjectType Loaded project type class. """ fpath = osp.join( path, get_project_config_folder(), 'config', WORKSPACE + ".ini") project_type_id = EmptyProject.ID if osp.isfile(fpath): config = configparser.ConfigParser() config.read(fpath) project_type_id = config[WORKSPACE].get( "project_type", EmptyProject.ID) EmptyProject._PARENT_PLUGIN = self project_types = self.get_project_types() project_type_class = project_types.get(project_type_id, EmptyProject) return project_type_class def register_project_type(self, parent_plugin, project_type): """ Register a new project type. Parameters ---------- parent_plugin: spyder.plugins.api.plugins.SpyderPluginV2 The parent plugin instance making the project type registration. project_type: spyder.plugins.projects.api.BaseProjectType Project type to register. """ if not issubclass(project_type, BaseProjectType): raise SpyderAPIError("A project type must subclass " "BaseProjectType!") project_id = project_type.ID if project_id in self._project_types: raise SpyderAPIError("A project type id '{}' has already been " "registered!".format(project_id)) project_type._PARENT_PLUGIN = parent_plugin self._project_types[project_id] = project_type def get_project_types(self): """ Return available registered project types. Returns ------- dict Project types dictionary. Keys are project type IDs and values are project type classes. """ return self._project_types