# ***************************************************************************
# * *
# * 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
from typing import Optional
import FreeCAD
import FreeCADGui
from PySide2.QtWidgets import QFileDialog, QTableWidgetItem, QDialog
from PySide2.QtGui import QIcon, QValidator, QRegularExpressionValidator, QPixmap, QDesktopServices
from PySide2.QtCore import QRegularExpression, QUrl, QFile, QIODevice
from addonmanager_git import GitManager
from addonmanager_devmode_license_selector import LicenseSelector
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._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):
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"
)
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()
self.dialog.licensesTableWidget.setRowCount(0)
self.dialog.peopleTableWidget.setRowCount(0)
def _setup_dialog_signals(self):
"""Set up the signal and slot connections for the main dialog."""
self.dialog.addonPathBrowseButton.clicked.connect(
self._addon_browse_button_clicked
)
self.dialog.pathToAddonComboBox.editTextChanged.connect(
self._addon_combo_text_changed
)
self.dialog.addLicenseToolButton.clicked.connect(self._add_license_clicked)
self.dialog.removeLicenseToolButton.clicked.connect(self._remove_license_clicked)
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.licensesTableWidget.itemSelectionChanged.connect(self._license_selection_changed)
# Finally, populate the combo boxes, etc.
self._populate_combo()
if self.dialog.pathToAddonComboBox.currentIndex() != -1:
self._populate_dialog(self.dialog.pathToAddonComboBox.currentText())
self.dialog.removeLicenseToolButton.setDisabled(True)
self.dialog.removePersonToolButton.setDisabled(True)
###############################################################################################
# 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)
def _person_selection_changed(self):
items = self.dialog.peopleTableWidget.selectedItems()
if items:
self.dialog.removePersonToolButton.setDisabled(False)
else:
self.dialog.removePersonToolButton.setDisabled(True)
def _license_selection_changed(self):
items = self.dialog.licensesTableWidget.selectedItems()
if items:
self.dialog.removeLicenseToolButton.setDisabled(False)
else:
self.dialog.removeLicenseToolButton.setDisabled(True)
def _add_license_clicked(self):
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):
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 _add_person_clicked(self):
pass
def _remove_person_clicked(self):
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())