From 2524e8081af289778f986ab825bfa68d4ff4b094 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Tue, 30 Aug 2022 21:18:11 -0500 Subject: [PATCH] Addon Manager: Sketch out basics of Developer Mode --- src/Mod/AddonManager/AddonManager.py | 43 +- src/Mod/AddonManager/AddonManager.ui | 7 + src/Mod/AddonManager/AddonManagerOptions.ui | 24 +- src/Mod/AddonManager/CMakeLists.txt | 7 +- src/Mod/AddonManager/addonmanager_devmode.py | 508 +++++++++++++++++ src/Mod/AddonManager/addonmanager_git.py | 59 ++ src/Mod/AddonManager/developer_mode.ui | 520 ++++++++++++++++++ .../developer_mode_add_content.ui | 362 ++++++++++++ .../developer_mode_dependencies.ui | 240 ++++++++ .../developer_mode_select_from_list.ui | 77 +++ 10 files changed, 1831 insertions(+), 16 deletions(-) create mode 100644 src/Mod/AddonManager/addonmanager_devmode.py create mode 100644 src/Mod/AddonManager/developer_mode.ui create mode 100644 src/Mod/AddonManager/developer_mode_add_content.ui create mode 100644 src/Mod/AddonManager/developer_mode_dependencies.ui create mode 100644 src/Mod/AddonManager/developer_mode_select_from_list.ui diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index 5b50968101..a6aacb2f7e 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -32,6 +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." from datetime import date, timedelta from typing import Dict, List @@ -69,10 +70,13 @@ from manage_python_dependencies import ( CheckForPythonPackageUpdatesWorker, PythonPackageManager, ) +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" @@ -98,12 +102,6 @@ installed. # @{ -def QT_TRANSLATE_NOOP(ctx, txt): - return txt - - -ADDON_MANAGER_DEVELOPER_MODE = False - class CommandAddonManager: """The main Addon Manager class and FreeCAD command""" @@ -156,6 +154,7 @@ class CommandAddonManager: self.check_for_python_package_updates_worker = None self.install_worker = None self.update_all_worker = None + self.developer_mode = None def GetResources(self) -> Dict[str, str]: return { @@ -176,8 +175,7 @@ class CommandAddonManager: pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") readWarning = pref.GetBool("readWarning2022", False) - global ADDON_MANAGER_DEVELOPER_MODE - ADDON_MANAGER_DEVELOPER_MODE = pref.GetBool("developerMode", False) + dev_mode_active = pref.GetBool("developerMode", False) if not readWarning: warning_dialog = FreeCADGui.PySideUic.loadUi( @@ -263,7 +261,7 @@ class CommandAddonManager: ) self.connection_check_message.show() - def cancel_network_check(self, button): + def cancel_network_check(self, _): if not self.connection_checker.isFinished(): self.connection_checker.success.disconnect(self.launch) self.connection_checker.failure.disconnect(self.network_connection_failed) @@ -275,7 +273,7 @@ class CommandAddonManager: # This must run on the main GUI thread if hasattr(self, "connection_check_message") and self.connection_check_message: self.connection_check_message.close() - if HAVE_QTNETWORK: + if NetworkManager.HAVE_QTNETWORK: QtWidgets.QMessageBox.critical( None, translate("AddonsInstaller", "Connection failed"), message ) @@ -349,6 +347,9 @@ class CommandAddonManager: ) ) + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + dev_mode_active = pref.GetBool("developerMode", False) + # enable/disable stuff self.dialog.buttonUpdateAll.setEnabled(False) self.hide_progress_widgets() @@ -356,6 +357,10 @@ class CommandAddonManager: self.dialog.buttonUpdateCache.setText( translate("AddonsInstaller", "Starting up...") ) + if dev_mode_active: + self.dialog.buttonDevTools.show() + else: + self.dialog.buttonDevTools.hide() # Only shown if there are available Python package updates self.dialog.buttonUpdateDependencies.hide() @@ -372,6 +377,9 @@ class CommandAddonManager: self.dialog.buttonUpdateDependencies.clicked.connect( self.show_python_updates_dialog ) + 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) @@ -404,7 +412,7 @@ class CommandAddonManager: # rock 'n roll!!! self.dialog.exec_() - def cleanup_workers(self, wait=False) -> None: + def cleanup_workers(self) -> None: """Ensure that no workers are running by explicitly asking them to stop and waiting for them until they do""" for worker in self.workers: if hasattr(self, worker): @@ -614,8 +622,9 @@ class CommandAddonManager: if selection: self.startup_sequence.insert(2, functools.partial(self.select_addon, selection)) pref.SetString("SelectedAddon", "") - if ADDON_MANAGER_DEVELOPER_MODE: - self.startup_sequence.append(self.validate) + # TODO: migrate this to the developer mode tools + #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) self.do_next_startup_phase() @@ -650,7 +659,7 @@ class CommandAddonManager: from_json = json.loads(data) if len(from_json) == 0: use_cache = False - except Exception as e: + except json.JSONDecodeError: use_cache = False else: use_cache = False @@ -916,6 +925,12 @@ class CommandAddonManager: self.manage_python_packages_dialog = PythonPackageManager() self.manage_python_packages_dialog.show() + def show_developer_tools(self) -> None: + """ Display the developer tools dialog """ + if not self.developer_mode: + self.developer_mode = DeveloperMode() + self.developer_mode.show() + def add_addon_repo(self, addon_repo: Addon) -> None: """adds a workbench to the list""" diff --git a/src/Mod/AddonManager/AddonManager.ui b/src/Mod/AddonManager/AddonManager.ui index 463c82e7e4..b645aa6426 100644 --- a/src/Mod/AddonManager/AddonManager.ui +++ b/src/Mod/AddonManager/AddonManager.ui @@ -134,6 +134,13 @@ + + + + Developer tools... + + + diff --git a/src/Mod/AddonManager/AddonManagerOptions.ui b/src/Mod/AddonManager/AddonManagerOptions.ui index 51c0cf551d..8964e6de7e 100644 --- a/src/Mod/AddonManager/AddonManagerOptions.ui +++ b/src/Mod/AddonManager/AddonManagerOptions.ui @@ -342,10 +342,32 @@ of the line after a space (e.g. https://github.com/FreeCAD/FreeCAD master). - + Disable git (fall back to ZIP downloads only) + + disableGit + + + Addons + + + + + + + Activate Addon Manager options intended for developers of new Addons. + + + Addon developer mode + + + Addons + + + developerMode + diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 6e42aec7fb..9b3b644f8f 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -7,11 +7,12 @@ SET(AddonManager_SRCS Addon.py AddonManager.py AddonManager.ui + addonmanager_devmode.py addonmanager_git.py addonmanager_macro.py addonmanager_utilities.py - addonmanager_workers_startup.py addonmanager_workers_installation.py + addonmanager_workers_startup.py addonmanager_workers_utility.py AddonManagerOptions.ui ALLOWED_PYTHON_PACKAGES.txt @@ -19,6 +20,10 @@ SET(AddonManager_SRCS change_branch.ui compact_view.py dependency_resolution_dialog.ui + developer_mode.ui + developer_mode_add_workbench.ui + developer_mode_dependencies.ui + developer_mode_select_from_list.ui expanded_view.py first_run.ui Init.py diff --git a/src/Mod/AddonManager/addonmanager_devmode.py b/src/Mod/AddonManager/addonmanager_devmode.py new file mode 100644 index 0000000000..eec0a94dce --- /dev/null +++ b/src/Mod/AddonManager/addonmanager_devmode.py @@ -0,0 +1,508 @@ +# *************************************************************************** +# * * +# * 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 FreeCAD +import FreeCADGui + +from PySide2.QtWidgets import QFileDialog, QTableWidgetItem +from PySide2.QtGui import QIcon, QValidator, QRegularExpressionValidator, QPixmap +from PySide2.QtCore import QRegularExpression +from addonmanager_git import GitManager + +translate = FreeCAD.Qt.translate + +# pylint: disable=too-few-public-methods + + +class AddonGitInterface: + """Wrapper to handle the git calls needed by this class""" + + git_manager = GitManager() + + def __init__(self, path): + self.path = path + self.git_exists = False + 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 [] + + +class NameValidator(QValidator): + """Simple validator to exclude characters that are not valid in filenames.""" + + invalid = '/\\?%*:|"<>' + + def validate(self, value: str, _: int): + """Check the value against the validator""" + for char in value: + if char in NameValidator.invalid: + return QValidator.Invalid + return QValidator.Acceptable + + def fixup(self, value: str) -> str: + """Remove invalid characters from value""" + result = "" + for char in value: + if char not in NameValidator.invalid: + result += char + return result + + +class SemVerValidator(QRegularExpressionValidator): + """Implements the officially-recommended regex validator for Semantic version numbers.""" + + # https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + semver_re = QRegularExpression( + r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + ) + + def __init__(self): + super().__init__() + self.setRegularExpression(SemVerValidator.semver_re) + + @classmethod + def check(cls, value: str) -> bool: + """Returns true if value validates, and false if not""" + return cls.semver_re.match(value).hasMatch() + + +class CalVerValidator(QRegularExpressionValidator): + """Implements a basic regular expression validator that makes sure an entry corresponds + to a CalVer version numbering standard.""" + + calver_re = QRegularExpression( + r"^(?P[1-9]\d{3})\.(?P[0-9]{1,2})\.(?P0|[0-9]{0,2})" + + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + ) + + def __init__(self): + super().__init__() + self.setRegularExpression(CalVerValidator.calver_re) + + @classmethod + def check(cls, value: str) -> bool: + """Returns true if value validates, and false if not""" + return cls.calver_re.match(value).hasMatch() + + +class VersionValidator(QValidator): + """Implements the officially-recommended regex validator for Semantic version numbers, and a + decent approximation of the same thing for CalVer-style version numbers.""" + + def __init__(self): + super().__init__() + self.semver = SemVerValidator() + self.calver = CalVerValidator() + + def validate(self, value: str, position: int): + """Called for validation, returns a tuple of the validation state, the value, and the + position.""" + semver_result = self.semver.validate(value, position) + calver_result = self.calver.validate(value, position) + + if semver_result[0] == QValidator.Acceptable: + return semver_result + if calver_result[0] == QValidator.Acceptable: + return calver_result + if semver_result[0] == QValidator.Intermediate: + return semver_result + if calver_result[0] == QValidator.Intermediate: + return calver_result + return (QValidator.Invalid, value, position) + + +class DeveloperMode: + """The main Developer Mode dialog, for editing package.xml metadata graphically.""" + + def __init__(self): + self.dialog = FreeCADGui.PySideUic.loadUi( + os.path.join(os.path.dirname(__file__), "developer_mode.ui") + ) + self.pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + self.current_mod:str = "" + self.git_interface = None + self.has_toplevel_icon = False + self._setup_dialog_signals() + + 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")) + ) + self.dialog.removeContentItemToolButton.setIcon( + QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) + ) + + def show(self, parent=None): + """Show the main dev mode dialog""" + if parent: + self.dialog.setParent(parent) + self.dialog.exec() + + 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) + + metadata_path = os.path.join(path_to_repo, "package.xml") + metadata = None + if os.path.exists(metadata_path): + try: + 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" + ) + + if metadata: + self.dialog.displayNameLineEdit.setText(metadata.Name) + self.dialog.descriptionTextEdit.setPlainText(metadata.Description) + self.dialog.versionLineEdit.setText(metadata.Version) + + self._populate_people_from_metadata(metadata) + self._populate_licenses_from_metadata(metadata) + self._populate_urls_from_metadata(metadata) + self._populate_contents_from_metadata(metadata) + + self._populate_icon_from_metadata(metadata) + else: + self._populate_without_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.dialog.peopleTableWidget.insertRow(row) + self.dialog.peopleTableWidget.setItem( + row, 0, QTableWidgetItem(translate("AddonsInstaller", "Maintainer")) + ) + self.dialog.peopleTableWidget.setItem(row, 1, QTableWidgetItem(name)) + self.dialog.peopleTableWidget.setItem(row, 2, QTableWidgetItem(email)) + row += 1 + for author in metadata.Author: + name = author["name"] + email = author["email"] + self.dialog.peopleTableWidget.insertRow(row) + self.dialog.peopleTableWidget.setItem( + row, 0, QTableWidgetItem(translate("AddonsInstaller", "Author")) + ) + self.dialog.peopleTableWidget.setItem(row, 1, QTableWidgetItem(name)) + self.dialog.peopleTableWidget.setItem(row, 2, QTableWidgetItem(email)) + row += 1 + + if row == 0: + FreeCAD.Console.PrintWarning( + translate( + "AddonsInstaller", + "WARNING: No maintainer data found in metadata file.", + ) + + "\n" + ) + + 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.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" + ) + row += 1 + if row == 0: + FreeCAD.Console.PrintWarning( + translate( + "AddonsInstaller", + "WARNING: No license data found in metadata file", + ) + + "\n" + ) + + 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"]) + + 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: + 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) + + self.dialog.contentsListWidget.addItem(contents_string) + + 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 contents["workbench"]: + 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 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 _populate_without_metadata(self): + """If there is no metadata, try to guess at values for it""" + self._clear_all_fields() + + def _scan_for_git_info(self, path): + """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.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 + ) + + # Finally, populate the combo boxes, etc. + self._populate_combo() + if self.dialog.pathToAddonComboBox.currentIndex() != -1: + self._populate_dialog(self.dialog.pathToAddonComboBox.currentText()) + + ############################################################################################### + # 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 + 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) diff --git a/src/Mod/AddonManager/addonmanager_git.py b/src/Mod/AddonManager/addonmanager_git.py index f56f32bd4f..c5558633a7 100644 --- a/src/Mod/AddonManager/addonmanager_git.py +++ b/src/Mod/AddonManager/addonmanager_git.py @@ -256,6 +256,65 @@ class GitManager: os.chdir(old_dir) return result + def get_branches(self, local_path) -> List[str]: + """ 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)"]) + except GitFailed as e: + os.chdir(old_dir) + raise e + os.chdir(old_dir) + branches = [] + for branch in stdout.split("\n"): + branches.append(branch) + 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. """ + 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"]) + os.chdir(old_dir) + + result_dict = {} + for author,email in zip(authors,emails): + if author not in result_dict: + result_dict[author]["email"] = [email] + result_dict[author]["count"] = 1 + else: + if email not in result_dict[author]["email"]: + # Same author name, new email address -- treat it as the same + # person with a second email, instead of as a whole new person + result_dict[author]["email"].append(email) + result_dict[author]["count"] += 1 + 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. """ + old_dir = os.getcwd() + os.chdir(local_path) + authors = self._synchronous_call_git(["log", f"-{n}", "--format=%aN"]) + emails = self._synchronous_call_git(["log", f"-{n}", "--format=%aE"]) + os.chdir(old_dir) + + result_dict = {} + for author,email in zip(authors,emails): + if author not in result_dict: + result_dict[author]["email"] = [email] + result_dict[author]["count"] = 1 + else: + if email not in result_dict[author]["email"]: + # Same author name, new email address -- treat it as the same + # person with a second email, instead of as a whole new person + result_dict[author]["email"].append(email) + result_dict[author]["count"] += 1 + return result_dict + def _find_git(self): # Find git. In preference order # A) The value of the GitExecutable user preference diff --git a/src/Mod/AddonManager/developer_mode.ui b/src/Mod/AddonManager/developer_mode.ui new file mode 100644 index 0000000000..c0c63f03c8 --- /dev/null +++ b/src/Mod/AddonManager/developer_mode.ui @@ -0,0 +1,520 @@ + + + DeveloperModeDialog + + + + 0 + 0 + 595 + 677 + + + + Addon Developer Tools + + + + + + + + Path to Addon + + + + + + + + 0 + 0 + + + + true + + + + + + + Browse... + + + + + + + + + Metadata + + + + + + Displayed in the Addon Manager's list of Addons. Should not include the word "FreeCAD", and must be a valid directory name on all support operating systems. + + + Addon Name + + + + + + + Displayed in the Addon Manager's list of Addons. Should not include the word "FreeCAD", and must be a valid directory name on all support operating systems. + + + + + + + Explanation of what this Addon provides. Displayed in the Addon Manager. It is not necessary for this to state that this is a FreeCAD Addon. + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Explanation of what this Addon provides. Displayed in the Addon Manager. It is not necessary for this to state that this is a FreeCAD Addon. + + + true + + + TIP: Since this is displayed within FreeCAD, in the Addon Manager, it is not necessary to take up space saying things like "This is a FreeCAD Addon..." -- just say what it does. + + + + + + + Version + + + + + + + + + Semantic (1.2.3-beta) or CalVer (2022.08.30) styles supported + + + + + + + Set to today (CalVer style) + + + + + + + + + + + + 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 + + + + + + + + + + + + + + Repository URL + + + + + + + + + + + + Primary branch + + + + + + + + + + + + README URL + + + + + + + (Recommended) + + + + + + + Website URL + + + + + + + (Optional) + + + + + + + Bugtracker URL + + + + + + + (Optional) + + + + + + + Documentation URL + + + + + + + (Optional) + + + + + + + Icon + + + + + + + + + + + + + + + Browse... + + + + + + + + + + + + Addon Contents + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + + + + + + + buttonBox + accepted() + DeveloperModeDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + DeveloperModeDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/Mod/AddonManager/developer_mode_add_content.ui b/src/Mod/AddonManager/developer_mode_add_content.ui new file mode 100644 index 0000000000..deea6a3b97 --- /dev/null +++ b/src/Mod/AddonManager/developer_mode_add_content.ui @@ -0,0 +1,362 @@ + + + Dialog + + + + 0 + 0 + 641 + 388 + + + + Dialog + + + + + + If this is the only thing in the Addon, all other metadata can be inherited from the top level, and does not need to be specified here. + + + This workbench is the only item in the Addon + + + true + + + + + + + + + Class name + + + + + + + + + + Path + + + + + + + + + + Icon + + + + + + + + + actualIcon + + + + + + + + + + Browse... + + + + + + + + + + + + + Dependencies... + + + + + + + FreeCAD Versions... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Other Metadata + + + + + + License file path + + + + + + + Explanation of what this Addon provides. Displayed in the Addon Manager. It is not necessary for this to state that this is a FreeCAD Addon. + + + + + + + + + + + + + + + + + + + + + + Browse... + + + + + + + Create... + + + + + + + + + + + Primary maintainer name + + + + + + + + + + Contact email + + + + + + + + + + Add additional people + + + + + + + + + Version + + + + + + + Displayed in the Addon Manager's list of Addons. Should not include the word "FreeCAD". + + + + + + + License + + + + + + + Description + + + + + + + + + + Semantic (e.g. 1.2.3-beta) + + + + + CalVer (e.g. 2021.12.08) + + + + + + + + Y: + + + + + + + + + + M: + + + + + + + + + + D: + + + + + + + + + + - + + + + + + + (optional suffix) + + + + + + + Set to today + + + + + + + + + Display Name + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/Mod/AddonManager/developer_mode_dependencies.ui b/src/Mod/AddonManager/developer_mode_dependencies.ui new file mode 100644 index 0000000000..2a03d68b5e --- /dev/null +++ b/src/Mod/AddonManager/developer_mode_dependencies.ui @@ -0,0 +1,240 @@ + + + AddDependenciesDialog + + + + 0 + 0 + 743 + 221 + + + + Addon Dependencies + + + + + + + + FreeCAD Workbenches + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + External Addons + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + Required Python Packages + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + Optional Python Packages + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + AddDependenciesDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + AddDependenciesDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/Mod/AddonManager/developer_mode_select_from_list.ui b/src/Mod/AddonManager/developer_mode_select_from_list.ui new file mode 100644 index 0000000000..371208908c --- /dev/null +++ b/src/Mod/AddonManager/developer_mode_select_from_list.ui @@ -0,0 +1,77 @@ + + + SelectFromList + + + + 0 + 0 + 400 + 111 + + + + Dialog + + + + + + TextLabel + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + SelectFromList + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SelectFromList + reject() + + + 316 + 260 + + + 286 + 274 + + + + +