# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * * # * Copyright (c) 2022 FreeCAD Project Association * # * * # * This file is part of FreeCAD. * # * * # * FreeCAD is free software: you can redistribute it and/or modify it * # * under the terms of the GNU Lesser General Public License as * # * published by the Free Software Foundation, either version 2.1 of the * # * License, or (at your option) any later version. * # * * # * FreeCAD is distributed in the hope that it will be useful, but * # * WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * # * Lesser General Public License for more details. * # * * # * You should have received a copy of the GNU Lesser General Public * # * License along with FreeCAD. If not, see * # * . * # * * # *************************************************************************** """ Classes to manage "Developer Mode" """ import os import datetime import subprocess import FreeCAD import FreeCADGui from freecad.utils import get_python_exe from PySide.QtWidgets import ( QFileDialog, QListWidgetItem, QDialog, QSizePolicy, QMessageBox, ) from PySide.QtGui import ( QIcon, QPixmap, ) from PySide.QtCore import Qt from addonmanager_git import GitManager, NoGitFound from addonmanager_devmode_add_content import AddContent from addonmanager_devmode_validators import NameValidator, VersionValidator from addonmanager_devmode_predictor import Predictor from addonmanager_devmode_people_table import PeopleTable from addonmanager_devmode_licenses_table import LicensesTable import addonmanager_utilities as utils translate = FreeCAD.Qt.translate # pylint: disable=too-few-public-methods ContentTypeRole = Qt.UserRole ContentIndexRole = Qt.UserRole + 1 class AddonGitInterface: """Wrapper to handle the git calls needed by this class""" git_manager = None def __init__(self, path): self.git_exists = False if not AddonGitInterface.git_manager: try: AddonGitInterface.git_manager = GitManager() except NoGitFound: FreeCAD.Console.PrintLog("No git found, Addon Manager Developer Mode disabled.") return self.path = path if os.path.exists(os.path.join(path, ".git")): self.git_exists = True self.branch = AddonGitInterface.git_manager.current_branch(self.path) self.remote = AddonGitInterface.git_manager.get_remote(self.path) @property def branches(self): """The branches available for this repo.""" if self.git_exists: return AddonGitInterface.git_manager.get_branches(self.path) return [] @property def committers(self): """The committers to this repo, in the last ten commits""" if self.git_exists: return AddonGitInterface.git_manager.get_last_committers(self.path, 10) return [] @property def authors(self): """The committers to this repo, in the last ten commits""" if self.git_exists: return AddonGitInterface.git_manager.get_last_authors(self.path, 10) return [] # pylint: disable=too-many-instance-attributes class DeveloperMode: """The main Developer Mode dialog, for editing package.xml metadata graphically.""" def __init__(self): # In the UI we want to show a translated string for the person type, but the underlying # string must be the one expected by the metadata parser, in English self.person_type_translation = { "maintainer": translate("AddonsInstaller", "Maintainer"), "author": translate("AddonsInstaller", "Author"), } self.dialog = FreeCADGui.PySideUic.loadUi( os.path.join(os.path.dirname(__file__), "developer_mode.ui") ) self.people_table = PeopleTable() self.licenses_table = LicensesTable() large_size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) large_size_policy.setHorizontalStretch(2) small_size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) small_size_policy.setHorizontalStretch(1) self.people_table.widget.setSizePolicy(large_size_policy) self.licenses_table.widget.setSizePolicy(small_size_policy) self.dialog.peopleAndLicenseshorizontalLayout.addWidget(self.people_table.widget) self.dialog.peopleAndLicenseshorizontalLayout.addWidget(self.licenses_table.widget) self.pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") self.current_mod: str = "" self.git_interface = None self.has_toplevel_icon = False self.metadata = None self._setup_dialog_signals() self.dialog.displayNameLineEdit.setValidator(NameValidator()) self.dialog.versionLineEdit.setValidator(VersionValidator()) self.dialog.minPythonLineEdit.setValidator(VersionValidator()) self.dialog.addContentItemToolButton.setIcon( QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) ) self.dialog.removeContentItemToolButton.setIcon( QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) ) def show(self, parent=None, path: str = None): """Show the main dev mode dialog""" if parent: self.dialog.setParent(parent) if path and os.path.exists(path): self.dialog.pathToAddonComboBox.setEditText(path) elif self.pref.HasGroup("recentModsList"): recent_mods_group = self.pref.GetGroup("recentModsList") entry = recent_mods_group.GetString("Mod0", "") if entry: self._populate_dialog(entry) self._update_recent_mods(entry) self._populate_combo() else: self._clear_all_fields() else: self._clear_all_fields() result = self.dialog.exec() if result == QDialog.Accepted: self._sync_metadata_to_ui() now = datetime.date.today() self.metadata.Date = str(now) self.metadata.write(os.path.join(self.current_mod, "package.xml")) def _populate_dialog(self, path_to_repo: str): """Populate this dialog using the best available parsing of the contents of the repo at path_to_repo. This is a multi-layered process that starts with any existing package.xml file or other known metadata files, and proceeds through examining the contents of the directory structure.""" self.current_mod = path_to_repo self._scan_for_git_info(self.current_mod) metadata_path = os.path.join(path_to_repo, "package.xml") if os.path.exists(metadata_path): try: self.metadata = FreeCAD.Metadata(metadata_path) except FreeCAD.Base.XMLBaseException as e: FreeCAD.Console.PrintError( translate( "AddonsInstaller", "XML failure while reading metadata from file {}", ).format(metadata_path) + "\n\n" + str(e) + "\n\n" ) except FreeCAD.Base.RuntimeError as e: FreeCAD.Console.PrintError( translate("AddonsInstaller", "Invalid metadata in file {}").format( metadata_path ) + "\n\n" + str(e) + "\n\n" ) self._clear_all_fields() if not self.metadata: self._predict_metadata() self.dialog.displayNameLineEdit.setText(self.metadata.Name) self.dialog.descriptionTextEdit.setPlainText(self.metadata.Description) self.dialog.versionLineEdit.setText(self.metadata.Version) self.dialog.minPythonLineEdit.setText(self.metadata.PythonMin) self._populate_urls_from_metadata(self.metadata) self._populate_contents_from_metadata(self.metadata) self._populate_icon_from_metadata(self.metadata) self.people_table.show(self.metadata) self.licenses_table.show(self.metadata, self.current_mod) def _populate_urls_from_metadata(self, metadata): """Use the passed metadata object to populate the urls""" for url in metadata.Urls: if url["type"] == "website": self.dialog.websiteURLLineEdit.setText(url["location"]) elif url["type"] == "repository": self.dialog.repositoryURLLineEdit.setText(url["location"]) branch_from_metadata = url["branch"] branch_from_local_path = self.git_interface.branch if branch_from_metadata != branch_from_local_path: # pylint: disable=line-too-long FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "WARNING: Path specified in package.xml metadata does not match currently checked-out branch.", ) + "\n" ) self.dialog.branchComboBox.setCurrentText(branch_from_metadata) elif url["type"] == "bugtracker": self.dialog.bugtrackerURLLineEdit.setText(url["location"]) elif url["type"] == "readme": self.dialog.readmeURLLineEdit.setText(url["location"]) elif url["type"] == "documentation": self.dialog.documentationURLLineEdit.setText(url["location"]) elif url["type"] == "discussion": self.dialog.discussionURLLineEdit.setText(url["location"]) def _populate_contents_from_metadata(self, metadata): """Use the passed metadata object to populate the contents list""" contents = metadata.Content self.dialog.contentsListWidget.clear() for content_type in contents: counter = 0 for item in contents[content_type]: contents_string = f"[{content_type}] " info = [] if item.Name: info.append(translate("AddonsInstaller", "Name") + ": " + item.Name) if item.Classname: info.append(translate("AddonsInstaller", "Class") + ": " + item.Classname) if item.Description: info.append( translate("AddonsInstaller", "Description") + ": " + item.Description ) if item.Subdirectory: info.append( translate("AddonsInstaller", "Subdirectory") + ": " + item.Subdirectory ) if item.File: info.append(translate("AddonsInstaller", "Files") + ": " + ", ".join(item.File)) contents_string += ", ".join(info) item = QListWidgetItem(contents_string) item.setData(ContentTypeRole, content_type) item.setData(ContentIndexRole, counter) self.dialog.contentsListWidget.addItem(item) counter += 1 def _populate_icon_from_metadata(self, metadata): """Use the passed metadata object to populate the icon fields""" self.dialog.iconDisplayLabel.setPixmap(QPixmap()) icon = metadata.Icon icon_path = None if icon: icon_path = os.path.join(self.current_mod, icon.replace("/", os.path.sep)) self.has_toplevel_icon = True else: self.has_toplevel_icon = False contents = metadata.Content if "workbench" in contents: for wb in contents["workbench"]: icon = wb.Icon path = wb.Subdirectory if icon: icon_path = os.path.join( self.current_mod, path, icon.replace("/", os.path.sep) ) break if icon_path and os.path.isfile(icon_path): icon_data = QIcon(icon_path) if not icon_data.isNull(): self.dialog.iconDisplayLabel.setPixmap(icon_data.pixmap(32, 32)) self.dialog.iconPathLineEdit.setText(icon) def _predict_metadata(self): """If there is no metadata, try to guess at values for it""" self.metadata = FreeCAD.Metadata() predictor = Predictor() self.metadata = predictor.predict_metadata(self.current_mod) def _scan_for_git_info(self, path: str): """Look for branch availability""" self.git_interface = AddonGitInterface(path) if self.git_interface.git_exists: self.dialog.branchComboBox.clear() for branch in self.git_interface.branches: if branch and branch.startswith("origin/") and branch != "origin/HEAD": self.dialog.branchComboBox.addItem(branch[len("origin/") :]) self.dialog.branchComboBox.setCurrentText(self.git_interface.branch) def _clear_all_fields(self): """Clear out all fields""" self.dialog.displayNameLineEdit.clear() self.dialog.descriptionTextEdit.clear() self.dialog.versionLineEdit.clear() self.dialog.websiteURLLineEdit.clear() self.dialog.repositoryURLLineEdit.clear() self.dialog.bugtrackerURLLineEdit.clear() self.dialog.readmeURLLineEdit.clear() self.dialog.documentationURLLineEdit.clear() self.dialog.discussionURLLineEdit.clear() self.dialog.minPythonLineEdit.clear() self.dialog.iconDisplayLabel.setPixmap(QPixmap()) self.dialog.iconPathLineEdit.clear() def _setup_dialog_signals(self): """Set up the signal and slot connections for the main dialog.""" self.dialog.addonPathBrowseButton.clicked.connect(self._addon_browse_button_clicked) self.dialog.pathToAddonComboBox.editTextChanged.connect(self._addon_combo_text_changed) self.dialog.detectMinPythonButton.clicked.connect(self._detect_min_python_clicked) self.dialog.iconBrowseButton.clicked.connect(self._browse_for_icon_clicked) self.dialog.addContentItemToolButton.clicked.connect(self._add_content_clicked) self.dialog.removeContentItemToolButton.clicked.connect(self._remove_content_clicked) self.dialog.contentsListWidget.itemSelectionChanged.connect(self._content_selection_changed) self.dialog.contentsListWidget.itemDoubleClicked.connect(self._edit_content) self.dialog.versionToTodayButton.clicked.connect(self._set_to_today_clicked) # Finally, populate the combo boxes, etc. self._populate_combo() # Disable all the "Remove" buttons until something is selected self.dialog.removeContentItemToolButton.setDisabled(True) def _sync_metadata_to_ui(self): """Take the data from the UI fields and put it into the stored metadata object. Only overwrites known data fields: unknown metadata will be retained.""" if not self.metadata: self.metadata = FreeCAD.Metadata() self.metadata.Name = self.dialog.displayNameLineEdit.text() self.metadata.Description = self.dialog.descriptionTextEdit.document().toPlainText() self.metadata.Version = self.dialog.versionLineEdit.text() self.metadata.Icon = self.dialog.iconPathLineEdit.text() urls = [] if self.dialog.websiteURLLineEdit.text(): urls.append({"location": self.dialog.websiteURLLineEdit.text(), "type": "website"}) if self.dialog.repositoryURLLineEdit.text(): urls.append( { "location": self.dialog.repositoryURLLineEdit.text(), "type": "repository", "branch": self.dialog.branchComboBox.currentText(), } ) if self.dialog.bugtrackerURLLineEdit.text(): urls.append( { "location": self.dialog.bugtrackerURLLineEdit.text(), "type": "bugtracker", } ) if self.dialog.readmeURLLineEdit.text(): urls.append({"location": self.dialog.readmeURLLineEdit.text(), "type": "readme"}) if self.dialog.documentationURLLineEdit.text(): urls.append( { "location": self.dialog.documentationURLLineEdit.text(), "type": "documentation", } ) if self.dialog.discussionURLLineEdit.text(): urls.append( { "location": self.dialog.discussionURLLineEdit.text(), "type": "discussion", } ) self.metadata.Urls = urls if self.dialog.minPythonLineEdit.text(): self.metadata.PythonMin = self.dialog.minPythonLineEdit.text() else: self.metadata.PythonMin = "0.0.0" # Code for "unset" # Content, people, and licenses should already be synchronized ############################################################################################### # DIALOG SLOTS ############################################################################################### def _addon_browse_button_clicked(self): """Launch a modal file/folder selection dialog -- if something is selected, it is processed by the parsing code and used to fill in the contents of the rest of the dialog.""" start_dir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod") mod_dir = QFileDialog.getExistingDirectory( parent=self.dialog, caption=translate( "AddonsInstaller", "Select the folder containing your Addon", ), dir=start_dir, ) if mod_dir and os.path.exists(mod_dir): self.dialog.pathToAddonComboBox.setEditText(mod_dir) def _addon_combo_text_changed(self, new_text: str): """Called when the text is changed, either because it was directly edited, or because a new item was selected.""" if new_text == self.current_mod: # It doesn't look like it actually changed, bail out return self.metadata = None self._clear_all_fields() if not os.path.exists(new_text): # This isn't a thing (Yet. Maybe the user is still typing?) return self._populate_dialog(new_text) self._update_recent_mods(new_text) self._populate_combo() def _populate_combo(self): """Fill in the combo box with the values from the stored recent mods list, selecting the top one. Does not trigger any signals.""" combo = self.dialog.pathToAddonComboBox combo.blockSignals(True) recent_mods_group = self.pref.GetGroup("recentModsList") recent_mods = set() combo.clear() for i in range(10): entry_name = f"Mod{i}" mod = recent_mods_group.GetString(entry_name, "None") if mod != "None" and mod not in recent_mods and os.path.exists(mod): recent_mods.add(mod) combo.addItem(mod) if recent_mods: combo.setCurrentIndex(0) combo.blockSignals(False) def _update_recent_mods(self, path): """Update the list of recent mods, storing at most ten, with path at the top of the list.""" recent_mod_paths = [path] if self.pref.HasGroup("recentModsList"): recent_mods_group = self.pref.GetGroup("recentModsList") # This group has a maximum of ten entries, sorted by last-accessed date for i in range(0, 10): entry_name = f"Mod{i}" entry = recent_mods_group.GetString(entry_name, "") if entry and entry not in recent_mod_paths and os.path.exists(entry): recent_mod_paths.append(entry) # Remove the whole thing, so we can recreate it from scratch self.pref.RemGroup("recentModsList") if recent_mod_paths: recent_mods_group = self.pref.GetGroup("recentModsList") for i, mod in zip(range(10), recent_mod_paths): entry_name = f"Mod{i}" recent_mods_group.SetString(entry_name, mod) def _add_content_clicked(self): """Callback: The Add Content button was clicked""" dlg = AddContent(self.current_mod, self.metadata) singleton = False if self.dialog.contentsListWidget.count() == 0: singleton = True content_type, new_metadata = dlg.exec(singleton=singleton) if content_type and new_metadata: self.metadata.addContentItem(content_type, new_metadata) self._populate_contents_from_metadata(self.metadata) def _remove_content_clicked(self): """Callback: the remove content button was clicked""" item = self.dialog.contentsListWidget.currentItem() if not item: return content_type = item.data(ContentTypeRole) content_index = item.data(ContentIndexRole) if self.metadata.Content[content_type] and content_index < len( self.metadata.Content[content_type] ): content_name = self.metadata.Content[content_type][content_index].Name self.metadata.removeContentItem(content_type, content_name) self._populate_contents_from_metadata(self.metadata) def _content_selection_changed(self): """Callback: the selected content item changed""" items = self.dialog.contentsListWidget.selectedItems() if items: self.dialog.removeContentItemToolButton.setDisabled(False) else: self.dialog.removeContentItemToolButton.setDisabled(True) def _edit_content(self, item): """Callback: a content row was double-clicked""" dlg = AddContent(self.current_mod, self.metadata) content_type = item.data(ContentTypeRole) content_index = item.data(ContentIndexRole) content = self.metadata.Content metadata = content[content_type][content_index] old_name = metadata.Name new_type, new_metadata = dlg.exec(content_type, metadata, len(content) == 1) if new_type and new_metadata: self.metadata.removeContentItem(content_type, old_name) self.metadata.addContentItem(new_type, new_metadata) self._populate_contents_from_metadata(self.metadata) def _set_to_today_clicked(self): """Callback: the "set to today" button was clicked""" year = datetime.date.today().year month = datetime.date.today().month day = datetime.date.today().day version_string = f"{year}.{month:>02}.{day:>02}" self.dialog.versionLineEdit.setText(version_string) def _detect_min_python_clicked(self): if not self._ensure_vermin_loaded(): FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "No Vermin, cancelling operation.", "NOTE: Vermin is a Python package and proper noun - do not translate", ) + "\n" ) return FreeCAD.Console.PrintMessage( translate("AddonsInstaller", "Scanning Addon for Python version compatibility") + "...\n" ) # pylint: disable=import-outside-toplevel import vermin required_minor_version = 0 for dir_path, _, filenames in os.walk(self.current_mod): for filename in filenames: if filename.endswith(".py"): with open(os.path.join(dir_path, filename), encoding="utf-8") as f: contents = f.read() version_strings = vermin.version_strings(vermin.detect(contents)) version = version_strings.split(",") if len(version) >= 2: # Only care about Py3, and only if there is a dot in the version: if "." in version[1]: py3 = version[1].split(".") major = int(py3[0].strip()) minor = int(py3[1].strip()) if major == 3: FreeCAD.Console.PrintLog( f"Detected Python 3.{minor} required by {filename}\n" ) required_minor_version = max(required_minor_version, minor) self.dialog.minPythonLineEdit.setText(f"3.{required_minor_version}") QMessageBox.information( self.dialog, translate("AddonsInstaller", "Minimum Python Version Detected"), translate( "AddonsInstaller", "Vermin auto-detected a required version of Python 3.{}", ).format(required_minor_version), QMessageBox.Ok, ) def _ensure_vermin_loaded(self) -> bool: try: # pylint: disable=import-outside-toplevel,unused-import import vermin except ImportError: # pylint: disable=line-too-long response = QMessageBox.question( self.dialog, translate("AddonsInstaller", "Install Vermin?"), translate( "AddonsInstaller", "Auto-detecting the required version of Python for this Addon requires Vermin (https://pypi.org/project/vermin/). OK to install?", ), QMessageBox.Yes | QMessageBox.Cancel, ) if response == QMessageBox.Cancel: return False FreeCAD.Console.PrintMessage( translate("AddonsInstaller", "Attempting to install Vermin from PyPi") + "...\n" ) python_exe = get_python_exe() vendor_path = os.path.join(FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages") if not os.path.exists(vendor_path): os.makedirs(vendor_path) proc = subprocess.run( [ python_exe, "-m", "pip", "install", "--disable-pip-version-check", "--target", vendor_path, "vermin", ], capture_output=True, check=True, ) FreeCAD.Console.PrintMessage(proc.stdout.decode()) if proc.returncode != 0: QMessageBox.critical( self.dialog, translate("AddonsInstaller", "Installation failed"), translate( "AddonsInstaller", "Failed to install Vermin -- check Report View for details.", "'Vermin' is the name of a Python package, do not translate", ), QMessageBox.Cancel, ) return False try: # pylint: disable=import-outside-toplevel import vermin except ImportError: QMessageBox.critical( self.dialog, translate("AddonsInstaller", "Installation failed"), translate( "AddonsInstaller", "Failed to import vermin after installation -- cannot scan Addon.", "'vermin' is the name of a Python package, do not translate", ), QMessageBox.Cancel, ) return False return True def _browse_for_icon_clicked(self): """Callback: when the "Browse..." button for the icon field is clicked""" new_icon_path, _ = QFileDialog.getOpenFileName( parent=self.dialog, caption=translate( "AddonsInstaller", "Select an icon file for this package", ), dir=self.current_mod, ) if not new_icon_path: return base_path = self.current_mod.replace("/", os.path.sep) icon_path = new_icon_path.replace("/", os.path.sep) if base_path[-1] != os.path.sep: base_path += os.path.sep if not icon_path.startswith(base_path): FreeCAD.Console.PrintError( translate("AddonsInstaller", "{} is not a subdirectory of {}").format( new_icon_path, self.current_mod ) + "\n" ) return self.metadata.Icon = new_icon_path[len(base_path) :] self._populate_icon_from_metadata(self.metadata)