Addon Manager: DevMode content implementation

This commit is contained in:
Chris Hennes
2022-09-07 19:00:26 -05:00
parent 7a17106776
commit cb1f6bffa8
11 changed files with 1658 additions and 226 deletions

View File

@@ -9,9 +9,11 @@ SET(AddonManager_SRCS
AddonManager.ui
addonmanager_devmode.py
addonmanager_devmode_add_content.py
addonmanager_devmode_license_selector.py
addonmanager_devmode_dependencies.py
addonmanager_devmode_license_selector.py
addonmanager_devmode_person_editor.py
addonmanager_devmode_predictor.py
addonmanager_devmode_validators.py
addonmanager_git.py
addonmanager_macro.py
addonmanager_utilities.py

View File

@@ -27,24 +27,25 @@ import os
import FreeCAD
import FreeCADGui
from PySide2.QtWidgets import QFileDialog, QTableWidgetItem
from PySide2.QtWidgets import QFileDialog, QTableWidgetItem, QListWidgetItem, QDialog
from PySide2.QtGui import (
QIcon,
QValidator,
QRegularExpressionValidator,
QPixmap,
)
from PySide2.QtCore import QRegularExpression, Qt
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
translate = FreeCAD.Qt.translate
# pylint: disable=too-few-public-methods
ContentTypeRole = Qt.UserRole
ContentIndexRole = Qt.UserRole + 1
class AddonGitInterface:
"""Wrapper to handle the git calls needed by this class"""
@@ -66,94 +67,20 @@ class AddonGitInterface:
return AddonGitInterface.git_manager.get_branches(self.path)
return []
@property
def committers(self):
"""The commiters to this repo, in the last ten commits"""
if self.git_exists:
return AddonGitInterface.git_manager.get_last_committers(self.path, 10)
return []
class NameValidator(QValidator):
"""Simple validator to exclude characters that are not valid in filenames."""
@property
def authors(self):
"""The commiters to this repo, in the last ten commits"""
if self.git_exists:
return AddonGitInterface.git_manager.get_last_authors(self.path, 10)
return []
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"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
+ r"(?:-(?P<prerelease>(?: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<buildmetadata>[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<major>[1-9]\d{3})\.(?P<minor>[0-9]{1,2})\.(?P<patch>0|[0-9]{0,2})"
+ r"(?:-(?P<prerelease>(?: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<buildmetadata>[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:
@@ -174,6 +101,8 @@ class DeveloperMode:
self.current_mod: str = ""
self.git_interface = None
self.has_toplevel_icon = False
self.metadata = None
self._setup_dialog_signals()
self.dialog.displayNameLineEdit.setValidator(NameValidator())
@@ -202,7 +131,9 @@ class DeveloperMode:
"""Show the main dev mode dialog"""
if parent:
self.dialog.setParent(parent)
self.dialog.exec()
result = self.dialog.exec()
if result == QDialog.Accepted:
self._sync_metadata_to_ui()
def _populate_dialog(self, path_to_repo):
"""Populate this dialog using the best available parsing of the contents of the repo at
@@ -215,7 +146,6 @@ class DeveloperMode:
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:
self.metadata = FreeCAD.Metadata(metadata_path)
@@ -239,19 +169,21 @@ class DeveloperMode:
+ "\n\n"
)
if self.metadata:
self.dialog.displayNameLineEdit.setText(self.metadata.Name)
self.dialog.descriptionTextEdit.setPlainText(self.metadata.Description)
self.dialog.versionLineEdit.setText(self.metadata.Version)
self._clear_all_fields()
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)
if not self.metadata:
self._predict_metadata()
self._populate_icon_from_metadata(self.metadata)
else:
self._populate_without_metadata()
self.dialog.displayNameLineEdit.setText(self.metadata.Name)
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"""
@@ -305,6 +237,7 @@ class DeveloperMode:
)
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))
@@ -349,6 +282,7 @@ class DeveloperMode:
contents = metadata.Content
self.dialog.contentsListWidget.clear()
for content_type in contents:
counter = 0
for item in contents[content_type]:
contents_string = f"[{content_type}] "
info = []
@@ -378,7 +312,11 @@ class DeveloperMode:
)
contents_string += ", ".join(info)
self.dialog.contentsListWidget.addItem(contents_string)
item = QListWidgetItem (contents_string)
item.setData(ContentTypeRole, content_type)
item.setData(ContentIndexRole, counter)
self.dialog.contentsListWidget.addItem(item)
counter += 1
def _populate_icon_from_metadata(self, metadata):
"""Use the passed metadata object to populate the icon fields"""
@@ -407,9 +345,16 @@ class DeveloperMode:
self.dialog.iconDisplayLabel.setPixmap(icon_data.pixmap(32, 32))
self.dialog.iconPathLineEdit.setText(icon)
def _populate_without_metadata(self):
def _predict_metadata(self):
"""If there is no metadata, try to guess at values for it"""
self._clear_all_fields()
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()
def _scan_for_git_info(self, path):
"""Look for branch availability"""
@@ -436,6 +381,57 @@ class DeveloperMode:
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."""
@@ -467,14 +463,54 @@ class DeveloperMode:
self.dialog.contentsListWidget.itemSelectionChanged.connect(self._content_selection_changed)
self.dialog.contentsListWidget.itemDoubleClicked.connect(self._edit_content)
# 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. """
self.metadata.Name = self.dialog.displayNameLineEdit.text()
self.metadata.Description = self.descriptionTextEdit.text()
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)
# 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:
###############################################################################################
# DIALOG SLOTS
@@ -553,6 +589,7 @@ class DeveloperMode:
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)
@@ -560,6 +597,7 @@ class DeveloperMode:
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)
@@ -567,6 +605,7 @@ class DeveloperMode:
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:
@@ -575,6 +614,7 @@ class DeveloperMode:
)
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
@@ -582,6 +622,7 @@ class DeveloperMode:
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()
@@ -592,12 +633,14 @@ class DeveloperMode:
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
@@ -605,6 +648,7 @@ class DeveloperMode:
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()
@@ -621,14 +665,51 @@ class DeveloperMode:
def _add_content_clicked(self):
""" Callback: The Add Content button was clicked """
dlg = AddContent(self.current_mod, self.metadata)
dlg.exec()
singleton = False
if self.dialog.contentsListWidget.count() == 0:
singleton = True
content_type,new_metadata = dlg.exec(singleton=singleton)
if content_type and new_metadata:
self.metadata.addContentItem(content_type, new_metadata)
self._populate_contents_from_metadata(self.metadata)
def _remove_content_clicked(self):
pass
""" Callback: the remove content button was clicked """
item = self.dialog.contentsListWidget.currentItem()
if not item:
return
content_type = item.data(ContentTypeRole)
content_index = item.data(ContentIndexRole)
if self.metadata.Content[content_type] and content_index < len(self.metadata.Content[content_type]):
content_name = self.metadata.Content[content_type][content_index].Name
self.metadata.removeContentItem(content_type,content_name)
self._populate_contents_from_metadata(self.metadata)
def _content_selection_changed(self):
pass
""" Callback: the selected content item changed """
items = self.dialog.contentsListWidget.selectedItems()
if items:
self.dialog.removeContentItemToolButton.setDisabled(False)
else:
self.dialog.removeContentItemToolButton.setDisabled(True)
def _edit_content(self, item):
pass
""" Callback: a content row was double-clicked """
dlg = AddContent(self.current_mod, self.metadata)
content_type = item.data(ContentTypeRole)
content_index = item.data(ContentIndexRole)
content = self.metadata.Content
metadata = content[content_type][content_index]
old_name = metadata.Name
new_type, new_metadata = dlg.exec(content_type, metadata, len(content) == 1)
if new_type and new_metadata:
self.metadata.removeContentItem(content_type, old_name)
self.metadata.addContentItem(new_type, new_metadata)
self._populate_contents_from_metadata(self.metadata)

View File

@@ -35,6 +35,8 @@ from PySide2.QtWidgets import QDialog, QLayout, QFileDialog, QTableWidgetItem
from PySide2.QtGui import QIcon
from PySide2.QtCore import Qt
from addonmanager_devmode_validators import VersionValidator, NameValidator, PythonIdentifierValidator
# pylint: disable=too-few-public-methods
translate = FreeCAD.Qt.translate
@@ -77,6 +79,24 @@ class AddContent:
self._freecad_versions_clicked
)
self.dialog.versionLineEdit.setValidator(VersionValidator())
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,
content_kind: str = "workbench",
@@ -87,7 +107,8 @@ class AddContent:
is accepted, or None if it is rejected. This metadata object represents a single
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."""
self.metadata = metadata
if metadata:
self.metadata = FreeCAD.Metadata(metadata) # Deep copy
self.dialog.singletonCheckBox.setChecked(singleton)
if singleton:
# This doesn't happen automatically the first time
@@ -104,12 +125,15 @@ 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()
if result == QDialog.Accepted:
return self._generate_metadata()
return None
return None, None
def _populate_dialog(self, metadata: FreeCAD.Metadata) -> None:
"""Fill in the dialog with the details from the passed metadata object"""
@@ -117,9 +141,11 @@ class AddContent:
if addon_kind == "workbench":
self.dialog.workbenchClassnameLineEdit.setText(metadata.Classname)
elif addon_kind == "macro":
pass
files = self.metadata.File
if files:
self.dialog.macroFileLineEdit.setText(files[0])
elif addon_kind == "preferencepack":
pass
self.dialog.prefPackNameLineEdit.setText(self.metadata.Name)
else:
raise RuntimeError("Invalid data found for selection")
@@ -140,6 +166,12 @@ class AddContent:
else:
self.dialog.subdirectoryLineEdit.setText("")
self.dialog.displayNameLineEdit.setText(metadata.Name)
self.dialog.descriptionTextEdit.setPlainText(metadata.Description)
self.dialog.versionLineEdit.setText(metadata.Version)
#TODO: Add people and licenses
def _set_icon(self, icon_relative_path):
"""Load the icon and display it, and its path, in the dialog."""
icon_path = os.path.join(
@@ -161,7 +193,56 @@ class AddContent:
def _generate_metadata(self) -> Tuple[str, FreeCAD.Metadata]:
"""Create and return a new metadata object based on the contents of the dialog."""
return ("workbench", FreeCAD.Metadata())
if not self.metadata:
self.metadata = FreeCAD.Metadata()
##########################################################################################
# Required data:
current_data:str = self.dialog.addonKindComboBox.currentData()
if current_data == "preferencepack":
self.metadata.Name = self.dialog.prefPackNameLineEdit.text()
elif self.dialog.displayNameLineEdit.text():
self.metadata.Name = self.dialog.displayNameLineEdit.text()
if current_data == "workbench":
self.metadata.Classname = self.dialog.workbenchClassnameLineEdit.text()
elif current_data == "macro":
self.metadata.File = [self.dialog.macroFileLineEdit.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.Version = self.dialog.versionLineEdit.text()
maintainers = []
authors = []
for row in range(self.dialog.peopleTableWidget.rowCount()):
person_type = self.dialog.peopleTableWidget.item(row, 0).data()
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})
elif person_type == "author":
authors.append({"name":name,"email":email})
self.metadata.Maintainer = maintainers
self.metadata.Author = authors
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.License = licenses
return (self.dialog.addonKindComboBox.currentData(), self.metadata)
###############################################################################################
# DIALOG SLOTS
@@ -180,6 +261,9 @@ class AddContent:
dir=start_dir,
)
if not new_icon_path:
return
base_path = self.path_to_addon.replace("/", os.path.sep)
icon_path = new_icon_path.replace("/", os.path.sep)
@@ -192,6 +276,7 @@ class AddContent:
)
return
self._set_icon(new_icon_path[len(base_path) :])
self.metadata.Icon = new_icon_path[len(base_path) :]
def _browse_for_subdirectory_clicked(self):
"""Callback: when the "Browse..." button for the subdirectory field is clicked"""
@@ -205,6 +290,8 @@ class AddContent:
),
dir=start_dir,
)
if not new_subdir_path:
return
if new_subdir_path[-1] != "/":
new_subdir_path += "/"
@@ -238,20 +325,27 @@ class AddContent:
def _tags_clicked(self):
"""Show the tag editor"""
tags = []
if not self.metadata:
self.metadata = FreeCAD.Metadata()
if self.metadata:
tags = self.metadata.Tag
dlg = EditTags(tags)
new_tags = dlg.exec()
self.metadata.Tag = new_tags
def _freecad_versions_clicked(self):
"""Show the FreeCAD version editor"""
if not self.metadata:
self.metadata = FreeCAD.Metadata()
dlg = EditFreeCADVersions()
dlg.exec()
dlg.exec(self.metadata)
def _dependencies_clicked(self):
"""Show the dependencies editor"""
if not self.metadata:
self.metadata = FreeCAD.Metadata()
dlg = EditDependencies()
dlg.exec()
result = dlg.exec(self.metadata)
class EditTags:
@@ -304,20 +398,34 @@ class EditDependencies:
self.dialog.removeDependencyToolButton.setDisabled(True)
def exec(self):
def exec(self, metadata:FreeCAD.Metadata):
"""Execute the dialog"""
self.dialog.exec()
self.metadata = FreeCAD.Metadata(metadata) # Make a copy, in case we cancel
row = 0
for dep in self.metadata.Depend:
dep_type = dep["type"]
dep_name = dep["package"]
dep_optional = dep["optional"]
self._add_row(row, dep_type, dep_name, dep_optional)
row += 1
result = self.dialog.exec()
if result == QDialog.Accepted:
metadata.Depend = self.metadata.Depend
def _add_dependency_clicked(self):
"""Callback: The add button was clicked"""
dlg = EditDependency()
dep_type, dep_name, dep_optional = dlg.exec()
row = self.dialog.tableWidget.rowCount()
self._add_row(row, dep_type, dep_name, dep_optional)
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})
def _add_row(self, row, dep_type, dep_name, dep_optional):
"""Utility function to add a row to the table."""
translations = {
"automatic": translate("AddonsInstaller", "Automatic"),
"workbench": translate("AddonsInstaller", "Workbench"),
"addon": translate("AddonsInstaller", "Addon"),
"python": translate("AddonsInstaller", "Python"),
@@ -338,6 +446,10 @@ class EditDependencies:
items = self.dialog.tableWidget.selectedItems()
if items:
row = items[0].row()
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.dialog.tableWidget.removeRow(row)
def _edit_dependency(self, item):
@@ -347,8 +459,10 @@ 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))
dep_type, dep_name, 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.dialog.tableWidget.removeRow(row)
self._add_row(row, dep_type, dep_name, dep_optional)
@@ -384,9 +498,8 @@ class EditDependency:
self._dependency_selection_changed
)
self.dialog.typeComboBox.setCurrentIndex(
2
) # Expect mostly Python dependencies...
# Expect mostly Python dependencies...
self.dialog.typeComboBox.setCurrentIndex(2)
def exec(
self, dep_type="", dep_name="", dep_optional=False
@@ -488,9 +601,22 @@ class EditFreeCADVersions:
)
)
def exec(self):
def exec(self, metadata:FreeCAD.Metadata):
"""Execute the dialog"""
self.dialog.exec()
if metadata.FreeCADMin != "0.0.0":
self.dialog.minVersionLineEdit.setText(metadata.FreeCADMin)
if metadata.FreeCADMax != "0.0.0":
self.dialog.maxVersionLineEdit.setText(metadata.FreeCADMax)
result = self.dialog.exec()
if result == QDialog.Accepted:
if self.dialog.minVersionLineEdit.text():
metadata.FreeCADMin = self.dialog.minVersionLineEdit.text()
else:
metadata.FreeCADMin = None
if self.dialog.maxVersionLineEdit.text():
metadata.FreeCADMax = self.dialog.maxVersionLineEdit.text()
else:
metadata.FreeCADMax = None
class EditAdvancedVersions:

View File

@@ -60,7 +60,7 @@ class LicenseSelector:
"https://creativecommons.org/choose/zero/",
),
"GPLv2": (
"GNU Lesser General Public License version 2",
"GNU General Public License version 2",
"https://opensource.org/licenses/GPL-2.0",
),
"GPLv3": (

View File

@@ -0,0 +1,154 @@
# ***************************************************************************
# * *
# * 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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Class to guess metadata based on folder contents. Note that one of the functions
of this file is to guess the license being applied to the new software package based
in its contents. It is up to the user to make the final determination about whether
the selected license is the correct one, and inclusion here shouldn't be construed as
endorsement of any particular license. In addition, the inclusion of those text strings
does not imply a modification to the license for THIS software, which is licensed
under the LGPLv2.1 license (as stated above)."""
import os
import FreeCAD
from addonmanager_git import initialize_git, GitFailed
class Predictor:
""" 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 = ""
def predict_metadata(self, path:os.PathLike) -> FreeCAD.Metadata:
""" Create a predicted Metadata object based on the contents of the passed-in directory """
self.path = path
self._predict_author_info()
self._predict_name()
self._predict_description()
self._predict_contents()
self._predict_icon()
self._predict_urls()
self._predict_license()
def _predict_author_info(self):
""" Predict the author and maintainer info based on git history """
def _predict_name(self):
""" Predict the name based on the local path name and/or the contents of a
README.md file. """
def _predict_description(self):
""" Predict the description based on the contents of a README.md file. """
def _predict_contents(self):
""" 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. """
def _predict_urls(self):
""" Predict the URLs based on git settings """
def _predict_license(self):
""" 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.
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",
),
}
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}
return
for test_text in test_data:
if test_text in self.license_data
self.metadata.License = {"name":license,"file":self.license_file}
return
def _load_readme(self):
""" 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)
if os.path.exists(full_path):
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"]
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:
self.license_data = f.read()
self.license_file = name
return

View File

@@ -0,0 +1,145 @@
# ***************************************************************************
# * *
# * 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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Validators used for various line edits """
import keyword
from PySide2.QtGui import (
QValidator,
QRegularExpressionValidator,
)
from PySide2.QtCore import QRegularExpression
def isidentifier(ident: str) -> bool:
if not ident.isidentifier():
return False
if keyword.iskeyword(ident):
return False
return True
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 PythonIdentifierValidator(QValidator):
""" 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
if keyword.iskeyword(value):
return QValidator.Intermediate # They can keep typing and it might become valid
return QValidator.Acceptable
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"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
+ r"(?:-(?P<prerelease>(?: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<buildmetadata>[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<major>[1-9]\d{3})\.(?P<minor>[0-9]{1,2})\.(?P<patch>0|[0-9]{0,2})"
+ r"(?:-(?P<prerelease>(?: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<buildmetadata>[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)

View File

@@ -73,7 +73,7 @@
<item>
<widget class="QStackedWidget" name="stackedWidget">
<property name="currentIndex">
<number>1</number>
<number>0</number>
</property>
<widget class="QWidget" name="Macro">
<layout class="QHBoxLayout" name="horizontalLayout_3">
@@ -263,7 +263,7 @@
</widget>
</item>
<item row="2" column="1">
<widget class="QPlainTextEdit" name="plainTextEdit">
<widget class="QPlainTextEdit" name="descriptionTextEdit">
<property name="tabChangesFocus">
<bool>true</bool>
</property>