diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index aeafd40955..15421f4fe3 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -32,7 +32,7 @@ import tempfile import hashlib import threading import json -import re # Needed for py 3.6 and earlier, can remove later, search for "re." +import re # Needed for py 3.6 and earlier, can remove later, search for "re." from datetime import date, timedelta from typing import Dict, List @@ -75,9 +75,12 @@ from addonmanager_devmode import DeveloperMode import NetworkManager translate = FreeCAD.Qt.translate + + def QT_TRANSLATE_NOOP(_, txt): return txt + __title__ = "FreeCAD Addon Manager Module" __author__ = "Yorik van Havre", "Jonathan Wiedemann", "Kurt Kremitzki", "Chris Hennes" __url__ = "http://www.freecad.org" @@ -103,6 +106,7 @@ installed. INSTANCE = None + class CommandAddonManager: """The main Addon Manager class and FreeCAD command""" @@ -381,9 +385,7 @@ class CommandAddonManager: self.dialog.buttonUpdateDependencies.clicked.connect( self.show_python_updates_dialog ) - self.dialog.buttonDevTools.clicked.connect( - self.show_developer_tools - ) + self.dialog.buttonDevTools.clicked.connect(self.show_developer_tools) self.packageList.itemSelected.connect(self.table_row_activated) self.packageList.setEnabled(False) self.packageDetails.execute.connect(self.executemacro) @@ -624,10 +626,12 @@ class CommandAddonManager: self.startup_sequence.append(self.load_macro_metadata) selection = pref.GetString("SelectedAddon", "") if selection: - self.startup_sequence.insert(2, functools.partial(self.select_addon, selection)) + self.startup_sequence.insert( + 2, functools.partial(self.select_addon, selection) + ) pref.SetString("SelectedAddon", "") # TODO: migrate this to the developer mode tools - #if ADDON_MANAGER_DEVELOPER_MODE: + # if ADDON_MANAGER_DEVELOPER_MODE: # self.startup_sequence.append(self.validate) self.current_progress_region = 0 self.number_of_progress_regions = len(self.startup_sequence) @@ -922,7 +926,7 @@ class CommandAddonManager: self.check_for_python_package_updates_worker.finished.connect( self.do_next_startup_phase ) - self.update_allowed_packages_list() # Not really the best place for it... + self.update_allowed_packages_list() # Not really the best place for it... self.check_for_python_package_updates_worker.start() def show_python_updates_dialog(self) -> None: @@ -931,7 +935,7 @@ class CommandAddonManager: self.manage_python_packages_dialog.show() def show_developer_tools(self) -> None: - """ Display the developer tools dialog """ + """Display the developer tools dialog""" if not self.developer_mode: self.developer_mode = DeveloperMode() self.developer_mode.show() @@ -1123,7 +1127,7 @@ class CommandAddonManager: False.""" bad_packages = [] - #self.update_allowed_packages_list() + # self.update_allowed_packages_list() for dep in python_required: if dep not in self.allowed_packages: bad_packages.append(dep) @@ -1227,7 +1231,9 @@ class CommandAddonManager: ).clicked.connect(functools.partial(self.dependency_dialog_yes_clicked, repo)) self.dependency_dialog.buttonBox.button( QtWidgets.QDialogButtonBox.Ignore - ).clicked.connect(functools.partial(self.dependency_dialog_ignore_clicked, repo)) + ).clicked.connect( + functools.partial(self.dependency_dialog_ignore_clicked, repo) + ) self.dependency_dialog.buttonBox.button( QtWidgets.QDialogButtonBox.Cancel ).setDefault(True) @@ -1302,7 +1308,9 @@ class CommandAddonManager: self.dependency_installation_worker.failure.connect( self.dependency_installation_failure ) - self.dependency_installation_worker.success.connect(functools.partial(self.install,installing_repo)) + self.dependency_installation_worker.success.connect( + functools.partial(self.install, installing_repo) + ) self.dependency_installation_dialog = QtWidgets.QMessageBox( QtWidgets.QMessageBox.Information, translate("AddonsInstaller", "Installing dependencies"), @@ -1465,12 +1473,8 @@ class CommandAddonManager: self.update_all_worker = UpdateAllWorker(self.packages_with_updates) self.update_all_worker.progress_made.connect(self.update_progress_bar) self.update_all_worker.status_message.connect(self.show_information) - self.update_all_worker.success.connect( - self.subupdates_succeeded.append - ) - self.update_all_worker.failure.connect( - self.subupdates_failed.append - ) + self.update_all_worker.success.connect(self.subupdates_succeeded.append) + self.update_all_worker.failure.connect(self.subupdates_failed.append) self.update_all_worker.finished.connect(self.on_update_all_completed) self.update_all_worker.start() diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 6e7fd0480e..6c60c00e3d 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -11,7 +11,9 @@ SET(AddonManager_SRCS addonmanager_devmode_add_content.py addonmanager_devmode_dependencies.py addonmanager_devmode_license_selector.py + addonmanager_devmode_licenses_table.py addonmanager_devmode_person_editor.py + addonmanager_devmode_people_table.py addonmanager_devmode_predictor.py addonmanager_devmode_validators.py addonmanager_git.py @@ -34,7 +36,9 @@ SET(AddonManager_SRCS developer_mode_edit_dependency.ui developer_mode_freecad_versions.ui developer_mode_license.ui + developer_mode_licenses_table.ui developer_mode_people.ui + developer_mode_people_table.ui developer_mode_select_from_list.ui developer_mode_tags.ui expanded_view.py diff --git a/src/Mod/AddonManager/addonmanager_devmode.py b/src/Mod/AddonManager/addonmanager_devmode.py index 66a0164930..98b1f780e2 100644 --- a/src/Mod/AddonManager/addonmanager_devmode.py +++ b/src/Mod/AddonManager/addonmanager_devmode.py @@ -23,11 +23,18 @@ """ Classes to manage "Developer Mode" """ import os +import datetime import FreeCAD import FreeCADGui -from PySide2.QtWidgets import QFileDialog, QTableWidgetItem, QListWidgetItem, QDialog +from PySide2.QtWidgets import ( + QFileDialog, + QTableWidgetItem, + QListWidgetItem, + QDialog, + QSizePolicy, +) from PySide2.QtGui import ( QIcon, QPixmap, @@ -35,10 +42,11 @@ from PySide2.QtGui import ( from PySide2.QtCore import Qt from addonmanager_git import GitManager -from addonmanager_devmode_license_selector import LicenseSelector -from addonmanager_devmode_person_editor import PersonEditor 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 translate = FreeCAD.Qt.translate @@ -47,6 +55,7 @@ translate = FreeCAD.Qt.translate ContentTypeRole = Qt.UserRole ContentIndexRole = Qt.UserRole + 1 + class AddonGitInterface: """Wrapper to handle the git calls needed by this class""" @@ -82,7 +91,6 @@ class AddonGitInterface: return [] - class DeveloperMode: """The main Developer Mode dialog, for editing package.xml metadata graphically.""" @@ -97,6 +105,20 @@ class DeveloperMode: 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 @@ -108,18 +130,6 @@ class DeveloperMode: self.dialog.displayNameLineEdit.setValidator(NameValidator()) self.dialog.versionLineEdit.setValidator(VersionValidator()) - self.dialog.addPersonToolButton.setIcon( - QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) - ) - self.dialog.removePersonToolButton.setIcon( - QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) - ) - self.dialog.addLicenseToolButton.setIcon( - QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) - ) - self.dialog.removeLicenseToolButton.setIcon( - QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) - ) self.dialog.addContentItemToolButton.setIcon( QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) ) @@ -127,21 +137,34 @@ class DeveloperMode: QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) ) - def show(self, parent=None): + def show(self, parent=None, path=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() + self.metadata.write(os.path.join(self.current_mod, "package.xml")) def _populate_dialog(self, path_to_repo): """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.""" - if self.current_mod == path_to_repo: - return self.current_mod = path_to_repo self._scan_for_git_info(self.current_mod) @@ -178,78 +201,13 @@ class DeveloperMode: self.dialog.descriptionTextEdit.setPlainText(self.metadata.Description) self.dialog.versionLineEdit.setText(self.metadata.Version) - self._populate_people_from_metadata(self.metadata) - self._populate_licenses_from_metadata(self.metadata) self._populate_urls_from_metadata(self.metadata) self._populate_contents_from_metadata(self.metadata) self._populate_icon_from_metadata(self.metadata) - def _populate_people_from_metadata(self, metadata): - """Use the passed metadata object to populate the maintainers and authors""" - self.dialog.peopleTableWidget.setRowCount(0) - row = 0 - for maintainer in metadata.Maintainer: - name = maintainer["name"] - email = maintainer["email"] - self._add_person_row(row, "maintainer", name, email) - row += 1 - for author in metadata.Author: - name = author["name"] - email = author["email"] - self._add_person_row(row, "author", name, email) - row += 1 - - if row == 0: - FreeCAD.Console.PrintWarning( - translate( - "AddonsInstaller", - "WARNING: No maintainer data found in metadata file.", - ) - + "\n" - ) - - def _add_person_row(self, row, person_type, name, email): - """Add this person to the peopleTableWidget at row given""" - self.dialog.peopleTableWidget.insertRow(row) - item = QTableWidgetItem(self.person_type_translation[person_type]) - item.setData(Qt.UserRole, person_type) - self.dialog.peopleTableWidget.setItem(row, 0, item) - self.dialog.peopleTableWidget.setItem(row, 1, QTableWidgetItem(name)) - self.dialog.peopleTableWidget.setItem(row, 2, QTableWidgetItem(email)) - - def _populate_licenses_from_metadata(self, metadata): - """Use the passed metadata object to populate the licenses""" - self.dialog.licensesTableWidget.setRowCount(0) - row = 0 - for lic in metadata.License: - name = lic["name"] - path = lic["file"] - self._add_license_row(row, name, path) - row += 1 - if row == 0: - FreeCAD.Console.PrintWarning( - translate( - "AddonsInstaller", - "WARNING: No license data found in metadata file", - ) - + "\n" - ) - - def _add_license_row(self, row: int, name: str, path: str): - """ Add a row to the table of licenses """ - self.dialog.licensesTableWidget.insertRow(row) - self.dialog.licensesTableWidget.setItem(row, 0, QTableWidgetItem(name)) - self.dialog.licensesTableWidget.setItem(row, 1, QTableWidgetItem(path)) - full_path = os.path.join(self.current_mod, path) - if not os.path.isfile(full_path): - FreeCAD.Console.PrintError( - translate( - "AddonsInstaller", - "ERROR: Could not locate license file at {}", - ).format(full_path) - + "\n" - ) + 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""" @@ -312,7 +270,7 @@ class DeveloperMode: ) contents_string += ", ".join(info) - item = QListWidgetItem (contents_string) + item = QListWidgetItem(contents_string) item.setData(ContentTypeRole, content_type) item.setData(ContentIndexRole, counter) self.dialog.contentsListWidget.addItem(item) @@ -329,7 +287,7 @@ class DeveloperMode: else: self.has_toplevel_icon = False contents = metadata.Content - if contents["workbench"]: + if "workbench" in contents: for wb in contents["workbench"]: icon = wb.Icon path = wb.Subdirectory @@ -339,7 +297,7 @@ class DeveloperMode: ) break - if os.path.isfile(icon_path): + 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)) @@ -348,13 +306,8 @@ class DeveloperMode: def _predict_metadata(self): """If there is no metadata, try to guess at values for it""" self.metadata = FreeCAD.Metadata() - self._predict_author_info() - self._predict_name() - self._predict_description() - self._predict_contents() - self._predict_icon() - self._predict_urls() - self._predict_license() + predictor = Predictor() + self.metadata = predictor.predict_metadata(self.current_mod) def _scan_for_git_info(self, path): """Look for branch availability""" @@ -378,59 +331,6 @@ class DeveloperMode: self.dialog.documentationURLLineEdit.clear() self.dialog.iconDisplayLabel.setPixmap(QPixmap()) self.dialog.iconPathLineEdit.clear() - self.dialog.licensesTableWidget.setRowCount(0) - self.dialog.peopleTableWidget.setRowCount(0) - - def _predict_author_info(self): - """ Look at the git commit history and attempt to discern maintainer and author - information.""" - - self.git_interface = AddonGitInterface(path) - if self.git_interface.git_exists: - committers = self.git_interface.get_last_committers() - else: - return - - # This is a dictionary keyed to the author's name (which can be many different - # things, depending on the author) containing two fields, "email" and "count". It - # is common for there to be multiple entries representing the same human being, - # so a passing attempt is made to reconcile: - for key in committers: - emails = committers[key]["email"] - if "GitHub" in key: - # Robotic merge commit (or other similar), ignore - continue - # Does any other committer share any of these emails? - for other_key in committers: - if other_key == key: - continue - other_emails = committers[other_key]["email"] - for other_email in other_emails: - if other_email in emails: - # There is overlap in the two email lists, so this is probably the - # same author, with a different name (username, pseudonym, etc.) - if not committers[key]["aka"]: - committers[key]["aka"] = set() - committers[key]["aka"].add(other_key) - committers[key]["count"] += committers[other_key]["count"] - committers[key]["email"].combine(committers[other_key]["email"]) - committers.remove(other_key) - break - maintainers = [] - for name,info in committers.items(): - if info["aka"]: - for other_name in info["aka"]: - # Heuristic: the longer name is more likely to be the actual legal name - if len(other_name) > len(name): - name = other_name - # There is no logical basis to choose one email address over another, so just - # take the first one - email = info["email"][0] - commit_count = info["count"] - maintainers.append( {"name":name,"email":email,"count":commit_count} ) - - # Sort by count of commits - maintainers.sort(lambda i:i["count"],reverse=True) def _setup_dialog_signals(self): """Set up the signal and slot connections for the main dialog.""" @@ -442,75 +342,71 @@ class DeveloperMode: self._addon_combo_text_changed ) - self.dialog.addLicenseToolButton.clicked.connect(self._add_license_clicked) - self.dialog.removeLicenseToolButton.clicked.connect( - self._remove_license_clicked - ) - self.dialog.licensesTableWidget.itemSelectionChanged.connect( - self._license_selection_changed - ) - self.dialog.licensesTableWidget.itemDoubleClicked.connect(self._edit_license) - - self.dialog.addPersonToolButton.clicked.connect(self._add_person_clicked) - self.dialog.removePersonToolButton.clicked.connect(self._remove_person_clicked) - self.dialog.peopleTableWidget.itemSelectionChanged.connect( - self._person_selection_changed - ) - self.dialog.peopleTableWidget.itemDoubleClicked.connect(self._edit_person) - 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.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() - if self.dialog.pathToAddonComboBox.currentIndex() != -1: - self._populate_dialog(self.dialog.pathToAddonComboBox.currentText()) # Disable all of the "Remove" buttons until something is selected - self.dialog.removeLicenseToolButton.setDisabled(True) - self.dialog.removePersonToolButton.setDisabled(True) 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. """ + """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.descriptionTextEdit.text() + self.metadata.Description = ( + self.dialog.descriptionTextEdit.document().toPlainText() + ) self.metadata.Version = self.dialog.versionLineEdit.text() self.metadata.Icon = self.dialog.iconPathLineEdit.text() - - url = {} - url["website"] = self.dialog.websiteURLLineEdit.text() - url["repository"] = self.dialog.repositoryURLLineEdit.text() - url["bugtracker"] = self.dialog.bugtrackerURLLineEdit.text() - url["readme"] = self.dialog.readmeURLLineEdit.text() - url["documentation"] = self.dialog.documentationURLLineEdit.text() - self.metadata.setUrl(url) - # Licenses: - licenses = [] - for row in range(self.dialog.licensesTableWidget.rowCount()): - license = {} - license["name"] = self.dialog.licensesTableWidget.item(row,0).text() - license["file"] = self.dialog.licensesTableWidget.item(row,1).text() - licenses.append(license) - self.metadata.setLicense(licenses) + 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", + } + ) + self.metadata.Urls = urls - # Maintainers: - maintainers = [] - authors = [] - for row in range(self.dialog.peopleTableWidget.rowCount()): - person = {} - person["name"] = self.dialog.peopleTableWidget.item(row,1).text() - person["email"] = self.dialog.peopleTableWidget.item(row,2).text() - if self.dialog.peopleTableWidget.item(row,0).data(Qt.UserRole) == "maintainer": - maintainers.append(person) - elif self.dialog.peopleTableWidget.item(row,0).data(Qt.UserRole) == "author": - authors.append(person) - - # Content: + # Content, people, and licenses should already be sync'ed ############################################################################################### # DIALOG SLOTS @@ -540,6 +436,8 @@ class DeveloperMode: 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 @@ -588,108 +486,34 @@ class DeveloperMode: entry_name = f"Mod{i}" recent_mods_group.SetString(entry_name, mod) - def _person_selection_changed(self): - """ Callback: the current selection in the peopleTableWidget changed """ - items = self.dialog.peopleTableWidget.selectedItems() - if items: - self.dialog.removePersonToolButton.setDisabled(False) - else: - self.dialog.removePersonToolButton.setDisabled(True) - - def _license_selection_changed(self): - """ Callback: the current selection in the licensesTableWidget changed """ - items = self.dialog.licensesTableWidget.selectedItems() - if items: - self.dialog.removeLicenseToolButton.setDisabled(False) - else: - self.dialog.removeLicenseToolButton.setDisabled(True) - - def _add_license_clicked(self): - """ Callback: The Add License button was clicked """ - license_selector = LicenseSelector(self.current_mod) - short_code, path = license_selector.exec() - if short_code: - self._add_license_row( - self.dialog.licensesTableWidget.rowCount(), short_code, path - ) - - def _remove_license_clicked(self): - """ Callback: the Remove License button was clicked """ - items = self.dialog.licensesTableWidget.selectedIndexes() - if items: - # We only support single-selection, so can just pull the row # from - # the first entry - self.dialog.licensesTableWidget.removeRow(items[0].row()) - - def _edit_license(self, item): - """ Callback: a license row was double-clicked """ - row = item.row() - short_code = self.dialog.licensesTableWidget.item(row, 0).text() - path = self.dialog.licensesTableWidget.item(row, 1).text() - license_selector = LicenseSelector(self.current_mod) - short_code, path = license_selector.exec(short_code, path) - if short_code: - self.dialog.licensesTableWidget.removeRow(row) - self._add_license_row(row, short_code, path) - - def _add_person_clicked(self): - """ Callback: the Add Person button was clicked """ - dlg = PersonEditor() - person_type, name, email = dlg.exec() - if person_type and name: - self._add_person_row(row, person_type, name, email) - - def _remove_person_clicked(self): - """ Callback: the Remove Person button was clicked """ - items = self.dialog.peopleTableWidget.selectedIndexes() - if items: - # We only support single-selection, so can just pull the row # from - # the first entry - self.dialog.peopleTableWidget.removeRow(items[0].row()) - - def _edit_person(self, item): - """ Callback: a row in the peopleTableWidget was double-clicked """ - row = item.row() - person_type = self.dialog.peopleTableWidget.item(row, 0).data(Qt.UserRole) - name = self.dialog.peopleTableWidget.item(row, 1).text() - email = self.dialog.peopleTableWidget.item(row, 2).text() - - dlg = PersonEditor() - dlg.setup(person_type, name, email) - person_type, name, email = dlg.exec() - - if person_type and name: - self.dialog.peopleTableWidget.removeRow(row) - self._add_person_row(row, person_type, name, email) - self.dialog.peopleTableWidget.selectRow(row) - - def _add_content_clicked(self): - """ Callback: The Add Content button was clicked """ + """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) + 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 """ - + """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]): + 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.metadata.removeContentItem(content_type, content_name) self._populate_contents_from_metadata(self.metadata) def _content_selection_changed(self): - """ Callback: the selected content item changed """ + """Callback: the selected content item changed""" items = self.dialog.contentsListWidget.selectedItems() if items: self.dialog.removeContentItemToolButton.setDisabled(False) @@ -697,7 +521,7 @@ class DeveloperMode: self.dialog.removeContentItemToolButton.setDisabled(True) def _edit_content(self, item): - """ Callback: a content row was double-clicked """ + """Callback: a content row was double-clicked""" dlg = AddContent(self.current_mod, self.metadata) content_type = item.data(ContentTypeRole) @@ -712,4 +536,10 @@ class DeveloperMode: 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) diff --git a/src/Mod/AddonManager/addonmanager_devmode_add_content.py b/src/Mod/AddonManager/addonmanager_devmode_add_content.py index 7a3386547b..e05e0d1373 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_add_content.py +++ b/src/Mod/AddonManager/addonmanager_devmode_add_content.py @@ -31,11 +31,31 @@ import FreeCADGui from Addon import INTERNAL_WORKBENCHES -from PySide2.QtWidgets import QDialog, QLayout, QFileDialog, QTableWidgetItem +from PySide2.QtWidgets import ( + QDialog, + QLayout, + QFileDialog, + QTableWidgetItem, + QSizePolicy, +) from PySide2.QtGui import QIcon from PySide2.QtCore import Qt -from addonmanager_devmode_validators import VersionValidator, NameValidator, PythonIdentifierValidator +from addonmanager_devmode_license_selector import LicenseSelector +from addonmanager_devmode_person_editor import PersonEditor +from addonmanager_devmode_validators import ( + VersionValidator, + NameValidator, + PythonIdentifierValidator, +) +from addonmanager_devmode_utilities import ( + populate_people_from_metadata, + populate_licenses_from_metadata, + add_license_row, + add_person_row, +) +from addonmanager_devmode_people_table import PeopleTable +from addonmanager_devmode_licenses_table import LicensesTable # pylint: disable=too-few-public-methods @@ -59,6 +79,21 @@ class AddContent: self.dialog.addonKindComboBox.setItemData(1, "preferencepack") self.dialog.addonKindComboBox.setItemData(2, "workbench") + 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.toplevel_metadata = toplevel_metadata self.metadata = None self.path_to_addon = path_to_addon.replace("/", os.path.sep) @@ -83,19 +118,6 @@ class AddContent: self.dialog.prefPackNameLineEdit.setValidator(NameValidator()) self.dialog.displayNameLineEdit.setValidator(NameValidator()) self.dialog.workbenchClassnameLineEdit.setValidator(PythonIdentifierValidator()) - - self.dialog.addPersonToolButton.setIcon( - QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) - ) - self.dialog.removePersonToolButton.setIcon( - QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) - ) - self.dialog.addLicenseToolButton.setIcon( - QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) - ) - self.dialog.removeLicenseToolButton.setIcon( - QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) - ) def exec( self, @@ -108,7 +130,9 @@ class AddContent: new content item. It's returned as a tuple with the object type as the first component, and the metadata object itself as the second.""" if metadata: - self.metadata = FreeCAD.Metadata(metadata) # Deep copy + self.metadata = FreeCAD.Metadata(metadata) # Deep copy + else: + self.metadata = FreeCAD.Metadata() self.dialog.singletonCheckBox.setChecked(singleton) if singleton: # This doesn't happen automatically the first time @@ -125,9 +149,6 @@ class AddContent: self.dialog.addonKindComboBox.setCurrentIndex(index) if metadata: self._populate_dialog(metadata) - - self.dialog.removeLicenseToolButton.setDisabled(True) - self.dialog.removePersonToolButton.setDisabled(True) self.dialog.layout().setSizeConstraint(QLayout.SetFixedSize) result = self.dialog.exec() @@ -170,7 +191,8 @@ class AddContent: self.dialog.descriptionTextEdit.setPlainText(metadata.Description) self.dialog.versionLineEdit.setText(metadata.Version) - #TODO: Add people and licenses + self.people_table.show(metadata) + self.licenses_table.show(metadata, self.path_to_addon) def _set_icon(self, icon_relative_path): """Load the icon and display it, and its path, in the dialog.""" @@ -199,7 +221,7 @@ class AddContent: ########################################################################################## # Required data: - current_data:str = self.dialog.addonKindComboBox.currentData() + current_data: str = self.dialog.addonKindComboBox.currentData() if current_data == "preferencepack": self.metadata.Name = self.dialog.prefPackNameLineEdit.text() elif self.dialog.displayNameLineEdit.text(): @@ -208,17 +230,20 @@ class AddContent: if current_data == "workbench": self.metadata.Classname = self.dialog.workbenchClassnameLineEdit.text() elif current_data == "macro": - self.metadata.File = [self.dialog.macroFileLineEdit.text()] + self.metadata.File = [self.dialog.macroFileLineEdit.text()] ########################################################################################## + self.metadata.Subdirectory = self.dialog.subdirectoryLineEdit.text() + self.metadata.Icon = self.dialog.iconLineEdit.text() + # Early return if this is the only addon if self.dialog.singletonCheckBox.isChecked(): return (current_data, self.metadata) - self.metadata.Icon = self.dialog.iconLineEdit.text() - # Otherwise, process the rest of the metadata (display name is already done) - self.metadata.Description = self.dialog.descriptionTextEdit.document().toPlainText() + self.metadata.Description = ( + self.dialog.descriptionTextEdit.document().toPlainText() + ) self.metadata.Version = self.dialog.versionLineEdit.text() maintainers = [] @@ -228,9 +253,9 @@ class AddContent: name = self.dialog.peopleTableWidget.item(row, 1).text() email = self.dialog.peopleTableWidget.item(row, 2).text() if person_type == "maintainer": - maintainers.append({"name":name,"email":email}) + maintainers.append({"name": name, "email": email}) elif person_type == "author": - authors.append({"name":name,"email":email}) + authors.append({"name": name, "email": email}) self.metadata.Maintainer = maintainers self.metadata.Author = authors @@ -241,7 +266,7 @@ class AddContent: license["file"] = self.dialog.licensesTableWidget.item(row, 1).text() licenses.append(license) self.metadata.License = licenses - + return (self.dialog.addonKindComboBox.currentData(), self.metadata) ############################################################################################### @@ -347,6 +372,97 @@ class AddContent: dlg = EditDependencies() result = dlg.exec(self.metadata) + def _person_selection_changed(self): + """Callback: the current selection in the peopleTableWidget changed""" + items = self.dialog.peopleTableWidget.selectedItems() + if items: + self.dialog.removePersonToolButton.setDisabled(False) + else: + self.dialog.removePersonToolButton.setDisabled(True) + + def _license_selection_changed(self): + """Callback: the current selection in the licensesTableWidget changed""" + items = self.dialog.licensesTableWidget.selectedItems() + if items: + self.dialog.removeLicenseToolButton.setDisabled(False) + else: + self.dialog.removeLicenseToolButton.setDisabled(True) + + def _add_license_clicked(self): + """Callback: The Add License button was clicked""" + license_selector = LicenseSelector(self.current_mod) + short_code, path = license_selector.exec() + if short_code: + add_license_row( + self.dialog.licensesTableWidget.rowCount(), + short_code, + path, + self.path_to_addon, + self.dialog.licensesTableWidget, + ) + + def _remove_license_clicked(self): + """Callback: the Remove License button was clicked""" + items = self.dialog.licensesTableWidget.selectedIndexes() + if items: + # We only support single-selection, so can just pull the row # from + # the first entry + self.dialog.licensesTableWidget.removeRow(items[0].row()) + + def _edit_license(self, item): + """Callback: a license row was double-clicked""" + row = item.row() + short_code = self.dialog.licensesTableWidget.item(row, 0).text() + path = self.dialog.licensesTableWidget.item(row, 1).text() + license_selector = LicenseSelector(self.current_mod) + short_code, path = license_selector.exec(short_code, path) + if short_code: + self.dialog.licensesTableWidget.removeRow(row) + add_license_row( + row, + short_code, + path, + self.path_to_addon, + self.dialog.licensesTableWidget, + ) + + def _add_person_clicked(self): + """Callback: the Add Person button was clicked""" + dlg = PersonEditor() + person_type, name, email = dlg.exec() + if person_type and name: + add_person_row( + self.dialog.peopleTableWidget.rowCount(), + person_type, + name, + email, + self.dialog.peopleTableWidget, + ) + + def _remove_person_clicked(self): + """Callback: the Remove Person button was clicked""" + items = self.dialog.peopleTableWidget.selectedIndexes() + if items: + # We only support single-selection, so can just pull the row # from + # the first entry + self.dialog.peopleTableWidget.removeRow(items[0].row()) + + def _edit_person(self, item): + """Callback: a row in the peopleTableWidget was double-clicked""" + row = item.row() + person_type = self.dialog.peopleTableWidget.item(row, 0).data(Qt.UserRole) + name = self.dialog.peopleTableWidget.item(row, 1).text() + email = self.dialog.peopleTableWidget.item(row, 2).text() + + dlg = PersonEditor() + dlg.setup(person_type, name, email) + person_type, name, email = dlg.exec() + + if person_type and name: + self.dialog.peopleTableWidget.removeRow(row) + add_person_row(row, person_type, name, email, self.dialog.peopleTableWidget) + self.dialog.peopleTableWidget.selectRow(row) + class EditTags: """A dialog to edit tags""" @@ -398,9 +514,9 @@ class EditDependencies: self.dialog.removeDependencyToolButton.setDisabled(True) - def exec(self, metadata:FreeCAD.Metadata): + def exec(self, metadata: FreeCAD.Metadata): """Execute the dialog""" - self.metadata = FreeCAD.Metadata(metadata) # Make a copy, in case we cancel + self.metadata = FreeCAD.Metadata(metadata) # Make a copy, in case we cancel row = 0 for dep in self.metadata.Depend: dep_type = dep["type"] @@ -419,8 +535,9 @@ class EditDependencies: if dep_name: row = self.dialog.tableWidget.rowCount() self._add_row(row, dep_type, dep_name, dep_optional) - self.metadata.addDepend({"package":dep_name, "type":dep_type, "optional":dep_optional}) - + self.metadata.addDepend( + {"package": dep_name, "type": dep_type, "optional": dep_optional} + ) def _add_row(self, row, dep_type, dep_name, dep_optional): """Utility function to add a row to the table.""" @@ -449,7 +566,9 @@ class EditDependencies: dep_type = self.dialog.tableWidget.item(row, 0).data(Qt.UserRole) dep_name = self.dialog.tableWidget.item(row, 1).text() dep_optional = bool(self.dialog.tableWidget.item(row, 2)) - self.metadata.removeDepend({"package":dep_name, "type":dep_type, "optional":dep_optional}) + self.metadata.removeDepend( + {"package": dep_name, "type": dep_type, "optional": dep_optional} + ) self.dialog.tableWidget.removeRow(row) def _edit_dependency(self, item): @@ -459,10 +578,20 @@ class EditDependencies: dep_type = self.dialog.tableWidget.item(row, 0).data(Qt.UserRole) dep_name = self.dialog.tableWidget.item(row, 1).text() dep_optional = bool(self.dialog.tableWidget.item(row, 2)) - new_dep_type, new_dep_name, new_dep_optional = dlg.exec(dep_type, dep_name, dep_optional) + new_dep_type, new_dep_name, new_dep_optional = dlg.exec( + dep_type, dep_name, dep_optional + ) if dep_type and dep_name: - self.metadata.removeDepend({"package":dep_name, "type":dep_type, "optional":dep_optional}) - self.metadata.addDepend({"package":new_dep_name, "type":new_dep_type, "optional":new_dep_optional}) + self.metadata.removeDepend( + {"package": dep_name, "type": dep_type, "optional": dep_optional} + ) + self.metadata.addDepend( + { + "package": new_dep_name, + "type": new_dep_type, + "optional": new_dep_optional, + } + ) self.dialog.tableWidget.removeRow(row) self._add_row(row, dep_type, dep_name, dep_optional) @@ -499,7 +628,7 @@ class EditDependency: ) # Expect mostly Python dependencies... - self.dialog.typeComboBox.setCurrentIndex(2) + self.dialog.typeComboBox.setCurrentIndex(2) def exec( self, dep_type="", dep_name="", dep_optional=False @@ -541,7 +670,7 @@ class EditDependency: def _populate_external_addons(self): """Add all known addons to the list""" self.dialog.dependencyComboBox.clear() - #pylint: disable=import-outside-toplevel + # pylint: disable=import-outside-toplevel from AddonManager import INSTANCE as AM_INSTANCE repo_dict = {} @@ -561,7 +690,7 @@ class EditDependency: def _populate_allowed_python_packages(self): """Add all allowed python packages to the list""" self.dialog.dependencyComboBox.clear() - #pylint: disable=import-outside-toplevel + # pylint: disable=import-outside-toplevel from AddonManager import INSTANCE as AM_INSTANCE packages = sorted(AM_INSTANCE.allowed_packages) @@ -601,7 +730,7 @@ class EditFreeCADVersions: ) ) - def exec(self, metadata:FreeCAD.Metadata): + def exec(self, metadata: FreeCAD.Metadata): """Execute the dialog""" if metadata.FreeCADMin != "0.0.0": self.dialog.minVersionLineEdit.setText(metadata.FreeCADMin) diff --git a/src/Mod/AddonManager/addonmanager_devmode_dependencies.py b/src/Mod/AddonManager/addonmanager_devmode_dependencies.py index 7a1d4979ae..2c7df548b0 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_dependencies.py +++ b/src/Mod/AddonManager/addonmanager_devmode_dependencies.py @@ -19,4 +19,3 @@ # * . * # * * # *************************************************************************** - diff --git a/src/Mod/AddonManager/addonmanager_devmode_license_selector.py b/src/Mod/AddonManager/addonmanager_devmode_license_selector.py index ca2e2c1df6..eff0a28055 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_license_selector.py +++ b/src/Mod/AddonManager/addonmanager_devmode_license_selector.py @@ -24,7 +24,7 @@ import os from datetime import date -from typing import Optional +from typing import Optional, Tuple import FreeCAD import FreeCADGui @@ -114,7 +114,9 @@ class LicenseSelector: short_code = self.pref.GetString("devModeLastSelectedLicense", "LGPLv2.1") self.set_license(short_code) - def exec(self, short_code: str = None, license_path: str = "") -> Optional[str]: + def exec( + self, short_code: str = None, license_path: str = "" + ) -> Optional[Tuple[str, str]]: """The main method for executing this dialog, as a modal that returns a tuple of the license's "short code" and optionally the path to the license file. Returns a tuple of None,None if the user cancels the operation.""" diff --git a/src/Mod/AddonManager/addonmanager_devmode_licenses_table.py b/src/Mod/AddonManager/addonmanager_devmode_licenses_table.py new file mode 100644 index 0000000000..e10d14f477 --- /dev/null +++ b/src/Mod/AddonManager/addonmanager_devmode_licenses_table.py @@ -0,0 +1,127 @@ +# *************************************************************************** +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +""" Contains a wrapper class for a table listing authors and maintainers """ + +import os + +from PySide2.QtWidgets import QTableWidgetItem +from PySide2.QtGui import QIcon +from PySide2.QtCore import Qt + +import FreeCAD +import FreeCADGui + +from addonmanager_devmode_license_selector import LicenseSelector + +translate = FreeCAD.Qt.translate + + +class LicensesTable: + """A QTableWidget and associated buttons for managing the list of authors and maintainers.""" + + def __init__(self): + self.widget = FreeCADGui.PySideUic.loadUi( + os.path.join(os.path.dirname(__file__), "developer_mode_licenses_table.ui") + ) + + self.widget.addButton.setIcon( + QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) + ) + self.widget.removeButton.setIcon( + QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) + ) + + self.widget.addButton.clicked.connect(self._add_clicked) + self.widget.removeButton.clicked.connect(self._remove_clicked) + self.widget.tableWidget.itemSelectionChanged.connect(self._selection_changed) + self.widget.tableWidget.itemDoubleClicked.connect(self._edit) + + def show(self, metadata, path_to_addon): + """Set up the widget based on incoming metadata""" + self.metadata = metadata + self.path_to_addon = path_to_addon + self._populate_from_metadata() + self.widget.removeButton.setDisabled(True) + self.widget.show() + + def _populate_from_metadata(self): + """Use the passed metadata object to populate the maintainers and authors""" + self.widget.tableWidget.setRowCount(0) + row = 0 + for license in self.metadata.License: + shortcode = license["name"] + path = license["file"] + self._add_row(row, shortcode, path) + row += 1 + + def _add_row(self, row, shortcode, path): + """Add this license to the tableWidget at row given""" + self.widget.tableWidget.insertRow(row) + self.widget.tableWidget.setItem(row, 0, QTableWidgetItem(shortcode)) + self.widget.tableWidget.setItem(row, 1, QTableWidgetItem(path)) + + def _add_clicked(self): + """Callback: the Add License button was clicked""" + dlg = LicenseSelector(self.path_to_addon) + shortcode, path = dlg.exec() + if shortcode and path: + self._add_row(self.widget.tableWidget.rowCount(), shortcode, path) + self.metadata.addLicense(shortcode, path) + + def _remove_clicked(self): + """Callback: the Remove License button was clicked""" + items = self.widget.tableWidget.selectedIndexes() + if items: + # We only support single-selection, so can just pull the row # from + # the first entry + row = items[0].row() + shortcode = self.widget.tableWidget.item(row, 0).text() + path = self.widget.tableWidget.item(row, 1).text() + self.widget.tableWidget.removeRow(row) + self.metadata.removeLicense(shortcode, path) + + def _edit(self, item): + """Callback: a row in the tableWidget was double-clicked""" + row = item.row() + shortcode = self.widget.tableWidget.item(row, 0).text() + path = self.widget.tableWidget.item(row, 1).text() + + dlg = LicenseSelector(self.path_to_addon) + new_shortcode, new_path = dlg.exec(shortcode, path) + + if new_shortcode and new_path: + self.widget.tableWidget.removeRow(row) + self.metadata.removeLicense(new_shortcode, new_path) + + self._add_row(row, new_shortcode, new_path) + self.metadata.addLicense(new_shortcode, new_path) + + self.widget.tableWidget.selectRow(row) + + def _selection_changed(self): + """Callback: the current selection in the tableWidget changed""" + items = self.widget.tableWidget.selectedItems() + if items: + self.widget.removeButton.setDisabled(False) + else: + self.widget.removeButton.setDisabled(True) diff --git a/src/Mod/AddonManager/addonmanager_devmode_people_table.py b/src/Mod/AddonManager/addonmanager_devmode_people_table.py new file mode 100644 index 0000000000..08ea9ef4f7 --- /dev/null +++ b/src/Mod/AddonManager/addonmanager_devmode_people_table.py @@ -0,0 +1,151 @@ +# *************************************************************************** +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +""" Contains a wrapper class for a table listing authors and maintainers """ + +import os + +from PySide2.QtWidgets import QTableWidgetItem +from PySide2.QtGui import QIcon +from PySide2.QtCore import Qt + +import FreeCAD +import FreeCADGui + +from addonmanager_devmode_person_editor import PersonEditor + +translate = FreeCAD.Qt.translate + + +class PeopleTable: + """A QTableWidget and associated buttons for managing the list of authors and maintainers.""" + + def __init__(self): + self.widget = FreeCADGui.PySideUic.loadUi( + os.path.join(os.path.dirname(__file__), "developer_mode_people_table.ui") + ) + + self.widget.addButton.setIcon( + QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) + ) + self.widget.removeButton.setIcon( + QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) + ) + + self.widget.addButton.clicked.connect(self._add_clicked) + self.widget.removeButton.clicked.connect(self._remove_clicked) + self.widget.tableWidget.itemSelectionChanged.connect(self._selection_changed) + self.widget.tableWidget.itemDoubleClicked.connect(self._edit) + + def show(self, metadata): + """Set up the widget based on incoming metadata""" + self.metadata = metadata + self._populate_from_metadata() + self.widget.removeButton.setDisabled(True) + self.widget.show() + + def _populate_from_metadata(self): + """Use the passed metadata object to populate the maintainers and authors""" + self.widget.tableWidget.setRowCount(0) + row = 0 + for maintainer in self.metadata.Maintainer: + name = maintainer["name"] + email = maintainer["email"] + self._add_row(row, "maintainer", name, email) + row += 1 + for author in self.metadata.Author: + name = author["name"] + email = author["email"] + self._add_row(row, "author", name, email) + row += 1 + + def _add_row(self, row, person_type, name, email): + """Add this person to the tableWidget at row given""" + person_type_translation = { + "maintainer": translate("AddonsInstaller", "Maintainer"), + "author": translate("AddonsInstaller", "Author"), + } + self.widget.tableWidget.insertRow(row) + item = QTableWidgetItem(person_type_translation[person_type]) + item.setData(Qt.UserRole, person_type) + self.widget.tableWidget.setItem(row, 0, item) + self.widget.tableWidget.setItem(row, 1, QTableWidgetItem(name)) + self.widget.tableWidget.setItem(row, 2, QTableWidgetItem(email)) + + def _add_clicked(self): + """Callback: the Add Person button was clicked""" + dlg = PersonEditor() + person_type, name, email = dlg.exec() + if person_type and name: + self._add_row(self.widget.tableWidget.rowCount(), person_type, name, email) + if person_type == "maintainer": + self.metadata.addMaintainer(name, email) + else: + self.metadata.addAuthor(name, email) + + def _remove_clicked(self): + """Callback: the Remove Person button was clicked""" + items = self.widget.tableWidget.selectedIndexes() + if items: + # We only support single-selection, so can just pull the row # from + # the first entry + row = items[0].row() + person_type = self.widget.tableWidget.item(row, 0).data(Qt.UserRole) + name = self.widget.tableWidget.item(row, 1).text() + email = self.widget.tableWidget.item(row, 2).text() + self.widget.tableWidget.removeRow(row) + if person_type == "maintainer": + self.metadata.removeMaintainer(name, email) + else: + self.metadata.removeAuthor(name, email) + + def _edit(self, item): + """Callback: a row in the tableWidget was double-clicked""" + row = item.row() + person_type = self.widget.tableWidget.item(row, 0).data(Qt.UserRole) + name = self.widget.tableWidget.item(row, 1).text() + email = self.widget.tableWidget.item(row, 2).text() + + dlg = PersonEditor() + dlg.setup(person_type, name, email) + new_person_type, new_name, new_email = dlg.exec() + + if new_person_type and new_name: + self.widget.tableWidget.removeRow(row) + if person_type == "maintainer": + self.metadata.removeMaintainer(name, email) + else: + self.metadata.removeAuthor(name, email) + self._add_row(row, new_person_type, new_name, email) + if new_person_type == "maintainer": + self.metadata.addMaintainer(new_name, new_email) + else: + self.metadata.addAuthor(new_name, new_email) + self.widget.tableWidget.selectRow(row) + + def _selection_changed(self): + """Callback: the current selection in the tableWidget changed""" + items = self.widget.tableWidget.selectedItems() + if items: + self.widget.removeButton.setDisabled(False) + else: + self.widget.removeButton.setDisabled(True) diff --git a/src/Mod/AddonManager/addonmanager_devmode_predictor.py b/src/Mod/AddonManager/addonmanager_devmode_predictor.py index 8a9eb9f810..0c3063f38b 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_predictor.py +++ b/src/Mod/AddonManager/addonmanager_devmode_predictor.py @@ -28,23 +28,41 @@ endorsement of any particular license. In addition, the inclusion of those text does not imply a modification to the license for THIS software, which is licensed under the LGPLv2.1 license (as stated above).""" +import datetime import os import FreeCAD -from addonmanager_git import initialize_git, GitFailed +from addonmanager_git import initialize_git, GitFailed, GitManager +from addonmanager_utilities import get_readme_url + +translate = FreeCAD.Qt.translate + + +class AddonSlice: + """A tiny class to implement duck-typing for the URL-parsing utility functions""" + + def __init__(self, url, branch): + self.url = url + self.branch = branch + class Predictor: - """ Guess the appropriate metadata to apply to a project based on various parameters - found in the supplied directory. """ + """Guess the appropriate metadata to apply to a project based on various parameters + found in the supplied directory.""" def __init__(self): self.path = None self.metadata = FreeCAD.Metadata() self.license_data = None self.license_file = "" + self.git_manager: GitManager = initialize_git() + if not self.git_manager: + raise Exception("Cannot use Developer Mode without git installed") - def predict_metadata(self, path:os.PathLike) -> FreeCAD.Metadata: - """ Create a predicted Metadata object based on the contents of the passed-in directory """ + def predict_metadata(self, path: os.PathLike) -> FreeCAD.Metadata: + """Create a predicted Metadata object based on the contents of the passed-in directory""" + if not os.path.isdir(path): + return None self.path = path self._predict_author_info() self._predict_name() @@ -53,102 +71,207 @@ class Predictor: self._predict_icon() self._predict_urls() self._predict_license() + self._predict_version() + + return self.metadata def _predict_author_info(self): - """ Predict the author and maintainer info based on git history """ + """Look at the git commit history and attempt to discern maintainer and author + information.""" + + committers = self.git_manager.get_last_committers(self.path) + + # This is a dictionary keyed to the author's name (which can be many different + # things, depending on the author) containing two fields, "email" and "count". It + # is common for there to be multiple entries representing the same human being, + # so a passing attempt is made to reconcile: + filtered_committers = {} + for key in committers: + emails = committers[key]["email"] + if "github" in key.lower(): + # Robotic merge commit (or other similar), ignore + continue + # Does any other committer share any of these emails? + for other_key in committers: + if other_key == key: + continue + other_emails = committers[other_key]["email"] + for other_email in other_emails: + if other_email in emails: + # There is overlap in the two email lists, so this is probably the + # same author, with a different name (username, pseudonym, etc.) + if not committers[key]["aka"]: + committers[key]["aka"] = set() + committers[key]["aka"].add(other_key) + committers[key]["count"] += committers[other_key]["count"] + committers[key]["email"].combine(committers[other_key]["email"]) + committers.remove(other_key) + break + filtered_committers[key] = committers[key] + maintainers = [] + for name, info in filtered_committers.items(): + if "aka" in info: + for other_name in info["aka"]: + # Heuristic: the longer name is more likely to be the actual legal name + if len(other_name) > len(name): + name = other_name + # There is no logical basis to choose one email address over another, so just + # take the first one + email = info["email"][0] + commit_count = info["count"] + maintainers.append({"name": name, "email": email, "count": commit_count}) + + # Sort by count of commits + maintainers.sort(key=lambda i: i["count"], reverse=True) + + self.metadata.Maintainer = maintainers def _predict_name(self): - """ Predict the name based on the local path name and/or the contents of a - README.md file. """ + """Predict the name based on the local path name and/or the contents of a + README.md file.""" + + normed_path = self.path.replace(os.path.sep, "/") + path_components = normed_path.split("/") + final_path_component = path_components[-1] + predicted_name = final_path_component.replace("/", "") + self.metadata.Name = predicted_name def _predict_description(self): - """ Predict the description based on the contents of a README.md file. """ + """Predict the description based on the contents of a README.md file.""" + self._load_readme() + + if not self.readme_data: + return + + lines = self.readme_data.split("\n") + description = "" + for line in lines: + if "#" in line: + continue # Probably not a line of description + if "![" in line: + continue # An image link, probably separate from any description + if not line and description: + break # We're done: this is a blank line, and we've read some data already + if description: + description += " " + description += line + + if description: + self.metadata.Description = description def _predict_contents(self): - """ Predict the contents based on the contents of the directory. """ + """Predict the contents based on the contents of the directory.""" def _predict_icon(self): - """ Predict the icon based on either a class which defines an Icon member, or - the contents of the local directory structure. """ + """Predict the icon based on either a class which defines an Icon member, or + the contents of the local directory structure.""" def _predict_urls(self): - """ Predict the URLs based on git settings """ + """Predict the URLs based on git settings""" + + branch = self.git_manager.current_branch(self.path) + remote = self.git_manager.get_remote(self.path) + + addon = AddonSlice(remote, branch) + readme = get_readme_url(addon) + + self.metadata.addUrl("repository", remote, branch) + self.metadata.addUrl("readme", readme) def _predict_license(self): - """ Predict the license based on any existing license file. """ + """Predict the license based on any existing license file.""" - # These are processed in order, so the BSD 3 clause must come before the 2, for example, because - # the only difference between them is the additional clause. + # These are processed in order, so the BSD 3 clause must come before the 2, for example, because + # the only difference between them is the additional clause. known_strings = { - "Apache-2.0": ( - "Apache License, Version 2.0", - "Apache License\nVersion 2.0, January 2004", - ), - "BSD-3-Clause": ( - "The 3-Clause BSD License", - "3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission." - ), - "BSD-2-Clause": ( - "The 2-Clause BSD License", - "2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution." - ), - "CC0v1": ( - "CC0 1.0 Universal", - "voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms" - ), - "GPLv2": ( - "GNU General Public License version 2", - "GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991" - ), - "GPLv3": ( - "GNU General Public License version 3", - "The GNU General Public License is a free, copyleft license for software and other kinds of works." - ), - "LGPLv2.1": ( - "GNU Lesser General Public License version 2.1", - "GNU Lesser General Public License\nVersion 2.1, February 1999" - ), - "LGPLv3": ( - "GNU Lesser General Public License version 3", - "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007" - ), - "MIT": ( - "The MIT License", - "including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software", - ), - "MPL-2.0": ( - "Mozilla Public License 2.0", - "https://opensource.org/licenses/MPL-2.0", - ), - } + "Apache-2.0": ( + "Apache License, Version 2.0", + "Apache License\nVersion 2.0, January 2004", + ), + "BSD-3-Clause": ( + "The 3-Clause BSD License", + "3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.", + ), + "BSD-2-Clause": ( + "The 2-Clause BSD License", + "2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.", + ), + "CC0v1": ( + "CC0 1.0 Universal", + "voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms", + ), + "GPLv2": ( + "GNU General Public License version 2", + "GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991", + ), + "GPLv3": ( + "GNU General Public License version 3", + "The GNU General Public License is a free, copyleft license for software and other kinds of works.", + ), + "LGPLv2.1": ( + "GNU Lesser General Public License version 2.1", + "GNU Lesser General Public License\nVersion 2.1, February 1999", + ), + "LGPLv3": ( + "GNU Lesser General Public License version 3", + "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007", + ), + "MIT": ( + "The MIT License", + "including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software", + ), + "MPL-2.0": ( + "Mozilla Public License 2.0", + "https://opensource.org/licenses/MPL-2.0", + ), + } self._load_license() if self.license_data: for license, test_data in known_strings.items(): - if license in self.license_data: - self.metadata.License = {"name":license,"file":self.license_file} + if license.lower() in self.license_data.lower(): + self.metadata.addLicense(license, self.license_file) return for test_text in test_data: - if test_text in self.license_data - self.metadata.License = {"name":license,"file":self.license_file} + # Do the comparison without regard to whitespace or capitalization + if ( + "".join(test_text.split()).lower() + in "".join(self.license_data.split()).lower() + ): + self.metadata.addLicense(license, self.license_file) return + def _predict_version(self): + """Default to a CalVer style set to today's date""" + year = datetime.date.today().year + month = datetime.date.today().month + day = datetime.date.today().day + version_string = f"{year}.{month:>02}.{day:>02}" + self.metadata.Version = version_string + def _load_readme(self): - """ Load in any existing readme """ + """Load in any existing readme""" valid_names = ["README.md", "README.txt", "README"] for name in valid_names: - full_path = os.path.join(self.path,name) + full_path = os.path.join(self.path, name) if os.path.exists(full_path): - with open(full_path,"r",encoding="utf-8") as f: + with open(full_path, "r", encoding="utf-8") as f: self.readme_data = f.read() return def _load_license(self): - """ Load in any existing license """ - valid_names = ["LICENSE", "LICENCE", "COPYING","LICENSE.txt", "LICENCE.txt", "COPYING.txt"] + """Load in any existing license""" + valid_names = [ + "LICENSE", + "LICENCE", + "COPYING", + "LICENSE.txt", + "LICENCE.txt", + "COPYING.txt", + ] for name in valid_names: - full_path = os.path.join(self.path,name) - if os.path.exists(full_path): - with open(full_path,"r",encoding="utf-8") as f: + full_path = os.path.join(self.path.replace("/", os.path.sep), name) + if os.path.isfile(full_path): + with open(full_path, "r", encoding="utf-8") as f: self.license_data = f.read() self.license_file = name - return \ No newline at end of file + return diff --git a/src/Mod/AddonManager/addonmanager_devmode_validators.py b/src/Mod/AddonManager/addonmanager_devmode_validators.py index 7aacb46dc9..aefb5e8652 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_validators.py +++ b/src/Mod/AddonManager/addonmanager_devmode_validators.py @@ -30,6 +30,7 @@ from PySide2.QtGui import ( ) from PySide2.QtCore import QRegularExpression + def isidentifier(ident: str) -> bool: if not ident.isidentifier(): @@ -40,6 +41,7 @@ def isidentifier(ident: str) -> bool: return True + class NameValidator(QValidator): """Simple validator to exclude characters that are not valid in filenames.""" @@ -62,18 +64,20 @@ class NameValidator(QValidator): class PythonIdentifierValidator(QValidator): - """ Validates whether input is a valid Python identifier. """ + """Validates whether input is a valid Python identifier.""" def validate(self, value: str, _: int): if not value: return QValidator.Intermediate if not value.isidentifier(): - return QValidator.Invalid # Includes an illegal character of some sort + return QValidator.Invalid # Includes an illegal character of some sort if keyword.iskeyword(value): - return QValidator.Intermediate # They can keep typing and it might become valid - + return ( + QValidator.Intermediate + ) # They can keep typing and it might become valid + return QValidator.Acceptable @@ -142,4 +146,4 @@ class VersionValidator(QValidator): return semver_result if calver_result[0] == QValidator.Intermediate: return calver_result - return (QValidator.Invalid, value, position) \ No newline at end of file + return (QValidator.Invalid, value, position) diff --git a/src/Mod/AddonManager/addonmanager_git.py b/src/Mod/AddonManager/addonmanager_git.py index c5558633a7..a3703927a8 100644 --- a/src/Mod/AddonManager/addonmanager_git.py +++ b/src/Mod/AddonManager/addonmanager_git.py @@ -120,7 +120,9 @@ class GitManager: + "...\n" ) remote = self.get_remote(local_path) - with open(os.path.join(local_path, "ADDON_DISABLED"), "w", encoding="utf-8") as f: + with open( + os.path.join(local_path, "ADDON_DISABLED"), "w", encoding="utf-8" + ) as f: f.write( "This is a backup of an addon that failed to update cleanly so was re-cloned. " + "It was disabled by the Addon Manager's git update facility and can be " @@ -195,7 +197,9 @@ class GitManager: # branch = self._synchronous_call_git(["branch", "--show-current"]).strip() # This is more universal (albeit more opaque to the reader): - branch = self._synchronous_call_git(["rev-parse", "--abbrev-ref", "HEAD"]).strip() + branch = self._synchronous_call_git( + ["rev-parse", "--abbrev-ref", "HEAD"] + ).strip() except GitFailed as e: os.chdir(old_dir) raise e @@ -257,11 +261,13 @@ class GitManager: return result def get_branches(self, local_path) -> List[str]: - """ Get a list of all available branches (local and remote) """ + """Get a list of all available branches (local and remote)""" old_dir = os.getcwd() os.chdir(local_path) try: - stdout = self._synchronous_call_git(["branch", "-a", "--format=%(refname:lstrip=2)"]) + stdout = self._synchronous_call_git( + ["branch", "-a", "--format=%(refname:lstrip=2)"] + ) except GitFailed as e: os.chdir(old_dir) raise e @@ -272,17 +278,24 @@ class GitManager: return branches def get_last_committers(self, local_path, n=10): - """ Examine the last n entries of the commit history, and return a list of all of the - committers, their email addresses, and how many commits each one is responsible for. """ + """Examine the last n entries of the commit history, and return a list of all of the + committers, their email addresses, and how many commits each one is responsible for.""" old_dir = os.getcwd() os.chdir(local_path) - authors = self._synchronous_call_git(["log", f"-{n}", "--format=%cN"]) - emails = self._synchronous_call_git(["log", f"-{n}", "--format=%cE"]) + authors = self._synchronous_call_git(["log", f"-{n}", "--format=%cN"]).split( + "\n" + ) + emails = self._synchronous_call_git(["log", f"-{n}", "--format=%cE"]).split( + "\n" + ) os.chdir(old_dir) result_dict = {} - for author,email in zip(authors,emails): + for author, email in zip(authors, emails): + if not author or not email: + continue if author not in result_dict: + result_dict[author] = {} result_dict[author]["email"] = [email] result_dict[author]["count"] = 1 else: @@ -294,8 +307,8 @@ class GitManager: return result_dict def get_last_authors(self, local_path, n=10): - """ Examine the last n entries of the commit history, and return a list of all of the - authors, their email addresses, and how many commits each one is responsible for. """ + """Examine the last n entries of the commit history, and return a list of all of the + authors, their email addresses, and how many commits each one is responsible for.""" old_dir = os.getcwd() os.chdir(local_path) authors = self._synchronous_call_git(["log", f"-{n}", "--format=%aN"]) @@ -303,7 +316,7 @@ class GitManager: os.chdir(old_dir) result_dict = {} - for author,email in zip(authors,emails): + for author, email in zip(authors, emails): if author not in result_dict: result_dict[author]["email"] = [email] result_dict[author]["count"] = 1 @@ -348,7 +361,7 @@ class GitManager: stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, - shell=True # On Windows this will prevent all the pop-up consoles + shell=True, # On Windows this will prevent all the pop-up consoles ) else: proc = subprocess.run( diff --git a/src/Mod/AddonManager/addonmanager_workers_utility.py b/src/Mod/AddonManager/addonmanager_workers_utility.py index 1f612ab842..dd04e9057a 100644 --- a/src/Mod/AddonManager/addonmanager_workers_utility.py +++ b/src/Mod/AddonManager/addonmanager_workers_utility.py @@ -27,6 +27,8 @@ import FreeCAD from PySide2 import QtCore import NetworkManager +translate = FreeCAD.Qt.translate + class ConnectionChecker(QtCore.QThread): """A worker thread for checking the connection to GitHub as a proxy for overall diff --git a/src/Mod/AddonManager/developer_mode.ui b/src/Mod/AddonManager/developer_mode.ui index c0c63f03c8..c698b6d698 100644 --- a/src/Mod/AddonManager/developer_mode.ui +++ b/src/Mod/AddonManager/developer_mode.ui @@ -13,6 +13,9 @@ Addon Developer Tools + + true + @@ -120,205 +123,7 @@ - - - - - - 2 - 0 - - - - People - - - - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - 3 - - - true - - - 75 - - - true - - - false - - - - Kind - - - - - Name - - - - - Email - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - ... - - - true - - - - - - - ... - - - true - - - - - - - - - - - - - 1 - 0 - - - - - 150 - 0 - - - - - 0 - 0 - - - - Licenses - - - - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - 2 - - - true - - - 60 - - - true - - - false - - - - License - - - - - License file - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - ... - - - true - - - - - - - ... - - - true - - - - - - - - - + diff --git a/src/Mod/AddonManager/developer_mode_add_content.ui b/src/Mod/AddonManager/developer_mode_add_content.ui index 68c5acf29f..3d8ae48f75 100644 --- a/src/Mod/AddonManager/developer_mode_add_content.ui +++ b/src/Mod/AddonManager/developer_mode_add_content.ui @@ -13,6 +13,9 @@ Content Item + + true + @@ -312,205 +315,7 @@ - - - - - - 2 - 0 - - - - People - - - - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - 3 - - - true - - - 75 - - - true - - - false - - - - Kind - - - - - Name - - - - - Email - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - ... - - - true - - - - - - - ... - - - true - - - - - - - - - - - - - 1 - 0 - - - - - 150 - 0 - - - - - 0 - 0 - - - - Licenses - - - - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - 2 - - - true - - - 60 - - - true - - - false - - - - License - - - - - License file - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - ... - - - true - - - - - - - ... - - - true - - - - - - - - - + diff --git a/src/Mod/AddonManager/developer_mode_licenses_table.ui b/src/Mod/AddonManager/developer_mode_licenses_table.ui new file mode 100644 index 0000000000..c97a497eb1 --- /dev/null +++ b/src/Mod/AddonManager/developer_mode_licenses_table.ui @@ -0,0 +1,123 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + + 1 + 0 + + + + + 150 + 0 + + + + + 0 + 0 + + + + Licenses + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + 2 + + + true + + + 60 + + + true + + + false + + + + License + + + + + License file + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + true + + + + + + + ... + + + true + + + + + + + + + + + + + diff --git a/src/Mod/AddonManager/developer_mode_people_table.ui b/src/Mod/AddonManager/developer_mode_people_table.ui new file mode 100644 index 0000000000..d878511c49 --- /dev/null +++ b/src/Mod/AddonManager/developer_mode_people_table.ui @@ -0,0 +1,116 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + + 2 + 0 + + + + People + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + 3 + + + true + + + 75 + + + true + + + false + + + + Kind + + + + + Name + + + + + Email + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + true + + + + + + + ... + + + true + + + + + + + + + + + + +