FreeCAD
699 строк · 29.4 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022 FreeCAD Project Association *
5# * *
6# * This file is part of FreeCAD. *
7# * *
8# * FreeCAD is free software: you can redistribute it and/or modify it *
9# * under the terms of the GNU Lesser General Public License as *
10# * published by the Free Software Foundation, either version 2.1 of the *
11# * License, or (at your option) any later version. *
12# * *
13# * FreeCAD is distributed in the hope that it will be useful, but *
14# * WITHOUT ANY WARRANTY; without even the implied warranty of *
15# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
16# * Lesser General Public License for more details. *
17# * *
18# * You should have received a copy of the GNU Lesser General Public *
19# * License along with FreeCAD. If not, see *
20# * <https://www.gnu.org/licenses/>. *
21# * *
22# ***************************************************************************
23
24""" Classes to manage "Developer Mode" """
25
26import os
27import datetime
28import subprocess
29
30import FreeCAD
31import FreeCADGui
32from freecad.utils import get_python_exe
33
34from PySide.QtWidgets import (
35QFileDialog,
36QListWidgetItem,
37QDialog,
38QSizePolicy,
39QMessageBox,
40)
41from PySide.QtGui import (
42QIcon,
43QPixmap,
44)
45from PySide.QtCore import Qt
46from addonmanager_git import GitManager, NoGitFound
47
48from addonmanager_devmode_add_content import AddContent
49from addonmanager_devmode_validators import NameValidator, VersionValidator
50from addonmanager_devmode_predictor import Predictor
51from addonmanager_devmode_people_table import PeopleTable
52from addonmanager_devmode_licenses_table import LicensesTable
53import addonmanager_utilities as utils
54
55translate = FreeCAD.Qt.translate
56
57# pylint: disable=too-few-public-methods
58
59ContentTypeRole = Qt.UserRole
60ContentIndexRole = Qt.UserRole + 1
61
62
63class AddonGitInterface:
64"""Wrapper to handle the git calls needed by this class"""
65
66git_manager = None
67
68def __init__(self, path):
69self.git_exists = False
70if not AddonGitInterface.git_manager:
71try:
72AddonGitInterface.git_manager = GitManager()
73except NoGitFound:
74FreeCAD.Console.PrintLog("No git found, Addon Manager Developer Mode disabled.")
75return
76
77self.path = path
78if os.path.exists(os.path.join(path, ".git")):
79self.git_exists = True
80self.branch = AddonGitInterface.git_manager.current_branch(self.path)
81self.remote = AddonGitInterface.git_manager.get_remote(self.path)
82
83@property
84def branches(self):
85"""The branches available for this repo."""
86if self.git_exists:
87return AddonGitInterface.git_manager.get_branches(self.path)
88return []
89
90@property
91def committers(self):
92"""The committers to this repo, in the last ten commits"""
93if self.git_exists:
94return AddonGitInterface.git_manager.get_last_committers(self.path, 10)
95return []
96
97@property
98def authors(self):
99"""The committers to this repo, in the last ten commits"""
100if self.git_exists:
101return AddonGitInterface.git_manager.get_last_authors(self.path, 10)
102return []
103
104
105# pylint: disable=too-many-instance-attributes
106
107
108class DeveloperMode:
109"""The main Developer Mode dialog, for editing package.xml metadata graphically."""
110
111def __init__(self):
112# In the UI we want to show a translated string for the person type, but the underlying
113# string must be the one expected by the metadata parser, in English
114self.person_type_translation = {
115"maintainer": translate("AddonsInstaller", "Maintainer"),
116"author": translate("AddonsInstaller", "Author"),
117}
118self.dialog = FreeCADGui.PySideUic.loadUi(
119os.path.join(os.path.dirname(__file__), "developer_mode.ui")
120)
121self.people_table = PeopleTable()
122self.licenses_table = LicensesTable()
123large_size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
124large_size_policy.setHorizontalStretch(2)
125small_size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
126small_size_policy.setHorizontalStretch(1)
127self.people_table.widget.setSizePolicy(large_size_policy)
128self.licenses_table.widget.setSizePolicy(small_size_policy)
129self.dialog.peopleAndLicenseshorizontalLayout.addWidget(self.people_table.widget)
130self.dialog.peopleAndLicenseshorizontalLayout.addWidget(self.licenses_table.widget)
131self.pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
132self.current_mod: str = ""
133self.git_interface = None
134self.has_toplevel_icon = False
135self.metadata = None
136
137self._setup_dialog_signals()
138
139self.dialog.displayNameLineEdit.setValidator(NameValidator())
140self.dialog.versionLineEdit.setValidator(VersionValidator())
141self.dialog.minPythonLineEdit.setValidator(VersionValidator())
142
143self.dialog.addContentItemToolButton.setIcon(
144QIcon.fromTheme("add", QIcon(":/icons/list-add.svg"))
145)
146self.dialog.removeContentItemToolButton.setIcon(
147QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg"))
148)
149
150def show(self, parent=None, path: str = None):
151"""Show the main dev mode dialog"""
152if parent:
153self.dialog.setParent(parent)
154if path and os.path.exists(path):
155self.dialog.pathToAddonComboBox.setEditText(path)
156elif self.pref.HasGroup("recentModsList"):
157recent_mods_group = self.pref.GetGroup("recentModsList")
158entry = recent_mods_group.GetString("Mod0", "")
159if entry:
160self._populate_dialog(entry)
161self._update_recent_mods(entry)
162self._populate_combo()
163else:
164self._clear_all_fields()
165else:
166self._clear_all_fields()
167
168result = self.dialog.exec()
169if result == QDialog.Accepted:
170self._sync_metadata_to_ui()
171now = datetime.date.today()
172self.metadata.Date = str(now)
173self.metadata.write(os.path.join(self.current_mod, "package.xml"))
174
175def _populate_dialog(self, path_to_repo: str):
176"""Populate this dialog using the best available parsing of the contents of the repo at
177path_to_repo. This is a multi-layered process that starts with any existing package.xml
178file or other known metadata files, and proceeds through examining the contents of the
179directory structure."""
180self.current_mod = path_to_repo
181self._scan_for_git_info(self.current_mod)
182
183metadata_path = os.path.join(path_to_repo, "package.xml")
184if os.path.exists(metadata_path):
185try:
186self.metadata = FreeCAD.Metadata(metadata_path)
187except FreeCAD.Base.XMLBaseException as e:
188FreeCAD.Console.PrintError(
189translate(
190"AddonsInstaller",
191"XML failure while reading metadata from file {}",
192).format(metadata_path)
193+ "\n\n"
194+ str(e)
195+ "\n\n"
196)
197except FreeCAD.Base.RuntimeError as e:
198FreeCAD.Console.PrintError(
199translate("AddonsInstaller", "Invalid metadata in file {}").format(
200metadata_path
201)
202+ "\n\n"
203+ str(e)
204+ "\n\n"
205)
206
207self._clear_all_fields()
208
209if not self.metadata:
210self._predict_metadata()
211
212self.dialog.displayNameLineEdit.setText(self.metadata.Name)
213self.dialog.descriptionTextEdit.setPlainText(self.metadata.Description)
214self.dialog.versionLineEdit.setText(self.metadata.Version)
215self.dialog.minPythonLineEdit.setText(self.metadata.PythonMin)
216
217self._populate_urls_from_metadata(self.metadata)
218self._populate_contents_from_metadata(self.metadata)
219
220self._populate_icon_from_metadata(self.metadata)
221
222self.people_table.show(self.metadata)
223self.licenses_table.show(self.metadata, self.current_mod)
224
225def _populate_urls_from_metadata(self, metadata):
226"""Use the passed metadata object to populate the urls"""
227for url in metadata.Urls:
228if url["type"] == "website":
229self.dialog.websiteURLLineEdit.setText(url["location"])
230elif url["type"] == "repository":
231self.dialog.repositoryURLLineEdit.setText(url["location"])
232branch_from_metadata = url["branch"]
233branch_from_local_path = self.git_interface.branch
234if branch_from_metadata != branch_from_local_path:
235# pylint: disable=line-too-long
236FreeCAD.Console.PrintWarning(
237translate(
238"AddonsInstaller",
239"WARNING: Path specified in package.xml metadata does not match currently checked-out branch.",
240)
241+ "\n"
242)
243self.dialog.branchComboBox.setCurrentText(branch_from_metadata)
244elif url["type"] == "bugtracker":
245self.dialog.bugtrackerURLLineEdit.setText(url["location"])
246elif url["type"] == "readme":
247self.dialog.readmeURLLineEdit.setText(url["location"])
248elif url["type"] == "documentation":
249self.dialog.documentationURLLineEdit.setText(url["location"])
250elif url["type"] == "discussion":
251self.dialog.discussionURLLineEdit.setText(url["location"])
252
253def _populate_contents_from_metadata(self, metadata):
254"""Use the passed metadata object to populate the contents list"""
255contents = metadata.Content
256self.dialog.contentsListWidget.clear()
257for content_type in contents:
258counter = 0
259for item in contents[content_type]:
260contents_string = f"[{content_type}] "
261info = []
262if item.Name:
263info.append(translate("AddonsInstaller", "Name") + ": " + item.Name)
264if item.Classname:
265info.append(translate("AddonsInstaller", "Class") + ": " + item.Classname)
266if item.Description:
267info.append(
268translate("AddonsInstaller", "Description") + ": " + item.Description
269)
270if item.Subdirectory:
271info.append(
272translate("AddonsInstaller", "Subdirectory") + ": " + item.Subdirectory
273)
274if item.File:
275info.append(translate("AddonsInstaller", "Files") + ": " + ", ".join(item.File))
276contents_string += ", ".join(info)
277
278item = QListWidgetItem(contents_string)
279item.setData(ContentTypeRole, content_type)
280item.setData(ContentIndexRole, counter)
281self.dialog.contentsListWidget.addItem(item)
282counter += 1
283
284def _populate_icon_from_metadata(self, metadata):
285"""Use the passed metadata object to populate the icon fields"""
286self.dialog.iconDisplayLabel.setPixmap(QPixmap())
287icon = metadata.Icon
288icon_path = None
289if icon:
290icon_path = os.path.join(self.current_mod, icon.replace("/", os.path.sep))
291self.has_toplevel_icon = True
292else:
293self.has_toplevel_icon = False
294contents = metadata.Content
295if "workbench" in contents:
296for wb in contents["workbench"]:
297icon = wb.Icon
298path = wb.Subdirectory
299if icon:
300icon_path = os.path.join(
301self.current_mod, path, icon.replace("/", os.path.sep)
302)
303break
304
305if icon_path and os.path.isfile(icon_path):
306icon_data = QIcon(icon_path)
307if not icon_data.isNull():
308self.dialog.iconDisplayLabel.setPixmap(icon_data.pixmap(32, 32))
309self.dialog.iconPathLineEdit.setText(icon)
310
311def _predict_metadata(self):
312"""If there is no metadata, try to guess at values for it"""
313self.metadata = FreeCAD.Metadata()
314predictor = Predictor()
315self.metadata = predictor.predict_metadata(self.current_mod)
316
317def _scan_for_git_info(self, path: str):
318"""Look for branch availability"""
319self.git_interface = AddonGitInterface(path)
320if self.git_interface.git_exists:
321self.dialog.branchComboBox.clear()
322for branch in self.git_interface.branches:
323if branch and branch.startswith("origin/") and branch != "origin/HEAD":
324self.dialog.branchComboBox.addItem(branch[len("origin/") :])
325self.dialog.branchComboBox.setCurrentText(self.git_interface.branch)
326
327def _clear_all_fields(self):
328"""Clear out all fields"""
329self.dialog.displayNameLineEdit.clear()
330self.dialog.descriptionTextEdit.clear()
331self.dialog.versionLineEdit.clear()
332self.dialog.websiteURLLineEdit.clear()
333self.dialog.repositoryURLLineEdit.clear()
334self.dialog.bugtrackerURLLineEdit.clear()
335self.dialog.readmeURLLineEdit.clear()
336self.dialog.documentationURLLineEdit.clear()
337self.dialog.discussionURLLineEdit.clear()
338self.dialog.minPythonLineEdit.clear()
339self.dialog.iconDisplayLabel.setPixmap(QPixmap())
340self.dialog.iconPathLineEdit.clear()
341
342def _setup_dialog_signals(self):
343"""Set up the signal and slot connections for the main dialog."""
344
345self.dialog.addonPathBrowseButton.clicked.connect(self._addon_browse_button_clicked)
346self.dialog.pathToAddonComboBox.editTextChanged.connect(self._addon_combo_text_changed)
347self.dialog.detectMinPythonButton.clicked.connect(self._detect_min_python_clicked)
348self.dialog.iconBrowseButton.clicked.connect(self._browse_for_icon_clicked)
349
350self.dialog.addContentItemToolButton.clicked.connect(self._add_content_clicked)
351self.dialog.removeContentItemToolButton.clicked.connect(self._remove_content_clicked)
352self.dialog.contentsListWidget.itemSelectionChanged.connect(self._content_selection_changed)
353self.dialog.contentsListWidget.itemDoubleClicked.connect(self._edit_content)
354
355self.dialog.versionToTodayButton.clicked.connect(self._set_to_today_clicked)
356
357# Finally, populate the combo boxes, etc.
358self._populate_combo()
359
360# Disable all the "Remove" buttons until something is selected
361self.dialog.removeContentItemToolButton.setDisabled(True)
362
363def _sync_metadata_to_ui(self):
364"""Take the data from the UI fields and put it into the stored metadata
365object. Only overwrites known data fields: unknown metadata will be retained."""
366
367if not self.metadata:
368self.metadata = FreeCAD.Metadata()
369
370self.metadata.Name = self.dialog.displayNameLineEdit.text()
371self.metadata.Description = self.dialog.descriptionTextEdit.document().toPlainText()
372self.metadata.Version = self.dialog.versionLineEdit.text()
373self.metadata.Icon = self.dialog.iconPathLineEdit.text()
374
375urls = []
376if self.dialog.websiteURLLineEdit.text():
377urls.append({"location": self.dialog.websiteURLLineEdit.text(), "type": "website"})
378if self.dialog.repositoryURLLineEdit.text():
379urls.append(
380{
381"location": self.dialog.repositoryURLLineEdit.text(),
382"type": "repository",
383"branch": self.dialog.branchComboBox.currentText(),
384}
385)
386if self.dialog.bugtrackerURLLineEdit.text():
387urls.append(
388{
389"location": self.dialog.bugtrackerURLLineEdit.text(),
390"type": "bugtracker",
391}
392)
393if self.dialog.readmeURLLineEdit.text():
394urls.append({"location": self.dialog.readmeURLLineEdit.text(), "type": "readme"})
395if self.dialog.documentationURLLineEdit.text():
396urls.append(
397{
398"location": self.dialog.documentationURLLineEdit.text(),
399"type": "documentation",
400}
401)
402if self.dialog.discussionURLLineEdit.text():
403urls.append(
404{
405"location": self.dialog.discussionURLLineEdit.text(),
406"type": "discussion",
407}
408)
409self.metadata.Urls = urls
410
411if self.dialog.minPythonLineEdit.text():
412self.metadata.PythonMin = self.dialog.minPythonLineEdit.text()
413else:
414self.metadata.PythonMin = "0.0.0" # Code for "unset"
415
416# Content, people, and licenses should already be synchronized
417
418###############################################################################################
419# DIALOG SLOTS
420###############################################################################################
421
422def _addon_browse_button_clicked(self):
423"""Launch a modal file/folder selection dialog -- if something is selected, it is
424processed by the parsing code and used to fill in the contents of the rest of the
425dialog."""
426
427start_dir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod")
428mod_dir = QFileDialog.getExistingDirectory(
429parent=self.dialog,
430caption=translate(
431"AddonsInstaller",
432"Select the folder containing your Addon",
433),
434dir=start_dir,
435)
436
437if mod_dir and os.path.exists(mod_dir):
438self.dialog.pathToAddonComboBox.setEditText(mod_dir)
439
440def _addon_combo_text_changed(self, new_text: str):
441"""Called when the text is changed, either because it was directly edited, or because
442a new item was selected."""
443if new_text == self.current_mod:
444# It doesn't look like it actually changed, bail out
445return
446self.metadata = None
447self._clear_all_fields()
448if not os.path.exists(new_text):
449# This isn't a thing (Yet. Maybe the user is still typing?)
450return
451self._populate_dialog(new_text)
452self._update_recent_mods(new_text)
453self._populate_combo()
454
455def _populate_combo(self):
456"""Fill in the combo box with the values from the stored recent mods list, selecting the
457top one. Does not trigger any signals."""
458combo = self.dialog.pathToAddonComboBox
459combo.blockSignals(True)
460recent_mods_group = self.pref.GetGroup("recentModsList")
461recent_mods = set()
462combo.clear()
463for i in range(10):
464entry_name = f"Mod{i}"
465mod = recent_mods_group.GetString(entry_name, "None")
466if mod != "None" and mod not in recent_mods and os.path.exists(mod):
467recent_mods.add(mod)
468combo.addItem(mod)
469if recent_mods:
470combo.setCurrentIndex(0)
471combo.blockSignals(False)
472
473def _update_recent_mods(self, path):
474"""Update the list of recent mods, storing at most ten, with path at the top of the
475list."""
476recent_mod_paths = [path]
477if self.pref.HasGroup("recentModsList"):
478recent_mods_group = self.pref.GetGroup("recentModsList")
479
480# This group has a maximum of ten entries, sorted by last-accessed date
481for i in range(0, 10):
482entry_name = f"Mod{i}"
483entry = recent_mods_group.GetString(entry_name, "")
484if entry and entry not in recent_mod_paths and os.path.exists(entry):
485recent_mod_paths.append(entry)
486
487# Remove the whole thing, so we can recreate it from scratch
488self.pref.RemGroup("recentModsList")
489
490if recent_mod_paths:
491recent_mods_group = self.pref.GetGroup("recentModsList")
492for i, mod in zip(range(10), recent_mod_paths):
493entry_name = f"Mod{i}"
494recent_mods_group.SetString(entry_name, mod)
495
496def _add_content_clicked(self):
497"""Callback: The Add Content button was clicked"""
498dlg = AddContent(self.current_mod, self.metadata)
499singleton = False
500if self.dialog.contentsListWidget.count() == 0:
501singleton = True
502content_type, new_metadata = dlg.exec(singleton=singleton)
503if content_type and new_metadata:
504self.metadata.addContentItem(content_type, new_metadata)
505self._populate_contents_from_metadata(self.metadata)
506
507def _remove_content_clicked(self):
508"""Callback: the remove content button was clicked"""
509
510item = self.dialog.contentsListWidget.currentItem()
511if not item:
512return
513content_type = item.data(ContentTypeRole)
514content_index = item.data(ContentIndexRole)
515if self.metadata.Content[content_type] and content_index < len(
516self.metadata.Content[content_type]
517):
518content_name = self.metadata.Content[content_type][content_index].Name
519self.metadata.removeContentItem(content_type, content_name)
520self._populate_contents_from_metadata(self.metadata)
521
522def _content_selection_changed(self):
523"""Callback: the selected content item changed"""
524items = self.dialog.contentsListWidget.selectedItems()
525if items:
526self.dialog.removeContentItemToolButton.setDisabled(False)
527else:
528self.dialog.removeContentItemToolButton.setDisabled(True)
529
530def _edit_content(self, item):
531"""Callback: a content row was double-clicked"""
532dlg = AddContent(self.current_mod, self.metadata)
533
534content_type = item.data(ContentTypeRole)
535content_index = item.data(ContentIndexRole)
536
537content = self.metadata.Content
538metadata = content[content_type][content_index]
539old_name = metadata.Name
540new_type, new_metadata = dlg.exec(content_type, metadata, len(content) == 1)
541if new_type and new_metadata:
542self.metadata.removeContentItem(content_type, old_name)
543self.metadata.addContentItem(new_type, new_metadata)
544self._populate_contents_from_metadata(self.metadata)
545
546def _set_to_today_clicked(self):
547"""Callback: the "set to today" button was clicked"""
548year = datetime.date.today().year
549month = datetime.date.today().month
550day = datetime.date.today().day
551version_string = f"{year}.{month:>02}.{day:>02}"
552self.dialog.versionLineEdit.setText(version_string)
553
554def _detect_min_python_clicked(self):
555if not self._ensure_vermin_loaded():
556FreeCAD.Console.PrintWarning(
557translate(
558"AddonsInstaller",
559"No Vermin, cancelling operation.",
560"NOTE: Vermin is a Python package and proper noun - do not translate",
561)
562+ "\n"
563)
564return
565FreeCAD.Console.PrintMessage(
566translate("AddonsInstaller", "Scanning Addon for Python version compatibility")
567+ "...\n"
568)
569# pylint: disable=import-outside-toplevel
570import vermin
571
572required_minor_version = 0
573for dir_path, _, filenames in os.walk(self.current_mod):
574for filename in filenames:
575if filename.endswith(".py"):
576with open(os.path.join(dir_path, filename), encoding="utf-8") as f:
577contents = f.read()
578version_strings = vermin.version_strings(vermin.detect(contents))
579version = version_strings.split(",")
580if len(version) >= 2:
581# Only care about Py3, and only if there is a dot in the version:
582if "." in version[1]:
583py3 = version[1].split(".")
584major = int(py3[0].strip())
585minor = int(py3[1].strip())
586if major == 3:
587FreeCAD.Console.PrintLog(
588f"Detected Python 3.{minor} required by {filename}\n"
589)
590required_minor_version = max(required_minor_version, minor)
591self.dialog.minPythonLineEdit.setText(f"3.{required_minor_version}")
592QMessageBox.information(
593self.dialog,
594translate("AddonsInstaller", "Minimum Python Version Detected"),
595translate(
596"AddonsInstaller",
597"Vermin auto-detected a required version of Python 3.{}",
598).format(required_minor_version),
599QMessageBox.Ok,
600)
601
602def _ensure_vermin_loaded(self) -> bool:
603try:
604# pylint: disable=import-outside-toplevel,unused-import
605import vermin
606except ImportError:
607# pylint: disable=line-too-long
608response = QMessageBox.question(
609self.dialog,
610translate("AddonsInstaller", "Install Vermin?"),
611translate(
612"AddonsInstaller",
613"Auto-detecting the required version of Python for this Addon requires Vermin (https://pypi.org/project/vermin/). OK to install?",
614),
615QMessageBox.Yes | QMessageBox.Cancel,
616)
617if response == QMessageBox.Cancel:
618return False
619FreeCAD.Console.PrintMessage(
620translate("AddonsInstaller", "Attempting to install Vermin from PyPi") + "...\n"
621)
622python_exe = get_python_exe()
623vendor_path = os.path.join(FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages")
624if not os.path.exists(vendor_path):
625os.makedirs(vendor_path)
626
627proc = subprocess.run(
628[
629python_exe,
630"-m",
631"pip",
632"install",
633"--disable-pip-version-check",
634"--target",
635vendor_path,
636"vermin",
637],
638capture_output=True,
639check=True,
640)
641FreeCAD.Console.PrintMessage(proc.stdout.decode())
642if proc.returncode != 0:
643QMessageBox.critical(
644self.dialog,
645translate("AddonsInstaller", "Installation failed"),
646translate(
647"AddonsInstaller",
648"Failed to install Vermin -- check Report View for details.",
649"'Vermin' is the name of a Python package, do not translate",
650),
651QMessageBox.Cancel,
652)
653return False
654try:
655# pylint: disable=import-outside-toplevel
656import vermin
657except ImportError:
658QMessageBox.critical(
659self.dialog,
660translate("AddonsInstaller", "Installation failed"),
661translate(
662"AddonsInstaller",
663"Failed to import vermin after installation -- cannot scan Addon.",
664"'vermin' is the name of a Python package, do not translate",
665),
666QMessageBox.Cancel,
667)
668return False
669return True
670
671def _browse_for_icon_clicked(self):
672"""Callback: when the "Browse..." button for the icon field is clicked"""
673new_icon_path, _ = QFileDialog.getOpenFileName(
674parent=self.dialog,
675caption=translate(
676"AddonsInstaller",
677"Select an icon file for this package",
678),
679dir=self.current_mod,
680)
681
682if not new_icon_path:
683return
684
685base_path = self.current_mod.replace("/", os.path.sep)
686icon_path = new_icon_path.replace("/", os.path.sep)
687if base_path[-1] != os.path.sep:
688base_path += os.path.sep
689
690if not icon_path.startswith(base_path):
691FreeCAD.Console.PrintError(
692translate("AddonsInstaller", "{} is not a subdirectory of {}").format(
693new_icon_path, self.current_mod
694)
695+ "\n"
696)
697return
698self.metadata.Icon = new_icon_path[len(base_path) :]
699self._populate_icon_from_metadata(self.metadata)
700