Addon Manager: Move to git submodule (#20672)

This commit is contained in:
Chris Hennes
2025-04-08 09:40:31 -05:00
committed by GitHub
parent 22d7e421bc
commit fc782a6c8d
321 changed files with 8 additions and 148747 deletions

3
.gitmodules vendored
View File

@@ -7,3 +7,6 @@
[submodule "src/3rdParty/GSL"]
path = src/3rdParty/GSL
url = https://github.com/microsoft/GSL
[submodule "src/Mod/AddonManager"]
path = src/Mod/AddonManager
url = https://github.com/FreeCAD/AddonManager.git

View File

@@ -9,7 +9,6 @@ files: |
src/Main|
src/Tools|
tests/src|
src/Mod/AddonManager|
src/Mod/Assembly|
src/Mod/CAM|
src/Mod/Cloud|

1
src/Mod/AddonManager Submodule

Submodule src/Mod/AddonManager added at 34d433a02c

View File

@@ -1,35 +0,0 @@
# This file lists the Python packages that the Addon Manager allows to be installed
# automatically via pip. To request that a package be added to this list, submit a
# pull request to the FreeCAD git repository with the requested package added. Only
# packages in this list will be processed from the metadata.txt and requirements.txt
# files specified by an Addon. Note that this is NOT a requirements.txt-format file,
# no version information may be specified, and no wildcards are supported.
# Allow these packages to be installed:
aiofiles
autobahn
ezdxf
gmsh
gmsh-dev
lxml
markdown
matplotlib
msgpack
numpy
ocp
olefile
openpyxl
pandas
pillow
ply
py-slvs
pycollada
pygit2
pynastran
requests
rhino3dm
scipy
xlrd
xlutils
xlwt
PyYAML

View File

@@ -1,863 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
"""Defines the Addon class to encapsulate information about FreeCAD Addons"""
import os
import re
from datetime import datetime
from urllib.parse import urlparse
from typing import Dict, Set, List, Optional
from threading import Lock
from enum import IntEnum, auto
import xml.etree.ElementTree
import addonmanager_freecad_interface as fci
from addonmanager_macro import Macro
import addonmanager_utilities as utils
from addonmanager_utilities import construct_git_url, process_date_string_to_python_datetime
from addonmanager_metadata import (
Metadata,
MetadataReader,
UrlType,
Version,
DependencyType,
)
from AddonStats import AddonStats
translate = fci.translate
# A list of internal workbenches that can be used as a dependency of an Addon
INTERNAL_WORKBENCHES = {
"bim": "BIM",
"assembly": "Assembly",
"draft": "Draft",
"fem": "FEM",
"mesh": "Mesh",
"openscad": "OpenSCAD",
"part": "Part",
"partdesign": "PartDesign",
"cam": "CAM",
"plot": "Plot",
"points": "Points",
"robot": "Robot",
"sketcher": "Sketcher",
"spreadsheet": "Spreadsheet",
"techdraw": "TechDraw",
}
class Addon:
"""Encapsulates information about a FreeCAD addon"""
class Kind(IntEnum):
"""The type of Addon: Workbench, macro, or package"""
WORKBENCH = 1
MACRO = 2
PACKAGE = 3
def __str__(self) -> str:
if self.value == 1:
return "Workbench"
if self.value == 2:
return "Macro"
if self.value == 3:
return "Package"
return "ERROR_TYPE"
class Status(IntEnum):
"""The installation status of an Addon"""
NOT_INSTALLED = 0
UNCHECKED = 1
NO_UPDATE_AVAILABLE = 2
UPDATE_AVAILABLE = 3
PENDING_RESTART = 4
CANNOT_CHECK = 5 # If we don't have git, etc.
UNKNOWN = 100
def __lt__(self, other):
if self.__class__ is other.__class__:
return self.value < other.value
return NotImplemented
def __str__(self) -> str:
if self.value == 0:
result = "Not installed"
elif self.value == 1:
result = "Unchecked"
elif self.value == 2:
result = "No update available"
elif self.value == 3:
result = "Update available"
elif self.value == 4:
result = "Restart required"
elif self.value == 5:
result = "Can't check"
else:
result = "ERROR_STATUS"
return result
class Dependencies:
"""Addon dependency information"""
def __init__(self):
self.required_external_addons = [] # A list of Addons
self.blockers = [] # A list of Addons
self.replaces = [] # A list of Addons
self.internal_workbenches: Set[str] = set() # Required internal workbenches
self.python_requires: Set[str] = set()
self.python_optional: Set[str] = set()
self.python_min_version = {"major": 3, "minor": 0}
class DependencyType(IntEnum):
"""Several types of dependency information is stored"""
INTERNAL_WORKBENCH = auto()
REQUIRED_ADDON = auto()
BLOCKED_ADDON = auto()
REPLACED_ADDON = auto()
REQUIRED_PYTHON = auto()
OPTIONAL_PYTHON = auto()
class ResolutionFailed(RuntimeError):
"""An exception type for dependency resolution failure."""
# The location of Addon Manager cache files: overridden by testing code
cache_directory = os.path.join(fci.DataPaths().cache_dir, "AddonManager")
# The location of the Mod directory: overridden by testing code
mod_directory = fci.DataPaths().mod_dir
# The location of the Macro directory: overridden by testing code
macro_directory = fci.DataPaths().macro_dir
def __init__(
self,
name: str,
url: str = "",
status: Status = Status.UNKNOWN,
branch: str = "",
):
self.name = name.strip()
self.display_name = self.name
self.url = url.strip()
self.branch = branch.strip()
self.python2 = False
self.obsolete = False
self.rejected = False
self.repo_type = Addon.Kind.WORKBENCH
self.description = None
self.tags = set() # Just a cache, loaded from Metadata
self.last_updated = None
self.stats = AddonStats()
self.score = 0
# To prevent multiple threads from running git actions on this repo at the
# same time
self.git_lock = Lock()
# To prevent multiple threads from accessing the status at the same time
self.status_lock = Lock()
self.update_status = status
self._clean_url()
if utils.recognized_git_location(self):
self.metadata_url = construct_git_url(self, "package.xml")
else:
self.metadata_url = None
self.metadata: Optional[Metadata] = None
self.icon = None # A QIcon version of this Addon's icon
self.icon_file: str = "" # Absolute local path to cached icon file
self.best_icon_relative_path = ""
self.macro = None # Bridge to Gaël Écorchard's macro management class
self.updated_timestamp = None
self.installed_version = None
self.installed_metadata = None
# Each repo is also a node in a directed dependency graph (referenced by name so
# they can be serialized):
self.requires: Set[str] = set()
self.blocks: Set[str] = set()
# And maintains a list of required and optional Python dependencies
self.python_requires: Set[str] = set()
self.python_optional: Set[str] = set()
self.python_min_version = {"major": 3, "minor": 0}
self._icon_file = None
self._cached_license: str = ""
self._cached_update_date = None
def _clean_url(self):
# The url should never end in ".git", so strip it if it's there
parsed_url = urlparse(self.url)
if parsed_url.path.endswith(".git"):
self.url = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path[:-4]
if parsed_url.query:
self.url += "?" + parsed_url.query
if parsed_url.fragment:
self.url += "#" + parsed_url.fragment
def __str__(self) -> str:
result = f"FreeCAD {self.repo_type}\n"
result += f"Name: {self.name}\n"
result += f"URL: {self.url}\n"
result += "Has metadata\n" if self.metadata is not None else "No metadata found\n"
if self.macro is not None:
result += "Has linked Macro object\n"
return result
@property
def license(self):
if not self._cached_license:
self._cached_license = "UNLICENSED"
if self.metadata and self.metadata.license:
self._cached_license = self.metadata.license
elif self.stats and self.stats.license:
self._cached_license = self.stats.license
elif self.macro:
if self.macro.license:
self._cached_license = self.macro.license
elif self.macro.on_wiki:
self._cached_license = "CC-BY-3.0"
return self._cached_license
@property
def update_date(self):
if self._cached_update_date is None:
self._cached_update_date = 0
if self.stats and self.stats.last_update_time:
self._cached_update_date = self.stats.last_update_time
elif self.macro and self.macro.date:
# Try to parse the date:
try:
self._cached_update_date = process_date_string_to_python_datetime(
self.macro.date
)
except ValueError as e:
fci.Console.PrintWarning(str(e) + "\n")
else:
fci.Console.PrintWarning(f"No update date info for {self.name}\n")
return self._cached_update_date
@classmethod
def from_macro(cls, macro: Macro):
"""Create an Addon object from a Macro wrapper object"""
if macro.is_installed():
status = Addon.Status.UNCHECKED
else:
status = Addon.Status.NOT_INSTALLED
instance = Addon(macro.name, macro.url, status, "master")
instance.macro = macro
instance.repo_type = Addon.Kind.MACRO
instance.description = macro.desc
return instance
@classmethod
def from_cache(cls, cache_dict: Dict):
"""Load basic data from cached dict data. Does not include Macro or Metadata
information, which must be populated separately."""
mod_dir = os.path.join(cls.mod_directory, cache_dict["name"])
if os.path.isdir(mod_dir):
status = Addon.Status.UNCHECKED
else:
status = Addon.Status.NOT_INSTALLED
instance = Addon(cache_dict["name"], cache_dict["url"], status, cache_dict["branch"])
for key, value in cache_dict.items():
if not str(key).startswith("_"):
instance.__dict__[key] = value
instance.repo_type = Addon.Kind(cache_dict["repo_type"])
if instance.repo_type == Addon.Kind.PACKAGE:
# There must be a cached metadata file, too
cached_package_xml_file = os.path.join(
instance.cache_directory,
"PackageMetadata",
instance.name,
)
if os.path.isfile(cached_package_xml_file):
instance.load_metadata_file(cached_package_xml_file)
instance._load_installed_metadata()
if "requires" in cache_dict:
instance.requires = set(cache_dict["requires"])
instance.blocks = set(cache_dict["blocks"])
instance.python_requires = set(cache_dict["python_requires"])
instance.python_optional = set(cache_dict["python_optional"])
instance._clean_url()
return instance
def to_cache(self) -> Dict:
"""Returns a dictionary with cache information that can be used later with
from_cache to recreate this object."""
return {
"name": self.name,
"display_name": self.display_name,
"url": self.url,
"branch": self.branch,
"repo_type": int(self.repo_type),
"description": self.description,
"cached_icon_filename": self.get_cached_icon_filename(),
"best_icon_relative_path": self.get_best_icon_relative_path(),
"python2": self.python2,
"obsolete": self.obsolete,
"rejected": self.rejected,
"requires": list(self.requires),
"blocks": list(self.blocks),
"python_requires": list(self.python_requires),
"python_optional": list(self.python_optional),
}
def load_metadata_file(self, file: str) -> None:
"""Read a given metadata file and set it as this object's metadata"""
if os.path.exists(file):
try:
metadata = MetadataReader.from_file(file)
except xml.etree.ElementTree.ParseError:
fci.Console.PrintWarning(
"An invalid or corrupted package.xml file was found in the cache for"
)
fci.Console.PrintWarning(f" {self.name}... ignoring the bad data.\n")
return
self.set_metadata(metadata)
self._clean_url()
else:
fci.Console.PrintLog(f"Internal error: {file} does not exist")
def _load_installed_metadata(self) -> None:
# If it is actually installed, there is a SECOND metadata file, in the actual installation,
# that may not match the cached one if the Addon has not been updated but the cache has.
mod_dir = os.path.join(self.mod_directory, self.name)
installed_metadata_path = os.path.join(mod_dir, "package.xml")
if os.path.isfile(installed_metadata_path):
try:
self.installed_metadata = MetadataReader.from_file(installed_metadata_path)
except xml.etree.ElementTree.ParseError:
fci.Console.PrintWarning(
"An invalid or corrupted package.xml file was found in installation of"
)
fci.Console.PrintWarning(f" {self.name}... ignoring the bad data.\n")
return
def set_metadata(self, metadata: Metadata) -> None:
"""Set the given metadata object as this object's metadata, updating the
object's display name and package type information to match, as well as
updating any dependency information, etc.
"""
self.metadata = metadata
self.display_name = metadata.name
self.repo_type = Addon.Kind.PACKAGE
self.description = metadata.description
for url in metadata.url:
if url.type == UrlType.repository:
self.url = url.location
self.branch = url.branch if url.branch else "master"
self._clean_url()
self.extract_tags(self.metadata)
self.extract_metadata_dependencies(self.metadata)
@staticmethod
def version_is_ok(metadata: Metadata) -> bool:
"""Checks to see if the current running version of FreeCAD meets the
requirements set by the passed-in metadata parameter."""
from_fci = list(fci.Version())
fc_version = Version(from_list=from_fci)
dep_fc_min = metadata.freecadmin if metadata.freecadmin else fc_version
dep_fc_max = metadata.freecadmax if metadata.freecadmax else fc_version
return dep_fc_min <= fc_version <= dep_fc_max
def extract_metadata_dependencies(self, metadata: Metadata):
"""Read dependency information from a metadata object and store it in this
Addon"""
# Version check: if this piece of metadata doesn't apply to this version of
# FreeCAD, just skip it.
if not Addon.version_is_ok(metadata):
return
if metadata.pythonmin:
self.python_min_version["major"] = metadata.pythonmin.version_as_list[0]
self.python_min_version["minor"] = metadata.pythonmin.version_as_list[1]
for dep in metadata.depend:
if dep.dependency_type == DependencyType.internal:
if dep.package in INTERNAL_WORKBENCHES:
self.requires.add(dep.package)
else:
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
"{}: Unrecognized internal workbench '{}'",
).format(self.name, dep.package)
)
elif dep.dependency_type == DependencyType.addon:
self.requires.add(dep.package)
elif dep.dependency_type == DependencyType.python:
if dep.optional:
self.python_optional.add(dep.package)
else:
self.python_requires.add(dep.package)
else:
# Automatic resolution happens later, once we have a complete list of
# Addons
self.requires.add(dep.package)
for dep in metadata.conflict:
self.blocks.add(dep.package)
# Recurse
content = metadata.content
for _, value in content.items():
for item in value:
self.extract_metadata_dependencies(item)
def verify_url_and_branch(self, url: str, branch: str) -> None:
"""Print diagnostic information for Addon Developers if their metadata is
inconsistent with the actual fetch location. Most often this is due to using
the wrong branch name."""
if self.url != url:
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
"Addon Developer Warning: Repository URL set in package.xml file for addon {} ({}) does not match the URL it was fetched from ({})",
).format(self.display_name, self.url, url)
+ "\n"
)
if self.branch != branch:
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
"Addon Developer Warning: Repository branch set in package.xml file for addon {} ({}) does not match the branch it was fetched from ({})",
).format(self.display_name, self.branch, branch)
+ "\n"
)
def extract_tags(self, metadata: Metadata) -> None:
"""Read the tags from the metadata object"""
# Version check: if this piece of metadata doesn't apply to this version of
# FreeCAD, just skip it.
if not Addon.version_is_ok(metadata):
return
for new_tag in metadata.tag:
self.tags.add(new_tag)
content = metadata.content
for _, value in content.items():
for item in value:
self.extract_tags(item)
def contains_workbench(self) -> bool:
"""Determine if this package contains (or is) a workbench"""
if self.repo_type == Addon.Kind.WORKBENCH:
return True
return self.contains_packaged_content("workbench")
def contains_macro(self) -> bool:
"""Determine if this package contains (or is) a macro"""
if self.repo_type == Addon.Kind.MACRO:
return True
return self.contains_packaged_content("macro")
def contains_packaged_content(self, content_type: str):
"""Determine if the package contains content_type"""
if self.repo_type == Addon.Kind.PACKAGE:
if self.metadata is None:
fci.Console.PrintLog(
f"Addon Manager internal error: lost metadata for package {self.name}\n"
)
return False
content = self.metadata.content
return content_type in content
return False
def contains_preference_pack(self) -> bool:
"""Determine if this package contains a preference pack"""
return self.contains_packaged_content("preferencepack")
def contains_bundle(self) -> bool:
"""Determine if this package contains a bundle"""
return self.contains_packaged_content("bundle")
def contains_other(self) -> bool:
"""Determine if this package contains an "other" content item"""
return self.contains_packaged_content("other")
def get_best_icon_relative_path(self) -> str:
"""Get the path within the repo the addon's icon. Usually specified by
top-level metadata, but some authors omit it and specify only icons for the
contents. Find the first one of those, in such cases."""
if self.best_icon_relative_path:
return self.best_icon_relative_path
if not self.metadata:
return ""
real_icon = self.metadata.icon
if not real_icon:
# If there is no icon set for the entire package, see if there are any
# workbenches, which are required to have icons, and grab the first one
# we find:
content = self.metadata.content
if "workbench" in content:
wb = content["workbench"][0]
if wb.icon:
if wb.subdirectory:
subdir = wb.subdirectory
else:
subdir = wb.name
real_icon = subdir + wb.icon
self.best_icon_relative_path = real_icon
return self.best_icon_relative_path
def get_cached_icon_filename(self) -> str:
"""NOTE: This function is deprecated and will be removed in a coming update."""
if hasattr(self, "cached_icon_filename") and self.cached_icon_filename:
return self.cached_icon_filename
if not self.metadata:
return ""
real_icon = self.metadata.icon
if not real_icon:
# If there is no icon set for the entire package, see if there are any
# workbenches, which are required to have icons, and grab the first one
# we find:
content = self.metadata.content
if "workbench" in content:
wb = content["workbench"][0]
if wb.icon:
if wb.subdirectory:
subdir = wb.subdirectory
else:
subdir = wb.name
real_icon = subdir + wb.icon
real_icon = real_icon.replace(
"/", os.path.sep
) # Required path separator in the metadata.xml file to local separator
_, file_extension = os.path.splitext(real_icon)
store = os.path.join(self.cache_directory, "PackageMetadata")
self.cached_icon_filename = os.path.join(store, self.name, "cached_icon" + file_extension)
return self.cached_icon_filename
def walk_dependency_tree(self, all_repos, deps):
"""Compute the total dependency tree for this repo (recursive)
- all_repos is a dictionary of repos, keyed on the name of the repo
- deps is an Addon.Dependency object encapsulating all the types of dependency
information that may be needed.
"""
deps.python_requires |= self.python_requires
deps.python_optional |= self.python_optional
deps.python_min_version["major"] = max(
deps.python_min_version["major"], self.python_min_version["major"]
)
if deps.python_min_version["major"] == 3:
deps.python_min_version["minor"] = max(
deps.python_min_version["minor"], self.python_min_version["minor"]
)
else:
fci.Console.PrintWarning("Unrecognized Python version information")
for dep in self.requires:
if dep in all_repos:
if dep not in deps.required_external_addons:
deps.required_external_addons.append(all_repos[dep])
all_repos[dep].walk_dependency_tree(all_repos, deps)
else:
# See if this is an internal workbench:
if dep.upper().endswith("WB"):
real_name = dep[:-2].strip().lower()
elif dep.upper().endswith("WORKBENCH"):
real_name = dep[:-9].strip().lower()
else:
real_name = dep.strip().lower()
if real_name in INTERNAL_WORKBENCHES:
deps.internal_workbenches.add(INTERNAL_WORKBENCHES[real_name])
else:
# Assume it's a Python requirement of some kind:
deps.python_requires.add(dep)
for dep in self.blocks:
if dep in all_repos:
deps.blockers[dep] = all_repos[dep]
def status(self):
"""Threadsafe access to the current update status"""
with self.status_lock:
return self.update_status
def set_status(self, status):
"""Threadsafe setting of the update status"""
with self.status_lock:
self.update_status = status
def is_disabled(self):
"""Check to see if the disabling stopfile exists"""
stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")
return os.path.exists(stopfile)
def disable(self):
"""Disable this addon from loading when FreeCAD starts up by creating a
stopfile"""
stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")
with open(stopfile, "w", encoding="utf-8") as f:
f.write(
"The existence of this file prevents FreeCAD from loading this Addon. To re-enable, delete the file."
)
if self.contains_workbench():
self.disable_workbench()
def enable(self):
"""Re-enable loading this addon by deleting the stopfile"""
stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")
try:
os.unlink(stopfile)
except FileNotFoundError:
pass
if self.contains_workbench():
self.enable_workbench()
def enable_workbench(self):
wbName = self.get_workbench_name()
# Remove from the list of disabled.
self.remove_from_disabled_wbs(wbName)
def disable_workbench(self):
pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")
wbName = self.get_workbench_name()
# Add the wb to the list of disabled if it was not already
disabled_wbs = pref.GetString("Disabled", "NoneWorkbench,TestWorkbench")
# print(f"start disabling {disabled_wbs}")
disabled_wbs_list = disabled_wbs.split(",")
if not (wbName in disabled_wbs_list):
disabled_wbs += "," + wbName
pref.SetString("Disabled", disabled_wbs)
# print(f"done disabling : {disabled_wbs} \n")
def desinstall_workbench(self):
pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")
wbName = self.get_workbench_name()
# Remove from the list of ordered.
ordered_wbs = pref.GetString("Ordered", "")
# print(f"start remove from ordering {ordered_wbs}")
ordered_wbs_list = ordered_wbs.split(",")
ordered_wbs = ""
for wb in ordered_wbs_list:
if wb != wbName:
if ordered_wbs != "":
ordered_wbs += ","
ordered_wbs += wb
pref.SetString("Ordered", ordered_wbs)
# print(f"end remove from ordering {ordered_wbs}")
# Remove from the list of disabled.
self.remove_from_disabled_wbs(wbName)
def remove_from_disabled_wbs(self, wbName: str):
pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")
disabled_wbs = pref.GetString("Disabled", "NoneWorkbench,TestWorkbench")
# print(f"start enabling : {disabled_wbs}")
disabled_wbs_list = disabled_wbs.split(",")
disabled_wbs = ""
for wb in disabled_wbs_list:
if wb != wbName:
if disabled_wbs != "":
disabled_wbs += ","
disabled_wbs += wb
pref.SetString("Disabled", disabled_wbs)
# print(f"Done enabling {disabled_wbs} \n")
def get_workbench_name(self) -> str:
"""Find the name of the workbench class (ie the name under which it's
registered in freecad core)'"""
wb_name = ""
if self.repo_type == Addon.Kind.PACKAGE:
for wb in self.metadata.content["workbench"]: # we may have more than one wb.
if wb_name != "":
wb_name += ","
wb_name += wb.classname
if self.repo_type == Addon.Kind.WORKBENCH or wb_name == "":
wb_name = self.try_find_wbname_in_files()
if wb_name == "":
wb_name = self.name
return wb_name
def try_find_wbname_in_files(self) -> str:
"""Attempt to locate a line with an addWorkbench command in the workbench's
Python files. If it is directly instantiating a workbench, then we can use
the line to determine classname for this workbench. If it uses a variable,
or if the line doesn't exist at all, an empty string is returned."""
mod_dir = os.path.join(self.mod_directory, self.name)
for root, _, files in os.walk(mod_dir):
for f in files:
current_file = os.path.join(root, f)
if not os.path.isdir(current_file):
filename, extension = os.path.splitext(current_file)
if extension == ".py":
wb_classname = self._find_classname_in_file(current_file)
if wb_classname:
return wb_classname
return ""
@staticmethod
def _find_classname_in_file(current_file) -> str:
try:
with open(current_file, "r", encoding="utf-8") as python_file:
content = python_file.read()
search_result = re.search(r"Gui.addWorkbench\s*\(\s*(\w+)\s*\(\s*\)\s*\)", content)
if search_result:
return search_result.group(1)
except OSError:
pass
return ""
# @dataclass(frozen)
class MissingDependencies:
"""Encapsulates a group of four types of dependencies:
* Internal workbenches -> wbs
* External addons -> external_addons
* Required Python packages -> python_requires
* Optional Python packages -> python_optional
"""
def __init__(self, repo: Addon, all_repos: List[Addon]):
deps = Addon.Dependencies()
repo_name_dict = {}
for r in all_repos:
repo_name_dict[r.name] = r
if hasattr(r, "display_name"):
# Test harness might not provide a display name
repo_name_dict[r.display_name] = r
if hasattr(repo, "walk_dependency_tree"):
# Sometimes the test harness doesn't provide this function, to override
# any dependency checking
repo.walk_dependency_tree(repo_name_dict, deps)
self.external_addons = []
for dep in deps.required_external_addons:
if dep.status() == Addon.Status.NOT_INSTALLED:
self.external_addons.append(dep.name)
# Now check the loaded addons to see if we are missing an internal workbench:
if fci.FreeCADGui:
wbs = [wb.lower() for wb in fci.FreeCADGui.listWorkbenches()]
else:
wbs = []
self.wbs = []
for dep in deps.internal_workbenches:
if dep.lower() + "workbench" not in wbs:
if dep.lower() == "plot":
# Special case for plot, which is no longer a full workbench:
try:
__import__("Plot")
except ImportError:
# Plot might fail for a number of reasons
self.wbs.append(dep)
fci.Console.PrintLog("Failed to import Plot module")
else:
self.wbs.append(dep)
# Check the Python dependencies:
self.python_min_version = deps.python_min_version
self.python_requires = []
for py_dep in deps.python_requires:
if py_dep not in self.python_requires:
try:
__import__(py_dep)
except ImportError:
self.python_requires.append(py_dep)
except (OSError, NameError, TypeError, RuntimeError) as e:
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
"Got an error when trying to import {}",
).format(py_dep)
+ ":\n"
+ str(e)
)
self.python_optional = []
for py_dep in deps.python_optional:
try:
__import__(py_dep)
except ImportError:
self.python_optional.append(py_dep)
except (OSError, NameError, TypeError, RuntimeError) as e:
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
"Got an error when trying to import {}",
).format(py_dep)
+ ":\n"
+ str(e)
)
self.wbs.sort()
self.external_addons.sort()
self.python_requires.sort()
self.python_optional.sort()
self.python_optional = [
option for option in self.python_optional if option not in self.python_requires
]

View File

@@ -1,143 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 The FreeCAD project association AISBL *
# * *
# * 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/>. *
# * *
# ***************************************************************************
"""The Addon Catalog is the main list of all Addons along with their various
sources and compatible versions. Added in FreeCAD 1.1 to replace .gitmodules."""
from dataclasses import dataclass
from hashlib import sha256
from typing import Any, Dict, List, Optional, Tuple
from addonmanager_metadata import Version
from Addon import Addon
import addonmanager_freecad_interface as fci
@dataclass
class AddonCatalogEntry:
"""Each individual entry in the catalog, storing data about a particular version of an
Addon. Note that this class needs to be identical to the one that is used in the remote cache
generation, so don't make changes here without ensuring that the classes are synchronized."""
freecad_min: Optional[Version] = None
freecad_max: Optional[Version] = None
repository: Optional[str] = None
git_ref: Optional[str] = None
zip_url: Optional[str] = None
note: Optional[str] = None
branch_display_name: Optional[str] = None
def __init__(self, raw_data: Dict[str, str]) -> None:
"""Create an AddonDictionaryEntry from the raw JSON data"""
super().__init__()
for key, value in raw_data.items():
if hasattr(self, key):
if key in ("freecad_min", "freecad_max"):
value = Version(from_string=value)
setattr(self, key, value)
def is_compatible(self) -> bool:
"""Check whether this AddonCatalogEntry is compatible with the current version of FreeCAD"""
if self.freecad_min is None and self.freecad_max is None:
return True
current_version = Version(from_list=fci.Version())
if self.freecad_min is None:
return current_version <= self.freecad_max
if self.freecad_max is None:
return current_version >= self.freecad_min
return self.freecad_min <= current_version <= self.freecad_max
def unique_identifier(self) -> str:
"""Return a unique identifier of the AddonCatalogEntry, guaranteed to be repeatable: when
given the same basic information, the same ID is created. Used as the key when storing
the metadata for a given AddonCatalogEntry."""
sha256_hash = sha256()
sha256_hash.update(str(self).encode("utf-8"))
return sha256_hash.hexdigest()
class AddonCatalog:
"""A catalog of addons grouped together into sets representing versions that are
compatible with different versions of FreeCAD and/or represent different available branches
of a given addon (e.g. a Development branch that users are presented)."""
def __init__(self, data: Dict[str, Any]):
self._original_data = data
self._dictionary = {}
self._parse_raw_data()
def _parse_raw_data(self):
self._dictionary = {} # Clear pre-existing contents
for key, value in self._original_data.items():
if key == "_meta": # Don't add the documentation object to the tree
continue
self._dictionary[key] = []
for entry in value:
self._dictionary[key].append(AddonCatalogEntry(entry))
def load_metadata_cache(self, cache: Dict[str, Any]):
"""Given the raw dictionary, couple that with the remote metadata cache to create the
final working addon dictionary. Only create Addons that are compatible with the current
version of FreeCAD."""
for value in self._dictionary.values():
for entry in value:
sha256_hash = entry.unique_identifier()
print(sha256_hash)
if sha256_hash in cache and entry.is_compatible():
entry.addon = Addon.from_cache(cache[sha256_hash])
def get_available_addon_ids(self) -> List[str]:
"""Get a list of IDs that have at least one entry compatible with the current version of
FreeCAD"""
id_list = []
for key, value in self._dictionary.items():
for entry in value:
if entry.is_compatible():
id_list.append(key)
break
return id_list
def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]:
"""For a given ID, get the list of available branches compatible with this version of
FreeCAD along with the branch display name. Either field may be empty, but not both. The
first entry in the list is expected to be the "primary"."""
if addon_id not in self._dictionary:
return []
result = []
for entry in self._dictionary[addon_id]:
if entry.is_compatible():
result.append((entry.git_ref, entry.branch_display_name))
return result
def get_addon_from_id(self, addon_id: str, branch: Optional[Tuple[str, str]] = None) -> Addon:
"""Get the instantiated Addon object for the given ID and optionally branch. If no
branch is provided, whichever branch is the "primary" branch will be returned (i.e. the
first branch that matches). Raises a ValueError if no addon matches the request."""
if addon_id not in self._dictionary:
raise ValueError(f"Addon '{addon_id}' not found")
for entry in self._dictionary[addon_id]:
if not entry.is_compatible():
continue
if not branch or entry.branch_display_name == branch:
return entry.addon
raise ValueError(f"Addon '{addon_id}' has no compatible branches named '{branch}'")

View File

@@ -1,951 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 FreeCAD Project Association *
# * Copyright (c) 2015 Yorik van Havre <yorik@uncreated.net> *
# * *
# * 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/>. *
# * *
# ***************************************************************************
import os
import functools
import tempfile
import threading
import json
from datetime import date
from typing import Dict
from PySide import QtGui, QtCore, QtWidgets
import FreeCAD
import FreeCADGui
from addonmanager_workers_startup import (
CreateAddonListWorker,
LoadPackagesFromCacheWorker,
LoadMacrosFromCacheWorker,
CheckWorkbenchesForUpdatesWorker,
CacheMacroCodeWorker,
GetBasicAddonStatsWorker,
GetAddonScoreWorker,
)
from addonmanager_workers_installation import (
UpdateMetadataCacheWorker,
)
from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI
from addonmanager_uninstaller_gui import AddonUninstallerGUI
from addonmanager_update_all_gui import UpdateAllGUI
import addonmanager_utilities as utils
import addonmanager_freecad_interface as fci
import AddonManager_rc # pylint: disable=unused-import
from composite_view import CompositeView
from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar
from Widgets.addonmanager_widget_progress_bar import Progress
from package_list import PackageListItemModel
from Addon import Addon
from addonmanager_python_deps_gui import (
PythonPackageManager,
)
from addonmanager_cache import local_cache_needs_update
from addonmanager_devmode import DeveloperMode
from addonmanager_firstrun import FirstRunDialog
from addonmanager_connection_checker import ConnectionCheckerGUI
from addonmanager_devmode_metadata_checker import MetadataValidators
import NetworkManager
from AddonManagerOptions import AddonManagerOptions
translate = FreeCAD.Qt.translate
def QT_TRANSLATE_NOOP(_, txt):
return txt
__title__ = "FreeCAD Addon Manager Module"
__author__ = "Yorik van Havre", "Jonathan Wiedemann", "Kurt Kremitzki", "Chris Hennes"
__url__ = "https://www.freecad.org"
"""
FreeCAD Addon Manager Module
Fetches various types of addons from a variety of sources. Built-in sources are:
* https://github.com/FreeCAD/FreeCAD-addons
* https://github.com/FreeCAD/FreeCAD-macros
* https://wiki.freecad.org/
Additional git sources may be configure via user preferences.
You need a working internet connection, and optionally git -- if git is not available, ZIP archives
are downloaded instead.
"""
# \defgroup ADDONMANAGER AddonManager
# \ingroup ADDONMANAGER
# \brief The Addon Manager allows users to install workbenches and macros made by other users
# @{
INSTANCE = None
def get_icon(repo: Addon, update: bool = False) -> QtGui.QIcon:
"""Returns an icon for an Addon. Uses a cached icon if possible, unless update is True,
in which case the icon is regenerated."""
if not update and repo.icon and not repo.icon.isNull() and repo.icon.isValid():
return repo.icon
path = ":/icons/" + repo.name.replace(" ", "_")
default_icon = QtGui.QIcon(":/icons/document-package.svg")
if repo.repo_type == Addon.Kind.WORKBENCH:
path += "_workbench_icon.svg"
default_icon = QtGui.QIcon(":/icons/document-package.svg")
elif repo.repo_type == Addon.Kind.MACRO:
if repo.macro and repo.macro.icon:
if os.path.isabs(repo.macro.icon):
path = repo.macro.icon
default_icon = QtGui.QIcon(":/icons/document-python.svg")
else:
path = os.path.join(os.path.dirname(repo.macro.src_filename), repo.macro.icon)
default_icon = QtGui.QIcon(":/icons/document-python.svg")
elif repo.macro and repo.macro.xpm:
cache_path = FreeCAD.getUserCachePath()
am_path = os.path.join(cache_path, "AddonManager", "MacroIcons")
os.makedirs(am_path, exist_ok=True)
path = os.path.join(am_path, repo.name + "_icon.xpm")
if not os.path.exists(path):
with open(path, "w") as f:
f.write(repo.macro.xpm)
default_icon = QtGui.QIcon(repo.macro.xpm)
else:
path += "_macro_icon.svg"
default_icon = QtGui.QIcon(":/icons/document-python.svg")
elif repo.repo_type == Addon.Kind.PACKAGE:
# The cache might not have been downloaded yet, check to see if it's there...
if os.path.isfile(repo.get_cached_icon_filename()):
path = repo.get_cached_icon_filename()
elif repo.contains_workbench():
path += "_workbench_icon.svg"
default_icon = QtGui.QIcon(":/icons/document-package.svg")
elif repo.contains_macro():
path += "_macro_icon.svg"
default_icon = QtGui.QIcon(":/icons/document-python.svg")
else:
default_icon = QtGui.QIcon(":/icons/document-package.svg")
if QtCore.QFile.exists(path):
addon_icon = QtGui.QIcon(path)
else:
addon_icon = default_icon
repo.icon = addon_icon
return addon_icon
class CommandAddonManager(QtCore.QObject):
"""The main Addon Manager class and FreeCAD command"""
workers = [
"create_addon_list_worker",
"check_worker",
"show_worker",
"showmacro_worker",
"macro_worker",
"update_metadata_cache_worker",
"load_macro_metadata_worker",
"update_all_worker",
"check_for_python_package_updates_worker",
"get_basic_addon_stats_worker",
"get_addon_score_worker",
]
lock = threading.Lock()
restart_required = False
finished = QtCore.Signal()
def __init__(self):
super().__init__()
QT_TRANSLATE_NOOP("QObject", "Addon Manager")
FreeCADGui.addPreferencePage(
AddonManagerOptions,
"Addon Manager",
)
self.item_model = None
self.developer_mode = None
self.installer_gui = None
self.composite_view = None
self.button_bar = None
self.update_cache = False
self.dialog = None
self.startup_sequence = []
self.packages_with_updates = set()
self.macro_repo_dir = None
self.number_of_progress_regions = 0
self.current_progress_region = 0
self.check_worker = None
self.check_for_python_package_updates_worker = None
self.update_all_worker = None
self.update_metadata_cache_worker = None
self.macro_worker = None
self.create_addon_list_worker = None
self.get_addon_score_worker = None
self.get_basic_addon_stats_worker = None
self.load_macro_metadata_worker = None
self.macro_cache = []
self.package_cache = {}
self.manage_python_packages_dialog = None
# Set up the connection checker
self.connection_checker = ConnectionCheckerGUI()
self.connection_checker.connection_available.connect(self.launch)
# Give other parts of the AM access to the current instance
global INSTANCE
INSTANCE = self
def GetResources(self) -> Dict[str, str]:
"""FreeCAD-required function: get the core resource information for this Mod."""
return {
"Pixmap": "AddonManager",
"MenuText": QT_TRANSLATE_NOOP("Std_AddonMgr", "&Addon manager"),
"ToolTip": QT_TRANSLATE_NOOP(
"Std_AddonMgr",
"Manage external workbenches, macros, and preference packs",
),
"Group": "Tools",
}
def Activated(self) -> None:
"""FreeCAD-required function: called when the command is activated."""
NetworkManager.InitializeNetworkManager()
first_run_dialog = FirstRunDialog()
if not first_run_dialog.exec():
return
self.connection_checker.start()
def launch(self) -> None:
"""Shows the Addon Manager UI"""
# create the dialog
self.dialog = FreeCADGui.PySideUic.loadUi(
os.path.join(os.path.dirname(__file__), "AddonManager.ui")
)
self.dialog.setObjectName("AddonManager_Main_Window")
# self.dialog.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint, True)
# cleanup the leftovers from previous runs
self.macro_repo_dir = FreeCAD.getUserMacroDir(True)
self.packages_with_updates = set()
self.startup_sequence = []
self.cleanup_workers()
self.update_cache = local_cache_needs_update()
# restore window geometry from stored state
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
w = pref.GetInt("WindowWidth", 800)
h = pref.GetInt("WindowHeight", 600)
self.composite_view = CompositeView(self.dialog)
self.button_bar = WidgetGlobalButtonBar(self.dialog)
# If we are checking for updates automatically, hide the Check for updates button:
autocheck = pref.GetBool("AutoCheck", True)
if autocheck:
self.button_bar.check_for_updates.hide()
else:
self.button_bar.update_all_addons.hide()
# Set up the listing of packages using the model-view-controller architecture
self.item_model = PackageListItemModel()
self.composite_view.setModel(self.item_model)
self.dialog.layout().addWidget(self.composite_view)
self.dialog.layout().addWidget(self.button_bar)
# set nice icons to everything, by theme with fallback to FreeCAD icons
self.dialog.setWindowIcon(QtGui.QIcon(":/icons/AddonManager.svg"))
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
dev_mode_active = pref.GetBool("developerMode", False)
# enable/disable stuff
self.button_bar.update_all_addons.setEnabled(False)
self.hide_progress_widgets()
self.button_bar.refresh_local_cache.setEnabled(False)
self.button_bar.refresh_local_cache.setText(translate("AddonsInstaller", "Starting up..."))
if dev_mode_active:
self.button_bar.developer_tools.show()
else:
self.button_bar.developer_tools.hide()
# connect slots
self.dialog.rejected.connect(self.reject)
self.dialog.accepted.connect(self.accept)
self.button_bar.update_all_addons.clicked.connect(self.update_all)
self.button_bar.close.clicked.connect(self.dialog.reject)
self.button_bar.refresh_local_cache.clicked.connect(self.on_button_update_cache_clicked)
self.button_bar.check_for_updates.clicked.connect(
lambda: self.force_check_updates(standalone=True)
)
self.button_bar.python_dependencies.clicked.connect(self.show_python_updates_dialog)
self.button_bar.developer_tools.clicked.connect(self.show_developer_tools)
self.composite_view.package_list.stop_loading.connect(self.stop_update)
self.composite_view.package_list.setEnabled(False)
self.composite_view.execute.connect(self.execute_macro)
self.composite_view.install.connect(self.launch_installer_gui)
self.composite_view.uninstall.connect(self.remove)
self.composite_view.update.connect(self.update)
self.composite_view.update_status.connect(self.status_updated)
# center the dialog over the FreeCAD window
self.dialog.resize(w, h)
mw = FreeCADGui.getMainWindow()
self.dialog.move(
mw.frameGeometry().topLeft() + mw.rect().center() - self.dialog.rect().center()
)
# begin populating the table in a set of sub-threads
self.startup()
# rock 'n roll!!!
self.dialog.exec()
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):
thread = getattr(self, worker)
if thread:
if not thread.isFinished():
thread.blockSignals(True)
thread.requestInterruption()
for worker in self.workers:
if hasattr(self, worker):
thread = getattr(self, worker)
if thread:
if not thread.isFinished():
finished = thread.wait(500)
if not finished:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
"Worker process {} is taking a long time to stop...",
).format(worker)
+ "\n"
)
def accept(self) -> None:
self.finished.emit()
def reject(self) -> None:
"""called when the window has been closed"""
# save window geometry for next use
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetInt("WindowWidth", self.dialog.width())
pref.SetInt("WindowHeight", self.dialog.height())
# ensure all threads are finished before closing
ok_to_close = True
worker_killed = False
self.startup_sequence = []
for worker in self.workers:
if hasattr(self, worker):
thread = getattr(self, worker)
if thread:
if not thread.isFinished():
thread.blockSignals(True)
thread.requestInterruption()
worker_killed = True
ok_to_close = False
while not ok_to_close:
ok_to_close = True
for worker in self.workers:
if hasattr(self, worker):
thread = getattr(self, worker)
if thread:
thread.wait(25)
if not thread.isFinished():
ok_to_close = False
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
# Write the cache data if it's safe to do so:
if not worker_killed:
for repo in self.item_model.repos:
if repo.repo_type == Addon.Kind.MACRO:
self.cache_macro(repo)
else:
self.cache_package(repo)
self.write_package_cache()
self.write_macro_cache()
else:
self.write_cache_stopfile()
FreeCAD.Console.PrintLog(
"Not writing the cache because a process was forcibly terminated and the state is "
"unknown.\n"
)
if self.restart_required:
# display restart dialog
m = QtWidgets.QMessageBox()
m.setWindowTitle(translate("AddonsInstaller", "Addon manager"))
m.setWindowIcon(QtGui.QIcon(":/icons/AddonManager.svg"))
m.setText(
translate(
"AddonsInstaller",
"You must restart FreeCAD for changes to take effect.",
)
)
m.setIcon(QtWidgets.QMessageBox.Icon.Warning)
m.setStandardButtons(
QtWidgets.QMessageBox.StandardButton.Ok
| QtWidgets.QMessageBox.StandardButton.Cancel
)
m.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel)
ok_btn = m.button(QtWidgets.QMessageBox.StandardButton.Ok)
cancel_btn = m.button(QtWidgets.QMessageBox.StandardButton.Cancel)
ok_btn.setText(translate("AddonsInstaller", "Restart now"))
cancel_btn.setText(translate("AddonsInstaller", "Restart later"))
ret = m.exec_()
if ret == QtWidgets.QMessageBox.StandardButton.Ok:
# restart FreeCAD after a delay to give time to this dialog to close
QtCore.QTimer.singleShot(1000, utils.restart_freecad)
self.finished.emit()
def startup(self) -> None:
"""Downloads the available packages listings and populates the table
This proceeds in four stages: first, the main GitHub repository is queried for a list of
possible addons. Each addon is specified as a git submodule with name and branch
information. The actual specific commit ID of the submodule (as listed on GitHub) is
ignored. Any extra repositories specified by the user are appended to this list.
Second, the list of macros is downloaded from the FreeCAD/FreeCAD-macros repository and
the wiki.
Third, each of these items is queried for a package.xml metadata file. If that file exists
it is downloaded, cached, and any icons that it references are also downloaded and cached.
Finally, for workbenches that are not contained within a package (e.g. they provide no
metadata), an additional git query is made to see if an update is available. Macros are
checked for file changes.
Each of these stages is launched in a separate thread to ensure that the UI remains
responsive, and the operation can be cancelled.
Each stage is also subject to caching, so may return immediately, if no cache update has
been requested."""
# Each function in this list is expected to launch a thread and connect its completion
# signal to self.do_next_startup_phase, or to shortcut to calling
# self.do_next_startup_phase if it is not launching a worker
self.startup_sequence = [
self.populate_packages_table,
self.activate_table_widgets,
self.populate_macros,
self.update_metadata_cache,
self.check_updates,
self.check_python_updates,
self.fetch_addon_stats,
self.fetch_addon_score,
self.select_addon,
]
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
if pref.GetBool("DownloadMacros", True):
self.startup_sequence.append(self.load_macro_metadata)
self.number_of_progress_regions = len(self.startup_sequence)
self.current_progress_region = 0
self.do_next_startup_phase()
def do_next_startup_phase(self) -> None:
"""Pop the top item in self.startup_sequence off the list and run it"""
if len(self.startup_sequence) > 0:
phase_runner = self.startup_sequence.pop(0)
self.current_progress_region += 1
phase_runner()
else:
self.hide_progress_widgets()
self.update_cache = False
self.button_bar.refresh_local_cache.setEnabled(True)
self.button_bar.refresh_local_cache.setText(
translate("AddonsInstaller", "Refresh local cache")
)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetString("LastCacheUpdate", date.today().isoformat())
self.composite_view.package_list.item_filter.invalidateFilter()
def populate_packages_table(self) -> None:
self.item_model.clear()
use_cache = not self.update_cache
if use_cache:
if os.path.isfile(utils.get_cache_file_name("package_cache.json")):
with open(utils.get_cache_file_name("package_cache.json"), encoding="utf-8") as f:
data = f.read()
try:
from_json = json.loads(data)
if len(from_json) == 0:
use_cache = False
except json.JSONDecodeError:
use_cache = False
else:
use_cache = False
if not use_cache:
self.update_cache = True # Make sure to trigger the other cache updates, if the json
# file was missing
self.create_addon_list_worker = CreateAddonListWorker()
self.create_addon_list_worker.addon_repo.connect(self.add_addon_repo)
self.update_progress_bar(translate("AddonsInstaller", "Creating addon list"), 10, 100)
self.create_addon_list_worker.finished.connect(
self.do_next_startup_phase
) # Link to step 2
self.create_addon_list_worker.start()
else:
self.create_addon_list_worker = LoadPackagesFromCacheWorker(
utils.get_cache_file_name("package_cache.json")
)
self.create_addon_list_worker.addon_repo.connect(self.add_addon_repo)
self.update_progress_bar(translate("AddonsInstaller", "Loading addon list"), 10, 100)
self.create_addon_list_worker.finished.connect(
self.do_next_startup_phase
) # Link to step 2
self.create_addon_list_worker.start()
def cache_package(self, repo: Addon):
if not hasattr(self, "package_cache"):
self.package_cache = {}
self.package_cache[repo.name] = repo.to_cache()
def write_package_cache(self):
if hasattr(self, "package_cache"):
package_cache_path = utils.get_cache_file_name("package_cache.json")
with open(package_cache_path, "w", encoding="utf-8") as f:
f.write(json.dumps(self.package_cache, indent=" "))
def activate_table_widgets(self) -> None:
self.composite_view.package_list.setEnabled(True)
self.composite_view.package_list.ui.view_bar.search.setFocus()
self.do_next_startup_phase()
def populate_macros(self) -> None:
macro_cache_file = utils.get_cache_file_name("macro_cache.json")
cache_is_bad = True
if os.path.isfile(macro_cache_file):
size = os.path.getsize(macro_cache_file)
if size > 1000: # Make sure there is actually data in there
cache_is_bad = False
if cache_is_bad:
if not self.update_cache:
self.update_cache = True # Make sure to trigger the other cache updates, if the
# json file was missing
self.create_addon_list_worker = CreateAddonListWorker()
self.create_addon_list_worker.addon_repo.connect(self.add_addon_repo)
self.update_progress_bar(
translate("AddonsInstaller", "Creating macro list"), 10, 100
)
self.create_addon_list_worker.finished.connect(
self.do_next_startup_phase
) # Link to step 2
self.create_addon_list_worker.start()
else:
# It's already been done in the previous step (TODO: Refactor to eliminate this
# step)
self.do_next_startup_phase()
else:
self.macro_worker = LoadMacrosFromCacheWorker(
utils.get_cache_file_name("macro_cache.json")
)
self.macro_worker.add_macro_signal.connect(self.add_addon_repo)
self.macro_worker.finished.connect(self.do_next_startup_phase)
self.macro_worker.start()
def cache_macro(self, repo: Addon):
if not hasattr(self, "macro_cache"):
self.macro_cache = []
if repo.macro is not None:
self.macro_cache.append(repo.macro.to_cache())
else:
FreeCAD.Console.PrintError(
f"Addon Manager: Internal error, cache_macro called on non-macro {repo.name}\n"
)
def write_macro_cache(self):
if not hasattr(self, "macro_cache"):
return
macro_cache_path = utils.get_cache_file_name("macro_cache.json")
with open(macro_cache_path, "w", encoding="utf-8") as f:
f.write(json.dumps(self.macro_cache, indent=" "))
self.macro_cache = []
def update_metadata_cache(self) -> None:
if self.update_cache:
self.update_metadata_cache_worker = UpdateMetadataCacheWorker(self.item_model.repos)
self.update_metadata_cache_worker.finished.connect(
self.do_next_startup_phase
) # Link to step 4
self.update_metadata_cache_worker.progress_made.connect(self.update_progress_bar)
self.update_metadata_cache_worker.package_updated.connect(self.on_package_updated)
self.update_metadata_cache_worker.start()
else:
self.do_next_startup_phase()
def on_button_update_cache_clicked(self) -> None:
self.update_cache = True
cache_path = FreeCAD.getUserCachePath()
am_path = os.path.join(cache_path, "AddonManager")
utils.rmdir(am_path)
self.button_bar.refresh_local_cache.setEnabled(False)
self.button_bar.refresh_local_cache.setText(
translate("AddonsInstaller", "Updating cache...")
)
self.startup()
# Re-caching implies checking for updates, regardless of the user's autocheck option
if self.check_updates in self.startup_sequence:
self.startup_sequence.remove(self.check_updates)
self.startup_sequence.append(self.force_check_updates)
def on_package_updated(self, repo: Addon) -> None:
"""Called when the named package has either new metadata or a new icon (or both)"""
with self.lock:
repo.icon = get_icon(repo, update=True)
self.item_model.reload_item(repo)
def load_macro_metadata(self) -> None:
if self.update_cache:
self.load_macro_metadata_worker = CacheMacroCodeWorker(self.item_model.repos)
self.load_macro_metadata_worker.update_macro.connect(self.on_package_updated)
self.load_macro_metadata_worker.progress_made.connect(self.update_progress_bar)
self.load_macro_metadata_worker.finished.connect(self.do_next_startup_phase)
self.load_macro_metadata_worker.start()
else:
self.do_next_startup_phase()
def select_addon(self) -> None:
prefs = fci.Preferences()
selection = prefs.get("SelectedAddon")
if selection:
self.composite_view.package_list.select_addon(selection)
prefs.set("SelectedAddon", "")
self.do_next_startup_phase()
def check_updates(self) -> None:
"""checks every installed addon for available updates"""
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
autocheck = pref.GetBool("AutoCheck", True)
if not autocheck:
FreeCAD.Console.PrintLog(
"Addon Manager: Skipping update check because AutoCheck user preference is False\n"
)
self.do_next_startup_phase()
return
if not self.packages_with_updates:
self.force_check_updates(standalone=False)
else:
self.do_next_startup_phase()
def force_check_updates(self, standalone=False) -> None:
if hasattr(self, "check_worker"):
thread = self.check_worker
if thread:
if not thread.isFinished():
self.do_next_startup_phase()
return
self.button_bar.update_all_addons.setText(
translate("AddonsInstaller", "Checking for updates...")
)
self.packages_with_updates.clear()
self.button_bar.update_all_addons.show()
self.button_bar.check_for_updates.setDisabled(True)
self.check_worker = CheckWorkbenchesForUpdatesWorker(self.item_model.repos)
self.check_worker.finished.connect(self.do_next_startup_phase)
self.check_worker.finished.connect(self.update_check_complete)
self.check_worker.progress_made.connect(self.update_progress_bar)
if standalone:
self.current_progress_region = 1
self.number_of_progress_regions = 1
self.check_worker.update_status.connect(self.status_updated)
self.check_worker.start()
self.enable_updates(len(self.packages_with_updates))
def status_updated(self, repo: Addon) -> None:
self.item_model.reload_item(repo)
if repo.status() == Addon.Status.UPDATE_AVAILABLE:
self.packages_with_updates.add(repo)
self.enable_updates(len(self.packages_with_updates))
elif repo.status() == Addon.Status.PENDING_RESTART:
self.restart_required = True
def enable_updates(self, number_of_updates: int) -> None:
"""enables the update button"""
if number_of_updates:
self.button_bar.set_number_of_available_updates(number_of_updates)
elif (
hasattr(self, "check_worker")
and self.check_worker is not None
and self.check_worker.isRunning()
):
self.button_bar.update_all_addons.setText(
translate("AddonsInstaller", "Checking for updates...")
)
else:
self.button_bar.set_number_of_available_updates(0)
def update_check_complete(self) -> None:
self.enable_updates(len(self.packages_with_updates))
self.button_bar.check_for_updates.setEnabled(True)
def check_python_updates(self) -> None:
PythonPackageManager.migrate_old_am_installations() # Migrate 0.20 to 0.21
self.do_next_startup_phase()
def show_python_updates_dialog(self) -> None:
if not self.manage_python_packages_dialog:
self.manage_python_packages_dialog = PythonPackageManager(self.item_model.repos)
self.manage_python_packages_dialog.show()
def fetch_addon_stats(self) -> None:
"""Fetch the Addon Stats JSON data from a URL"""
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
url = pref.GetString("AddonsStatsURL", "https://freecad.org/addon_stats.json")
if url and url != "NONE":
self.get_basic_addon_stats_worker = GetBasicAddonStatsWorker(
url, self.item_model.repos, self.dialog
)
self.get_basic_addon_stats_worker.finished.connect(self.do_next_startup_phase)
self.get_basic_addon_stats_worker.update_addon_stats.connect(self.update_addon_stats)
self.get_basic_addon_stats_worker.start()
else:
self.do_next_startup_phase()
def update_addon_stats(self, addon: Addon):
self.item_model.reload_item(addon)
def fetch_addon_score(self) -> None:
"""Fetch the Addon score JSON data from a URL"""
prefs = fci.Preferences()
url = prefs.get("AddonsScoreURL")
if url and url != "NONE":
self.get_addon_score_worker = GetAddonScoreWorker(
url, self.item_model.repos, self.dialog
)
self.get_addon_score_worker.finished.connect(self.score_fetched_successfully)
self.get_addon_score_worker.finished.connect(self.do_next_startup_phase)
self.get_addon_score_worker.update_addon_score.connect(self.update_addon_score)
self.get_addon_score_worker.start()
else:
self.composite_view.package_list.ui.view_bar.set_rankings_available(False)
self.do_next_startup_phase()
def update_addon_score(self, addon: Addon):
self.item_model.reload_item(addon)
def score_fetched_successfully(self):
self.composite_view.package_list.ui.view_bar.set_rankings_available(True)
def show_developer_tools(self) -> None:
"""Display the developer tools dialog"""
if not self.developer_mode:
self.developer_mode = DeveloperMode()
self.developer_mode.show()
checker = MetadataValidators()
checker.validate_all(self.item_model.repos)
def add_addon_repo(self, addon_repo: Addon) -> None:
"""adds a workbench to the list"""
if addon_repo.icon is None or addon_repo.icon.isNull():
addon_repo.icon = get_icon(addon_repo)
for repo in self.item_model.repos:
if repo.name == addon_repo.name:
# self.item_model.reload_item(repo) # If we want to have later additions superseded
# earlier
return
self.item_model.append_item(addon_repo)
def append_to_repos_list(self, repo: Addon) -> None:
"""this function allows threads to update the main list of workbenches"""
self.item_model.append_item(repo)
def update(self, repo: Addon) -> None:
self.launch_installer_gui(repo)
def mark_repo_update_available(self, repo: Addon, available: bool) -> None:
if available:
repo.set_status(Addon.Status.UPDATE_AVAILABLE)
else:
repo.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
self.item_model.reload_item(repo)
self.composite_view.package_details_controller.show_repo(repo)
def launch_installer_gui(self, addon: Addon) -> None:
if self.installer_gui is not None:
FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
"Cannot launch a new installer until the previous one has finished.",
)
)
return
if addon.macro is not None:
self.installer_gui = MacroInstallerGUI(addon)
else:
self.installer_gui = AddonInstallerGUI(addon, self.item_model.repos)
self.installer_gui.success.connect(self.on_package_status_changed)
self.installer_gui.finished.connect(self.cleanup_installer)
self.installer_gui.run() # Does not block
def cleanup_installer(self) -> None:
QtCore.QTimer.singleShot(500, self.no_really_clean_up_the_installer)
def no_really_clean_up_the_installer(self) -> None:
self.installer_gui = None
def update_all(self) -> None:
"""Asynchronously apply all available updates: individual failures are noted, but do not
stop other updates"""
if self.installer_gui is not None:
FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
"Cannot launch a new installer until the previous one has finished.",
)
)
return
self.installer_gui = UpdateAllGUI(self.item_model.repos)
self.installer_gui.addon_updated.connect(self.on_package_status_changed)
self.installer_gui.finished.connect(self.cleanup_installer)
self.installer_gui.run() # Does not block
def hide_progress_widgets(self) -> None:
"""hides the progress bar and related widgets"""
self.composite_view.package_list.set_loading(False)
def show_progress_widgets(self) -> None:
self.composite_view.package_list.set_loading(True)
def update_progress_bar(self, message: str, current_value: int, max_value: int) -> None:
"""Update the progress bar, showing it if it's hidden"""
max_value = max_value if max_value > 0 else 1
if current_value < 0:
current_value = 0
elif current_value > max_value:
current_value = max_value
self.show_progress_widgets()
progress = Progress(
status_text=message,
number_of_tasks=self.number_of_progress_regions,
current_task=self.current_progress_region - 1,
current_task_progress=current_value / max_value,
)
self.composite_view.package_list.update_loading_progress(progress)
def stop_update(self) -> None:
self.cleanup_workers()
self.hide_progress_widgets()
self.write_cache_stopfile()
self.button_bar.refresh_local_cache.setEnabled(True)
self.button_bar.refresh_local_cache.setText(
translate("AddonsInstaller", "Refresh local cache")
)
@staticmethod
def write_cache_stopfile() -> None:
stopfile = utils.get_cache_file_name("CACHE_UPDATE_INTERRUPTED")
with open(stopfile, "w", encoding="utf8") as f:
f.write(
"This file indicates that a cache operation was interrupted, and "
"the cache is in an unknown state. It will be deleted next time "
"AddonManager re-caches."
)
def on_package_status_changed(self, repo: Addon) -> None:
if repo.status() == Addon.Status.PENDING_RESTART:
self.restart_required = True
self.item_model.reload_item(repo)
self.composite_view.package_details_controller.show_repo(repo)
if repo in self.packages_with_updates:
self.packages_with_updates.remove(repo)
self.enable_updates(len(self.packages_with_updates))
def execute_macro(self, repo: Addon) -> None:
"""executes a selected macro"""
macro = repo.macro
if not macro or not macro.code:
return
if macro.is_installed():
macro_path = os.path.join(self.macro_repo_dir, macro.filename)
FreeCADGui.open(str(macro_path))
self.dialog.hide()
FreeCADGui.SendMsgToActiveView("Run")
else:
with tempfile.TemporaryDirectory() as temp_dir:
temp_install_succeeded = macro.install(temp_dir)
if not temp_install_succeeded:
FreeCAD.Console.PrintError(
translate("AddonsInstaller", "Temporary installation of macro failed.")
)
return
macro_path = os.path.join(temp_dir, macro.filename)
FreeCADGui.open(str(macro_path))
self.dialog.hide()
FreeCADGui.SendMsgToActiveView("Run")
def remove(self, addon: Addon) -> None:
"""Remove this addon."""
if self.installer_gui is not None:
FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
"Cannot launch a new installer until the previous one has finished.",
)
)
return
self.installer_gui = AddonUninstallerGUI(addon)
self.installer_gui.finished.connect(self.cleanup_installer)
self.installer_gui.finished.connect(
functools.partial(self.on_package_status_changed, addon)
)
self.installer_gui.run() # Does not block
# @}

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>928</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>Addon Manager</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4"/>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,314 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
"""Contains a the Addon Manager's preferences dialog management class"""
import os
import FreeCAD
import FreeCADGui
from PySide import QtCore
from PySide.QtGui import QIcon
from PySide.QtWidgets import (
QWidget,
QCheckBox,
QComboBox,
QDialog,
QHeaderView,
QRadioButton,
QLineEdit,
QTextEdit,
)
translate = FreeCAD.Qt.translate
# pylint: disable=too-few-public-methods
class AddonManagerOptions:
"""A class containing a form element that is inserted as a FreeCAD preference page."""
def __init__(self, _=None):
self.form = FreeCADGui.PySideUic.loadUi(
os.path.join(os.path.dirname(__file__), "AddonManagerOptions.ui")
)
self.table_model = CustomRepoDataModel()
self.form.customRepositoriesTableView.setModel(self.table_model)
self.form.addCustomRepositoryButton.setIcon(
QIcon.fromTheme("add", QIcon(":/icons/list-add.svg"))
)
self.form.removeCustomRepositoryButton.setIcon(
QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg"))
)
self.form.customRepositoriesTableView.horizontalHeader().setStretchLastSection(False)
self.form.customRepositoriesTableView.horizontalHeader().setSectionResizeMode(
0, QHeaderView.Stretch
)
self.form.customRepositoriesTableView.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents
)
self.form.addCustomRepositoryButton.clicked.connect(self._add_custom_repo_clicked)
self.form.removeCustomRepositoryButton.clicked.connect(self._remove_custom_repo_clicked)
self.form.customRepositoriesTableView.doubleClicked.connect(self._row_double_clicked)
def saveSettings(self):
"""Required function: called by the preferences dialog when Apply or Save is clicked,
saves out the preference data by reading it from the widgets."""
for widget in self.form.children():
self.recursive_widget_saver(widget)
self.table_model.save_model()
def recursive_widget_saver(self, widget):
"""Writes out the data for this widget and all of its children, recursively."""
if isinstance(widget, QWidget):
# See if it's one of ours:
pref_path = widget.property("prefPath")
pref_entry = widget.property("prefEntry")
if pref_path and pref_entry:
pref_path = pref_path.data()
pref_entry = pref_entry.data()
pref_access_string = f"User parameter:BaseApp/Preferences/{str(pref_path,'utf-8')}"
pref = FreeCAD.ParamGet(pref_access_string)
if isinstance(widget, QCheckBox):
checked = widget.isChecked()
pref.SetBool(str(pref_entry, "utf-8"), checked)
elif isinstance(widget, QRadioButton):
checked = widget.isChecked()
pref.SetBool(str(pref_entry, "utf-8"), checked)
elif isinstance(widget, QComboBox):
new_index = widget.currentIndex()
pref.SetInt(str(pref_entry, "utf-8"), new_index)
elif isinstance(widget, QTextEdit):
text = widget.toPlainText()
pref.SetString(str(pref_entry, "utf-8"), text)
elif isinstance(widget, QLineEdit):
text = widget.text()
pref.SetString(str(pref_entry, "utf-8"), text)
elif widget.metaObject().className() == "Gui::PrefFileChooser":
filename = str(widget.property("fileName"))
filename = pref.SetString(str(pref_entry, "utf-8"), filename)
# Recurse over children
if isinstance(widget, QtCore.QObject):
for child in widget.children():
self.recursive_widget_saver(child)
def loadSettings(self):
"""Required function: called by the preferences dialog when it is launched,
loads the preference data and assigns it to the widgets."""
for widget in self.form.children():
self.recursive_widget_loader(widget)
self.table_model.load_model()
def recursive_widget_loader(self, widget):
"""Loads the data for this widget and all of its children, recursively."""
if isinstance(widget, QWidget):
# See if it's one of ours:
pref_path = widget.property("prefPath")
pref_entry = widget.property("prefEntry")
if pref_path and pref_entry:
pref_path = pref_path.data()
pref_entry = pref_entry.data()
pref_access_string = f"User parameter:BaseApp/Preferences/{str(pref_path,'utf-8')}"
pref = FreeCAD.ParamGet(pref_access_string)
if isinstance(widget, QCheckBox):
widget.setChecked(pref.GetBool(str(pref_entry, "utf-8")))
elif isinstance(widget, QRadioButton):
if pref.GetBool(str(pref_entry, "utf-8")):
widget.setChecked(True)
elif isinstance(widget, QComboBox):
new_index = pref.GetInt(str(pref_entry, "utf-8"))
widget.setCurrentIndex(new_index)
elif isinstance(widget, QTextEdit):
text = pref.GetString(str(pref_entry, "utf-8"))
widget.setText(text)
elif isinstance(widget, QLineEdit):
text = pref.GetString(str(pref_entry, "utf-8"))
widget.setText(text)
elif widget.metaObject().className() == "Gui::PrefFileChooser":
filename = pref.GetString(str(pref_entry, "utf-8"))
widget.setProperty("fileName", filename)
# Recurse over children
if isinstance(widget, QtCore.QObject):
for child in widget.children():
self.recursive_widget_loader(child)
def _add_custom_repo_clicked(self):
"""Callback: show the Add custom repo dialog"""
dlg = CustomRepositoryDialog()
url, branch = dlg.exec()
if url and branch:
self.table_model.appendData(url, branch)
def _remove_custom_repo_clicked(self):
"""Callback: when the remove button is clicked, get the current selection and remove it."""
item = self.form.customRepositoriesTableView.currentIndex()
if not item.isValid():
return
row = item.row()
self.table_model.removeRows(row, 1, QtCore.QModelIndex())
def _row_double_clicked(self, item):
"""Edit the row that was double-clicked"""
row = item.row()
dlg = CustomRepositoryDialog()
url_index = self.table_model.createIndex(row, 0)
branch_index = self.table_model.createIndex(row, 1)
dlg.dialog.urlLineEdit.setText(self.table_model.data(url_index))
dlg.dialog.branchLineEdit.setText(self.table_model.data(branch_index))
url, branch = dlg.exec()
if url and branch:
self.table_model.setData(url_index, url)
self.table_model.setData(branch_index, branch)
class CustomRepoDataModel(QtCore.QAbstractTableModel):
"""The model for the custom repositories: wraps the underlying preference data and uses that
as its main data store."""
def __init__(self):
super().__init__()
pref_access_string = "User parameter:BaseApp/Preferences/Addons"
self.pref = FreeCAD.ParamGet(pref_access_string)
self.load_model()
def load_model(self):
"""Load the data from the preferences entry"""
pref_entry: str = self.pref.GetString("CustomRepositories", "")
# The entry is saved as a space- and newline-delimited text block: break it into its
# constituent parts
lines = pref_entry.split("\n")
self.model = []
for line in lines:
if not line:
continue
split_data = line.split()
if len(split_data) > 1:
branch = split_data[1]
else:
branch = "master"
url = split_data[0]
self.model.append([url, branch])
def save_model(self):
"""Save the data into a preferences entry"""
entry = ""
for row in self.model:
entry += f"{row[0]} {row[1]}\n"
self.pref.SetString("CustomRepositories", entry)
def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
"""The number of rows"""
if parent.isValid():
return 0
return len(self.model)
def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
"""The number of columns (which is always 2)"""
if parent.isValid():
return 0
return 2
def data(self, index, role=QtCore.Qt.DisplayRole):
"""The data at an index."""
if role != QtCore.Qt.DisplayRole:
return None
row = index.row()
column = index.column()
if row > len(self.model):
return None
if column > 1:
return None
return self.model[row][column]
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
"""Get the row and column header data."""
if role != QtCore.Qt.DisplayRole:
return None
if orientation == QtCore.Qt.Vertical:
return section + 1
if section == 0:
return translate(
"AddonsInstaller",
"Repository URL",
"Preferences header for custom repositories",
)
if section == 1:
return translate(
"AddonsInstaller",
"Branch name",
"Preferences header for custom repositories",
)
return None
def removeRows(self, row, count, parent):
"""Remove rows"""
self.beginRemoveRows(parent, row, row + count - 1)
for _ in range(count):
self.model.pop(row)
self.endRemoveRows()
def insertRows(self, row, count, parent):
"""Insert blank rows"""
self.beginInsertRows(parent, row, row + count - 1)
for _ in range(count):
self.model.insert(["", ""])
self.endInsertRows()
def appendData(self, url, branch):
"""Append this url and branch to the end of the list"""
row = self.rowCount()
self.beginInsertRows(QtCore.QModelIndex(), row, row)
self.model.append([url, branch])
self.endInsertRows()
def setData(self, index, value, role=QtCore.Qt.EditRole):
"""Set the data at this index"""
if role != QtCore.Qt.EditRole:
return
self.model[index.row()][index.column()] = value
self.dataChanged.emit(index, index)
class CustomRepositoryDialog:
"""A dialog for setting up a custom repository, with branch information"""
def __init__(self):
self.dialog = FreeCADGui.PySideUic.loadUi(
os.path.join(os.path.dirname(__file__), "AddonManagerOptions_AddCustomRepository.ui")
)
def exec(self):
"""Run the dialog modally, and return either None or a tuple or (url,branch)"""
result = self.dialog.exec()
if result == QDialog.Accepted:
url = self.dialog.urlLineEdit.text()
branch = self.dialog.branchLineEdit.text()
return (url, branch)
return (None, None)

View File

@@ -1,482 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Gui::Dialog::DlgSettingsAddonManager</class>
<widget class="QWidget" name="Gui::Dialog::DlgSettingsAddonManager">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>757</width>
<height>783</height>
</rect>
</property>
<property name="windowTitle">
<string>Addon manager options</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxcheckupdates">
<property name="toolTip">
<string>If this option is selected, when launching the Addon Manager,
installed addons will be checked for available updates</string>
</property>
<property name="text">
<string>Automatically check for updates at start (requires Git)</string>
</property>
<property name="autoExclusive">
<bool>false</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>AutoCheck</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxdownloadmacros">
<property name="text">
<string>Download Macro metadata (approximately 10MB)</string>
</property>
<property name="prefEntry" stdset="0">
<cstring>DownloadMacros</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Cache update frequency</string>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefComboBox" name="guiprefcomboboxupdatefrequency">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="prefEntry" stdset="0">
<cstring>UpdateFrequencyComboEntry</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
<item>
<property name="text">
<string>Manual (no automatic updates)</string>
</property>
</item>
<item>
<property name="text">
<string>Daily</string>
</property>
</item>
<item>
<property name="text">
<string>Weekly</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhideunlicensed">
<property name="text">
<string>Hide Addons without a license</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideUnlicensed</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhidenonfsf">
<property name="text">
<string>Hide Addons with non-FSF Free/Libre license</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideNonFSFFreeLibre</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxnonosi">
<property name="text">
<string>Hide Addons with non-OSI-approved license</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideNonOSIApproved</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhidepy2">
<property name="text">
<string>Hide Addons marked Python 2 Only</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HidePy2</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhideobsolete">
<property name="text">
<string>Hide Addons marked Obsolete</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideObsolete</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhidenewerfreecadrequired">
<property name="text">
<string>Hide Addons that require a newer version of FreeCAD</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideNewerFreeCADRequired</cstring>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Custom repositories</string>
</property>
</widget>
</item>
<item>
<widget class="QTableView" name="customRepositoriesTableView">
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="addCustomRepositoryButton">
<property name="text">
<string notr="true">...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="removeCustomRepositoryButton">
<property name="text">
<string notr="true">...</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Proxy</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="Gui::PrefRadioButton" name="guiprefradiobuttonnoproxy">
<property name="text">
<string>No proxy</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>NoProxyCheck</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefRadioButton" name="guiprefradiobuttonsystemproxy">
<property name="text">
<string>User system proxy</string>
</property>
<property name="prefEntry" stdset="0">
<cstring>SystemProxyCheck</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefRadioButton" name="guiprefradiobuttonuserproxy">
<property name="text">
<string>User-defined proxy:</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>UserProxyCheck</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefLineEdit" name="guipreflineedituserproxy">
<property name="prefEntry" stdset="0">
<cstring>ProxyUrl</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayoutScore">
<item>
<widget class="QLabel" name="label_score">
<property name="text">
<string>Score source URL</string>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefLineEdit" name="guipreflineeditscoresourceurl">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>The URL for the Addon Score data (see Addon Manager wiki page for formatting and hosting details).</string>
</property>
<property name="prefEntry" stdset="0">
<cstring>AddonsScoreURL</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Path to Git executable (optional):</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="Gui::PrefFileChooser" name="gui::preffilechooser" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>300</width>
<height>0</height>
</size>
</property>
<property name="toolTip">
<string>The path to the Git executable. Autodetected if needed and not specified.</string>
</property>
<property name="prefEntry" stdset="0">
<cstring>GitExecutable</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="advanced">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Advanced Options</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxShowBranchSwitcher">
<property name="text">
<string>Show option to change branches (requires Git)</string>
</property>
<property name="prefEntry" stdset="0">
<cstring>ShowBranchSwitcher</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxDisableGit">
<property name="text">
<string>Disable Git (fall back to ZIP downloads only)</string>
</property>
<property name="prefEntry" stdset="0">
<cstring>disableGit</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxDeveloperMode">
<property name="toolTip">
<string>Activate Addon Manager options intended for developers of new Addons.</string>
</property>
<property name="text">
<string>Addon developer mode</string>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
<property name="prefEntry" stdset="0">
<cstring>developerMode</cstring>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>Gui::PrefCheckBox</class>
<extends>QCheckBox</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
<customwidget>
<class>Gui::PrefComboBox</class>
<extends>QComboBox</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
<customwidget>
<class>Gui::PrefRadioButton</class>
<extends>QRadioButton</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
<customwidget>
<class>Gui::PrefLineEdit</class>
<extends>QLineEdit</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
<customwidget>
<class>Gui::PrefFileChooser</class>
<extends>QWidget</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>AddCustomRepositoryDialog</class>
<widget class="QDialog" name="AddCustomRepositoryDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>95</height>
</rect>
</property>
<property name="windowTitle">
<string>Custom repository</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Repository URL</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="2">
<widget class="QLineEdit" name="urlLineEdit"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Branch</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="3">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="branchLineEdit"/>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>AddCustomRepositoryDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>AddCustomRepositoryDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -1,463 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
"""Mock objects for use when testing the addon manager non-GUI code."""
# pylint: disable=too-few-public-methods,too-many-instance-attributes,missing-function-docstring
import os
from typing import Union, List
import xml.etree.ElementTree as ElemTree
class GitFailed(RuntimeError):
pass
class MockConsole:
"""Spy for the FreeCAD.Console -- does NOT print anything out, just logs it."""
def __init__(self):
self.log = []
self.messages = []
self.warnings = []
self.errors = []
def PrintLog(self, data: str):
self.log.append(data)
def PrintMessage(self, data: str):
self.messages.append(data)
def PrintWarning(self, data: str):
self.warnings.append(data)
def PrintError(self, data: str):
self.errors.append(data)
def missing_newlines(self) -> int:
"""In most cases, all console entries should end with newlines: this is a
convenience function for unit testing that is true."""
counter = 0
counter += self._count_missing_newlines(self.log)
counter += self._count_missing_newlines(self.messages)
counter += self._count_missing_newlines(self.warnings)
counter += self._count_missing_newlines(self.errors)
return counter
@staticmethod
def _count_missing_newlines(some_list) -> int:
counter = 0
for line in some_list:
if line[-1] != "\n":
counter += 1
return counter
class MockAddon:
"""Minimal Addon class"""
# pylint: disable=too-many-instance-attributes
def __init__(
self,
name: str = None,
url: str = None,
status: object = None,
branch: str = "main",
):
test_dir = os.path.join(os.path.dirname(__file__), "..", "data")
if name:
self.name = name
self.display_name = name
else:
self.name = "MockAddon"
self.display_name = "Mock Addon"
self.url = url if url else os.path.join(test_dir, "test_simple_repo.zip")
self.branch = branch
self.status = status
self.macro = None
self.update_status = None
self.metadata = None
self.icon_file = None
self.last_updated = None
self.requires = set()
self.python_requires = set()
self.python_optional = set()
self.on_git = False
self.on_wiki = True
def set_status(self, status):
self.update_status = status
@staticmethod
def get_best_icon_relative_path():
return ""
class MockMacro:
"""Minimal Macro class"""
def __init__(self, name="MockMacro"):
self.name = name
self.filename = self.name + ".FCMacro"
self.icon = "" # If set, should just be fake filename, doesn't have to exist
self.xpm = ""
self.code = ""
self.raw_code_url = ""
self.other_files = [] # If set, should be fake names, don't have to exist
self.details_filled_from_file = False
self.details_filled_from_code = False
self.parsed_wiki_page = False
self.on_git = False
self.on_wiki = True
def install(self, location: os.PathLike):
"""Installer function for the mock macro object: creates a file with the src_filename
attribute, and optionally an icon, xpm, and other_files. The data contained in these files
is not usable and serves only as a placeholder for the existence of the files.
"""
with open(
os.path.join(location, self.filename),
"w",
encoding="utf-8",
) as f:
f.write("Test file for macro installation unit tests")
if self.icon:
with open(os.path.join(location, self.icon), "wb") as f:
f.write(b"Fake icon data - nothing to see here\n")
if self.xpm:
with open(os.path.join(location, "MockMacro_icon.xpm"), "w", encoding="utf-8") as f:
f.write(self.xpm)
for name in self.other_files:
if "/" in name:
new_location = os.path.dirname(os.path.join(location, name))
os.makedirs(new_location, exist_ok=True)
with open(os.path.join(location, name), "w", encoding="utf-8") as f:
f.write("# Fake macro data for unit testing\n")
return True, []
def fill_details_from_file(self, _):
"""Tracks that this function was called, but otherwise does nothing"""
self.details_filled_from_file = True
def fill_details_from_code(self, _):
self.details_filled_from_code = True
def parse_wiki_page(self, _):
self.parsed_wiki_page = True
class SignalCatcher:
"""Object to track signals that it has caught.
Usage:
catcher = SignalCatcher()
my_signal.connect(catcher.catch_signal)
do_things_that_emit_the_signal()
self.assertTrue(catcher.caught)
"""
def __init__(self):
self.caught = False
self.killed = False
self.args = None
def catch_signal(self, *args):
self.caught = True
self.args = args
def die(self):
self.killed = True
class AddonSignalCatcher:
"""Signal catcher specifically designed for catching emitted addons."""
def __init__(self):
self.addons = []
def catch_signal(self, addon):
self.addons.append(addon)
class CallCatcher:
"""Generic call monitor -- use to override functions that are not themselves under
test so that you can detect when the function has been called, and how many times.
"""
def __init__(self):
self.called = False
self.call_count = 0
self.args = None
def catch_call(self, *args):
self.called = True
self.call_count += 1
self.args = args
class MockGitManager:
"""A mock git manager: does NOT require a git installation. Takes no actions, only records
which functions are called for instrumentation purposes. Can be forced to appear to fail as
needed. Various member variables can be set to emulate necessary return responses.
"""
def __init__(self):
self.called_methods = []
self.update_available_response = False
self.current_tag_response = "main"
self.current_branch_response = "main"
self.get_remote_response = "No remote set"
self.get_branches_response = ["main"]
self.get_last_committers_response = {"John Doe": {"email": "jdoe@freecad.org", "count": 1}}
self.get_last_authors_response = {"Jane Doe": {"email": "jdoe@freecad.org", "count": 1}}
self.should_fail = False
self.fail_once = False # Switch back to success after the simulated failure
def _check_for_failure(self):
if self.should_fail:
if self.fail_once:
self.should_fail = False
raise GitFailed("Unit test forced failure")
def clone(self, _remote, _local_path, _args: List[str] = None):
self.called_methods.append("clone")
self._check_for_failure()
def async_clone(self, _remote, _local_path, _progress_monitor, _args: List[str] = None):
self.called_methods.append("async_clone")
self._check_for_failure()
def checkout(self, _local_path, _spec, _args: List[str] = None):
self.called_methods.append("checkout")
self._check_for_failure()
def update(self, _local_path):
self.called_methods.append("update")
self._check_for_failure()
def status(self, _local_path) -> str:
self.called_methods.append("status")
self._check_for_failure()
return "Up-to-date"
def reset(self, _local_path, _args: List[str] = None):
self.called_methods.append("reset")
self._check_for_failure()
def async_fetch_and_update(self, _local_path, _progress_monitor, _args=None):
self.called_methods.append("async_fetch_and_update")
self._check_for_failure()
def update_available(self, _local_path) -> bool:
self.called_methods.append("update_available")
self._check_for_failure()
return self.update_available_response
def current_tag(self, _local_path) -> str:
self.called_methods.append("current_tag")
self._check_for_failure()
return self.current_tag_response
def current_branch(self, _local_path) -> str:
self.called_methods.append("current_branch")
self._check_for_failure()
return self.current_branch_response
def repair(self, _remote, _local_path):
self.called_methods.append("repair")
self._check_for_failure()
def get_remote(self, _local_path) -> str:
self.called_methods.append("get_remote")
self._check_for_failure()
return self.get_remote_response
def get_branches(self, _local_path) -> List[str]:
self.called_methods.append("get_branches")
self._check_for_failure()
return self.get_branches_response
def get_last_committers(self, _local_path, _n=10):
self.called_methods.append("get_last_committers")
self._check_for_failure()
return self.get_last_committers_response
def get_last_authors(self, _local_path, _n=10):
self.called_methods.append("get_last_authors")
self._check_for_failure()
return self.get_last_authors_response
class MockSignal:
"""A purely synchronous signal, instrumented and intended only for use in unit testing.
emit() is semi-functional, but does not use queued slots so cannot be used across
threads."""
def __init__(self, *args):
self.expected_types = args
self.connections = []
self.disconnections = []
self.emitted = False
def connect(self, func):
self.connections.append(func)
def disconnect(self, func):
if func in self.connections:
self.connections.remove(func)
self.disconnections.append(func)
def emit(self, *args):
self.emitted = True
for connection in self.connections:
connection(args)
class MockNetworkManager:
"""Instrumented mock for the NetworkManager. Does no network access, is not asynchronous, and
does not require a running event loop. No submitted requests ever complete."""
def __init__(self):
self.urls = []
self.aborted = []
self.data = MockByteArray()
self.called_methods = []
self.completed = MockSignal(int, int, MockByteArray)
self.progress_made = MockSignal(int, int, int)
self.progress_complete = MockSignal(int, int, os.PathLike)
def submit_unmonitored_get(self, url: str) -> int:
self.urls.append(url)
self.called_methods.append("submit_unmonitored_get")
return len(self.urls) - 1
def submit_monitored_get(self, url: str) -> int:
self.urls.append(url)
self.called_methods.append("submit_monitored_get")
return len(self.urls) - 1
def blocking_get(self, url: str):
self.urls.append(url)
self.called_methods.append("blocking_get")
return self.data
def abort_all(self):
self.called_methods.append("abort_all")
for url in self.urls:
self.aborted.append(url)
def abort(self, index: int):
self.called_methods.append("abort")
self.aborted.append(self.urls[index])
class MockByteArray:
"""Mock for QByteArray. Only provides the data() access member."""
def __init__(self, data_to_wrap="data".encode("utf-8")):
self.wrapped = data_to_wrap
def data(self) -> bytes:
return self.wrapped
class MockThread:
"""Mock for QThread for use when threading is not being used, but interruption
needs to be tested. Set interrupt_after_n_calls to the call number to stop at."""
def __init__(self):
self.interrupt_after_n_calls = 0
self.interrupt_check_counter = 0
def isInterruptionRequested(self):
self.interrupt_check_counter += 1
if (
self.interrupt_after_n_calls
and self.interrupt_check_counter >= self.interrupt_after_n_calls
):
return True
return False
class MockPref:
def __init__(self):
self.prefs = {}
self.pref_set_counter = {}
self.pref_get_counter = {}
def set_prefs(self, pref_dict: dict) -> None:
self.prefs = pref_dict
def GetInt(self, key: str, default: int) -> int:
return self.Get(key, default)
def GetString(self, key: str, default: str) -> str:
return self.Get(key, default)
def GetBool(self, key: str, default: bool) -> bool:
return self.Get(key, default)
def Get(self, key: str, default):
if key not in self.pref_set_counter:
self.pref_get_counter[key] = 1
else:
self.pref_get_counter[key] += 1
if key in self.prefs:
return self.prefs[key]
raise ValueError(f"Expected key not in mock preferences: {key}")
def SetInt(self, key: str, value: int) -> None:
return self.Set(key, value)
def SetString(self, key: str, value: str) -> None:
return self.Set(key, value)
def SetBool(self, key: str, value: bool) -> None:
return self.Set(key, value)
def Set(self, key: str, value):
if key not in self.pref_set_counter:
self.pref_set_counter[key] = 1
else:
self.pref_set_counter[key] += 1
self.prefs[key] = value
class MockExists:
def __init__(self, files: List[str] = None):
"""Returns True for all files in files, and False for all others"""
self.files = files
self.files_checked = []
def exists(self, check_file: str):
self.files_checked.append(check_file)
if not self.files:
return False
for file in self.files:
if check_file.endswith(file):
return True
return False

View File

@@ -1,407 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
import tempfile
import unittest
import os
import sys
sys.path.append("../../")
from Addon import Addon, INTERNAL_WORKBENCHES
from addonmanager_macro import Macro
class TestAddon(unittest.TestCase):
MODULE = "test_addon" # file name without extension
def setUp(self):
self.test_dir = os.path.join(os.path.dirname(__file__), "..", "data")
def test_display_name(self):
# Case 1: No display name set elsewhere: name == display_name
addon = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
self.assertEqual(addon.name, "FreeCAD")
self.assertEqual(addon.display_name, "FreeCAD")
# Case 2: Package.xml metadata file sets a display name:
addon.load_metadata_file(os.path.join(self.test_dir, "good_package.xml"))
self.assertEqual(addon.name, "FreeCAD")
self.assertEqual(addon.display_name, "Test Workbench")
def test_git_url_cleanup(self):
base_url = "https://github.com/FreeCAD/FreeCAD"
test_urls = [f" {base_url} ", f"{base_url}.git", f" {base_url}.git "]
for url in test_urls:
addon = Addon("FreeCAD", url, Addon.Status.NOT_INSTALLED, "master")
self.assertEqual(addon.url, base_url)
def test_tag_extraction(self):
addon = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon.load_metadata_file(os.path.join(self.test_dir, "good_package.xml"))
tags = addon.tags
self.assertEqual(len(tags), 5)
expected_tags = set()
expected_tags.add("Tag0")
expected_tags.add("Tag1")
expected_tags.add("TagA")
expected_tags.add("TagB")
expected_tags.add("TagC")
self.assertEqual(expected_tags, tags)
def test_contains_functions(self):
# Test package.xml combinations:
# Workbenches
addon_with_workbench = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon_with_workbench.load_metadata_file(os.path.join(self.test_dir, "workbench_only.xml"))
self.assertTrue(addon_with_workbench.contains_workbench())
self.assertFalse(addon_with_workbench.contains_macro())
self.assertFalse(addon_with_workbench.contains_preference_pack())
self.assertFalse(addon_with_workbench.contains_bundle())
self.assertFalse(addon_with_workbench.contains_other())
# Macros
addon_with_macro = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon_with_macro.load_metadata_file(os.path.join(self.test_dir, "macro_only.xml"))
self.assertFalse(addon_with_macro.contains_workbench())
self.assertTrue(addon_with_macro.contains_macro())
self.assertFalse(addon_with_macro.contains_preference_pack())
self.assertFalse(addon_with_workbench.contains_bundle())
self.assertFalse(addon_with_workbench.contains_other())
# Preference Packs
addon_with_prefpack = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon_with_prefpack.load_metadata_file(os.path.join(self.test_dir, "prefpack_only.xml"))
self.assertFalse(addon_with_prefpack.contains_workbench())
self.assertFalse(addon_with_prefpack.contains_macro())
self.assertTrue(addon_with_prefpack.contains_preference_pack())
self.assertFalse(addon_with_workbench.contains_bundle())
self.assertFalse(addon_with_workbench.contains_other())
# Combination
addon_with_all = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon_with_all.load_metadata_file(os.path.join(self.test_dir, "combination.xml"))
self.assertTrue(addon_with_all.contains_workbench())
self.assertTrue(addon_with_all.contains_macro())
self.assertTrue(addon_with_all.contains_preference_pack())
self.assertTrue(addon_with_all.contains_bundle())
self.assertTrue(addon_with_all.contains_other())
# Now do the simple, explicitly-set cases
addon_wb = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon_wb.repo_type = Addon.Kind.WORKBENCH
self.assertTrue(addon_wb.contains_workbench())
self.assertFalse(addon_wb.contains_macro())
self.assertFalse(addon_wb.contains_preference_pack())
addon_m = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon_m.repo_type = Addon.Kind.MACRO
self.assertFalse(addon_m.contains_workbench())
self.assertTrue(addon_m.contains_macro())
self.assertFalse(addon_m.contains_preference_pack())
# There is no equivalent for preference packs, they are always accompanied by a
# metadata file
def test_create_from_macro(self):
macro_file = os.path.join(self.test_dir, "DoNothing.FCMacro")
macro = Macro("DoNothing")
macro.fill_details_from_file(macro_file)
addon = Addon.from_macro(macro)
self.assertEqual(addon.repo_type, Addon.Kind.MACRO)
self.assertEqual(addon.name, "DoNothing")
self.assertEqual(
addon.macro.comment,
"Do absolutely nothing. For Addon Manager integration tests.",
)
self.assertEqual(addon.url, "https://github.com/FreeCAD/FreeCAD")
self.assertEqual(addon.macro.version, "1.0")
self.assertEqual(len(addon.macro.other_files), 3)
self.assertEqual(addon.macro.author, "Chris Hennes")
self.assertEqual(addon.macro.date, "2022-02-28")
self.assertEqual(addon.macro.icon, "not_real.png")
self.assertNotEqual(addon.macro.xpm, "")
def test_cache(self):
addon = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
cache_data = addon.to_cache()
second_addon = Addon.from_cache(cache_data)
self.assertTrue(addon.__dict__, second_addon.__dict__)
def test_dependency_resolution(self):
addonA = Addon(
"AddonA",
"https://github.com/FreeCAD/FakeAddonA",
Addon.Status.NOT_INSTALLED,
"master",
)
addonB = Addon(
"AddonB",
"https://github.com/FreeCAD/FakeAddonB",
Addon.Status.NOT_INSTALLED,
"master",
)
addonC = Addon(
"AddonC",
"https://github.com/FreeCAD/FakeAddonC",
Addon.Status.NOT_INSTALLED,
"master",
)
addonD = Addon(
"AddonD",
"https://github.com/FreeCAD/FakeAddonD",
Addon.Status.NOT_INSTALLED,
"master",
)
addonA.requires.add("AddonB")
addonB.requires.add("AddonC")
addonB.requires.add("AddonD")
addonD.requires.add("CAM")
all_addons = {
addonA.name: addonA,
addonB.name: addonB,
addonC.name: addonC,
addonD.name: addonD,
}
deps = Addon.Dependencies()
addonA.walk_dependency_tree(all_addons, deps)
self.assertEqual(len(deps.required_external_addons), 3)
addon_strings = [addon.name for addon in deps.required_external_addons]
self.assertTrue(
"AddonB" in addon_strings,
"AddonB not in required dependencies, and it should be.",
)
self.assertTrue(
"AddonC" in addon_strings,
"AddonC not in required dependencies, and it should be.",
)
self.assertTrue(
"AddonD" in addon_strings,
"AddonD not in required dependencies, and it should be.",
)
self.assertTrue(
"CAM" in deps.internal_workbenches,
"CAM not in workbench dependencies, and it should be.",
)
def test_internal_workbench_list(self):
addon = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon.load_metadata_file(os.path.join(self.test_dir, "depends_on_all_workbenches.xml"))
deps = Addon.Dependencies()
addon.walk_dependency_tree({}, deps)
self.assertEqual(len(deps.internal_workbenches), len(INTERNAL_WORKBENCHES))
def test_version_check(self):
addon = Addon(
"FreeCAD",
"https://github.com/FreeCAD/FreeCAD",
Addon.Status.NOT_INSTALLED,
"master",
)
addon.load_metadata_file(os.path.join(self.test_dir, "test_version_detection.xml"))
self.assertEqual(
len(addon.tags),
1,
"Wrong number of tags found: version requirements should have restricted to only one",
)
self.assertFalse(
"TagA" in addon.tags,
"Found 'TagA' in tags, it should have been excluded by version requirement",
)
self.assertTrue(
"TagB" in addon.tags,
"Failed to find 'TagB' in tags, it should have been included",
)
self.assertFalse(
"TagC" in addon.tags,
"Found 'TagA' in tags, it should have been excluded by version requirement",
)
def test_try_find_wbname_in_files_empty_dir(self):
with tempfile.TemporaryDirectory() as mod_dir:
# Arrange
test_addon = Addon("test")
test_addon.mod_directory = mod_dir
os.mkdir(os.path.join(mod_dir, test_addon.name))
# Act
wb_name = test_addon.try_find_wbname_in_files()
# Assert
self.assertEqual(wb_name, "")
def test_try_find_wbname_in_files_non_python_ignored(self):
with tempfile.TemporaryDirectory() as mod_dir:
# Arrange
test_addon = Addon("test")
test_addon.mod_directory = mod_dir
base_path = os.path.join(mod_dir, test_addon.name)
os.mkdir(base_path)
file_path = os.path.join(base_path, "test.txt")
with open(file_path, "w", encoding="utf-8") as f:
f.write("Gui.addWorkbench(TestWorkbench())")
# Act
wb_name = test_addon.try_find_wbname_in_files()
# Assert
self.assertEqual(wb_name, "")
def test_try_find_wbname_in_files_simple(self):
with tempfile.TemporaryDirectory() as mod_dir:
# Arrange
test_addon = Addon("test")
test_addon.mod_directory = mod_dir
base_path = os.path.join(mod_dir, test_addon.name)
os.mkdir(base_path)
file_path = os.path.join(base_path, "test.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write("Gui.addWorkbench(TestWorkbench())")
# Act
wb_name = test_addon.try_find_wbname_in_files()
# Assert
self.assertEqual(wb_name, "TestWorkbench")
def test_try_find_wbname_in_files_subdir(self):
with tempfile.TemporaryDirectory() as mod_dir:
# Arrange
test_addon = Addon("test")
test_addon.mod_directory = mod_dir
base_path = os.path.join(mod_dir, test_addon.name)
os.mkdir(base_path)
subdir = os.path.join(base_path, "subdirectory")
os.mkdir(subdir)
file_path = os.path.join(subdir, "test.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write("Gui.addWorkbench(TestWorkbench())")
# Act
wb_name = test_addon.try_find_wbname_in_files()
# Assert
self.assertEqual(wb_name, "TestWorkbench")
def test_try_find_wbname_in_files_variable_used(self):
with tempfile.TemporaryDirectory() as mod_dir:
# Arrange
test_addon = Addon("test")
test_addon.mod_directory = mod_dir
base_path = os.path.join(mod_dir, test_addon.name)
os.mkdir(base_path)
file_path = os.path.join(base_path, "test.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write("Gui.addWorkbench(wb)")
# Act
wb_name = test_addon.try_find_wbname_in_files()
# Assert
self.assertEqual(wb_name, "")
def test_try_find_wbname_in_files_variants(self):
variants = [
"Gui.addWorkbench(TestWorkbench())",
"Gui.addWorkbench (TestWorkbench())",
"Gui.addWorkbench( TestWorkbench() )",
"Gui.addWorkbench(TestWorkbench( ))",
"Gui.addWorkbench( TestWorkbench( ) )",
"Gui.addWorkbench( TestWorkbench ( ) )",
"Gui.addWorkbench ( TestWorkbench ( ) )",
]
for variant in variants:
with self.subTest(variant=variant):
with tempfile.TemporaryDirectory() as mod_dir:
# Arrange
test_addon = Addon("test")
test_addon.mod_directory = mod_dir
base_path = os.path.join(mod_dir, test_addon.name)
os.mkdir(base_path)
file_path = os.path.join(base_path, "test.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write(variant)
# Act
wb_name = test_addon.try_find_wbname_in_files()
# Assert
self.assertEqual(wb_name, "TestWorkbench")

View File

@@ -1,213 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# pylint: disable=global-at-module-level,global-statement,import-outside-toplevel,
"""Tests for the AddonCatalog and AddonCatalogEntry classes."""
import unittest
from unittest import mock
from unittest.mock import patch
global AddonCatalogEntry
global AddonCatalog
global Version
class TestAddonCatalogEntry(unittest.TestCase):
"""Tests for the AddonCatalogEntry class."""
def setUp(self):
"""Start mock for addonmanager_licenses class."""
global AddonCatalogEntry
global AddonCatalog
global Version
self.addon_patch = mock.patch.dict("sys.modules", {"addonmanager_licenses": mock.Mock()})
self.mock_addon_module = self.addon_patch.start()
from AddonCatalog import AddonCatalogEntry, AddonCatalog
from addonmanager_metadata import Version
def tearDown(self):
"""Stop patching the addonmanager_licenses class"""
self.addon_patch.stop()
def test_version_match_without_restrictions(self):
"""Given an AddonCatalogEntry that has no version restrictions, a fixed version matches."""
with patch("addonmanager_freecad_interface.Version") as mock_freecad:
mock_freecad.Version = lambda: (1, 2, 3, "dev")
ac = AddonCatalogEntry({})
self.assertTrue(ac.is_compatible())
def test_version_match_with_min_no_max_good_match(self):
"""Given an AddonCatalogEntry with a minimum FreeCAD version, a version smaller than that
does not match."""
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
ac = AddonCatalogEntry({"freecad_min": Version(from_string="1.2")})
self.assertTrue(ac.is_compatible())
def test_version_match_with_max_no_min_good_match(self):
"""Given an AddonCatalogEntry with a maximum FreeCAD version, a version larger than that
does not match."""
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
ac = AddonCatalogEntry({"freecad_max": Version(from_string="1.3")})
self.assertTrue(ac.is_compatible())
def test_version_match_with_min_and_max_good_match(self):
"""Given an AddonCatalogEntry with both a minimum and maximum FreeCAD version, a version
between the two matches."""
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
ac = AddonCatalogEntry(
{
"freecad_min": Version(from_string="1.1"),
"freecad_max": Version(from_string="1.3"),
}
)
self.assertTrue(ac.is_compatible())
def test_version_match_with_min_and_max_bad_match_high(self):
"""Given an AddonCatalogEntry with both a minimum and maximum FreeCAD version, a version
higher than the maximum does not match."""
with patch("addonmanager_freecad_interface.Version", return_value=(1, 3, 3, "dev")):
ac = AddonCatalogEntry(
{
"freecad_min": Version(from_string="1.1"),
"freecad_max": Version(from_string="1.3"),
}
)
self.assertFalse(ac.is_compatible())
def test_version_match_with_min_and_max_bad_match_low(self):
"""Given an AddonCatalogEntry with both a minimum and maximum FreeCAD version, a version
lower than the minimum does not match."""
with patch("addonmanager_freecad_interface.Version", return_value=(1, 0, 3, "dev")):
ac = AddonCatalogEntry(
{
"freecad_min": Version(from_string="1.1"),
"freecad_max": Version(from_string="1.3"),
}
)
self.assertFalse(ac.is_compatible())
class TestAddonCatalog(unittest.TestCase):
"""Tests for the AddonCatalog class."""
def setUp(self):
"""Start mock for addonmanager_licenses class."""
global AddonCatalog
global Version
self.addon_patch = mock.patch.dict("sys.modules", {"addonmanager_licenses": mock.Mock()})
self.mock_addon_module = self.addon_patch.start()
from AddonCatalog import AddonCatalog
from addonmanager_metadata import Version
def tearDown(self):
"""Stop patching the addonmanager_licenses class"""
self.addon_patch.stop()
def test_single_addon_simple_entry(self):
"""Test that an addon entry for an addon with only a git ref is accepted and added, and
appears as an available addon."""
data = {"AnAddon": [{"git_ref": "main"}]}
catalog = AddonCatalog(data)
ids = catalog.get_available_addon_ids()
self.assertEqual(len(ids), 1)
self.assertIn("AnAddon", ids)
def test_single_addon_max_single_entry(self):
"""Test that an addon with the maximum possible data load is accepted."""
data = {
"AnAddon": [
{
"freecad_min": "0.21.0",
"freecad_max": "1.99.99",
"repository": "https://github.com/FreeCAD/FreeCAD",
"git_ref": "main",
"zip_url": "https://github.com/FreeCAD/FreeCAD/archive/main.zip",
"note": "This is a fake repo, don't use it",
"branch_display_name": "main",
}
]
}
catalog = AddonCatalog(data)
ids = catalog.get_available_addon_ids()
self.assertEqual(len(ids), 1)
self.assertIn("AnAddon", ids)
def test_single_addon_multiple_entries(self):
"""Test that an addon with multiple entries is accepted and only appears as a single
addon."""
data = {
"AnAddon": [
{
"freecad_min": "1.0.0",
"repository": "https://github.com/FreeCAD/FreeCAD",
"git_ref": "main",
},
{
"freecad_min": "0.21.0",
"freecad_max": "0.21.99",
"repository": "https://github.com/FreeCAD/FreeCAD",
"git_ref": "0_21_compatibility_branch",
"branch_display_name": "FreeCAD 0.21 Compatibility Branch",
},
]
}
catalog = AddonCatalog(data)
ids = catalog.get_available_addon_ids()
self.assertEqual(len(ids), 1)
self.assertIn("AnAddon", ids)
def test_multiple_addon_entries(self):
"""Test that multiple distinct addon entries are added as distinct addons"""
data = {
"AnAddon": [{"git_ref": "main"}],
"AnotherAddon": [{"git_ref": "main"}],
"YetAnotherAddon": [{"git_ref": "main"}],
}
catalog = AddonCatalog(data)
ids = catalog.get_available_addon_ids()
self.assertEqual(len(ids), 3)
self.assertIn("AnAddon", ids)
self.assertIn("AnotherAddon", ids)
self.assertIn("YetAnotherAddon", ids)
def test_multiple_branches_single_match(self):
"""Test that an addon with multiple branches representing different configurations of
min and max FreeCAD versions returns only the appropriate match."""
data = {
"AnAddon": [
{
"freecad_min": "1.0.0",
"repository": "https://github.com/FreeCAD/FreeCAD",
"git_ref": "main",
},
{
"freecad_min": "0.21.0",
"freecad_max": "0.21.99",
"repository": "https://github.com/FreeCAD/FreeCAD",
"git_ref": "0_21_compatibility_branch",
"branch_display_name": "FreeCAD 0.21 Compatibility Branch",
},
{
"freecad_min": "0.19.0",
"freecad_max": "0.20.99",
"repository": "https://github.com/FreeCAD/FreeCAD",
"git_ref": "0_19_compatibility_branch",
"branch_display_name": "FreeCAD 0.19 Compatibility Branch",
},
]
}
with patch("addonmanager_freecad_interface.Version", return_value=(1, 0, 3, "dev")):
catalog = AddonCatalog(data)
branches = catalog.get_available_branches("AnAddon")
self.assertEqual(len(branches), 1)
def test_load_metadata_cache(self):
"""Test that an addon with a known hash is correctly loaded (e.g. no exception is raised)"""
data = {"AnAddon": [{"git_ref": "main"}]}
catalog = AddonCatalog(data)
sha = "cbce6737d7d058dca2b5ae3f2fdb8cc45b0c02bf711e75bdf5f12fb71ce87790"
cache = {sha: "CacheData"}
with patch("addonmanager_freecad_interface.Version", return_value=cache):
with patch("Addon.Addon") as addon_mock:
catalog.load_metadata_cache(cache)

View File

@@ -1,126 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
import datetime
import sys
import unittest
from datetime import date
from unittest import TestCase
from unittest.mock import MagicMock, patch
sys.path.append("../..")
import addonmanager_cache as cache
from AddonManagerTest.app.mocks import MockPref, MockExists
class TestCache(TestCase):
@patch("addonmanager_freecad_interface.getUserCachePath")
@patch("addonmanager_freecad_interface.ParamGet")
@patch("os.remove", MagicMock())
@patch("os.makedirs", MagicMock())
def test_local_cache_needs_update(self, param_mock: MagicMock, cache_mock: MagicMock):
cache_mock.return_value = ""
param_mock.return_value = MockPref()
default_prefs = {
"UpdateFrequencyComboEntry": 0,
"LastCacheUpdate": "2000-01-01",
"CustomRepoHash": "",
"CustomRepositories": "",
}
today = date.today().isoformat()
yesterday = (date.today() - datetime.timedelta(1)).isoformat()
# Organize these as subtests because of all the patching that has to be done: once we are in this function,
# the patch is complete, and we can just modify the return values of the fakes one by one
tests = (
{
"case": "No existing cache",
"files_that_exist": [],
"prefs_to_set": {},
"expect": True,
},
{
"case": "Last cache update was interrupted",
"files_that_exist": ["CACHE_UPDATE_INTERRUPTED"],
"prefs_to_set": {},
"expect": True,
},
{
"case": "Cache exists and updating is blocked",
"files_that_exist": ["AddonManager"],
"prefs_to_set": {},
"expect": False,
},
{
"case": "Daily updates set and last update was long ago",
"files_that_exist": ["AddonManager"],
"prefs_to_set": {"UpdateFrequencyComboEntry": 1},
"expect": True,
},
{
"case": "Daily updates set and last update was today",
"files_that_exist": ["AddonManager"],
"prefs_to_set": {"UpdateFrequencyComboEntry": 1, "LastCacheUpdate": today},
"expect": False,
},
{
"case": "Daily updates set and last update was yesterday",
"files_that_exist": ["AddonManager"],
"prefs_to_set": {"UpdateFrequencyComboEntry": 1, "LastCacheUpdate": yesterday},
"expect": True,
},
{
"case": "Weekly updates set and last update was long ago",
"files_that_exist": ["AddonManager"],
"prefs_to_set": {"UpdateFrequencyComboEntry": 1},
"expect": True,
},
{
"case": "Weekly updates set and last update was yesterday",
"files_that_exist": ["AddonManager"],
"prefs_to_set": {"UpdateFrequencyComboEntry": 2, "LastCacheUpdate": yesterday},
"expect": False,
},
{
"case": "Custom repo list changed",
"files_that_exist": ["AddonManager"],
"prefs_to_set": {"CustomRepositories": "NewRepo"},
"expect": True,
},
)
for test_case in tests:
with self.subTest(test_case["case"]):
case_prefs = default_prefs
for pref, setting in test_case["prefs_to_set"].items():
case_prefs[pref] = setting
param_mock.return_value.set_prefs(case_prefs)
exists_mock = MockExists(test_case["files_that_exist"])
with patch("os.path.exists", exists_mock.exists):
if test_case["expect"]:
self.assertTrue(cache.local_cache_needs_update())
else:
self.assertFalse(cache.local_cache_needs_update())
if __name__ == "__main__":
unittest.main()

View File

@@ -1,195 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
import functools
import os
import subprocess
import tempfile
from time import sleep
import unittest
from addonmanager_dependency_installer import DependencyInstaller
class CompleteProcessMock(subprocess.CompletedProcess):
def __init__(self):
super().__init__(["fake_arg"], 0)
self.stdout = "Mock subprocess call stdout result"
class SubprocessMock:
def __init__(self):
self.arg_log = []
self.called = False
self.call_count = 0
self.delay = 0
self.succeed = True
def subprocess_interceptor(self, args):
self.arg_log.append(args)
self.called = True
self.call_count += 1
sleep(self.delay)
if self.succeed:
return CompleteProcessMock()
raise subprocess.CalledProcessError(1, " ".join(args), "Unit test mock output")
class FakeFunction:
def __init__(self):
self.called = False
self.call_count = 0
self.return_value = None
self.arg_log = []
def func_call(self, *args):
self.arg_log.append(args)
self.called = True
self.call_count += 1
return self.return_value
class TestDependencyInstaller(unittest.TestCase):
"""Test the dependency installation class"""
def setUp(self):
self.subprocess_mock = SubprocessMock()
self.test_object = DependencyInstaller([], ["required_py_package"], ["optional_py_package"])
self.test_object._subprocess_wrapper = self.subprocess_mock.subprocess_interceptor
self.signals_caught = []
self.test_object.failure.connect(functools.partial(self.catch_signal, "failure"))
self.test_object.finished.connect(functools.partial(self.catch_signal, "finished"))
self.test_object.no_pip.connect(functools.partial(self.catch_signal, "no_pip"))
self.test_object.no_python_exe.connect(
functools.partial(self.catch_signal, "no_python_exe")
)
def tearDown(self):
pass
def catch_signal(self, signal_name, *_):
self.signals_caught.append(signal_name)
def test_run_no_pip(self):
self.test_object._verify_pip = lambda: False
self.test_object.run()
self.assertIn("finished", self.signals_caught)
def test_run_with_pip(self):
ff = FakeFunction()
self.test_object._verify_pip = lambda: True
self.test_object._install_python_packages = ff.func_call
self.test_object.run()
self.assertIn("finished", self.signals_caught)
self.assertTrue(ff.called)
def test_run_with_no_packages(self):
ff = FakeFunction()
self.test_object._verify_pip = lambda: True
self.test_object._install_python_packages = ff.func_call
self.test_object.python_requires = []
self.test_object.python_optional = []
self.test_object.run()
self.assertIn("finished", self.signals_caught)
self.assertFalse(ff.called)
def test_install_python_packages_new_location(self):
ff_required = FakeFunction()
ff_optional = FakeFunction()
self.test_object._install_required = ff_required.func_call
self.test_object._install_optional = ff_optional.func_call
with tempfile.TemporaryDirectory() as td:
self.test_object.location = os.path.join(td, "UnitTestLocation")
self.test_object._install_python_packages()
self.assertTrue(ff_required.called)
self.assertTrue(ff_optional.called)
self.assertTrue(os.path.exists(self.test_object.location))
def test_install_python_packages_existing_location(self):
ff_required = FakeFunction()
ff_optional = FakeFunction()
self.test_object._install_required = ff_required.func_call
self.test_object._install_optional = ff_optional.func_call
with tempfile.TemporaryDirectory() as td:
self.test_object.location = td
self.test_object._install_python_packages()
self.assertTrue(ff_required.called)
self.assertTrue(ff_optional.called)
def test_verify_pip_no_pip(self):
sm = SubprocessMock()
sm.succeed = False
self.test_object._subprocess_wrapper = sm.subprocess_interceptor
self.test_object._get_python = lambda: "fake_python"
result = self.test_object._verify_pip()
self.assertFalse(result)
self.assertIn("no_pip", self.signals_caught)
def test_verify_pip_with_pip(self):
sm = SubprocessMock()
sm.succeed = True
self.test_object._subprocess_wrapper = sm.subprocess_interceptor
self.test_object._get_python = lambda: "fake_python"
result = self.test_object._verify_pip()
self.assertTrue(result)
self.assertNotIn("no_pip", self.signals_caught)
def test_install_required_loops(self):
sm = SubprocessMock()
sm.succeed = True
self.test_object._subprocess_wrapper = sm.subprocess_interceptor
self.test_object._get_python = lambda: "fake_python"
self.test_object.python_requires = ["test1", "test2", "test3"]
self.test_object._install_required("vendor_path")
self.assertEqual(sm.call_count, 3)
def test_install_required_failure(self):
sm = SubprocessMock()
sm.succeed = False
self.test_object._subprocess_wrapper = sm.subprocess_interceptor
self.test_object._get_python = lambda: "fake_python"
self.test_object.python_requires = ["test1", "test2", "test3"]
self.test_object._install_required("vendor_path")
self.assertEqual(sm.call_count, 1)
self.assertIn("failure", self.signals_caught)
def test_install_optional_loops(self):
sm = SubprocessMock()
sm.succeed = True
self.test_object._subprocess_wrapper = sm.subprocess_interceptor
self.test_object._get_python = lambda: "fake_python"
self.test_object.python_optional = ["test1", "test2", "test3"]
self.test_object._install_optional("vendor_path")
self.assertEqual(sm.call_count, 3)
def test_install_optional_failure(self):
sm = SubprocessMock()
sm.succeed = False
self.test_object._subprocess_wrapper = sm.subprocess_interceptor
self.test_object._get_python = lambda: "fake_python"
self.test_object.python_optional = ["test1", "test2", "test3"]
self.test_object._install_optional("vendor_path")
self.assertEqual(sm.call_count, 3)
def test_run_pip(self):
pass

View File

@@ -1,299 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2023 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/>. *
# * *
# ***************************************************************************
"""Tests for the Addon Manager's FreeCAD interface classes."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch, MagicMock
# pylint: disable=protected-access,import-outside-toplevel
class TestConsole(unittest.TestCase):
"""Tests for the Console"""
def setUp(self) -> None:
self.saved_freecad = None
if "FreeCAD" in sys.modules:
self.saved_freecad = sys.modules["FreeCAD"]
sys.modules.pop("FreeCAD")
if "addonmanager_freecad_interface" in sys.modules:
sys.modules.pop("addonmanager_freecad_interface")
sys.path.append("../../")
def tearDown(self) -> None:
if "FreeCAD" in sys.modules:
sys.modules.pop("FreeCAD")
if self.saved_freecad is not None:
sys.modules["FreeCAD"] = self.saved_freecad
def test_log_with_freecad(self):
"""Ensure that if FreeCAD exists, the appropriate function is called"""
sys.modules["FreeCAD"] = unittest.mock.MagicMock()
import addonmanager_freecad_interface as fc
fc.Console.PrintLog("Test output")
self.assertTrue(isinstance(fc.Console, unittest.mock.MagicMock))
self.assertTrue(fc.Console.PrintLog.called)
def test_log_no_freecad(self):
"""Test that if the FreeCAD import fails, the logger is set up correctly, and
implements PrintLog"""
sys.modules["FreeCAD"] = None
with patch("addonmanager_freecad_interface.logging", new=MagicMock()) as mock_logging:
import addonmanager_freecad_interface as fc
fc.Console.PrintLog("Test output")
self.assertTrue(isinstance(fc.Console, fc.ConsoleReplacement))
self.assertTrue(mock_logging.log.called)
def test_message_no_freecad(self):
"""Test that if the FreeCAD import fails the logger implements PrintMessage"""
sys.modules["FreeCAD"] = None
with patch("addonmanager_freecad_interface.logging", new=MagicMock()) as mock_logging:
import addonmanager_freecad_interface as fc
fc.Console.PrintMessage("Test output")
self.assertTrue(mock_logging.info.called)
def test_warning_no_freecad(self):
"""Test that if the FreeCAD import fails the logger implements PrintWarning"""
sys.modules["FreeCAD"] = None
with patch("addonmanager_freecad_interface.logging", new=MagicMock()) as mock_logging:
import addonmanager_freecad_interface as fc
fc.Console.PrintWarning("Test output")
self.assertTrue(mock_logging.warning.called)
def test_error_no_freecad(self):
"""Test that if the FreeCAD import fails the logger implements PrintError"""
sys.modules["FreeCAD"] = None
with patch("addonmanager_freecad_interface.logging", new=MagicMock()) as mock_logging:
import addonmanager_freecad_interface as fc
fc.Console.PrintError("Test output")
self.assertTrue(mock_logging.error.called)
class TestParameters(unittest.TestCase):
"""Tests for the Parameters"""
def setUp(self) -> None:
self.saved_freecad = None
if "FreeCAD" in sys.modules:
self.saved_freecad = sys.modules["FreeCAD"]
sys.modules.pop("FreeCAD")
if "addonmanager_freecad_interface" in sys.modules:
sys.modules.pop("addonmanager_freecad_interface")
sys.path.append("../../")
def tearDown(self) -> None:
if "FreeCAD" in sys.modules:
sys.modules.pop("FreeCAD")
if self.saved_freecad is not None:
sys.modules["FreeCAD"] = self.saved_freecad
def test_param_get_with_freecad(self):
"""Ensure that if FreeCAD exists, the built-in FreeCAD function is called"""
sys.modules["FreeCAD"] = unittest.mock.MagicMock()
import addonmanager_freecad_interface as fc
prefs = fc.ParamGet("some/fake/path")
self.assertTrue(isinstance(prefs, unittest.mock.MagicMock))
def test_param_get_no_freecad(self):
"""Test that if the FreeCAD import fails, param_get returns a ParametersReplacement"""
sys.modules["FreeCAD"] = None
import addonmanager_freecad_interface as fc
prefs = fc.ParamGet("some/fake/path")
self.assertTrue(isinstance(prefs, fc.ParametersReplacement))
def test_replacement_getters_and_setters(self):
"""Test that ParameterReplacement's getters, setters, and deleters work"""
sys.modules["FreeCAD"] = None
import addonmanager_freecad_interface as fc
prf = fc.ParamGet("some/fake/path")
gs_types = [
("Bool", prf.GetBool, prf.SetBool, prf.RemBool, True, False),
("Int", prf.GetInt, prf.SetInt, prf.RemInt, 42, 0),
("Float", prf.GetFloat, prf.SetFloat, prf.RemFloat, 1.2, 3.4),
("String", prf.GetString, prf.SetString, prf.RemString, "test", "other"),
]
for gs_type in gs_types:
with self.subTest(msg=f"Testing {gs_type[0]}", gs_type=gs_type):
getter = gs_type[1]
setter = gs_type[2]
deleter = gs_type[3]
value_1 = gs_type[4]
value_2 = gs_type[5]
self.assertEqual(getter("test", value_1), value_1)
self.assertEqual(getter("test", value_2), value_2)
self.assertNotIn("test", prf.parameters)
setter("test", value_1)
self.assertIn("test", prf.parameters)
self.assertEqual(getter("test", value_2), value_1)
deleter("test")
self.assertNotIn("test", prf.parameters)
class TestDataPaths(unittest.TestCase):
"""Tests for the data paths"""
def setUp(self) -> None:
self.saved_freecad = None
if "FreeCAD" in sys.modules:
self.saved_freecad = sys.modules["FreeCAD"]
sys.modules.pop("FreeCAD")
if "addonmanager_freecad_interface" in sys.modules:
sys.modules.pop("addonmanager_freecad_interface")
sys.path.append("../../")
def tearDown(self) -> None:
if "FreeCAD" in sys.modules:
sys.modules.pop("FreeCAD")
if self.saved_freecad is not None:
sys.modules["FreeCAD"] = self.saved_freecad
def test_init_with_freecad(self):
"""Ensure that if FreeCAD exists, the appropriate functions are called"""
sys.modules["FreeCAD"] = unittest.mock.MagicMock()
import addonmanager_freecad_interface as fc
data_paths = fc.DataPaths()
self.assertTrue(sys.modules["FreeCAD"].getUserAppDataDir.called)
self.assertTrue(sys.modules["FreeCAD"].getUserMacroDir.called)
self.assertTrue(sys.modules["FreeCAD"].getUserCachePath.called)
self.assertIsNotNone(data_paths.mod_dir)
self.assertIsNotNone(data_paths.cache_dir)
self.assertIsNotNone(data_paths.macro_dir)
def test_init_without_freecad(self):
"""Ensure that if FreeCAD does not exist, the appropriate functions are called"""
sys.modules["FreeCAD"] = None
import addonmanager_freecad_interface as fc
data_paths = fc.DataPaths()
self.assertIsNotNone(data_paths.mod_dir)
self.assertIsNotNone(data_paths.cache_dir)
self.assertIsNotNone(data_paths.macro_dir)
self.assertNotEqual(data_paths.mod_dir, data_paths.cache_dir)
self.assertNotEqual(data_paths.mod_dir, data_paths.macro_dir)
self.assertNotEqual(data_paths.cache_dir, data_paths.macro_dir)
class TestPreferences(unittest.TestCase):
"""Tests for the preferences wrapper"""
def setUp(self) -> None:
sys.path.append("../../")
import addonmanager_freecad_interface as fc
self.fc = fc
def tearDown(self) -> None:
pass
def test_load_preferences_defaults(self):
"""Preferences are loaded from a given file"""
defaults = self.given_defaults()
with tempfile.TemporaryDirectory() as temp_dir:
json_file = os.path.join(temp_dir, "defaults.json")
with open(json_file, "w", encoding="utf-8") as f:
f.write(json.dumps(defaults))
self.fc.Preferences._load_preferences_defaults(json_file)
self.assertDictEqual(defaults, self.fc.Preferences.preferences_defaults)
def test_in_memory_defaults(self):
"""Preferences are loaded from memory"""
defaults = self.given_defaults()
prefs = self.fc.Preferences(defaults)
self.assertDictEqual(defaults, prefs.preferences_defaults)
def test_get_good(self):
"""Get returns results when matching an existing preference"""
defaults = self.given_defaults()
prefs = self.fc.Preferences(defaults)
self.assertEqual(prefs.get("TestBool"), defaults["TestBool"])
self.assertEqual(prefs.get("TestInt"), defaults["TestInt"])
self.assertEqual(prefs.get("TestFloat"), defaults["TestFloat"])
self.assertEqual(prefs.get("TestString"), defaults["TestString"])
def test_get_nonexistent(self):
"""Get raises an exception when asked for a non-existent preference"""
defaults = self.given_defaults()
prefs = self.fc.Preferences(defaults)
with self.assertRaises(RuntimeError):
prefs.get("No_such_thing")
def test_get_bad_type(self):
"""Get raises an exception when getting an unsupported type"""
defaults = self.given_defaults()
defaults["TestArray"] = ["This", "Is", "Legal", "JSON"]
prefs = self.fc.Preferences(defaults)
with self.assertRaises(RuntimeError):
prefs.get("TestArray")
def test_set_good(self):
"""Set works when matching an existing preference"""
defaults = self.given_defaults()
prefs = self.fc.Preferences(defaults)
prefs.set("TestBool", False)
self.assertEqual(prefs.get("TestBool"), False)
prefs.set("TestInt", 4321)
self.assertEqual(prefs.get("TestInt"), 4321)
prefs.set("TestFloat", 3.14159)
self.assertEqual(prefs.get("TestFloat"), 3.14159)
prefs.set("TestString", "Forty two")
self.assertEqual(prefs.get("TestString"), "Forty two")
def test_set_nonexistent(self):
"""Set raises an exception when asked for a non-existent preference"""
defaults = self.given_defaults()
prefs = self.fc.Preferences(defaults)
with self.assertRaises(RuntimeError):
prefs.get("No_such_thing")
def test_set_bad_type(self):
"""Set raises an exception when setting an unsupported type"""
defaults = self.given_defaults()
defaults["TestArray"] = ["This", "Is", "Legal", "JSON"]
prefs = self.fc.Preferences(defaults)
with self.assertRaises(RuntimeError):
prefs.get("TestArray")
@staticmethod
def given_defaults():
"""Get a dictionary of fake defaults for testing"""
defaults = {
"TestBool": True,
"TestInt": 42,
"TestFloat": 1.2,
"TestString": "Test",
}
return defaults

View File

@@ -1,177 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
import unittest
import os
import shutil
import stat
import tempfile
import time
from zipfile import ZipFile
import FreeCAD
from addonmanager_git import GitManager, NoGitFound, GitFailed
try:
git_manager = GitManager()
except NoGitFound:
git_manager = None
@unittest.skipIf(git_manager is None, "No git executable -- not running git-based tests")
class TestGit(unittest.TestCase):
MODULE = "test_git" # file name without extension
def setUp(self):
"""Set up the test case: called by the unit test system"""
self.cwd = os.getcwd()
test_data_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
git_repo_zip = os.path.join(test_data_dir, "test_repo.zip")
self.test_dir = os.path.join(
tempfile.gettempdir(), "FreeCADTesting", "AddonManagerTests", "Git"
)
os.makedirs(self.test_dir, exist_ok=True)
self.test_repo_remote = os.path.join(self.test_dir, "TEST_REPO_REMOTE")
if os.path.exists(self.test_repo_remote):
# Make sure any old copy that got left around is deleted
self._rmdir(self.test_repo_remote)
if not os.path.exists(git_repo_zip):
self.skipTest("Can't find test repo")
return
with ZipFile(git_repo_zip, "r") as zip_repo:
zip_repo.extractall(self.test_repo_remote)
self.test_repo_remote = os.path.join(self.test_repo_remote, "test_repo")
self.git = git_manager
def tearDown(self):
"""Clean up after the test"""
os.chdir(self.cwd)
# self._rmdir(self.test_dir)
os.rename(self.test_dir, self.test_dir + ".old." + str(time.time()))
def test_clone(self):
"""Test git clone"""
checkout_dir = self._clone_test_repo()
self.assertTrue(os.path.exists(checkout_dir))
self.assertTrue(os.path.exists(os.path.join(checkout_dir, ".git")))
self.assertEqual(os.getcwd(), self.cwd, "We should be left in the same CWD we started")
def test_checkout(self):
"""Test git checkout"""
checkout_dir = self._clone_test_repo()
self.git.checkout(checkout_dir, "HEAD~1")
status = self.git.status(checkout_dir).strip()
expected_status = "## HEAD (no branch)"
self.assertEqual(status, expected_status)
self.assertEqual(os.getcwd(), self.cwd, "We should be left in the same CWD we started")
def test_update(self):
"""Test using git to update the local repo"""
checkout_dir = self._clone_test_repo()
self.git.reset(checkout_dir, ["--hard", "HEAD~1"])
self.assertTrue(self.git.update_available(checkout_dir))
self.git.update(checkout_dir)
self.assertFalse(self.git.update_available(checkout_dir))
self.assertEqual(os.getcwd(), self.cwd, "We should be left in the same CWD we started")
def test_tag_and_branch(self):
"""Test checking the currently checked-out tag"""
checkout_dir = self._clone_test_repo()
expected_tag = "TestTag"
self.git.checkout(checkout_dir, expected_tag)
found_tag = self.git.current_tag(checkout_dir)
self.assertEqual(found_tag, expected_tag)
self.assertFalse(self.git.update_available(checkout_dir))
expected_branch = "TestBranch"
self.git.checkout(checkout_dir, expected_branch)
found_branch = self.git.current_branch(checkout_dir)
self.assertEqual(found_branch, expected_branch)
self.assertFalse(self.git.update_available(checkout_dir))
expected_branch = "main"
self.git.checkout(checkout_dir, expected_branch)
found_branch = self.git.current_branch(checkout_dir)
self.assertEqual(found_branch, expected_branch)
self.assertFalse(self.git.update_available(checkout_dir))
self.assertEqual(os.getcwd(), self.cwd, "We should be left in the same CWD we started")
def test_get_remote(self):
"""Test getting the remote location"""
checkout_dir = self._clone_test_repo()
expected_remote = self.test_repo_remote
returned_remote = self.git.get_remote(checkout_dir)
self.assertEqual(expected_remote, returned_remote)
self.assertEqual(os.getcwd(), self.cwd, "We should be left in the same CWD we started")
def test_repair(self):
"""Test the repair feature (and some exception throwing)"""
checkout_dir = self._clone_test_repo()
remote = self.git.get_remote(checkout_dir)
git_dir = os.path.join(checkout_dir, ".git")
self.assertTrue(os.path.exists(git_dir))
self._rmdir(git_dir)
# Make sure that we've truly broken the install
with self.assertRaises(GitFailed):
self.git.status(checkout_dir)
self.git.repair(remote, checkout_dir)
status = self.git.status(checkout_dir)
self.assertEqual(status, "## main...origin/main\n")
self.assertEqual(os.getcwd(), self.cwd, "We should be left in the same CWD we started")
def _rmdir(self, path):
try:
shutil.rmtree(path, onerror=self._remove_readonly)
except Exception as e:
print(e)
def _remove_readonly(self, func, path, _) -> None:
"""Remove a read-only file."""
os.chmod(path, stat.S_IWRITE)
func(path)
def _clone_test_repo(self) -> str:
checkout_dir = os.path.join(self.test_dir, "test_repo")
try:
# Git won't clone to an existing directory, so make sure to remove it first
if os.path.exists(checkout_dir):
self._rmdir(checkout_dir)
self.git.clone(self.test_repo_remote, checkout_dir)
except GitFailed as e:
self.fail(str(e))
return checkout_dir

View File

@@ -1,412 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
"""Contains the unit test class for addonmanager_installer.py non-GUI functionality."""
import unittest
from unittest.mock import Mock
import os
import shutil
import tempfile
from zipfile import ZipFile
import sys
sys.path.append("../../") # So the IDE can find the imports below
import FreeCAD
from addonmanager_installer import InstallationMethod, AddonInstaller, MacroInstaller
from addonmanager_git import GitManager, initialize_git
from addonmanager_metadata import MetadataReader
from Addon import Addon
from AddonManagerTest.app.mocks import MockAddon, MockMacro
class TestAddonInstaller(unittest.TestCase):
"""Test class for addonmanager_installer.py non-GUI functionality"""
MODULE = "test_installer" # file name without extension
def setUp(self):
"""Initialize data needed for all tests"""
# self.start_time = time.perf_counter()
self.test_data_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
self.real_addon = Addon(
"TestAddon",
"https://github.com/FreeCAD/FreeCAD-addons",
Addon.Status.NOT_INSTALLED,
"master",
)
self.mock_addon = MockAddon()
def tearDown(self):
"""Finalize the test."""
# end_time = time.perf_counter()
# print(f"Test '{self.id()}' ran in {end_time-self.start_time:.4f} seconds")
def test_validate_object(self):
"""An object is valid if it has a name, url, and branch attribute."""
AddonInstaller._validate_object(self.real_addon) # Won't raise
AddonInstaller._validate_object(self.mock_addon) # Won't raise
class NoName:
def __init__(self):
self.url = "https://github.com/FreeCAD/FreeCAD-addons"
self.branch = "master"
no_name = NoName()
with self.assertRaises(RuntimeError):
AddonInstaller._validate_object(no_name)
class NoUrl:
def __init__(self):
self.name = "TestAddon"
self.branch = "master"
no_url = NoUrl()
with self.assertRaises(RuntimeError):
AddonInstaller._validate_object(no_url)
class NoBranch:
def __init__(self):
self.name = "TestAddon"
self.url = "https://github.com/FreeCAD/FreeCAD-addons"
no_branch = NoBranch()
with self.assertRaises(RuntimeError):
AddonInstaller._validate_object(no_branch)
def test_update_metadata(self):
"""If a metadata file exists in the installation location, it should be
loaded."""
addon = Mock()
addon.name = "MockAddon"
installer = AddonInstaller(addon, [])
installer._update_metadata() # Does nothing, but should not crash
installer = AddonInstaller(self.real_addon, [])
with tempfile.TemporaryDirectory() as temp_dir:
installer.installation_path = temp_dir
installer._update_metadata()
addon_dir = os.path.join(temp_dir, self.real_addon.name)
os.mkdir(addon_dir)
shutil.copy(
os.path.join(self.test_data_dir, "good_package.xml"),
os.path.join(addon_dir, "package.xml"),
)
good_metadata = MetadataReader.from_file(os.path.join(addon_dir, "package.xml"))
installer._update_metadata()
self.assertEqual(self.real_addon.installed_version, good_metadata.version)
def test_finalize_zip_installation_non_github(self):
"""Ensure that zip files are correctly extracted."""
with tempfile.TemporaryDirectory() as temp_dir:
test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip")
non_gh_mock = MockAddon()
non_gh_mock.url = test_simple_repo[:-4]
non_gh_mock.name = "NonGitHubMock"
installer = AddonInstaller(non_gh_mock, [])
installer.installation_path = temp_dir
installer._finalize_zip_installation(test_simple_repo)
expected_location = os.path.join(temp_dir, non_gh_mock.name, "README")
self.assertTrue(os.path.isfile(expected_location), "Non-GitHub zip extraction failed")
def test_finalize_zip_installation_github(self):
with tempfile.TemporaryDirectory() as temp_dir:
test_github_style_repo = os.path.join(self.test_data_dir, "test_github_style_repo.zip")
self.mock_addon.url = "https://github.com/something/test_github_style_repo"
self.mock_addon.branch = "master"
installer = AddonInstaller(self.mock_addon, [])
installer.installation_path = temp_dir
installer._finalize_zip_installation(test_github_style_repo)
expected_location = os.path.join(temp_dir, self.mock_addon.name, "README")
self.assertTrue(os.path.isfile(expected_location), "GitHub zip extraction failed")
def test_code_in_branch_subdirectory_true(self):
"""When there is a subdirectory with the branch name in it, find it"""
self.mock_addon.url = "https://something.com/something_else/something"
installer = AddonInstaller(self.mock_addon, [])
with tempfile.TemporaryDirectory() as temp_dir:
os.mkdir(os.path.join(temp_dir, f"something-{self.mock_addon.branch}"))
result = installer._code_in_branch_subdirectory(temp_dir)
self.assertTrue(result, "Failed to find ZIP subdirectory")
def test_code_in_branch_subdirectory_false(self):
"""When there is not a subdirectory with the branch name in it, don't find
one"""
installer = AddonInstaller(self.mock_addon, [])
with tempfile.TemporaryDirectory() as temp_dir:
result = installer._code_in_branch_subdirectory(temp_dir)
self.assertFalse(result, "Found ZIP subdirectory when there was none")
def test_code_in_branch_subdirectory_more_than_one(self):
"""When there are multiple subdirectories, never find a branch subdirectory"""
installer = AddonInstaller(self.mock_addon, [])
with tempfile.TemporaryDirectory() as temp_dir:
os.mkdir(os.path.join(temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}"))
os.mkdir(os.path.join(temp_dir, "AnotherSubdir"))
result = installer._code_in_branch_subdirectory(temp_dir)
self.assertFalse(result, "Found ZIP subdirectory when there were multiple subdirs")
def test_move_code_out_of_subdirectory(self):
"""All files are moved out and the subdirectory is deleted"""
self.mock_addon.url = "https://something.com/something_else/something"
installer = AddonInstaller(self.mock_addon, [])
with tempfile.TemporaryDirectory() as temp_dir:
subdir = os.path.join(temp_dir, f"something-{self.mock_addon.branch}")
os.mkdir(subdir)
with open(os.path.join(subdir, "README.txt"), "w", encoding="utf-8") as f:
f.write("# Test file for unit testing")
with open(os.path.join(subdir, "AnotherFile.txt"), "w", encoding="utf-8") as f:
f.write("# Test file for unit testing")
installer._move_code_out_of_subdirectory(temp_dir)
self.assertTrue(os.path.isfile(os.path.join(temp_dir, "README.txt")))
self.assertTrue(os.path.isfile(os.path.join(temp_dir, "AnotherFile.txt")))
self.assertFalse(os.path.isdir(subdir))
def test_install_by_git(self):
"""Test using git to install. Depends on there being a local git
installation: the test is skipped if there is no local git."""
git_manager = initialize_git()
if not git_manager:
self.skipTest("git not found, skipping git installer tests")
return
# Our test git repo has to be in a zipfile, otherwise it cannot itself be
# stored in git, since it has a .git subdirectory.
with tempfile.TemporaryDirectory() as temp_dir:
git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip")
with ZipFile(git_repo_zip, "r") as zip_repo:
zip_repo.extractall(temp_dir)
mock_addon = MockAddon()
mock_addon.url = os.path.join(temp_dir, "test_repo")
mock_addon.branch = "main"
installer = AddonInstaller(mock_addon, [])
installer.installation_path = os.path.join(temp_dir, "installed_addon")
installer._install_by_git()
self.assertTrue(os.path.exists(installer.installation_path))
addon_name_dir = os.path.join(installer.installation_path, mock_addon.name)
self.assertTrue(os.path.exists(addon_name_dir))
readme = os.path.join(addon_name_dir, "README.md")
self.assertTrue(os.path.exists(readme))
def test_install_by_copy(self):
"""Test using a simple filesystem copy to install an addon."""
with tempfile.TemporaryDirectory() as temp_dir:
git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip")
with ZipFile(git_repo_zip, "r") as zip_repo:
zip_repo.extractall(temp_dir)
mock_addon = MockAddon()
mock_addon.url = os.path.join(temp_dir, "test_repo")
mock_addon.branch = "main"
installer = AddonInstaller(mock_addon, [])
installer.addon_to_install = mock_addon
installer.installation_path = os.path.join(temp_dir, "installed_addon")
installer._install_by_copy()
self.assertTrue(os.path.exists(installer.installation_path))
addon_name_dir = os.path.join(installer.installation_path, mock_addon.name)
self.assertTrue(os.path.exists(addon_name_dir))
readme = os.path.join(addon_name_dir, "README.md")
self.assertTrue(os.path.exists(readme))
def test_determine_install_method_local_path(self):
"""Test which install methods are accepted for a local path"""
with tempfile.TemporaryDirectory() as temp_dir:
installer = AddonInstaller(self.mock_addon, [])
method = installer._determine_install_method(temp_dir, InstallationMethod.COPY)
self.assertEqual(method, InstallationMethod.COPY)
git_manager = initialize_git()
if git_manager:
method = installer._determine_install_method(temp_dir, InstallationMethod.GIT)
self.assertEqual(method, InstallationMethod.GIT)
method = installer._determine_install_method(temp_dir, InstallationMethod.ZIP)
self.assertIsNone(method)
method = installer._determine_install_method(temp_dir, InstallationMethod.ANY)
self.assertEqual(method, InstallationMethod.COPY)
def test_determine_install_method_file_url(self):
"""Test which install methods are accepted for a file:// url"""
with tempfile.TemporaryDirectory() as temp_dir:
installer = AddonInstaller(self.mock_addon, [])
temp_dir = "file://" + temp_dir.replace(os.path.sep, "/")
method = installer._determine_install_method(temp_dir, InstallationMethod.COPY)
self.assertEqual(method, InstallationMethod.COPY)
git_manager = initialize_git()
if git_manager:
method = installer._determine_install_method(temp_dir, InstallationMethod.GIT)
self.assertEqual(method, InstallationMethod.GIT)
method = installer._determine_install_method(temp_dir, InstallationMethod.ZIP)
self.assertIsNone(method)
method = installer._determine_install_method(temp_dir, InstallationMethod.ANY)
self.assertEqual(method, InstallationMethod.COPY)
def test_determine_install_method_local_zip(self):
"""Test which install methods are accepted for a local path to a zipfile"""
with tempfile.TemporaryDirectory() as temp_dir:
installer = AddonInstaller(self.mock_addon, [])
temp_file = os.path.join(temp_dir, "dummy.zip")
method = installer._determine_install_method(temp_file, InstallationMethod.COPY)
self.assertEqual(method, InstallationMethod.ZIP)
method = installer._determine_install_method(temp_file, InstallationMethod.GIT)
self.assertIsNone(method)
method = installer._determine_install_method(temp_file, InstallationMethod.ZIP)
self.assertEqual(method, InstallationMethod.ZIP)
method = installer._determine_install_method(temp_file, InstallationMethod.ANY)
self.assertEqual(method, InstallationMethod.ZIP)
def test_determine_install_method_remote_zip(self):
"""Test which install methods are accepted for a remote path to a zipfile"""
installer = AddonInstaller(self.mock_addon, [])
temp_file = "https://freecad.org/dummy.zip" # Doesn't have to actually exist!
method = installer._determine_install_method(temp_file, InstallationMethod.COPY)
self.assertIsNone(method)
method = installer._determine_install_method(temp_file, InstallationMethod.GIT)
self.assertIsNone(method)
method = installer._determine_install_method(temp_file, InstallationMethod.ZIP)
self.assertEqual(method, InstallationMethod.ZIP)
method = installer._determine_install_method(temp_file, InstallationMethod.ANY)
self.assertEqual(method, InstallationMethod.ZIP)
def test_determine_install_method_https_known_sites_copy(self):
"""Test which install methods are accepted for an https GitHub URL"""
installer = AddonInstaller(self.mock_addon, [])
installer.git_manager = True
for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]:
with self.subTest(site=site):
temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist!
method = installer._determine_install_method(temp_file, InstallationMethod.COPY)
self.assertIsNone(method, f"Allowed copying from {site} URL")
def test_determine_install_method_https_known_sites_git(self):
"""Test which install methods are accepted for an https GitHub URL"""
installer = AddonInstaller(self.mock_addon, [])
installer.git_manager = True
for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]:
with self.subTest(site=site):
temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist!
method = installer._determine_install_method(temp_file, InstallationMethod.GIT)
self.assertEqual(
method,
InstallationMethod.GIT,
f"Failed to allow git access to {site} URL",
)
def test_determine_install_method_https_known_sites_zip(self):
"""Test which install methods are accepted for an https GitHub URL"""
installer = AddonInstaller(self.mock_addon, [])
installer.git_manager = True
for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]:
with self.subTest(site=site):
temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist!
method = installer._determine_install_method(temp_file, InstallationMethod.ZIP)
self.assertEqual(
method,
InstallationMethod.ZIP,
f"Failed to allow zip access to {site} URL",
)
def test_determine_install_method_https_known_sites_any_gm(self):
"""Test which install methods are accepted for an https GitHub URL"""
installer = AddonInstaller(self.mock_addon, [])
installer.git_manager = True
for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]:
with self.subTest(site=site):
temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist!
method = installer._determine_install_method(temp_file, InstallationMethod.ANY)
self.assertEqual(
method,
InstallationMethod.GIT,
f"Failed to allow git access to {site} URL",
)
def test_determine_install_method_https_known_sites_any_no_gm(self):
"""Test which install methods are accepted for an https GitHub URL"""
installer = AddonInstaller(self.mock_addon, [])
installer.git_manager = None
for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]:
with self.subTest(site=site):
temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist!
method = installer._determine_install_method(temp_file, InstallationMethod.ANY)
self.assertEqual(
method,
InstallationMethod.ZIP,
f"Failed to allow zip access to {site} URL",
)
def test_fcmacro_copying(self):
with tempfile.TemporaryDirectory() as temp_dir:
mock_addon = MockAddon()
mock_addon.url = os.path.join(self.test_data_dir, "test_addon_with_fcmacro.zip")
installer = AddonInstaller(mock_addon, [])
installer.installation_path = temp_dir
installer.macro_installation_path = os.path.join(temp_dir, "Macros")
installer.run()
self.assertTrue(
os.path.exists(os.path.join(temp_dir, "Macros", "TestMacro.FCMacro")),
"FCMacro file was not copied to macro installation location",
)
class TestMacroInstaller(unittest.TestCase):
MODULE = "test_installer" # file name without extension
def setUp(self):
"""Set up the mock objects"""
self.mock = MockAddon()
self.mock.macro = MockMacro()
def test_installation(self):
"""Test the wrapper around the macro installer"""
# Note that this doesn't test the underlying Macro object's install function,
# it only tests whether that function is called appropriately by the
# MacroInstaller wrapper.
with tempfile.TemporaryDirectory() as temp_dir:
installer = MacroInstaller(self.mock)
installer.installation_path = temp_dir
installation_succeeded = installer.run()
self.assertTrue(installation_succeeded)
self.assertTrue(os.path.exists(os.path.join(temp_dir, self.mock.macro.filename)))

View File

@@ -1,213 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
import os
import sys
import tempfile
from typing import Dict
import unittest
from unittest.mock import MagicMock
sys.path.append("../../") # So the IDE can find the
import FreeCAD
from addonmanager_macro import Macro
class TestMacro(unittest.TestCase):
MODULE = "test_macro" # file name without extension
def setUp(self):
self.test_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
def test_basic_metadata(self):
replacements = {
"COMMENT": "test comment",
"WEB": "https://test.url",
"VERSION": "1.2.3",
"AUTHOR": "Test Author",
"DATE": "2022-03-09",
"ICON": "testicon.svg",
}
m = self.generate_macro(replacements)
self.assertEqual(m.comment, replacements["COMMENT"])
self.assertEqual(m.url, replacements["WEB"])
self.assertEqual(m.version, replacements["VERSION"])
self.assertEqual(m.author, replacements["AUTHOR"])
self.assertEqual(m.date, replacements["DATE"])
self.assertEqual(m.icon, replacements["ICON"])
def test_other_files(self):
replacements = {
"FILES": "file_a,file_b,file_c",
}
m = self.generate_macro(replacements)
self.assertEqual(len(m.other_files), 3)
self.assertEqual(m.other_files[0], "file_a")
self.assertEqual(m.other_files[1], "file_b")
self.assertEqual(m.other_files[2], "file_c")
replacements = {
"FILES": "file_a, file_b, file_c",
}
m = self.generate_macro(replacements)
self.assertEqual(len(m.other_files), 3)
self.assertEqual(m.other_files[0], "file_a")
self.assertEqual(m.other_files[1], "file_b")
self.assertEqual(m.other_files[2], "file_c")
replacements = {
"FILES": "file_a file_b file_c",
}
m = self.generate_macro(replacements)
self.assertEqual(len(m.other_files), 1)
self.assertEqual(m.other_files[0], "file_a file_b file_c")
def test_version_from_string(self):
replacements = {
"VERSION": "1.2.3",
}
m = self.generate_macro(replacements)
self.assertEqual(m.version, "1.2.3")
def test_version_from_date(self):
replacements = {
"DATE": "2022-03-09",
}
outfile = self.generate_macro_file(replacements)
with open(outfile) as f:
lines = f.readlines()
output_lines = []
for line in lines:
if "VERSION" in line:
line = "__Version__ = __Date__"
output_lines.append(line)
with open(outfile, "w") as f:
f.write("\n".join(output_lines))
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
self.assertEqual(m.version, "2022-03-09")
def test_version_from_float(self):
outfile = self.generate_macro_file()
with open(outfile) as f:
lines = f.readlines()
output_lines = []
for line in lines:
if "VERSION" in line:
line = "__Version__ = 1.23"
output_lines.append(line)
with open(outfile, "w") as f:
f.write("\n".join(output_lines))
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
self.assertEqual(m.version, "1.23")
def test_version_from_int(self):
outfile = self.generate_macro_file()
with open(outfile) as f:
lines = f.readlines()
output_lines = []
for line in lines:
if "VERSION" in line:
line = "__Version__ = 1"
output_lines.append(line)
with open(outfile, "w") as f:
f.write("\n".join(output_lines))
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
self.assertEqual(m.version, "1")
def test_xpm(self):
outfile = self.generate_macro_file()
xpm_data = """/* XPM */
static char * blarg_xpm[] = {
"16 7 2 1",
"* c #000000",
". c #ffffff",
"**..*...........",
"*.*.*...........",
"**..*..**.**..**",
"*.*.*.*.*.*..*.*",
"**..*..**.*...**",
"...............*",
".............**."
};"""
with open(outfile) as f:
contents = f.read()
contents += f'\n__xpm__ = """{xpm_data}"""\n'
with open(outfile, "w") as f:
f.write(contents)
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
self.assertEqual(m.xpm, xpm_data)
def generate_macro_file(self, replacements: Dict[str, str] = {}) -> os.PathLike:
with open(os.path.join(self.test_dir, "macro_template.FCStd")) as f:
lines = f.readlines()
outfile = tempfile.NamedTemporaryFile(mode="wt", delete=False)
for line in lines:
for key, value in replacements.items():
line = line.replace(key, value)
outfile.write(line)
outfile.close()
return outfile.name
def generate_macro(self, replacements: Dict[str, str] = {}) -> Macro:
outfile = self.generate_macro_file(replacements)
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
os.unlink(outfile)
return m
def test_fetch_raw_code_no_data(self):
m = Macro("Unit Test Macro")
Macro.blocking_get = MagicMock(return_value=None)
returned_data = m._fetch_raw_code(
'rawcodeurl <a href="https://fake_url.com">Totally fake</a>'
)
self.assertIsNone(returned_data)
m.blocking_get.assert_called_with("https://fake_url.com")
Macro.blocking_get = None
def test_fetch_raw_code_no_url(self):
m = Macro("Unit Test Macro")
Macro.blocking_get = MagicMock(return_value=None)
returned_data = m._fetch_raw_code("Fake pagedata with no URL at all.")
self.assertIsNone(returned_data)
m.blocking_get.assert_not_called()
Macro.blocking_get = None
def test_fetch_raw_code_with_data(self):
m = Macro("Unit Test Macro")
Macro.blocking_get = MagicMock(return_value=b"Data returned to _fetch_raw_code")
returned_data = m._fetch_raw_code(
'rawcodeurl <a href="https://fake_url.com">Totally fake</a>'
)
self.assertEqual(returned_data, "Data returned to _fetch_raw_code")
Macro.blocking_get = None

View File

@@ -1,344 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
"""Tests for the MacroParser class"""
import io
import os
import sys
import unittest
sys.path.append("../../") # So the IDE can find the classes to run with
from addonmanager_macro_parser import MacroParser
from AddonManagerTest.app.mocks import MockConsole, CallCatcher, MockThread
# pylint: disable=protected-access, too-many-public-methods
class TestMacroParser(unittest.TestCase):
"""Test the MacroParser class"""
def setUp(self) -> None:
self.test_object = MacroParser("UnitTestMacro")
self.test_object.console = MockConsole()
self.test_object.current_thread = MockThread()
def tearDown(self) -> None:
pass
def test_fill_details_from_code_normal(self):
"""Test to make sure _process_line gets called as expected"""
catcher = CallCatcher()
self.test_object._process_line = catcher.catch_call
fake_macro_data = self.given_some_lines(20, 10)
self.test_object.fill_details_from_code(fake_macro_data)
self.assertEqual(catcher.call_count, 10)
def test_fill_details_from_code_too_many_lines(self):
"""Test to make sure _process_line gets limited as expected"""
catcher = CallCatcher()
self.test_object._process_line = catcher.catch_call
self.test_object.MAX_LINES_TO_SEARCH = 5
fake_macro_data = self.given_some_lines(20, 10)
self.test_object.fill_details_from_code(fake_macro_data)
self.assertEqual(catcher.call_count, 5)
def test_fill_details_from_code_thread_interrupted(self):
"""Test to make sure _process_line gets stopped as expected"""
catcher = CallCatcher()
self.test_object._process_line = catcher.catch_call
self.test_object.current_thread.interrupt_after_n_calls = 6 # Stop on the 6th
fake_macro_data = self.given_some_lines(20, 10)
self.test_object.fill_details_from_code(fake_macro_data)
self.assertEqual(catcher.call_count, 5)
@staticmethod
def given_some_lines(num_lines, num_dunder_lines) -> str:
"""Generate fake macro header data with the given number of lines and number of
lines beginning with a double-underscore."""
result = ""
for i in range(num_lines):
if i < num_dunder_lines:
result += f"__something_{i}__ = 'Test{i}' # A line to be scanned\n"
else:
result += f"# Nothing to see on line {i}\n"
return result
def test_process_line_known_lines(self):
"""Lines starting with keys are processed"""
test_lines = ["__known_key__ = 'Test'", "__another_known_key__ = 'Test'"]
for line in test_lines:
with self.subTest(line=line):
self.test_object.remaining_item_map = {
"__known_key__": "known_key",
"__another_known_key__": "another_known_key",
}
content_lines = io.StringIO(line)
read_in_line = content_lines.readline()
catcher = CallCatcher()
self.test_object._process_key = catcher.catch_call
self.test_object._process_line(read_in_line, content_lines)
self.assertTrue(catcher.called, "_process_key was not called for a known key")
def test_process_line_unknown_lines(self):
"""Lines starting with non-keys are not processed"""
test_lines = [
"# Just a line with a comment",
"\n",
"__dont_know_this_one__ = 'Who cares?'",
"# __known_key__ = 'Aha, but it is commented out!'",
]
for line in test_lines:
with self.subTest(line=line):
self.test_object.remaining_item_map = {
"__known_key__": "known_key",
"__another_known_key__": "another_known_key",
}
content_lines = io.StringIO(line)
read_in_line = content_lines.readline()
catcher = CallCatcher()
self.test_object._process_key = catcher.catch_call
self.test_object._process_line(read_in_line, content_lines)
self.assertFalse(catcher.called, "_process_key was called for an unknown key")
def test_process_key_standard(self):
"""Normal expected data is processed"""
self.test_object._reset_map()
in_memory_data = '__comment__ = "Test"'
content_lines = io.StringIO(in_memory_data)
line = content_lines.readline()
self.test_object._process_key("__comment__", line, content_lines)
self.assertTrue(self.test_object.parse_results["comment"], "Test")
def test_process_key_special(self):
"""Special handling for version = date is processed"""
self.test_object._reset_map()
self.test_object.parse_results["date"] = "2001-01-01"
in_memory_data = "__version__ = __date__"
content_lines = io.StringIO(in_memory_data)
line = content_lines.readline()
self.test_object._process_key("__version__", line, content_lines)
self.assertTrue(self.test_object.parse_results["version"], "2001-01-01")
def test_handle_backslash_continuation_no_backslashes(self):
"""The backslash handling code doesn't change a line with no backslashes"""
in_memory_data = '"Not a backslash in sight"'
content_lines = io.StringIO(in_memory_data)
line = content_lines.readline()
result = self.test_object._handle_backslash_continuation(line, content_lines)
self.assertEqual(result, in_memory_data)
def test_handle_backslash_continuation(self):
"""Lines ending in a backslash get stripped and concatenated"""
in_memory_data = '"Line1\\\nLine2\\\nLine3\\\nLine4"'
content_lines = io.StringIO(in_memory_data)
line = content_lines.readline()
result = self.test_object._handle_backslash_continuation(line, content_lines)
self.assertEqual(result, '"Line1Line2Line3Line4"')
def test_handle_triple_quoted_string_no_triple_quotes(self):
"""The triple-quote handler leaves alone lines without triple-quotes"""
in_memory_data = '"Line1"'
content_lines = io.StringIO(in_memory_data)
line = content_lines.readline()
result, was_triple_quoted = self.test_object._handle_triple_quoted_string(
line, content_lines
)
self.assertEqual(result, in_memory_data)
self.assertFalse(was_triple_quoted)
def test_handle_triple_quoted_string(self):
"""Data is extracted across multiple lines for a triple-quoted string"""
in_memory_data = '"""Line1\nLine2\nLine3\nLine4"""\nLine5\n'
content_lines = io.StringIO(in_memory_data)
line = content_lines.readline()
result, was_triple_quoted = self.test_object._handle_triple_quoted_string(
line, content_lines
)
self.assertEqual(result, '"""Line1\nLine2\nLine3\nLine4"""')
self.assertTrue(was_triple_quoted)
def test_strip_quotes_single(self):
"""Single quotes are stripped from the final string"""
expected = "test"
quoted = f"'{expected}'"
actual = self.test_object._strip_quotes(quoted)
self.assertEqual(actual, expected)
def test_strip_quotes_double(self):
"""Double quotes are stripped from the final string"""
expected = "test"
quoted = f'"{expected}"'
actual = self.test_object._strip_quotes(quoted)
self.assertEqual(actual, expected)
def test_strip_quotes_triple(self):
"""Triple quotes are stripped from the final string"""
expected = "test"
quoted = f'"""{expected}"""'
actual = self.test_object._strip_quotes(quoted)
self.assertEqual(actual, expected)
def test_strip_quotes_unquoted(self):
"""Unquoted data results in None"""
unquoted = "This has no quotation marks of any kind"
actual = self.test_object._strip_quotes(unquoted)
self.assertIsNone(actual)
def test_standard_extraction_string(self):
"""String variables are extracted and stored"""
string_keys = [
"comment",
"url",
"wiki",
"version",
"author",
"date",
"icon",
"xpm",
]
for key in string_keys:
with self.subTest(key=key):
self.test_object._standard_extraction(key, "test")
self.assertEqual(self.test_object.parse_results[key], "test")
def test_standard_extraction_list(self):
"""List variable is extracted and stored"""
key = "other_files"
self.test_object._standard_extraction(key, "test1, test2, test3")
self.assertIn("test1", self.test_object.parse_results[key])
self.assertIn("test2", self.test_object.parse_results[key])
self.assertIn("test3", self.test_object.parse_results[key])
def test_apply_special_handling_version(self):
"""If the tag is __version__, apply our special handling"""
self.test_object._reset_map()
self.test_object._apply_special_handling("__version__", 42)
self.assertNotIn("__version__", self.test_object.remaining_item_map)
self.assertEqual(self.test_object.parse_results["version"], "42")
def test_apply_special_handling_not_version(self):
"""If the tag is not __version__, raise an error"""
self.test_object._reset_map()
with self.assertRaises(SyntaxError):
self.test_object._apply_special_handling("__not_version__", 42)
self.assertIn("__version__", self.test_object.remaining_item_map)
def test_process_noncompliant_version_date(self):
"""Detect and allow __date__ for the __version__"""
self.test_object.parse_results["date"] = "1/2/3"
self.test_object._process_noncompliant_version("__date__")
self.assertEqual(
self.test_object.parse_results["version"],
self.test_object.parse_results["date"],
)
def test_process_noncompliant_version_float(self):
"""Detect and allow floats for the __version__"""
self.test_object._process_noncompliant_version(1.2)
self.assertEqual(self.test_object.parse_results["version"], "1.2")
def test_process_noncompliant_version_int(self):
"""Detect and allow integers for the __version__"""
self.test_object._process_noncompliant_version(42)
self.assertEqual(self.test_object.parse_results["version"], "42")
def test_detect_illegal_content_prefixed_string(self):
"""Detect and raise an error for various kinds of prefixed strings"""
illegal_strings = [
"f'Some fancy {thing}'",
'f"Some fancy {thing}"',
"r'Some fancy {thing}'",
'r"Some fancy {thing}"',
"u'Some fancy {thing}'",
'u"Some fancy {thing}"',
"fr'Some fancy {thing}'",
'fr"Some fancy {thing}"',
"rf'Some fancy {thing}'",
'rf"Some fancy {thing}"',
]
for test_string in illegal_strings:
with self.subTest(test_string=test_string):
with self.assertRaises(SyntaxError):
MacroParser._detect_illegal_content(test_string)
def test_detect_illegal_content_not_a_string(self):
"""Detect and raise an error for (some) non-strings"""
illegal_strings = [
"no quotes",
"do_stuff()",
'print("A function call sporting quotes!")',
"__name__",
"__version__",
"1.2.3",
]
for test_string in illegal_strings:
with self.subTest(test_string=test_string):
with self.assertRaises(SyntaxError):
MacroParser._detect_illegal_content(test_string)
def test_detect_illegal_content_no_failure(self):
"""Recognize strings of various kinds, plus ints, and floats"""
legal_strings = [
'"Some legal value in double quotes"',
"'Some legal value in single quotes'",
'"""Some legal value in triple quotes"""',
"__date__",
"42",
"4.2",
]
for test_string in legal_strings:
with self.subTest(test_string=test_string):
MacroParser._detect_illegal_content(test_string)
#####################
# INTEGRATION TESTS #
#####################
def test_macro_parser(self):
"""INTEGRATION TEST: Given "real" data, ensure the parsing yields the expected results."""
data_dir = os.path.join(os.path.dirname(__file__), "../data")
macro_file = os.path.join(data_dir, "DoNothing.FCMacro")
with open(macro_file, "r", encoding="utf-8") as f:
code = f.read()
self.test_object.fill_details_from_code(code)
self.assertEqual(len(self.test_object.console.errors), 0)
self.assertEqual(len(self.test_object.console.warnings), 0)
self.assertEqual(self.test_object.parse_results["author"], "Chris Hennes")
self.assertEqual(self.test_object.parse_results["version"], "1.0")
self.assertEqual(self.test_object.parse_results["date"], "2022-02-28")
self.assertEqual(
self.test_object.parse_results["comment"],
"Do absolutely nothing. For Addon Manager integration tests.",
)
self.assertEqual(
self.test_object.parse_results["url"], "https://github.com/FreeCAD/FreeCAD"
)
self.assertEqual(self.test_object.parse_results["icon"], "not_real.png")
self.assertListEqual(
self.test_object.parse_results["other_files"],
["file1.py", "file2.py", "file3.py"],
)
self.assertNotEqual(self.test_object.parse_results["xpm"], "")

View File

@@ -1,649 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2023 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/>. *
# * *
# ***************************************************************************
import os
import sys
import tempfile
import unittest
import unittest.mock
Mock = unittest.mock.MagicMock
sys.path.append("../../")
class TestVersion(unittest.TestCase):
def setUp(self) -> None:
if "addonmanager_metadata" in sys.modules:
sys.modules.pop("addonmanager_metadata")
self.packaging_version = None
if "packaging.version" in sys.modules:
self.packaging_version = sys.modules["packaging.version"]
sys.modules.pop("packaging.version")
def tearDown(self) -> None:
if self.packaging_version is not None:
sys.modules["packaging.version"] = self.packaging_version
def test_init_from_string_manual(self):
import addonmanager_metadata as amm
version = amm.Version()
version._parse_string_to_tuple = unittest.mock.MagicMock()
version._init_from_string("1.2.3beta")
self.assertTrue(version._parse_string_to_tuple.called)
def test_init_from_list_good(self):
"""Initialization from a list works for good input"""
import addonmanager_metadata as amm
test_cases = [
{"input": (1,), "output": [1, 0, 0, ""]},
{"input": (1, 2), "output": [1, 2, 0, ""]},
{"input": (1, 2, 3), "output": [1, 2, 3, ""]},
{"input": (1, 2, 3, "b1"), "output": [1, 2, 3, "b1"]},
]
for test_case in test_cases:
with self.subTest(test_case=test_case):
v = amm.Version(from_list=test_case["input"])
self.assertListEqual(test_case["output"], v.version_as_list)
def test_parse_string_to_tuple_normal(self):
"""Parsing of complete version string works for normal cases"""
import addonmanager_metadata as amm
cases = {
"1": [1, 0, 0, ""],
"1.2": [1, 2, 0, ""],
"1.2.3": [1, 2, 3, ""],
"1.2.3beta": [1, 2, 3, "beta"],
"12_345.6_7.8pre-alpha": [12345, 67, 8, "pre-alpha"],
# The above test is mostly to point out that Python gets permits underscore
# characters in a number.
}
for inp, output in cases.items():
with self.subTest(inp=inp, output=output):
version = amm.Version()
version._parse_string_to_tuple(inp)
self.assertListEqual(version.version_as_list, output)
def test_parse_string_to_tuple_invalid(self):
"""Parsing of invalid version string raises an exception"""
import addonmanager_metadata as amm
cases = {"One", "1,2,3", "1-2-3", "1/2/3"}
for inp in cases:
with self.subTest(inp=inp):
with self.assertRaises(ValueError):
version = amm.Version()
version._parse_string_to_tuple(inp)
def test_parse_final_entry_normal(self):
"""Parsing of the final entry works for normal cases"""
import addonmanager_metadata as amm
cases = {
"3beta": (3, "beta"),
"42.alpha": (42, ".alpha"),
"123.45.6": (123, ".45.6"),
"98_delta": (98, "_delta"),
"1 and some words": (1, " and some words"),
}
for inp, output in cases.items():
with self.subTest(inp=inp, output=output):
number, text = amm.Version._parse_final_entry(inp)
self.assertEqual(number, output[0])
self.assertEqual(text, output[1])
def test_parse_final_entry_invalid(self):
"""Invalid input raises an exception"""
import addonmanager_metadata as amm
cases = ["beta", "", ["a", "b"]]
for case in cases:
with self.subTest(case=case):
with self.assertRaises(ValueError):
amm.Version._parse_final_entry(case)
def test_operators_internal(self):
"""Test internal (non-package) comparison operators"""
sys.modules["packaging.version"] = None
import addonmanager_metadata as amm
cases = self.given_comparison_cases()
for case in cases:
with self.subTest(case=case):
first = amm.Version(case[0])
second = amm.Version(case[1])
self.assertEqual(first < second, case[0] < case[1])
self.assertEqual(first > second, case[0] > case[1])
self.assertEqual(first <= second, case[0] <= case[1])
self.assertEqual(first >= second, case[0] >= case[1])
self.assertEqual(first == second, case[0] == case[1])
@staticmethod
def given_comparison_cases():
return [
("0.0.0alpha", "1.0.0alpha"),
("0.0.0alpha", "0.1.0alpha"),
("0.0.0alpha", "0.0.1alpha"),
("0.0.0alpha", "0.0.0beta"),
("0.0.0alpha", "0.0.0alpha"),
("1.0.0alpha", "0.0.0alpha"),
("0.1.0alpha", "0.0.0alpha"),
("0.0.1alpha", "0.0.0alpha"),
("0.0.0beta", "0.0.0alpha"),
]
class TestDependencyType(unittest.TestCase):
"""Ensure that the DependencyType dataclass converts to the correct strings"""
def setUp(self) -> None:
from addonmanager_metadata import DependencyType
self.DependencyType = DependencyType
def test_string_conversion_automatic(self):
self.assertEqual(str(self.DependencyType.automatic), "automatic")
def test_string_conversion_internal(self):
self.assertEqual(str(self.DependencyType.internal), "internal")
def test_string_conversion_addon(self):
self.assertEqual(str(self.DependencyType.addon), "addon")
def test_string_conversion_python(self):
self.assertEqual(str(self.DependencyType.python), "python")
class TestUrlType(unittest.TestCase):
"""Ensure that the UrlType dataclass converts to the correct strings"""
def setUp(self) -> None:
from addonmanager_metadata import UrlType
self.UrlType = UrlType
def test_string_conversion_website(self):
self.assertEqual(str(self.UrlType.website), "website")
def test_string_conversion_repository(self):
self.assertEqual(str(self.UrlType.repository), "repository")
def test_string_conversion_bugtracker(self):
self.assertEqual(str(self.UrlType.bugtracker), "bugtracker")
def test_string_conversion_readme(self):
self.assertEqual(str(self.UrlType.readme), "readme")
def test_string_conversion_documentation(self):
self.assertEqual(str(self.UrlType.documentation), "documentation")
def test_string_conversion_discussion(self):
self.assertEqual(str(self.UrlType.discussion), "discussion")
class TestMetadataAuxiliaryFunctions(unittest.TestCase):
def test_get_first_supported_freecad_version_simple(self):
from addonmanager_metadata import (
Metadata,
Version,
get_first_supported_freecad_version,
)
expected_result = Version(from_string="0.20.2beta")
metadata = self.given_metadata_with_freecadmin_set(expected_result)
first_version = get_first_supported_freecad_version(metadata)
self.assertEqual(expected_result, first_version)
@staticmethod
def given_metadata_with_freecadmin_set(min_version):
from addonmanager_metadata import Metadata
metadata = Metadata()
metadata.freecadmin = min_version
return metadata
def test_get_first_supported_freecad_version_with_content(self):
from addonmanager_metadata import (
Metadata,
Version,
get_first_supported_freecad_version,
)
expected_result = Version(from_string="0.20.2beta")
metadata = self.given_metadata_with_freecadmin_in_content(expected_result)
first_version = get_first_supported_freecad_version(metadata)
self.assertEqual(expected_result, first_version)
@staticmethod
def given_metadata_with_freecadmin_in_content(min_version):
from addonmanager_metadata import Metadata, Version
v_list = min_version.version_as_list
metadata = Metadata()
wb1 = Metadata()
wb1.freecadmin = Version(from_list=[v_list[0] + 1, v_list[1], v_list[2], v_list[3]])
wb2 = Metadata()
wb2.freecadmin = Version(from_list=[v_list[0], v_list[1] + 1, v_list[2], v_list[3]])
wb3 = Metadata()
wb3.freecadmin = Version(from_list=[v_list[0], v_list[1], v_list[2] + 1, v_list[3]])
m1 = Metadata()
m1.freecadmin = min_version
metadata.content = {"workbench": [wb1, wb2, wb3], "macro": [m1]}
return metadata
class TestMetadataReader(unittest.TestCase):
"""Test reading metadata from XML"""
def setUp(self) -> None:
if "xml.etree.ElementTree" in sys.modules:
sys.modules.pop("xml.etree.ElementTree")
if "addonmanager_metadata" in sys.modules:
sys.modules.pop("addonmanager_metadata")
def tearDown(self) -> None:
if "xml.etree.ElementTree" in sys.modules:
sys.modules.pop("xml.etree.ElementTree")
if "addonmanager_metadata" in sys.modules:
sys.modules.pop("addonmanager_metadata")
def test_from_file(self):
from addonmanager_metadata import MetadataReader
MetadataReader.from_bytes = Mock()
with tempfile.NamedTemporaryFile(delete=False) as temp:
temp.write(b"Some data")
temp.close()
MetadataReader.from_file(temp.name)
self.assertTrue(MetadataReader.from_bytes.called)
MetadataReader.from_bytes.assert_called_once_with(b"Some data")
os.unlink(temp.name)
@unittest.skip("Breaks other tests, needs to be fixed")
def test_from_bytes(self):
import xml.etree.ElementTree
with unittest.mock.patch("xml.etree.ElementTree") as element_tree_mock:
from addonmanager_metadata import MetadataReader
MetadataReader._process_element_tree = Mock()
MetadataReader.from_bytes(b"Some data")
element_tree_mock.parse.assert_called_once_with(b"Some data")
def test_process_element_tree(self):
from addonmanager_metadata import MetadataReader
MetadataReader._determine_namespace = Mock(return_value="")
element_tree_mock = Mock()
MetadataReader._create_node = Mock()
MetadataReader._process_element_tree(element_tree_mock)
MetadataReader._create_node.assert_called_once()
def test_determine_namespace_found_full(self):
from addonmanager_metadata import MetadataReader
root = Mock()
root.tag = "{https://wiki.freecad.org/Package_Metadata}package"
found_ns = MetadataReader._determine_namespace(root)
self.assertEqual(found_ns, "{https://wiki.freecad.org/Package_Metadata}")
def test_determine_namespace_found_empty(self):
from addonmanager_metadata import MetadataReader
root = Mock()
root.tag = "package"
found_ns = MetadataReader._determine_namespace(root)
self.assertEqual(found_ns, "")
def test_determine_namespace_not_found(self):
from addonmanager_metadata import MetadataReader
root = Mock()
root.find = Mock(return_value=False)
with self.assertRaises(RuntimeError):
MetadataReader._determine_namespace(root)
def test_parse_child_element_simple_strings(self):
from addonmanager_metadata import Metadata, MetadataReader
tags = ["name", "date", "description", "icon", "classname", "subdirectory"]
for tag in tags:
with self.subTest(tag=tag):
text = f"Test Data for {tag}"
child = self.given_mock_tree_node(tag, text)
mock_metadata = Metadata()
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(mock_metadata.__dict__[tag], text)
def test_parse_child_element_version(self):
from addonmanager_metadata import Metadata, Version, MetadataReader
mock_metadata = Metadata()
child = self.given_mock_tree_node("version", "1.2.3")
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(Version("1.2.3"), mock_metadata.version)
def test_parse_child_element_version_bad(self):
from addonmanager_metadata import Metadata, Version, MetadataReader
mock_metadata = Metadata()
child = self.given_mock_tree_node("version", "1-2-3")
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(Version("0.0.0"), mock_metadata.version)
def test_parse_child_element_lists_of_strings(self):
from addonmanager_metadata import Metadata, MetadataReader
tags = ["file", "tag"]
for tag in tags:
with self.subTest(tag=tag):
mock_metadata = Metadata()
expected_results = []
for i in range(10):
text = f"Test {i} for {tag}"
expected_results.append(text)
child = self.given_mock_tree_node(tag, text)
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(len(mock_metadata.__dict__[tag]), 10)
self.assertListEqual(mock_metadata.__dict__[tag], expected_results)
def test_parse_child_element_lists_of_contacts(self):
from addonmanager_metadata import Metadata, Contact, MetadataReader
tags = ["maintainer", "author"]
for tag in tags:
with self.subTest(tag=tag):
mock_metadata = Metadata()
expected_results = []
for i in range(10):
text = f"Test {i} for {tag}"
email = f"Email {i} for {tag}" if i % 2 == 0 else None
expected_results.append(Contact(name=text, email=email))
child = self.given_mock_tree_node(tag, text, {"email": email})
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(len(mock_metadata.__dict__[tag]), 10)
self.assertListEqual(mock_metadata.__dict__[tag], expected_results)
def test_parse_child_element_list_of_licenses(self):
from addonmanager_metadata import Metadata, License, MetadataReader
mock_metadata = Metadata()
expected_results = []
tag = "license"
for i in range(10):
text = f"Test {i} for {tag}"
file = f"Filename {i} for {tag}" if i % 2 == 0 else None
expected_results.append(License(name=text, file=file))
child = self.given_mock_tree_node(tag, text, {"file": file})
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(len(mock_metadata.__dict__[tag]), 10)
self.assertListEqual(mock_metadata.__dict__[tag], expected_results)
def test_parse_child_element_list_of_urls(self):
from addonmanager_metadata import Metadata, Url, UrlType, MetadataReader
mock_metadata = Metadata()
expected_results = []
tag = "url"
for i in range(10):
text = f"Test {i} for {tag}"
url_type = UrlType(i % len(UrlType))
type = str(url_type)
branch = ""
if type == "repository":
branch = f"Branch {i} for {tag}"
expected_results.append(Url(location=text, type=url_type, branch=branch))
child = self.given_mock_tree_node(tag, text, {"type": type, "branch": branch})
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(len(mock_metadata.__dict__[tag]), 10)
self.assertListEqual(mock_metadata.__dict__[tag], expected_results)
def test_parse_child_element_lists_of_dependencies(self):
from addonmanager_metadata import (
Metadata,
Dependency,
DependencyType,
MetadataReader,
)
tags = ["depend", "conflict", "replace"]
attributes = {
"version_lt": "1.0.0",
"version_lte": "1.0.0",
"version_eq": "1.0.0",
"version_gte": "1.0.0",
"version_gt": "1.0.0",
"condition": "$BuildVersionMajor<1",
"optional": True,
}
for tag in tags:
for attribute, attr_value in attributes.items():
with self.subTest(tag=tag, attribute=attribute):
mock_metadata = Metadata()
expected_results = []
for i in range(10):
text = f"Test {i} for {tag}"
dependency_type = DependencyType(i % len(DependencyType))
dependency_type_str = str(dependency_type)
expected = Dependency(package=text, dependency_type=dependency_type)
expected.__dict__[attribute] = attr_value
expected_results.append(expected)
child = self.given_mock_tree_node(
tag,
text,
{"type": dependency_type_str, attribute: str(attr_value)},
)
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(len(mock_metadata.__dict__[tag]), 10)
self.assertListEqual(mock_metadata.__dict__[tag], expected_results)
def test_parse_child_element_ignore_unknown_tag(self):
from addonmanager_metadata import Metadata, MetadataReader
tag = "invalid_tag"
text = "Shouldn't matter"
child = self.given_mock_tree_node(tag, text)
mock_metadata = Metadata()
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertNotIn(tag, mock_metadata.__dict__)
def test_parse_child_element_versions(self):
from addonmanager_metadata import Metadata, Version, MetadataReader
tags = ["version", "freecadmin", "freecadmax", "pythonmin"]
for tag in tags:
with self.subTest(tag=tag):
mock_metadata = Metadata()
text = "3.4.5beta"
child = self.given_mock_tree_node(tag, text)
MetadataReader._parse_child_element("", child, mock_metadata)
self.assertEqual(mock_metadata.__dict__[tag], Version(from_string=text))
def given_mock_tree_node(self, tag, text, attributes=None):
class MockTreeNode:
def __init__(self):
self.tag = tag
self.text = text
self.attrib = attributes if attributes is not None else []
return MockTreeNode()
def test_parse_content_valid(self):
from addonmanager_metadata import MetadataReader
valid_content_items = ["workbench", "macro", "preferencepack"]
MetadataReader._create_node = Mock()
for content_type in valid_content_items:
with self.subTest(content_type=content_type):
tree_mock = [self.given_mock_tree_node(content_type, None)]
metadata_mock = Mock()
MetadataReader._parse_content("", metadata_mock, tree_mock)
MetadataReader._create_node.assert_called_once()
MetadataReader._create_node.reset_mock()
def test_parse_content_invalid(self):
from addonmanager_metadata import MetadataReader
MetadataReader._create_node = Mock()
content_item = "no_such_content_type"
tree_mock = [self.given_mock_tree_node(content_item, None)]
metadata_mock = Mock()
MetadataReader._parse_content("", metadata_mock, tree_mock)
MetadataReader._create_node.assert_not_called()
class TestMetadataReaderIntegration(unittest.TestCase):
"""Full-up tests of the MetadataReader class (no mocking)."""
def setUp(self) -> None:
self.test_data_dir = os.path.join(os.path.dirname(__file__), "..", "data")
remove_list = []
for key in sys.modules:
if "addonmanager_metadata" in key:
remove_list.append(key)
for key in remove_list:
print(f"Removing {key}")
sys.modules.pop(key)
def test_loading_simple_metadata_file(self):
from addonmanager_metadata import (
Contact,
Dependency,
License,
MetadataReader,
Url,
UrlType,
Version,
)
filename = os.path.join(self.test_data_dir, "good_package.xml")
metadata = MetadataReader.from_file(filename)
self.assertEqual("Test Workbench", metadata.name)
self.assertEqual("A package.xml file for unit testing.", metadata.description)
self.assertEqual(Version("1.0.1"), metadata.version)
self.assertEqual("2022-01-07", metadata.date)
self.assertEqual("Resources/icons/PackageIcon.svg", metadata.icon)
self.assertListEqual([License(name="LGPL-2.1", file="LICENSE")], metadata.license)
self.assertListEqual(
[Contact(name="FreeCAD Developer", email="developer@freecad.org")],
metadata.maintainer,
)
self.assertListEqual(
[
Url(
location="https://github.com/chennes/FreeCAD-Package",
type=UrlType.repository,
branch="main",
),
Url(
location="https://github.com/chennes/FreeCAD-Package/blob/main/README.md",
type=UrlType.readme,
),
],
metadata.url,
)
self.assertListEqual(["Tag0", "Tag1"], metadata.tag)
self.assertIn("workbench", metadata.content)
self.assertEqual(len(metadata.content["workbench"]), 1)
wb_metadata = metadata.content["workbench"][0]
self.assertEqual("MyWorkbench", wb_metadata.classname)
self.assertEqual("./", wb_metadata.subdirectory)
self.assertListEqual(["TagA", "TagB", "TagC"], wb_metadata.tag)
def test_multiple_workbenches(self):
from addonmanager_metadata import MetadataReader
filename = os.path.join(self.test_data_dir, "workbench_only.xml")
metadata = MetadataReader.from_file(filename)
self.assertIn("workbench", metadata.content)
self.assertEqual(len(metadata.content["workbench"]), 3)
expected_wb_classnames = [
"MyFirstWorkbench",
"MySecondWorkbench",
"MyThirdWorkbench",
]
for wb in metadata.content["workbench"]:
self.assertIn(wb.classname, expected_wb_classnames)
expected_wb_classnames.remove(wb.classname)
self.assertEqual(len(expected_wb_classnames), 0)
def test_multiple_macros(self):
from addonmanager_metadata import MetadataReader
filename = os.path.join(self.test_data_dir, "macro_only.xml")
metadata = MetadataReader.from_file(filename)
self.assertIn("macro", metadata.content)
self.assertEqual(len(metadata.content["macro"]), 2)
expected_wb_files = ["MyMacro.FCStd", "MyOtherMacro.FCStd"]
for wb in metadata.content["macro"]:
self.assertIn(wb.file[0], expected_wb_files)
expected_wb_files.remove(wb.file[0])
self.assertEqual(len(expected_wb_files), 0)
def test_multiple_preference_packs(self):
from addonmanager_metadata import MetadataReader
filename = os.path.join(self.test_data_dir, "prefpack_only.xml")
metadata = MetadataReader.from_file(filename)
self.assertIn("preferencepack", metadata.content)
self.assertEqual(len(metadata.content["preferencepack"]), 3)
expected_packs = ["MyFirstPack", "MySecondPack", "MyThirdPack"]
for wb in metadata.content["preferencepack"]:
self.assertIn(wb.name, expected_packs)
expected_packs.remove(wb.name)
self.assertEqual(len(expected_packs), 0)
def test_bundle(self):
from addonmanager_metadata import MetadataReader
filename = os.path.join(self.test_data_dir, "bundle_only.xml")
metadata = MetadataReader.from_file(filename)
self.assertIn("bundle", metadata.content)
self.assertEqual(len(metadata.content["bundle"]), 1)
def test_other(self):
from addonmanager_metadata import MetadataReader
filename = os.path.join(self.test_data_dir, "other_only.xml")
metadata = MetadataReader.from_file(filename)
self.assertIn("other", metadata.content)
self.assertEqual(len(metadata.content["other"]), 1)
def test_content_combination(self):
from addonmanager_metadata import MetadataReader
filename = os.path.join(self.test_data_dir, "combination.xml")
metadata = MetadataReader.from_file(filename)
self.assertIn("preferencepack", metadata.content)
self.assertEqual(len(metadata.content["preferencepack"]), 1)
self.assertIn("macro", metadata.content)
self.assertEqual(len(metadata.content["macro"]), 1)
self.assertIn("workbench", metadata.content)
self.assertEqual(len(metadata.content["workbench"]), 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,437 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
"""Contains the unit test class for addonmanager_uninstaller.py non-GUI functionality."""
import functools
import os
from stat import S_IREAD, S_IRGRP, S_IROTH, S_IWUSR
import tempfile
import unittest
import FreeCAD
from addonmanager_uninstaller import AddonUninstaller, MacroUninstaller
from Addon import Addon
from AddonManagerTest.app.mocks import MockAddon, MockMacro
class TestAddonUninstaller(unittest.TestCase):
"""Test class for addonmanager_uninstaller.py non-GUI functionality"""
MODULE = "test_uninstaller" # file name without extension
def setUp(self):
"""Initialize data needed for all tests"""
self.test_data_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
self.mock_addon = MockAddon()
self.signals_caught = []
self.test_object = AddonUninstaller(self.mock_addon)
self.test_object.finished.connect(functools.partial(self.catch_signal, "finished"))
self.test_object.success.connect(functools.partial(self.catch_signal, "success"))
self.test_object.failure.connect(functools.partial(self.catch_signal, "failure"))
def tearDown(self):
"""Finalize the test."""
def catch_signal(self, signal_name, *_):
"""Internal use: used to catch and log any emitted signals. Not called directly."""
self.signals_caught.append(signal_name)
def setup_dummy_installation(self, temp_dir) -> str:
"""Set up a dummy Addon in temp_dir"""
toplevel_path = os.path.join(temp_dir, self.mock_addon.name)
os.makedirs(toplevel_path)
with open(os.path.join(toplevel_path, "README.md"), "w") as f:
f.write("## Mock Addon ##\n\nFile created by the unit test code.")
self.test_object.installation_path = temp_dir
return toplevel_path
def create_fake_macro(self, macro_directory, fake_macro_name, digest):
"""Create an FCMacro file and matching digest entry for later removal"""
os.makedirs(macro_directory, exist_ok=True)
fake_file_installed = os.path.join(macro_directory, fake_macro_name)
with open(digest, "a", encoding="utf-8") as f:
f.write("# The following files were created outside this installation:\n")
f.write(fake_file_installed + "\n")
with open(fake_file_installed, "w", encoding="utf-8") as f:
f.write("# Fake macro data for unit testing")
def test_uninstall_normal(self):
"""Test the integrated uninstall function under normal circumstances"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
self.test_object.run()
self.assertTrue(os.path.exists(temp_dir))
self.assertFalse(os.path.exists(toplevel_path))
self.assertNotIn("failure", self.signals_caught)
self.assertIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_uninstall_no_name(self):
"""Test the integrated uninstall function for an addon without a name"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
self.mock_addon.name = None
result = self.test_object.run()
self.assertTrue(os.path.exists(temp_dir))
self.assertIn("failure", self.signals_caught)
self.assertNotIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_uninstall_dangerous_name(self):
"""Test the integrated uninstall function for an addon with a dangerous name"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
self.mock_addon.name = "./"
result = self.test_object.run()
self.assertTrue(os.path.exists(temp_dir))
self.assertIn("failure", self.signals_caught)
self.assertNotIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_uninstall_unmatching_name(self):
"""Test the integrated uninstall function for an addon with a name that isn't installed"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
self.mock_addon.name += "Nonexistent"
result = self.test_object.run()
self.assertTrue(os.path.exists(temp_dir))
self.assertIn("failure", self.signals_caught)
self.assertNotIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_uninstall_addon_with_macros(self):
"""Tests that the uninstaller removes the macro files"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
macro_directory = os.path.join(temp_dir, "Macros")
self.create_fake_macro(
macro_directory,
"FakeMacro.FCMacro",
os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
)
result = self.test_object.run()
self.assertNotIn("failure", self.signals_caught)
self.assertIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
self.assertFalse(os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro")))
self.assertTrue(os.path.exists(macro_directory))
def test_uninstall_calls_script(self):
"""Tests that the main uninstaller run function calls the uninstall.py script"""
class Interceptor:
def __init__(self):
self.called = False
self.args = []
def func(self, *args):
self.called = True
self.args = args
interceptor = Interceptor()
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
self.test_object.run_uninstall_script = interceptor.func
result = self.test_object.run()
self.assertTrue(interceptor.called, "Failed to call uninstall script")
def test_remove_extra_files_no_digest(self):
"""Tests that a lack of digest file is not an error, and nothing gets removed"""
with tempfile.TemporaryDirectory() as temp_dir:
self.test_object.remove_extra_files(temp_dir) # Shouldn't throw
self.assertTrue(os.path.exists(temp_dir))
def test_remove_extra_files_empty_digest(self):
"""Test that an empty digest file is not an error, and nothing gets removed"""
with tempfile.TemporaryDirectory() as temp_dir:
with open("AM_INSTALLATION_DIGEST.txt", "w", encoding="utf-8") as f:
f.write("")
self.test_object.remove_extra_files(temp_dir) # Shouldn't throw
self.assertTrue(os.path.exists(temp_dir))
def test_remove_extra_files_comment_only_digest(self):
"""Test that a digest file that contains only comment lines is not an error, and nothing
gets removed"""
with tempfile.TemporaryDirectory() as temp_dir:
with open("AM_INSTALLATION_DIGEST.txt", "w", encoding="utf-8") as f:
f.write("# Fake digest file for unit testing")
self.test_object.remove_extra_files(temp_dir) # Shouldn't throw
self.assertTrue(os.path.exists(temp_dir))
def test_remove_extra_files_repeated_files(self):
"""Test that a digest with the same file repeated removes it once, but doesn't error on
later requests to remove it."""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
macro_directory = os.path.join(temp_dir, "Macros")
self.create_fake_macro(
macro_directory,
"FakeMacro.FCMacro",
os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
)
self.create_fake_macro(
macro_directory,
"FakeMacro.FCMacro",
os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
)
self.create_fake_macro(
macro_directory,
"FakeMacro.FCMacro",
os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
)
self.test_object.remove_extra_files(toplevel_path) # Shouldn't throw
self.assertFalse(os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro")))
def test_remove_extra_files_normal_case(self):
"""Test that a digest that is a "normal" case removes the requested files"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
macro_directory = os.path.join(temp_dir, "Macros")
self.create_fake_macro(
macro_directory,
"FakeMacro1.FCMacro",
os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
)
self.create_fake_macro(
macro_directory,
"FakeMacro2.FCMacro",
os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
)
self.create_fake_macro(
macro_directory,
"FakeMacro3.FCMacro",
os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
)
# Make sure the setup worked as expected, otherwise the test is meaningless
self.assertTrue(os.path.exists(os.path.join(macro_directory, "FakeMacro1.FCMacro")))
self.assertTrue(os.path.exists(os.path.join(macro_directory, "FakeMacro2.FCMacro")))
self.assertTrue(os.path.exists(os.path.join(macro_directory, "FakeMacro3.FCMacro")))
self.test_object.remove_extra_files(toplevel_path) # Shouldn't throw
self.assertFalse(os.path.exists(os.path.join(macro_directory, "FakeMacro1.FCMacro")))
self.assertFalse(os.path.exists(os.path.join(macro_directory, "FakeMacro2.FCMacro")))
self.assertFalse(os.path.exists(os.path.join(macro_directory, "FakeMacro3.FCMacro")))
def test_runs_uninstaller_script_successful(self):
"""Tests that the uninstall.py script is called"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
with open(os.path.join(toplevel_path, "uninstall.py"), "w", encoding="utf-8") as f:
double_escaped = temp_dir.replace("\\", "\\\\")
f.write(
f"""# Mock uninstaller script
import os
path = '{double_escaped}'
with open(os.path.join(path,"RAN_UNINSTALLER.txt"),"w",encoding="utf-8") as f:
f.write("File created by uninstall.py from unit tests")
"""
)
self.test_object.run_uninstall_script(toplevel_path) # The exception does not leak out
self.assertTrue(os.path.exists(os.path.join(temp_dir, "RAN_UNINSTALLER.txt")))
def test_runs_uninstaller_script_failure(self):
"""Tests that exceptions in the uninstall.py script do not leak out"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
with open(os.path.join(toplevel_path, "uninstall.py"), "w", encoding="utf-8") as f:
f.write(
f"""# Mock uninstaller script
raise RuntimeError("Fake exception for unit testing")
"""
)
self.test_object.run_uninstall_script(toplevel_path) # The exception does not leak out
class TestMacroUninstaller(unittest.TestCase):
"""Test class for addonmanager_uninstaller.py non-GUI functionality"""
MODULE = "test_uninstaller" # file name without extension
def setUp(self):
self.mock_addon = MockAddon()
self.mock_addon.macro = MockMacro()
self.test_object = MacroUninstaller(self.mock_addon)
self.signals_caught = []
self.test_object.finished.connect(functools.partial(self.catch_signal, "finished"))
self.test_object.success.connect(functools.partial(self.catch_signal, "success"))
self.test_object.failure.connect(functools.partial(self.catch_signal, "failure"))
def tearDown(self):
pass
def catch_signal(self, signal_name, *_):
"""Internal use: used to catch and log any emitted signals. Not called directly."""
self.signals_caught.append(signal_name)
def test_remove_simple_macro(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.test_object.installation_location = temp_dir
self.mock_addon.macro.install(temp_dir)
# Make sure the setup worked, otherwise the test is meaningless
self.assertTrue(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.test_object.run()
self.assertFalse(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.assertNotIn("failure", self.signals_caught)
self.assertIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_remove_macro_with_icon(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.test_object.installation_location = temp_dir
self.mock_addon.macro.icon = "mock_icon_test.svg"
self.mock_addon.macro.install(temp_dir)
# Make sure the setup worked, otherwise the test is meaningless
self.assertTrue(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.assertTrue(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.icon)))
self.test_object.run()
self.assertFalse(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.assertFalse(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.icon)))
self.assertNotIn("failure", self.signals_caught)
self.assertIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_remove_macro_with_xpm_data(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.test_object.installation_location = temp_dir
self.mock_addon.macro.xpm = "/*Fake XPM data*/"
self.mock_addon.macro.install(temp_dir)
# Make sure the setup worked, otherwise the test is meaningless
self.assertTrue(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.assertTrue(os.path.exists(os.path.join(temp_dir, "MockMacro_icon.xpm")))
self.test_object.run()
self.assertFalse(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.assertFalse(os.path.exists(os.path.join(temp_dir, "MockMacro_icon.xpm")))
self.assertNotIn("failure", self.signals_caught)
self.assertIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_remove_macro_with_files(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.test_object.installation_location = temp_dir
self.mock_addon.macro.other_files = [
"test_file_1.txt",
"test_file_2.FCMacro",
"subdir/test_file_3.txt",
]
self.mock_addon.macro.install(temp_dir)
# Make sure the setup worked, otherwise the test is meaningless
for f in self.mock_addon.macro.other_files:
self.assertTrue(
os.path.exists(os.path.join(temp_dir, f)),
f"Expected {f} to exist, and it does not",
)
self.test_object.run()
for f in self.mock_addon.macro.other_files:
self.assertFalse(
os.path.exists(os.path.join(temp_dir, f)),
f"Expected {f} to be removed, and it was not",
)
self.assertFalse(
os.path.exists(os.path.join(temp_dir, "subdir")),
"Failed to remove empty subdirectory",
)
self.assertNotIn("failure", self.signals_caught)
self.assertIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_remove_nonexistent_macro(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.test_object.installation_location = temp_dir
# Don't run the installer:
self.assertFalse(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.test_object.run() # Should not raise an exception
self.assertFalse(os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename)))
self.assertNotIn("failure", self.signals_caught)
self.assertIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_remove_write_protected_macro(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.test_object.installation_location = temp_dir
self.mock_addon.macro.install(temp_dir)
# Make sure the setup worked, otherwise the test is meaningless
f = os.path.join(temp_dir, self.mock_addon.macro.filename)
self.assertTrue(os.path.exists(f))
os.chmod(f, S_IREAD | S_IRGRP | S_IROTH)
self.test_object.run()
if os.path.exists(f):
os.chmod(f, S_IWUSR | S_IREAD)
self.assertNotIn("success", self.signals_caught)
self.assertIn("failure", self.signals_caught)
else:
# In some cases we managed to delete it anyway:
self.assertIn("success", self.signals_caught)
self.assertNotIn("failure", self.signals_caught)
self.assertIn("finished", self.signals_caught)
def test_cleanup_directories_multiple_empty(self):
with tempfile.TemporaryDirectory() as temp_dir:
empty_directories = set(["empty1", "empty2", "empty3"])
full_paths = set()
for directory in empty_directories:
full_path = os.path.join(temp_dir, directory)
os.mkdir(full_path)
full_paths.add(full_path)
for directory in full_paths:
self.assertTrue(directory, "Test code failed to create {directory}")
self.test_object._cleanup_directories(full_paths)
for directory in full_paths:
self.assertFalse(os.path.exists(directory))
def test_cleanup_directories_none(self):
with tempfile.TemporaryDirectory() as temp_dir:
full_paths = set()
self.test_object._cleanup_directories(full_paths) # Shouldn't throw
def test_cleanup_directories_not_empty(self):
with tempfile.TemporaryDirectory() as temp_dir:
empty_directories = set(["empty1", "empty2", "empty3"])
full_paths = set()
for directory in empty_directories:
full_path = os.path.join(temp_dir, directory)
os.mkdir(full_path)
full_paths.add(full_path)
with open(os.path.join(full_path, "test.txt"), "w", encoding="utf-8") as f:
f.write("Unit test dummy data\n")
for directory in full_paths:
self.assertTrue(directory, "Test code failed to create {directory}")
self.test_object._cleanup_directories(full_paths)
for directory in full_paths:
self.assertTrue(os.path.exists(directory))

View File

@@ -1,287 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
from datetime import datetime
import unittest
from unittest.mock import MagicMock, patch, mock_open
import os
import sys
import subprocess
try:
import FreeCAD
except ImportError:
FreeCAD = None
sys.path.append("../..")
from AddonManagerTest.app.mocks import MockAddon as Addon
from addonmanager_utilities import (
get_assigned_string_literal,
get_macro_version_from_file,
get_readme_url,
process_date_string_to_python_datetime,
recognized_git_location,
run_interruptable_subprocess,
)
class TestUtilities(unittest.TestCase):
MODULE = "test_utilities" # file name without extension
@classmethod
def tearDownClass(cls):
try:
os.remove("AM_INSTALLATION_DIGEST.txt")
except FileNotFoundError:
pass
def test_recognized_git_location(self):
recognized_urls = [
"https://github.com/FreeCAD/FreeCAD",
"https://gitlab.com/freecad/FreeCAD",
"https://framagit.org/freecad/FreeCAD",
"https://salsa.debian.org/science-team/freecad",
]
for url in recognized_urls:
repo = Addon("Test Repo", url, "Addon.Status.NOT_INSTALLED", "branch")
self.assertTrue(recognized_git_location(repo), f"{url} was unexpectedly not recognized")
unrecognized_urls = [
"https://google.com",
"https://freecad.org",
"https://not.quite.github.com/FreeCAD/FreeCAD",
"https://github.com.malware.com/",
]
for url in unrecognized_urls:
repo = Addon("Test Repo", url, "Addon.Status.NOT_INSTALLED", "branch")
self.assertFalse(recognized_git_location(repo), f"{url} was unexpectedly recognized")
def test_get_readme_url(self):
github_urls = [
"https://github.com/FreeCAD/FreeCAD",
]
gitlab_urls = [
"https://gitlab.com/freecad/FreeCAD",
"https://framagit.org/freecad/FreeCAD",
"https://salsa.debian.org/science-team/freecad",
"https://unknown.location/and/path",
]
# GitHub and Gitlab have two different schemes for file URLs: unrecognized URLs are
# presumed to be local instances of a GitLab server. Note that in neither case does this
# take into account the redirects that are used to actually fetch the data.
for url in github_urls:
branch = "branchname"
expected_result = f"{url}/raw/{branch}/README.md"
repo = Addon("Test Repo", url, "Addon.Status.NOT_INSTALLED", branch)
actual_result = get_readme_url(repo)
self.assertEqual(actual_result, expected_result)
for url in gitlab_urls:
branch = "branchname"
expected_result = f"{url}/-/raw/{branch}/README.md"
repo = Addon("Test Repo", url, "Addon.Status.NOT_INSTALLED", branch)
actual_result = get_readme_url(repo)
self.assertEqual(actual_result, expected_result)
def test_get_assigned_string_literal(self):
good_lines = [
["my_var = 'Single-quoted literal'", "Single-quoted literal"],
['my_var = "Double-quoted literal"', "Double-quoted literal"],
["my_var = \t 'Extra whitespace'", "Extra whitespace"],
["my_var = 42", "42"],
["my_var = 1.23", "1.23"],
]
for line in good_lines:
result = get_assigned_string_literal(line[0])
self.assertEqual(result, line[1])
bad_lines = [
"my_var = __date__",
"my_var 'No equals sign'",
"my_var = 'Unmatched quotes\"",
"my_var = No quotes at all",
"my_var = 1.2.3",
]
for line in bad_lines:
result = get_assigned_string_literal(line)
self.assertIsNone(result)
def test_get_macro_version_from_file_good_metadata(self):
good_metadata = """__Version__ = "1.2.3" """
with patch("builtins.open", new_callable=mock_open, read_data=good_metadata):
version = get_macro_version_from_file("mocked_file.FCStd")
self.assertEqual(version, "1.2.3")
def test_get_macro_version_from_file_missing_quotes(self):
bad_metadata = """__Version__ = 1.2.3 """ # No quotes
with patch("builtins.open", new_callable=mock_open, read_data=bad_metadata):
version = get_macro_version_from_file("mocked_file.FCStd")
self.assertEqual(version, "", "Bad version did not yield empty string")
def test_get_macro_version_from_file_no_version(self):
good_metadata = ""
with patch("builtins.open", new_callable=mock_open, read_data=good_metadata):
version = get_macro_version_from_file("mocked_file.FCStd")
self.assertEqual(version, "", "Missing version did not yield empty string")
@patch("subprocess.Popen")
def test_run_interruptable_subprocess_success_instant_return(self, mock_popen):
mock_process = MagicMock()
mock_process.communicate.return_value = ("Mocked stdout", "Mocked stderr")
mock_process.returncode = 0
mock_popen.return_value = mock_process
completed_process = run_interruptable_subprocess(["arg0", "arg1"])
self.assertEqual(completed_process.returncode, 0)
self.assertEqual(completed_process.stdout, "Mocked stdout")
self.assertEqual(completed_process.stderr, "Mocked stderr")
@patch("subprocess.Popen")
def test_run_interruptable_subprocess_returns_nonzero(self, mock_popen):
mock_process = MagicMock()
mock_process.communicate.return_value = ("Mocked stdout", "Mocked stderr")
mock_process.returncode = 1
mock_popen.return_value = mock_process
with self.assertRaises(subprocess.CalledProcessError):
run_interruptable_subprocess(["arg0", "arg1"])
@patch("subprocess.Popen")
def test_run_interruptable_subprocess_timeout_five_times(self, mock_popen):
"""Five times is below the limit for an error to be raised"""
def raises_first_five_times(timeout):
raises_first_five_times.counter += 1
if raises_first_five_times.counter <= 5:
raise subprocess.TimeoutExpired("Test", timeout)
return "Mocked stdout", None
raises_first_five_times.counter = 0
mock_process = MagicMock()
mock_process.communicate = raises_first_five_times
mock_process.returncode = 0
mock_popen.return_value = mock_process
result = run_interruptable_subprocess(["arg0", "arg1"], 10)
self.assertEqual(result.returncode, 0)
@patch("subprocess.Popen")
def test_run_interruptable_subprocess_timeout_exceeded(self, mock_popen):
"""Exceeding the set timeout gives a CalledProcessError exception"""
def raises_one_time(timeout=0):
if not raises_one_time.raised:
raises_one_time.raised = True
raise subprocess.TimeoutExpired("Test", timeout)
return "Mocked stdout", None
raises_one_time.raised = False
def fake_time():
"""Time that advances by one second every time it is called"""
fake_time.time += 1.0
return fake_time.time
fake_time.time = 0.0
mock_process = MagicMock()
mock_process.communicate = raises_one_time
raises_one_time.mock_access = mock_process
mock_process.returncode = None
mock_popen.return_value = mock_process
with self.assertRaises(subprocess.CalledProcessError):
with patch("time.time", fake_time):
run_interruptable_subprocess(["arg0", "arg1"], 0.1)
def test_process_date_string_to_python_datetime_non_numeric(self):
with self.assertRaises(ValueError):
process_date_string_to_python_datetime("TwentyTwentyFour-January-ThirtyFirst")
def test_process_date_string_to_python_datetime_year_first(self):
result = process_date_string_to_python_datetime("2024-01-31")
expected_result = datetime(2024, 1, 31, 0, 0)
self.assertEqual(result, expected_result)
def test_process_date_string_to_python_datetime_day_first(self):
result = process_date_string_to_python_datetime("31-01-2024")
expected_result = datetime(2024, 1, 31, 0, 0)
self.assertEqual(result, expected_result)
def test_process_date_string_to_python_datetime_month_first(self):
result = process_date_string_to_python_datetime("01-31-2024")
expected_result = datetime(2024, 1, 31, 0, 0)
self.assertEqual(result, expected_result)
def test_process_date_string_to_python_datetime_ambiguous(self):
"""In the ambiguous case, the code should assume that the date is in the DD-MM-YYYY format."""
result = process_date_string_to_python_datetime("01-12-2024")
expected_result = datetime(2024, 12, 1, 0, 0)
self.assertEqual(result, expected_result)
def test_process_date_string_to_python_datetime_invalid_date(self):
with self.assertRaises(ValueError):
process_date_string_to_python_datetime("13-31-2024")
def test_process_date_string_to_python_datetime_too_many_components(self):
with self.assertRaises(ValueError):
process_date_string_to_python_datetime("01-01-31-2024")
def test_process_date_string_to_python_datetime_too_few_components(self):
"""Month-Year-only dates are not supported"""
with self.assertRaises(ValueError):
process_date_string_to_python_datetime("01-2024")
def test_process_date_string_to_python_datetime_unrecognizable(self):
"""Two-digit years are not supported"""
with self.assertRaises(ValueError):
process_date_string_to_python_datetime("01-02-24")
def test_process_date_string_to_python_datetime_valid_separators(self):
"""Four individual separators are supported, plus any combination of multiple of those separators"""
valid_separators = [" ", ".", "/", "-", " - ", " / ", "--"]
for separator in valid_separators:
with self.subTest(separator=separator):
result = process_date_string_to_python_datetime(f"2024{separator}01{separator}31")
expected_result = datetime(2024, 1, 31, 0, 0)
self.assertEqual(result, expected_result)
def test_process_date_string_to_python_datetime_invalid_separators(self):
"""Only the four separators [ ./-] are supported: ensure others fail"""
invalid_separators = ["a", "\\", "|", "'", ";", "*", " \\ "]
for separator in invalid_separators:
with self.subTest(separator=separator):
with self.assertRaises(ValueError):
process_date_string_to_python_datetime(f"2024{separator}01{separator}31")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
__Title__ = 'Do Nothing'
__Author__ = 'Chris Hennes'
__Version__ = '1.0'
__Date__ = '2022-02-28'
__Comment__ = 'Do absolutely nothing. For Addon Manager integration tests.'
__Web__ = 'https://github.com/FreeCAD/FreeCAD'
__Wiki__ = ''
__Icon__ = 'not_real.png'
__Help__ = 'Not much to help with'
__Status__ = 'Very Stable'
__Requires__ = ''
__Communication__ = 'Shout into the void'
__Files__ = 'file1.py, file2.py, file3.py'
__Xpm__ = """/* XPM */
static char * blarg_xpm[] = {
"16 7 2 1",
"* c #000000",
". c #ffffff",
"**..*...........",
"*.*.*...........",
"**..*..**.**..**",
"*.*.*.*.*.*..*.*",
"**..*..**.*...**",
"...............*",
".............**."
};"""
print("Well, not quite *nothing*... it does print this line out.")

View File

@@ -1,156 +0,0 @@
{
"3DfindIT": {
"refs/remotes/origin/HEAD": [
"2022-09-08T13:58:17+02:00",
"dc99f8f1bdb17c1e55c00ac0dffa3ec15caf5b9d"
],
"refs/remotes/origin/master": [
"2022-09-08T13:58:17+02:00",
"dc99f8f1bdb17c1e55c00ac0dffa3ec15caf5b9d"
],
"refs/tags/v1.0": [
"2020-11-09T10:11:44+01:00",
"e97b6caf5eaed0b709dfeabecedac14c9bc2cc2b"
],
"refs/tags/v1.1": [
"2021-06-21T13:33:58+02:00",
"b9ab38ae93fcb8c1f69516ea563494bca63ebf49"
],
"refs/tags/v1.2": [
"2021-08-31T07:42:11+02:00",
"d8553961a43dd450681f6df6e8ce5bce72da1b0a"
]
},
"3D_Printing_Tools": {
"refs/remotes/origin/HEAD": [
"2019-06-30T23:01:34+01:00",
"e7ea9cd05dc11d5503115522b87cac7704eafc0e"
],
"refs/remotes/origin/master": [
"2019-06-30T23:01:34+01:00",
"e7ea9cd05dc11d5503115522b87cac7704eafc0e"
]
},
"A2plus": {
"refs/remotes/origin/HEAD": [
"2022-10-02T22:20:41+02:00",
"3392d72ded45918ea28cd46ed4de778571488639"
],
"refs/remotes/origin/devel": [
"2022-01-27T19:10:27+01:00",
"dcc6193f36d4c7c8c43c4f3384e2bc03d27debd3"
],
"refs/remotes/origin/master": [
"2022-10-02T22:20:41+02:00",
"3392d72ded45918ea28cd46ed4de778571488639"
],
"refs/tags/V0.1.4.1": [
"2018-10-28T16:38:46+01:00",
"6f6ff0a892efca602ec916faac11b041be792fe2"
],
"refs/tags/V0.1.5": [
"2018-11-01T19:03:15+01:00",
"db6cdde71d4a2e9a53f2b14694e2ba2b42ac58c0"
],
"refs/tags/V0.1.6": [
"2018-11-11T16:04:51+01:00",
"46d4039c1fd08b3e5604d4fad3e955bca5855338"
],
"refs/tags/V0.4.6": [
"2019-03-19T15:19:32+01:00",
"42d5ed3846f24c7a7f0f6db655513e9038c3f651"
]
},
"AirPlaneDesign": {
"refs/remotes/origin/HEAD": [
"2022-07-03T19:21:44+02:00",
"34a5c5a827a378d4b6c9c16c54a1f2238b8e3b6b"
],
"refs/remotes/origin/dev-v0.4": [
"2021-01-23T16:15:58+01:00",
"f21bb8fca55b7d34fe920f0b823a7722ff977e43"
],
"refs/remotes/origin/dev-v03bis": [
"2021-08-03T18:28:22+02:00",
"5e2b4ebafaaed6ca87b99f480ae2fecd3722a83b"
],
"refs/remotes/origin/master": [
"2022-07-03T19:21:44+02:00",
"34a5c5a827a378d4b6c9c16c54a1f2238b8e3b6b"
],
"refs/remotes/origin/refactoring": [
"2021-10-18T18:41:10+02:00",
"2f22aa7faa049b0e8ec7dd43a39554b597c7e772"
],
"refs/tags/V0.1": [
"2018-08-18T20:27:30+02:00",
"c5bf85c552b31c0abdca7cadf428099c0b38e97e"
],
"refs/tags/V0.2": [
"2019-07-30T08:36:55+02:00",
"39a7c90ac53148c3317d50e07584c96f54db549e"
],
"refs/tags/V0.3": [
"2019-10-25T08:44:43+02:00",
"8e11fb6a5c33479570e0f572e6319f220e3cf6fd"
]
},
"Curves": {
"refs/remotes/origin/HEAD": [
"2022-10-06T19:18:14+02:00",
"b43970d71ccfed6a85d6e547993863520db9920c"
],
"refs/remotes/origin/blendcurve": [
"2020-02-26T18:55:13+01:00",
"41dff49edfadc7b3085d4afff3c32b598fcd3ed0"
],
"refs/remotes/origin/blending": [
"2022-06-24T17:52:22+02:00",
"687b38faad85187e486fe68ccf072adb7d06be11"
],
"refs/remotes/origin/decimate_edges": [
"2021-05-18T18:40:44+02:00",
"b299597f3817cfb29fd45a8c5710706ae01261fe"
],
"refs/remotes/origin/facemap": [
"2021-12-21T19:06:20+01:00",
"7f315ab0e72d61fb6d04a2da631c66f8f3a30c82"
],
"refs/remotes/origin/flatten": [
"2022-05-09T15:18:05+02:00",
"436b135ff1c2d93686911c2e81ef4fa379e5b001"
],
"refs/remotes/origin/master": [
"2022-10-06T19:18:14+02:00",
"b43970d71ccfed6a85d6e547993863520db9920c"
],
"refs/remotes/origin/reflect2": [
"2021-04-10T13:42:07+02:00",
"6245d86e18f0e5c0a1e724ac614a369da18590e3"
],
"refs/remotes/origin/rotsweep": [
"2022-10-05T17:50:39+02:00",
"99b5e2619481257636d864921d22f60ca0af24d8"
],
"refs/remotes/origin/seamcheck": [
"2022-03-18T17:53:05+01:00",
"2703f93074a94ecdf09c4bb445bef347c206f00f"
],
"refs/remotes/origin/solid": [
"2021-03-01T22:16:08+01:00",
"4f4cc7ec6672b48660601b566777366fd0685a5d"
],
"refs/tags/v0.1": [
"2019-02-17T08:57:09+01:00",
"74ea77f091cf3a62e1dee3b64b49e9ab9fcdb560"
],
"refs/tags/v0.2": [
"2020-06-16T15:02:23+02:00",
"521037588be1aa28320ce6c9b9f77b40f4c8aeb6"
],
"refs/tags/v0.3": [
"2021-01-08T19:06:59+01:00",
"a1fa4857b1de95da1062acc63919acdee59f045c"
]
}
}

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Bundle</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.0</version>
<date>2025-02-22</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<content>
<bundle>
<name>A bunch of great addons you should install</name>
<depend type="addon">TestAddon1</depend>
<depend type="addon">TestAddon2</depend>
<depend type="addon">TestAddon3</depend>
<depend type="addon">TestAddon4</depend>
<depend type="addon">TestAddon5</depend>
</bundle>
</content>
</package>

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Combination Test</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<icon>Resources/icons/PackageIcon.svg</icon>
<tag>Tag0</tag>
<tag>Tag1</tag>
<content>
<workbench>
<classname>MyFirstWorkbench</classname>
<icon>Resources/icons/PackageIcon.svg</icon>
</workbench>
<macro>
<file>MyMacro.FCStd</file>
</macro>
<preferencepack>
<name>MyFirstPack</name>
</preferencepack>
<bundle>
<name>A bundle that bundles nothing</name>
</bundle>
<other>
<name>Mysterious Object</name>
</other>
</content>
</package>

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Workbenches</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<content>
<workbench>
<classname>MyFirstWorkbench</classname>
<icon>Resources/icons/PackageIcon.svg</icon>
<depend>BIM</depend>
<depend>Assembly</depend>
<depend>DraftWB</depend>
<depend>FEM WB</depend>
<depend>MeshWorkbench</depend>
<depend>OpenSCAD Workbench</depend>
<depend>Part WORKBENCH</depend>
<depend>PartDesign WB</depend>
<depend>CAM</depend>
<depend>Plot</depend>
<depend>POINTS</depend>
<depend>ROBOTWB</depend>
<depend>Sketcher workbench</depend>
<depend>Spreadsheet</depend>
<depend>TechDraw</depend>
</workbench>
</content>
</package>

View File

@@ -1,23 +0,0 @@
[submodule "3DfindIT"]
path = 3DfindIT
url = https://github.com/cadenasgmbh/3dfindit-freecad-integration
[submodule "A2plus"]
path = A2plus
url = https://github.com/kbwbe/A2plus
[submodule "Behave-Dark-Colors"]
path = Behave-Dark-Colors
url = https://github.com/Chrismettal/FreeCAD-Behave-Dark-Preference-Pack
branch = main
[submodule "Beltrami"]
path = Beltrami
url = https://github.com/Simturb/Beltrami
branch = main
[submodule "CurvedShapes"]
path = CurvedShapes
url = https://github.com/chbergmann/CurvedShapesWorkbench.git
[submodule "Curves"]
path = Curves
url = https://github.com/tomate44/CurvesWB.git
[submodule "Defeaturing"]
path = Defeaturing
url = https://github.com/easyw/Defeaturing_WB.git

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Workbench</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<icon>Resources/icons/PackageIcon.svg</icon>
<tag>Tag0</tag>
<tag>Tag1</tag>
<content>
<workbench>
<classname>MyWorkbench</classname>
<subdirectory>./</subdirectory>
<tag>TagA</tag>
<tag>TagB</tag>
<tag>TagC</tag>
</workbench>
</content>
</package>

View File

@@ -1 +0,0 @@
67b372f9a5ac11e5377a4075537f31dfebf61753 *icon_cache.zip

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Macros</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<icon>Resources/icons/PackageIcon.svg</icon>
<content>
<macro>
<file>MyMacro.FCStd</file>
</macro>
<macro>
<file>MyOtherMacro.FCStd</file>
</macro>
</content>
</package>

View File

@@ -1,37 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
# * This file is part of the FreeCAD CAx development system. *
# * *
# * This library 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. *
# * *
# * This library 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 this library; if not, write to the Free Software *
# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
# * 02110-1301 USA *
# * *
# ***************************************************************************
__Title__ = "TITLE"
__Author__ = "AUTHOR"
__Date__ = "DATE"
__Version__ = "VERSION"
__Comment__ = "COMMENT"
__Web__ = "WEB"
__Wiki__ = "WIKI"
__Icon__ = "ICON"
__Help__ = "HELP"
__Status__ = "STATUS"
__Requires__ = "REQUIRES"
__Communication__ = "COMMUNICATION"
__Files__ = "FILES"

View File

@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
# * This file is part of the FreeCAD CAx development system. *
# * *
# * This library 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. *
# * *
# * This library 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 this library; if not, write to the Free Software *
# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
# * 02110-1301 USA *
# * *
# ***************************************************************************
# This file contains no metadata

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Other</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.0</version>
<date>2025-02-22</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<content>
<other>
<name>A thing that's not a workbench, macro, preference pack, or bundle</name>
</other>
</content>
</package>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Preference Packs</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<content>
<preferencepack>
<name>MyFirstPack</name>
</preferencepack>
<preferencepack>
<name>MySecondPack</name>
</preferencepack>
<preferencepack>
<name>MyThirdPack</name>
</preferencepack>
</content>
</package>

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Workbenches</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<content>
<macro>
<name>MacroA</name>
<tag>TagA</tag>
<freecadmin>0.1</freecadmin>
<freecadmax>0.10</freecadmax>
</macro>
<macro>
<name>MacroB</name>
<tag>TagB</tag>
<freecadmin>0.20</freecadmin>
<freecadmax>9999.98</freecadmax>
</macro>
<macro>
<name>MacroC</name>
<tag>TagC</tag>
<freecadmin>9999.99</freecadmin>
<freecadmax>99999.99</freecadmax>
</macro>
</content>
</package>

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Test Workbenches</name>
<description>A package.xml file for unit testing.</description>
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<content>
<workbench>
<classname>MyFirstWorkbench</classname>
<icon>Resources/icons/PackageIcon.svg</icon>
</workbench>
<workbench>
<classname>MySecondWorkbench</classname>
<icon>Resources/icons/PackageIcon.svg</icon>
</workbench>
<workbench>
<classname>MyThirdWorkbench</classname>
<icon>Resources/icons/PackageIcon.svg</icon>
</workbench>
</content>
</package>

View File

@@ -1,157 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
import sys
try:
from PySide import QtCore, QtWidgets
except ImportError:
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
from PySide2 import QtCore, QtWidgets
sys.path.append("../../") # For running in standalone mode during testing
from AddonManagerTest.app.mocks import SignalCatcher
class DialogInteractor(QtCore.QObject):
"""Takes the title of the dialog and a callable. The callable is passed the widget
we found and can do whatever it wants to it. Whatever it does should eventually
close the dialog, however."""
def __init__(self, dialog_to_watch_for, interaction):
super().__init__()
# Status variables for tests to check:
self.dialog_found = False
self.has_run = False
self.button_found = False
self.interaction = interaction
self.dialog_to_watch_for = dialog_to_watch_for
self.execution_counter = 0
self.timer = QtCore.QTimer()
self.timer.timeout.connect(self.run)
self.timer.start(
1
) # At 10 this occasionally left open dialogs; less than 1 produced failed tests
def run(self):
widget = QtWidgets.QApplication.activeModalWidget()
if widget and self._dialog_matches(widget):
# Found the dialog we are looking for: now try to run the interaction
if self.interaction is not None and callable(self.interaction):
self.interaction(widget)
self.dialog_found = True
self.timer.stop()
self.has_run = True
self.execution_counter += 1
if self.execution_counter > 100:
print("Stopped timer after 100 iterations")
self.timer.stop()
def _dialog_matches(self, widget) -> bool:
# Is this the widget we are looking for? Only applies on Linux and Windows: macOS
# doesn't set the title of a modal dialog:
os = QtCore.QSysInfo.productType() # Qt5 gives "osx", Qt6 gives "macos"
if os in ["osx", "macos"] or (
hasattr(widget, "windowTitle")
and callable(widget.windowTitle)
and widget.windowTitle() == self.dialog_to_watch_for
):
return True
return False
class DialogWatcher(DialogInteractor):
"""Examine the running GUI and look for a modal dialog with a given title, containing a button
with a role. Click that button, which is expected to close the dialog. Generally run on
a one-shot QTimer to allow the dialog time to open up. If the specified dialog is found, but
it does not contain the expected button, button_found will be false, and the dialog will be
closed with a reject() slot."""
def __init__(self, dialog_to_watch_for, button=QtWidgets.QDialogButtonBox.NoButton):
super().__init__(dialog_to_watch_for, self.click_button)
if button != QtWidgets.QDialogButtonBox.NoButton:
self.button = button
else:
self.button = QtWidgets.QDialogButtonBox.Cancel
def click_button(self, widget):
button_boxes = widget.findChildren(QtWidgets.QDialogButtonBox)
if len(button_boxes) == 1: # There should be one, and only one
button_to_click = button_boxes[0].button(self.button)
if button_to_click:
self.button_found = True
button_to_click.click()
else:
widget.reject()
else:
widget.reject()
class FakeWorker:
def __init__(self):
self.called = False
self.should_continue = True
def work(self):
while self.should_continue:
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
def stop(self):
self.should_continue = False
class MockThread:
def wait(self):
pass
def isRunning(self):
return False
class AsynchronousMonitor:
"""Watch for a signal to be emitted for at most some given number of milliseconds"""
def __init__(self, signal):
self.signal = signal
self.signal_catcher = SignalCatcher()
self.signal.connect(self.signal_catcher.catch_signal)
self.kill_timer = QtCore.QTimer()
self.kill_timer.setSingleShot(True)
self.kill_timer.timeout.connect(self.signal_catcher.die)
def wait_for_at_most(self, max_wait_millis) -> None:
self.kill_timer.setInterval(max_wait_millis)
self.kill_timer.start()
while not self.signal_catcher.caught and not self.signal_catcher.killed:
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 10)
self.kill_timer.stop()
def good(self) -> bool:
return self.signal_catcher.caught and not self.signal_catcher.killed

View File

@@ -1,234 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 The FreeCAD Project Association AISBL *
# * *
# * 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/>. *
# * *
# ***************************************************************************
"""Test the Change Branch GUI code"""
# pylint: disable=wrong-import-position, deprecated-module, too-many-return-statements
import sys
import unittest
from unittest.mock import patch, Mock, MagicMock
# So that when run standalone, the Addon Manager classes imported below are available
sys.path.append("../..")
from AddonManagerTest.gui.gui_mocks import DialogWatcher, AsynchronousMonitor
from change_branch import ChangeBranchDialog
from addonmanager_freecad_interface import translate
from addonmanager_git import GitFailed
try:
from PySide import QtCore, QtWidgets
except ImportError:
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
from PySide2 import QtCore, QtWidgets
class MockFilter(QtCore.QSortFilterProxyModel):
"""Replaces a filter with a non-filter that simply always returns whatever it's given"""
def mapToSource(self, something):
return something
class MockChangeBranchDialogModel(QtCore.QAbstractTableModel):
"""Replace a data-connected model with a static one for testing"""
branches = [
{"ref_name": "ref1", "upstream": "us1"},
{"ref_name": "ref2", "upstream": "us2"},
{"ref_name": "ref3", "upstream": "us3"},
]
current_branch = "ref1"
DataSortRole = QtCore.Qt.UserRole
RefAccessRole = QtCore.Qt.UserRole + 1
def __init__(self, _: str, parent=None) -> None:
super().__init__(parent)
def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
"""Number of rows: should always return 3"""
if parent.isValid():
return 0
return len(self.branches)
def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
"""Number of columns (identical to non-mocked version)"""
if parent.isValid():
return 0
return 3 # Local name, remote name, date
def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole):
"""Mock returns static untranslated strings for DisplayRole, no tooltips at all, and
otherwise matches the non-mock version"""
if not index.isValid():
return None
row = index.row()
column = index.column()
if role == QtCore.Qt.DisplayRole:
if column == 2:
return "date"
if column == 0:
return "ref_name"
if column == 1:
return "upstream"
return None
if role == MockChangeBranchDialogModel.DataSortRole:
return None
if role == MockChangeBranchDialogModel.RefAccessRole:
return self.branches[row]
return None
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role: int = QtCore.Qt.DisplayRole,
):
"""Mock returns untranslated strings for DisplayRole, and no tooltips at all"""
if orientation == QtCore.Qt.Vertical:
return None
if role != QtCore.Qt.DisplayRole:
return None
if section == 0:
return "Local"
if section == 1:
return "Remote tracking"
if section == 2:
return "Last Updated"
return None
def currentBranch(self) -> str:
"""Mock returns a static string stored in the class: that string could be modified to
return something else by tests that require it."""
return self.current_branch
class TestChangeBranchGui(unittest.TestCase):
"""Tests for the ChangeBranch GUI code"""
MODULE = "test_change_branch" # file name without extension
def setUp(self):
pass
def tearDown(self):
pass
@patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
@patch("change_branch.initialize_git", new=Mock(return_value=None))
def test_no_git(self):
"""If git is not present, a dialog saying so is presented"""
# Arrange
gui = ChangeBranchDialog("/some/path")
ref = {"ref_name": "foo/bar", "upstream": "us1"}
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Cannot find git"),
QtWidgets.QDialogButtonBox.Ok,
)
# Act
gui.change_branch("/foo/bar/baz", ref)
# Assert
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
@patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
@patch("change_branch.initialize_git")
def test_git_failed(self, init_git: MagicMock):
"""If git fails when attempting to change branches, a dialog saying so is presented"""
# Arrange
git_manager = MagicMock()
git_manager.checkout = MagicMock()
git_manager.checkout.side_effect = GitFailed()
init_git.return_value = git_manager
gui = ChangeBranchDialog("/some/path")
ref = {"ref_name": "foo/bar", "upstream": "us1"}
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "git operation failed"),
QtWidgets.QDialogButtonBox.Ok,
)
# Act
gui.change_branch("/foo/bar/baz", ref)
# Assert
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
@patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
@patch("change_branch.initialize_git", new=MagicMock)
def test_branch_change_succeeded(self):
"""If nothing gets thrown, then the process is assumed to have worked, and the appropriate
signal is emitted."""
# Arrange
gui = ChangeBranchDialog("/some/path")
ref = {"ref_name": "foo/bar", "upstream": "us1"}
monitor = AsynchronousMonitor(gui.branch_changed)
# Act
gui.change_branch("/foo/bar/baz", ref)
# Assert
monitor.wait_for_at_most(10) # Should be effectively instantaneous
self.assertTrue(monitor.good())
@patch("change_branch.ChangeBranchDialogFilter", new=MockFilter)
@patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
@patch("change_branch.initialize_git", new=MagicMock)
def test_warning_is_shown_when_dialog_is_accepted(self):
"""If the dialog is accepted (e.g. a branch change is requested) then a warning dialog is
displayed, and gives the opportunity to cancel. If cancelled, no signal is emitted."""
# Arrange
gui = ChangeBranchDialog("/some/path")
gui.ui.exec = MagicMock()
gui.ui.exec.return_value = QtWidgets.QDialog.Accepted
gui.ui.tableView.selectedIndexes = MagicMock()
gui.ui.tableView.selectedIndexes.return_value = [MagicMock()]
gui.ui.tableView.selectedIndexes.return_value[0].isValid = MagicMock()
gui.ui.tableView.selectedIndexes.return_value[0].isValid.return_value = True
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "DANGER: Developer feature"),
QtWidgets.QDialogButtonBox.Cancel,
)
monitor = AsynchronousMonitor(gui.branch_changed)
# Act
gui.exec()
# Assert
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertFalse(monitor.good()) # The watcher cancelled the op, so no signal is emitted
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
QtCore.QTimer.singleShot(0, unittest.main)
if hasattr(app, "exec"):
app.exec() # PySide6
else:
app.exec_() # PySide2

View File

@@ -1,33 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
import unittest
import FreeCAD
class TestGui(unittest.TestCase):
MODULE = "test_gui" # file name without extension
def setUp(self):
pass

View File

@@ -1,650 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
import os
import tempfile
import unittest
import FreeCAD
from PySide import QtCore, QtWidgets
from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI
from AddonManagerTest.gui.gui_mocks import DialogWatcher, DialogInteractor
from AddonManagerTest.app.mocks import MockAddon
translate = FreeCAD.Qt.translate
class TestInstallerGui(unittest.TestCase):
MODULE = "test_installer_gui" # file name without extension
def setUp(self):
self.addon_to_install = MockAddon()
self.installer_gui = AddonInstallerGUI(self.addon_to_install)
self.finalized_thread = False
def tearDown(self):
pass
def test_success_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to an OK click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Success"),
QtWidgets.QDialogButtonBox.Ok,
)
self.installer_gui._installation_succeeded()
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_failure_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a Cancel click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Installation Failed"),
QtWidgets.QDialogButtonBox.Cancel,
)
self.installer_gui._installation_failed(
self.addon_to_install, "Test of installation failure"
)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_no_python_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a No click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Cannot execute Python"),
QtWidgets.QDialogButtonBox.No,
)
self.installer_gui._report_no_python_exe()
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_no_pip_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a No click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Cannot execute pip"),
QtWidgets.QDialogButtonBox.No,
)
self.installer_gui._report_no_pip("pip not actually run, this was a test")
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_dependency_failure_dialog(self):
# Pop the modal dialog and verify that it opens, and responds to a No click
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Package installation failed"),
QtWidgets.QDialogButtonBox.No,
)
self.installer_gui._report_dependency_failure(
"Unit test", "Nothing really failed, this is a test of the dialog box"
)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_install(self):
# Run the installation code and make sure it puts the directory in place
with tempfile.TemporaryDirectory() as temp_dir:
self.installer_gui.installer.installation_path = temp_dir
self.installer_gui.install() # This does not block
self.installer_gui.installer.success.disconnect(
self.installer_gui._installation_succeeded
)
self.installer_gui.installer.failure.disconnect(self.installer_gui._installation_failed)
while not self.installer_gui.worker_thread.isFinished():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.assertTrue(
os.path.exists(os.path.join(temp_dir, "MockAddon")),
"Installed directory not found",
)
def test_handle_disallowed_python(self):
disallowed_packages = ["disallowed_package_name"]
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
QtWidgets.QDialogButtonBox.Cancel,
)
self.installer_gui._handle_disallowed_python(disallowed_packages)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_handle_disallowed_python_long_list(self):
"""A separate test for when there are MANY packages, which takes a separate code path."""
disallowed_packages = []
for i in range(50):
disallowed_packages.append(f"disallowed_package_name_{i}")
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
QtWidgets.QDialogButtonBox.Cancel,
)
self.installer_gui._handle_disallowed_python(disallowed_packages)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_report_missing_workbenches_single(self):
"""Test only missing one workbench"""
wbs = ["OneMissingWorkbench"]
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
QtWidgets.QDialogButtonBox.Cancel,
)
self.installer_gui._report_missing_workbenches(wbs)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_report_missing_workbenches_multiple(self):
"""Test only missing one workbench"""
wbs = ["FirstMissingWorkbench", "SecondMissingWorkbench"]
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Missing Requirement"),
QtWidgets.QDialogButtonBox.Cancel,
)
self.installer_gui._report_missing_workbenches(wbs)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_resolve_dependencies_then_install(self):
class MissingDependenciesMock:
def __init__(self):
self.external_addons = ["addon_1", "addon_2"]
self.python_requires = ["py_req_1", "py_req_2"]
self.python_optional = ["py_opt_1", "py_opt_2"]
missing = MissingDependenciesMock()
dialog_watcher = DialogWatcher(
translate("DependencyResolutionDialog", "Resolve Dependencies"),
QtWidgets.QDialogButtonBox.Cancel,
)
self.installer_gui._resolve_dependencies_then_install(missing)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_check_python_version_bad(self):
class MissingDependenciesMock:
def __init__(self):
self.python_min_version = {"major": 3, "minor": 9999}
missing = MissingDependenciesMock()
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Incompatible Python version"),
QtWidgets.QDialogButtonBox.Cancel,
)
stop_installing = self.installer_gui._check_python_version(missing)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
self.assertTrue(stop_installing, "Failed to halt installation on bad Python version")
def test_check_python_version_good(self):
class MissingDependenciesMock:
def __init__(self):
self.python_min_version = {"major": 3, "minor": 0}
missing = MissingDependenciesMock()
stop_installing = self.installer_gui._check_python_version(missing)
self.assertFalse(stop_installing, "Failed to continue installation on good Python version")
def test_clean_up_optional(self):
class MissingDependenciesMock:
def __init__(self):
self.python_optional = [
"allowed_packages_1",
"allowed_packages_2",
"disallowed_package",
]
allowed_packages = ["allowed_packages_1", "allowed_packages_2"]
missing = MissingDependenciesMock()
self.installer_gui.installer.allowed_packages = set(allowed_packages)
self.installer_gui._clean_up_optional(missing)
self.assertTrue("allowed_packages_1" in missing.python_optional)
self.assertTrue("allowed_packages_2" in missing.python_optional)
self.assertFalse("disallowed_package" in missing.python_optional)
def intercept_run_dependency_installer(self, addons, python_requires, python_optional):
self.assertEqual(python_requires, ["py_req_1", "py_req_2"])
self.assertEqual(python_optional, ["py_opt_1", "py_opt_2"])
self.assertEqual(addons[0].name, "addon_1")
self.assertEqual(addons[1].name, "addon_2")
def test_dependency_dialog_yes_clicked(self):
class DialogMock:
class ListWidgetMock:
class ListWidgetItemMock:
def __init__(self, name):
self.name = name
def text(self):
return self.name
def checkState(self):
return QtCore.Qt.Checked
def __init__(self, items):
self.list = []
for item in items:
self.list.append(DialogMock.ListWidgetMock.ListWidgetItemMock(item))
def count(self):
return len(self.list)
def item(self, i):
return self.list[i]
def __init__(self):
self.listWidgetAddons = DialogMock.ListWidgetMock(["addon_1", "addon_2"])
self.listWidgetPythonRequired = DialogMock.ListWidgetMock(["py_req_1", "py_req_2"])
self.listWidgetPythonOptional = DialogMock.ListWidgetMock(["py_opt_1", "py_opt_2"])
class AddonMock:
def __init__(self, name):
self.name = name
self.installer_gui.dependency_dialog = DialogMock()
self.installer_gui.addons = [AddonMock("addon_1"), AddonMock("addon_2")]
self.installer_gui._run_dependency_installer = self.intercept_run_dependency_installer
self.installer_gui._dependency_dialog_yes_clicked()
class TestMacroInstallerGui(unittest.TestCase):
class MockMacroAddon:
class MockMacro:
def __init__(self):
self.install_called = False
self.install_result = (
True # External code can change to False to test failed install
)
self.name = "MockMacro"
self.filename = "mock_macro_no_real_file.FCMacro"
self.comment = "This is a mock macro for unit testing"
self.icon = None
self.xpm = None
def install(self):
self.install_called = True
return self.install_result
def __init__(self):
self.macro = TestMacroInstallerGui.MockMacroAddon.MockMacro()
self.name = self.macro.name
self.display_name = self.macro.name
class MockParameter:
"""Mock the parameter group to allow simplified behavior and introspection."""
def __init__(self):
self.params = {}
self.groups = {}
self.accessed_parameters = {} # Dict is param name: default value
types = ["Bool", "String", "Int", "UInt", "Float"]
for t in types:
setattr(self, f"Get{t}", self.get)
setattr(self, f"Set{t}", self.set)
setattr(self, f"Rem{t}", self.rem)
def get(self, p, default=None):
self.accessed_parameters[p] = default
if p in self.params:
return self.params[p]
else:
return default
def set(self, p, value):
self.params[p] = value
def rem(self, p):
if p in self.params:
self.params.erase(p)
def GetGroup(self, name):
if name not in self.groups:
self.groups[name] = TestMacroInstallerGui.MockParameter()
return self.groups[name]
def GetGroups(self):
return self.groups.keys()
class ToolbarIntercepter:
def __init__(self):
self.ask_for_toolbar_called = False
self.install_macro_to_toolbar_called = False
self.tb = None
self.custom_group = TestMacroInstallerGui.MockParameter()
self.custom_group.set("Name", "MockCustomToolbar")
def _ask_for_toolbar(self, _):
self.ask_for_toolbar_called = True
return self.custom_group
def _install_macro_to_toolbar(self, tb):
self.install_macro_to_toolbar_called = True
self.tb = tb
class InstallerInterceptor:
def __init__(self):
self.ccc_called = False
def _create_custom_command(
self,
toolbar,
filename,
menuText,
tooltipText,
whatsThisText,
statustipText,
pixmapText,
):
self.ccc_called = True
self.toolbar = toolbar
self.filename = filename
self.menuText = menuText
self.tooltipText = tooltipText
self.whatsThisText = whatsThisText
self.statustipText = statustipText
self.pixmapText = pixmapText
def setUp(self):
self.mock_macro = TestMacroInstallerGui.MockMacroAddon()
self.installer = MacroInstallerGUI(self.mock_macro)
self.installer.addon_params = TestMacroInstallerGui.MockParameter()
self.installer.toolbar_params = TestMacroInstallerGui.MockParameter()
def tearDown(self):
pass
def test_class_is_initialized(self):
"""Connecting to a signal does not throw"""
self.installer.finished.connect(lambda: None)
def test_ask_for_toolbar_no_dialog_default_exists(self):
self.installer.addon_params.set("alwaysAskForToolbar", False)
self.installer.addon_params.set("CustomToolbarName", "UnitTestCustomToolbar")
utct = self.installer.toolbar_params.GetGroup("UnitTestCustomToolbar")
utct.set("Name", "UnitTestCustomToolbar")
utct.set("Active", True)
result = self.installer._ask_for_toolbar([])
self.assertIsNotNone(result)
self.assertTrue(hasattr(result, "get"))
name = result.get("Name")
self.assertEqual(name, "UnitTestCustomToolbar")
def test_ask_for_toolbar_with_dialog_cancelled(self):
# First test: the user cancels the dialog
self.installer.addon_params.set("alwaysAskForToolbar", True)
dialog_watcher = DialogWatcher(
translate("select_toolbar_dialog", "Select Toolbar"),
QtWidgets.QDialogButtonBox.Cancel,
)
result = self.installer._ask_for_toolbar([])
self.assertIsNone(result)
def test_ask_for_toolbar_with_dialog_defaults(self):
# Second test: the user leaves the dialog at all default values, so:
# - The checkbox "Ask every time" is unchecked
# - The selected toolbar option is "Create new toolbar", which triggers a search for
# a new custom toolbar name by calling _create_new_custom_toolbar, which we mock.
fake_custom_toolbar_group = TestMacroInstallerGui.MockParameter()
fake_custom_toolbar_group.set("Name", "UnitTestCustomToolbar")
self.installer._create_new_custom_toolbar = lambda: fake_custom_toolbar_group
dialog_watcher = DialogWatcher(
translate("select_toolbar_dialog", "Select Toolbar"),
QtWidgets.QDialogButtonBox.Ok,
)
result = self.installer._ask_for_toolbar([])
self.assertIsNotNone(result)
self.assertTrue(hasattr(result, "get"))
name = result.get("Name")
self.assertEqual(name, "UnitTestCustomToolbar")
self.assertIn("alwaysAskForToolbar", self.installer.addon_params.params)
self.assertFalse(self.installer.addon_params.get("alwaysAskForToolbar", True))
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_ask_for_toolbar_with_dialog_selection(self):
# Third test: the user selects a custom toolbar in the dialog, and checks the box to always
# ask.
dialog_interactor = DialogInteractor(
translate("select_toolbar_dialog", "Select Toolbar"),
self.interactor_selection_option_and_checkbox,
)
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
result = self.installer._ask_for_toolbar(["UT_TB_1", "UT_TB_2", "UT_TB_3"])
self.assertIsNotNone(result)
self.assertTrue(hasattr(result, "get"))
name = result.get("Name")
self.assertEqual(name, "UT_TB_3")
self.assertIn("alwaysAskForToolbar", self.installer.addon_params.params)
self.assertTrue(self.installer.addon_params.get("alwaysAskForToolbar", False))
def interactor_selection_option_and_checkbox(self, parent):
boxes = parent.findChildren(QtWidgets.QComboBox)
self.assertEqual(len(boxes), 1) # Just to make sure...
box = boxes[0]
box.setCurrentIndex(box.count() - 2) # Select the last thing but one
checkboxes = parent.findChildren(QtWidgets.QCheckBox)
self.assertEqual(len(checkboxes), 1) # Just to make sure...
checkbox = checkboxes[0]
checkbox.setChecked(True)
parent.accept()
def test_macro_button_exists_no_command(self):
# Test 1: No command for this macro
self.installer._find_custom_command = lambda _: None
button_exists = self.installer._macro_button_exists()
self.assertFalse(button_exists)
def test_macro_button_exists_true(self):
# Test 2: Macro is in the list of buttons
ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestCommand")
ut_tb_1.set("UnitTestCommand", "FreeCAD") # This is what the real thing looks like...
self.installer._find_custom_command = lambda _: "UnitTestCommand"
self.assertTrue(self.installer._macro_button_exists())
def test_macro_button_exists_false(self):
# Test 3: Macro is not in the list of buttons
self.installer._find_custom_command = lambda _: "UnitTestCommand"
self.assertFalse(self.installer._macro_button_exists())
def test_ask_to_install_toolbar_button_disabled(self):
self.installer.addon_params.SetBool("dontShowAddMacroButtonDialog", True)
self.installer._ask_to_install_toolbar_button()
# This should NOT block when dontShowAddMacroButtonDialog is True
def test_ask_to_install_toolbar_button_enabled_no(self):
self.installer.addon_params.SetBool("dontShowAddMacroButtonDialog", False)
dialog_watcher = DialogWatcher(
translate("toolbar_button", "Add button?"),
QtWidgets.QDialogButtonBox.No,
)
# Note: that dialog does not use a QButtonBox, so we can really only test its
# reject() signal, which is triggered by the DialogWatcher when it cannot find
# the button. In this case, failure to find that button is NOT an error.
self.installer._ask_to_install_toolbar_button() # Blocks until killed by watcher
self.assertTrue(dialog_watcher.dialog_found)
def test_get_toolbar_with_name_found(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestToolbar")
ut_tb_1.set("Name", "Unit Test Toolbar")
ut_tb_1.set("UnitTestParam", True)
tb = self.installer._get_toolbar_with_name("Unit Test Toolbar")
self.assertIsNotNone(tb)
self.assertTrue(tb.get("UnitTestParam", False))
def test_get_toolbar_with_name_not_found(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestToolbar")
ut_tb_1.set("Name", "Not the Unit Test Toolbar")
tb = self.installer._get_toolbar_with_name("Unit Test Toolbar")
self.assertIsNone(tb)
def test_create_new_custom_toolbar_no_existing(self):
tb = self.installer._create_new_custom_toolbar()
self.assertEqual(tb.get("Name", ""), "Auto-Created Macro Toolbar")
self.assertTrue(tb.get("Active", False), True)
def test_create_new_custom_toolbar_one_existing(self):
_ = self.installer._create_new_custom_toolbar()
tb = self.installer._create_new_custom_toolbar()
self.assertEqual(tb.get("Name", ""), "Auto-Created Macro Toolbar (2)")
self.assertTrue(tb.get("Active", False), True)
def test_check_for_toolbar_true(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
self.assertTrue(self.installer._check_for_toolbar("UT_TB_1"))
def test_check_for_toolbar_false(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
self.assertFalse(self.installer._check_for_toolbar("Not UT_TB_1"))
def test_install_toolbar_button_first_custom_toolbar(self):
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertFalse(tbi.ask_for_toolbar_called)
self.assertTrue("Custom_1" in self.installer.toolbar_params.GetGroups())
def test_install_toolbar_button_existing_custom_toolbar_1(self):
# There is an existing custom toolbar, and we should use it
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_1")
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertFalse(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "UT_TB_1")
def test_install_toolbar_button_existing_custom_toolbar_2(self):
# There are multiple existing custom toolbars, and we should use one of them
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_3")
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertFalse(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "UT_TB_3")
def test_install_toolbar_button_existing_custom_toolbar_3(self):
# There are multiple existing custom toolbars, but none of them match
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_4")
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertTrue(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "MockCustomToolbar")
def test_install_toolbar_button_existing_custom_toolbar_4(self):
# There are multiple existing custom toolbars, one of them matches, but we have set
# "alwaysAskForToolbar" to True
tbi = TestMacroInstallerGui.ToolbarIntercepter()
self.installer._ask_for_toolbar = tbi._ask_for_toolbar
self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
ut_tb_1.set("Name", "UT_TB_1")
ut_tb_2.set("Name", "UT_TB_2")
ut_tb_3.set("Name", "UT_TB_3")
self.installer.addon_params.set("CustomToolbarName", "UT_TB_3")
self.installer.addon_params.set("alwaysAskForToolbar", True)
self.installer._install_toolbar_button()
self.assertTrue(tbi.install_macro_to_toolbar_called)
self.assertTrue(tbi.ask_for_toolbar_called)
self.assertEqual(tbi.tb.get("Name", ""), "MockCustomToolbar")
def test_install_macro_to_toolbar_icon_abspath(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.NamedTemporaryFile() as ntf:
self.mock_macro.macro.icon = ntf.name
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertEqual(ii.pixmapText, ntf.name)
def test_install_macro_to_toolbar_icon_relpath(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.TemporaryDirectory() as td:
self.installer.macro_dir = td
self.mock_macro.macro.icon = "RelativeIconPath.png"
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertEqual(ii.pixmapText, os.path.join(td, "RelativeIconPath.png"))
def test_install_macro_to_toolbar_xpm(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.TemporaryDirectory() as td:
self.installer.macro_dir = td
self.mock_macro.macro.xpm = "Not really xpm data, don't try to use it!"
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertEqual(ii.pixmapText, os.path.join(td, "MockMacro_icon.xpm"))
self.assertTrue(os.path.exists(os.path.join(td, "MockMacro_icon.xpm")))
def test_install_macro_to_toolbar_no_icon(self):
ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
ut_tb_1.set("Name", "UT_TB_1")
ii = TestMacroInstallerGui.InstallerInterceptor()
self.installer._create_custom_command = ii._create_custom_command
with tempfile.TemporaryDirectory() as td:
self.installer.macro_dir = td
self.installer._install_macro_to_toolbar(ut_tb_1)
self.assertTrue(ii.ccc_called)
self.assertIsNone(ii.pixmapText)

View File

@@ -1,139 +0,0 @@
import logging
import subprocess
import sys
import unittest
from unittest.mock import MagicMock, patch
try:
import FreeCAD
import FreeCADGui
except ImportError:
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
from PySide2 import QtCore, QtWidgets
sys.path.append(
"../.."
) # So that when run standalone, the Addon Manager classes imported below are available
from addonmanager_python_deps_gui import (
PythonPackageManager,
call_pip,
PipFailed,
python_package_updates_are_available,
parse_pip_list_output,
)
from AddonManagerTest.gui.gui_mocks import DialogInteractor, DialogWatcher
class TestPythonPackageManager(unittest.TestCase):
def setUp(self) -> None:
self.manager = PythonPackageManager([])
def tearDown(self) -> None:
if self.manager.worker_thread:
self.manager.worker_thread.terminate()
self.manager.worker_thread.wait()
@patch("addonmanager_python_deps_gui.PythonPackageManager._create_list_from_pip")
def test_show(self, patched_create_list_from_pip):
dialog_watcher = DialogWatcher("Manage Python Dependencies")
self.manager.show()
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
class TestPythonDepsStandaloneFunctions(unittest.TestCase):
@patch("addonmanager_utilities.run_interruptable_subprocess")
def test_call_pip(self, mock_run_subprocess: MagicMock):
call_pip(["arg1", "arg2", "arg3"])
mock_run_subprocess.assert_called()
args = mock_run_subprocess.call_args[0][0]
self.assertTrue("pip" in args)
@patch("addonmanager_python_deps_gui.get_python_exe")
def test_call_pip_no_python(self, mock_get_python_exe: MagicMock):
mock_get_python_exe.return_value = None
with self.assertRaises(PipFailed):
call_pip(["arg1", "arg2", "arg3"])
@patch("addonmanager_utilities.run_interruptable_subprocess")
def test_call_pip_exception_raised(self, mock_run_subprocess: MagicMock):
mock_run_subprocess.side_effect = subprocess.CalledProcessError(
-1, "dummy_command", "Fake contents of stdout", "Fake contents of stderr"
)
with self.assertRaises(PipFailed):
call_pip(["arg1", "arg2", "arg3"])
@patch("addonmanager_utilities.run_interruptable_subprocess")
def test_call_pip_splits_results(self, mock_run_subprocess: MagicMock):
result_mock = MagicMock()
result_mock.stdout = "\n".join(["Value 1", "Value 2", "Value 3"])
mock_run_subprocess.return_value = result_mock
result = call_pip(["arg1", "arg2", "arg3"])
self.assertEqual(len(result), 3)
@patch("addonmanager_python_deps_gui.call_pip")
def test_python_package_updates_are_available(self, mock_call_pip: MagicMock):
mock_call_pip.return_value = "Some result"
result = python_package_updates_are_available()
self.assertEqual(result, True)
@patch("addonmanager_python_deps_gui.call_pip")
def test_python_package_updates_are_available_no_results(self, mock_call_pip: MagicMock):
"""An empty string is an indication that no updates are available"""
mock_call_pip.return_value = ""
result = python_package_updates_are_available()
self.assertEqual(result, False)
@patch("addonmanager_python_deps_gui.call_pip")
def test_python_package_updates_are_available_pip_failure(self, mock_call_pip: MagicMock):
logging.disable()
mock_call_pip.side_effect = PipFailed("Test error message")
logging.disable() # A logging error message is expected here, but not desirable during test runs
result = python_package_updates_are_available()
self.assertEqual(result, False)
logging.disable(logging.NOTSET)
def test_parse_pip_list_output_no_input(self):
results_dict = parse_pip_list_output("", "")
self.assertEqual(len(results_dict), 0)
def test_parse_pip_list_output_all_packages_no_updates(self):
results_dict = parse_pip_list_output(
["Package Version", "---------- -------", "gitdb 4.0.9", "setuptools 41.2.0"],
[],
)
self.assertEqual(len(results_dict), 2)
self.assertTrue("gitdb" in results_dict)
self.assertTrue("setuptools" in results_dict)
self.assertEqual(results_dict["gitdb"]["installed_version"], "4.0.9")
self.assertEqual(results_dict["gitdb"]["available_version"], "")
self.assertEqual(results_dict["setuptools"]["installed_version"], "41.2.0")
self.assertEqual(results_dict["setuptools"]["available_version"], "")
def test_parse_pip_list_output_all_packages_with_updates(self):
results_dict = parse_pip_list_output(
[],
[
"Package Version Latest Type",
"---------- ------- ------ -----",
"pip 21.0.1 22.1.2 wheel",
"setuptools 41.2.0 63.2.0 wheel",
],
)
self.assertEqual(len(results_dict), 2)
self.assertTrue("pip" in results_dict)
self.assertTrue("setuptools" in results_dict)
self.assertEqual(results_dict["pip"]["installed_version"], "21.0.1")
self.assertEqual(results_dict["pip"]["available_version"], "22.1.2")
self.assertEqual(results_dict["setuptools"]["installed_version"], "41.2.0")
self.assertEqual(results_dict["setuptools"]["available_version"], "63.2.0")
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
QtCore.QTimer.singleShot(0, unittest.main)
app.exec()

View File

@@ -1,131 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
import functools
import unittest
from PySide import QtCore, QtWidgets
import FreeCAD
from AddonManagerTest.gui.gui_mocks import (
DialogWatcher,
FakeWorker,
MockThread,
)
from AddonManagerTest.app.mocks import MockAddon
from addonmanager_uninstaller_gui import AddonUninstallerGUI
translate = FreeCAD.Qt.translate
class TestUninstallerGUI(unittest.TestCase):
MODULE = "test_uninstaller_gui" # file name without extension
def setUp(self):
self.addon_to_remove = MockAddon()
self.uninstaller_gui = AddonUninstallerGUI(self.addon_to_remove)
self.finalized_thread = False
self.signals_caught = []
def tearDown(self):
pass
def catch_signal(self, signal_name, *_):
self.signals_caught.append(signal_name)
def test_confirmation_dialog_yes(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Confirm remove"),
QtWidgets.QDialogButtonBox.Yes,
)
answer = self.uninstaller_gui._confirm_uninstallation()
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
self.assertTrue(answer, "Expected a 'Yes' click to return True, but got False")
def test_confirmation_dialog_cancel(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Confirm remove"),
QtWidgets.QDialogButtonBox.Cancel,
)
answer = self.uninstaller_gui._confirm_uninstallation()
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
self.assertFalse(answer, "Expected a 'Cancel' click to return False, but got True")
def test_progress_dialog(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Removing Addon"),
QtWidgets.QDialogButtonBox.Cancel,
)
self.uninstaller_gui._show_progress_dialog()
# That call isn't modal, so spin our own event loop:
while self.uninstaller_gui.progress_dialog.isVisible():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_timer_launches_progress_dialog(self):
worker = FakeWorker()
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Removing Addon"),
QtWidgets.QDialogButtonBox.Cancel,
)
QtCore.QTimer.singleShot(1000, worker.stop) # If the test fails, this kills the "worker"
self.uninstaller_gui._confirm_uninstallation = lambda: True
self.uninstaller_gui._run_uninstaller = worker.work
self.uninstaller_gui._finalize = lambda: None
self.uninstaller_gui.dialog_timer.setInterval(1) # To speed up the test, only wait 1ms
self.uninstaller_gui.run() # Blocks once it hits the fake worker
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
worker.stop()
def test_success_dialog(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Uninstall complete"),
QtWidgets.QDialogButtonBox.Ok,
)
self.uninstaller_gui._succeeded(self.addon_to_remove)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_failure_dialog(self):
dialog_watcher = DialogWatcher(
translate("AddonsInstaller", "Uninstall failed"),
QtWidgets.QDialogButtonBox.Ok,
)
self.uninstaller_gui._failed(
self.addon_to_remove, "Some failure message\nAnother failure message"
)
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
self.assertTrue(dialog_watcher.button_found, "Failed to find the expected button")
def test_finalize(self):
self.uninstaller_gui.finished.connect(functools.partial(self.catch_signal, "finished"))
self.uninstaller_gui.worker_thread = MockThread()
self.uninstaller_gui._finalize()
self.assertIn("finished", self.signals_caught)

View File

@@ -1,260 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2025 FreeCAD project association AISBL *
# * *
# * 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/>. *
# * *
# ***************************************************************************
from time import sleep
import unittest
import FreeCAD
from Addon import Addon
from PySide import QtCore, QtWidgets
from addonmanager_update_all_gui import UpdateAllGUI, AddonStatus
class MockUpdater(QtCore.QObject):
success = QtCore.Signal(object)
failure = QtCore.Signal(object)
finished = QtCore.Signal()
def __init__(self, addon, addons=[]):
super().__init__()
self.addon_to_install = addon
self.addons = addons
self.has_run = False
self.emit_success = True
self.work_function = None # Set to some kind of callable to make this function take time
def run(self):
self.has_run = True
if self.work_function is not None and callable(self.work_function):
self.work_function()
if self.emit_success:
self.success.emit(self.addon_to_install)
else:
self.failure.emit(self.addon_to_install)
self.finished.emit()
class MockUpdaterFactory:
def __init__(self, addons):
self.addons = addons
self.work_function = None
self.updater = None
def get_updater(self, addon):
self.updater = MockUpdater(addon, self.addons)
self.updater.work_function = self.work_function
return self.updater
class MockAddon:
def __init__(self, name):
self.display_name = name
self.name = name
self.macro = None
self.metadata = None
self.installed_metadata = None
def status(self):
return Addon.Status.UPDATE_AVAILABLE
class CallInterceptor:
def __init__(self):
self.called = False
self.args = None
def intercept(self, *args):
self.called = True
self.args = args
class TestUpdateAllGui(unittest.TestCase):
def setUp(self):
self.addons = []
for i in range(3):
self.addons.append(MockAddon(f"Mock Addon {i}"))
self.factory = MockUpdaterFactory(self.addons)
self.test_object = UpdateAllGUI(self.addons)
self.test_object.updater_factory = self.factory
def tearDown(self):
pass
def test_run(self):
self.factory.work_function = lambda: sleep(0.1)
self.test_object.run()
while self.test_object.is_running():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.test_object.dialog.accept()
def test_setup_dialog(self):
self.test_object._setup_dialog()
self.assertIsNotNone(
self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
)
self.assertEqual(self.test_object.dialog.tableWidget.rowCount(), 3)
def test_cancelling_installation(self):
class Worker:
def __init__(self):
self.counter = 0
self.LIMIT = 100
self.limit_reached = False
def run(self):
while self.counter < self.LIMIT:
if QtCore.QThread.currentThread().isInterruptionRequested():
return
self.counter += 1
sleep(0.01)
self.limit_reached = True
worker = Worker()
self.factory.work_function = worker.run
self.test_object.run()
cancel_timer = QtCore.QTimer()
cancel_timer.timeout.connect(
self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel).click
)
cancel_timer.start(90)
while self.test_object.is_running():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 10)
self.assertGreater(len(self.test_object.addons_with_update), 0)
def test_add_addon_to_table(self):
mock_addon = MockAddon("MockAddon")
self.test_object.dialog.tableWidget.clear()
self.test_object._add_addon_to_table(mock_addon, 1)
self.assertEqual(self.test_object.dialog.tableWidget.rowCount(), 1)
def test_update_addon_status(self):
self.test_object._setup_dialog()
self.test_object._update_addon_status(0, AddonStatus.WAITING)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 2).text(),
AddonStatus.WAITING.ui_string(),
)
self.test_object._update_addon_status(0, AddonStatus.INSTALLING)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 2).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._update_addon_status(0, AddonStatus.SUCCEEDED)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 2).text(),
AddonStatus.SUCCEEDED.ui_string(),
)
self.test_object._update_addon_status(0, AddonStatus.FAILED)
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 2).text(),
AddonStatus.FAILED.ui_string(),
)
def test_process_next_update(self):
self.test_object._setup_dialog()
self.test_object._launch_active_installer = lambda: None
self.test_object._process_next_update()
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 2).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._process_next_update()
self.assertEqual(
self.test_object.dialog.tableWidget.item(1, 2).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._process_next_update()
self.assertEqual(
self.test_object.dialog.tableWidget.item(2, 2).text(),
AddonStatus.INSTALLING.ui_string(),
)
self.test_object._process_next_update()
def test_launch_active_installer(self):
self.test_object.active_installer = self.factory.get_updater(self.addons[0])
self.test_object._update_succeeded = lambda _: None
self.test_object._update_failed = lambda _: None
self.test_object.process_next_update = lambda: None
self.test_object._launch_active_installer()
# The above call does not block, so spin until it has completed (basically instantly in testing)
while self.test_object.worker_thread.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.test_object.dialog.accept()
def test_update_succeeded(self):
self.test_object._setup_dialog()
self.test_object._update_succeeded(self.addons[0])
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 2).text(),
AddonStatus.SUCCEEDED.ui_string(),
)
def test_update_failed(self):
self.test_object._setup_dialog()
self.test_object._update_failed(self.addons[0])
self.assertEqual(
self.test_object.dialog.tableWidget.item(0, 2).text(),
AddonStatus.FAILED.ui_string(),
)
def test_update_finished(self):
self.test_object._setup_dialog()
call_interceptor = CallInterceptor()
self.test_object.worker_thread = QtCore.QThread()
self.test_object.worker_thread.start()
self.test_object._process_next_update = call_interceptor.intercept
self.test_object.active_installer = self.factory.get_updater(self.addons[0])
self.test_object._update_finished()
self.assertFalse(self.test_object.worker_thread.isRunning())
self.test_object.worker_thread.quit()
self.assertTrue(call_interceptor.called)
self.test_object.worker_thread.wait()
def test_finalize(self):
self.test_object._setup_dialog()
self.test_object.worker_thread = QtCore.QThread()
self.test_object.worker_thread.start()
self.test_object._finalize()
self.assertFalse(self.test_object.worker_thread.isRunning())
self.test_object.worker_thread.quit()
self.test_object.worker_thread.wait()
self.assertFalse(self.test_object.running)
self.assertIsNotNone(
self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Close)
)
self.assertIsNone(
self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
)
def test_is_running(self):
self.assertFalse(self.test_object.is_running())
self.test_object.run()
self.assertTrue(self.test_object.is_running())
while self.test_object.is_running():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
self.test_object.dialog.accept()

View File

@@ -1,145 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * 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/>. *
# * *
# ***************************************************************************
import sys
import unittest
sys.path.append("../..")
from Widgets.addonmanager_widget_progress_bar import Progress
class TestProgress(unittest.TestCase):
def test_default_construction(self):
"""Given no parameters, a single-task Progress object is initialized with zero progress"""
progress = Progress()
self.assertEqual(progress.status_text, "")
self.assertEqual(progress.number_of_tasks, 1)
self.assertEqual(progress.current_task, 0)
self.assertEqual(progress.current_task_progress, 0.0)
def test_good_parameters(self):
"""Given good parameters, no exception is raised"""
_ = Progress(
status_text="Some text", number_of_tasks=1, current_task=0, current_task_progress=0.0
)
def test_zero_task_count(self):
with self.assertRaises(ValueError):
_ = Progress(number_of_tasks=0)
def test_negative_task_count(self):
with self.assertRaises(ValueError):
_ = Progress(number_of_tasks=-1)
def test_setting_status_post_creation(self):
progress = Progress()
self.assertEqual(progress.status_text, "")
progress.status_text = "Some status"
self.assertEqual(progress.status_text, "Some status")
def test_setting_task_count(self):
progress = Progress()
progress.number_of_tasks = 10
self.assertEqual(progress.number_of_tasks, 10)
def test_setting_negative_task_count(self):
progress = Progress()
with self.assertRaises(ValueError):
progress.number_of_tasks = -1
def test_setting_invalid_task_count(self):
progress = Progress()
with self.assertRaises(TypeError):
progress.number_of_tasks = 3.14159
def test_setting_current_task(self):
progress = Progress(number_of_tasks=10)
progress.number_of_tasks = 5
self.assertEqual(progress.number_of_tasks, 5)
def test_setting_current_task_greater_than_task_count(self):
progress = Progress()
progress.number_of_tasks = 10
with self.assertRaises(ValueError):
progress.current_task = 11
def test_setting_current_task_equal_to_task_count(self):
"""current_task is zero-indexed, so this is too high"""
progress = Progress()
progress.number_of_tasks = 10
with self.assertRaises(ValueError):
progress.current_task = 10
def test_setting_current_task_negative(self):
progress = Progress()
with self.assertRaises(ValueError):
progress.current_task = -1
def test_setting_current_task_invalid(self):
progress = Progress()
with self.assertRaises(TypeError):
progress.current_task = 2.718281
def test_setting_current_task_progress(self):
progress = Progress()
progress.current_task_progress = 50.0
self.assertEqual(progress.current_task_progress, 50.0)
def test_setting_current_task_progress_too_low(self):
progress = Progress()
progress.current_task_progress = -0.01
self.assertEqual(progress.current_task_progress, 0.0)
def test_setting_current_task_progress_too_high(self):
progress = Progress()
progress.current_task_progress = 100.001
self.assertEqual(progress.current_task_progress, 100.0)
def test_incrementing_task(self):
progress = Progress(number_of_tasks=10, current_task_progress=100.0)
progress.next_task()
self.assertEqual(progress.current_task, 1)
self.assertEqual(progress.current_task_progress, 0.0)
def test_incrementing_task_too_high(self):
progress = Progress(number_of_tasks=10, current_task=9, current_task_progress=100.0)
with self.assertRaises(ValueError):
progress.next_task()
def test_overall_progress_simple(self):
progress = Progress()
self.assertEqual(progress.overall_progress(), 0.0)
def test_overall_progress_with_ranges(self):
progress = Progress(number_of_tasks=2, current_task=1, current_task_progress=0.0)
self.assertAlmostEqual(progress.overall_progress(), 0.5)
def test_overall_progress_with_ranges_and_progress(self):
progress = Progress(number_of_tasks=10, current_task=5, current_task_progress=50.0)
self.assertAlmostEqual(progress.overall_progress(), 0.55)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,200 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
import json
import unittest
import os
import tempfile
import FreeCAD
from PySide import QtCore
import NetworkManager
from Addon import Addon
from addonmanager_workers_startup import (
CreateAddonListWorker,
LoadPackagesFromCacheWorker,
LoadMacrosFromCacheWorker,
)
run_slow_tests = False
class TestWorkersStartup(unittest.TestCase):
MODULE = "test_workers_startup" # file name without extension
@unittest.skipUnless(run_slow_tests, "This integration test is slow and uses the network")
def setUp(self):
"""Set up the test"""
self.test_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
self.saved_mod_directory = Addon.mod_directory
self.saved_cache_directory = Addon.cache_directory
Addon.mod_directory = os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Mod")
Addon.cache_directory = os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Cache")
os.makedirs(Addon.mod_directory, mode=0o777, exist_ok=True)
os.makedirs(Addon.cache_directory, mode=0o777, exist_ok=True)
url = "https://api.github.com/zen"
NetworkManager.InitializeNetworkManager()
result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
if result is None:
self.skipTest("No active internet connection detected")
self.addon_list = []
self.macro_counter = 0
self.workbench_counter = 0
self.prefpack_counter = 0
self.addon_from_cache_counter = 0
self.macro_from_cache_counter = 0
self.package_cache = {}
self.macro_cache = []
self.package_cache_filename = os.path.join(Addon.cache_directory, "packages.json")
self.macro_cache_filename = os.path.join(Addon.cache_directory, "macros.json")
# Store the user's preference for whether git is enabled or disabled
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
self.saved_git_disabled_status = pref.GetBool("disableGit", False)
def tearDown(self):
"""Tear down the test"""
Addon.mod_directory = self.saved_mod_directory
Addon.cache_directory = self.saved_cache_directory
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetBool("disableGit", self.saved_git_disabled_status)
def test_create_addon_list_worker(self):
"""Test whether any addons are added: runs the full query, so this potentially is a SLOW
test."""
worker = CreateAddonListWorker()
worker.addon_repo.connect(self._addon_added)
worker.start()
while worker.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
self.assertGreater(self.macro_counter, 0, "No macros returned")
self.assertGreater(self.workbench_counter, 0, "No workbenches returned")
# Make sure there are no duplicates:
addon_name_set = set()
for addon in self.addon_list:
addon_name_set.add(addon.name)
self.assertEqual(
len(addon_name_set), len(self.addon_list), "Duplicate names are not allowed"
)
# Write the cache data
if hasattr(self, "package_cache"):
with open(self.package_cache_filename, "w", encoding="utf-8") as f:
f.write(json.dumps(self.package_cache, indent=" "))
if hasattr(self, "macro_cache"):
with open(self.macro_cache_filename, "w", encoding="utf-8") as f:
f.write(json.dumps(self.macro_cache, indent=" "))
original_macro_counter = self.macro_counter
original_addon_list = self.addon_list.copy()
self.macro_counter = 0
self.workbench_counter = 0
self.addon_list.clear()
# Now try loading the same data from the cache we just created
worker = LoadPackagesFromCacheWorker(self.package_cache_filename)
worker.override_metadata_cache_path(os.path.join(Addon.cache_directory, "PackageMetadata"))
worker.addon_repo.connect(self._addon_added)
worker.start()
while worker.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
worker = LoadMacrosFromCacheWorker(self.macro_cache_filename)
worker.add_macro_signal.connect(self._addon_added)
worker.start()
while worker.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
# Make sure that every addon in the original list is also in the new list
fail_counter = 0
for original_addon in original_addon_list:
found = False
for addon in self.addon_list:
if addon.name == original_addon.name:
found = True
break
if not found:
print(f"Failed to load {addon.name} from cache")
fail_counter += 1
self.assertEqual(fail_counter, 0)
# Make sure there are no duplicates:
addon_name_set.clear()
for addon in self.addon_list:
addon_name_set.add(addon.name)
self.assertEqual(len(addon_name_set), len(self.addon_list))
self.assertEqual(len(original_addon_list), len(self.addon_list))
self.assertEqual(
original_macro_counter,
self.macro_counter,
"Cache loaded a different number of macros",
)
# We can't check workbench and preference pack counting at this point, because that relies
# on the package.xml metadata file, which this test does not download.
def test_create_addon_list_git_disabled(self):
"""If the user has git enabled, also test the addon manager with git disabled"""
if self.saved_git_disabled_status:
self.skipTest("Git is disabled, this test is redundant")
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetBool("disableGit", True)
self.test_create_addon_list_worker()
def _addon_added(self, addon: Addon):
"""Callback for adding an Addon: tracks the list, and counts the various types"""
print(f"Addon added: {addon.name}")
self.addon_list.append(addon)
if addon.contains_workbench():
self.workbench_counter += 1
if addon.contains_macro():
self.macro_counter += 1
if addon.contains_preference_pack():
self.prefpack_counter += 1
# Also record the information for cache purposes
if addon.macro is None:
self.package_cache[addon.name] = addon.to_cache()
else:
self.macro_cache.append(addon.macro.to_cache())

View File

@@ -1,78 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
import unittest
import os
import FreeCAD
from addonmanager_workers_utility import ConnectionChecker
from PySide import QtCore
import NetworkManager
class TestWorkersUtility(unittest.TestCase):
MODULE = "test_workers_utility" # file name without extension
@unittest.skip("Test is slow and uses the network: refactor!")
def setUp(self):
self.test_dir = os.path.join(
FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
)
self.last_result = None
url = "https://api.github.com/zen"
NetworkManager.InitializeNetworkManager()
result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
if result is None:
self.skipTest("No active internet connection detected")
def test_connection_checker_basic(self):
"""Tests the connection checking worker's basic operation: does not exit until worker thread completes"""
worker = ConnectionChecker()
worker.success.connect(self.connection_succeeded)
worker.failure.connect(self.connection_failed)
self.last_result = None
worker.start()
while worker.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
self.assertEqual(self.last_result, "SUCCESS")
def test_connection_checker_thread_interrupt(self):
worker = ConnectionChecker()
worker.success.connect(self.connection_succeeded)
worker.failure.connect(self.connection_failed)
self.last_result = None
worker.start()
worker.requestInterruption()
while worker.isRunning():
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
self.assertIsNone(self.last_result, "Requesting interruption of thread failed to interrupt")
def connection_succeeded(self):
self.last_result = "SUCCESS"
def connection_failed(self):
self.last_result = "FAILURE"

View File

@@ -1,3 +0,0 @@
## Unit tests for the Addon Manager
Data files are located in the `data/` subdirectory.

View File

@@ -1,81 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2024 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/>. *
# * *
# ***************************************************************************
"""Classes and structures related to Addon sidecar information"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import addonmanager_freecad_interface as fci
def to_int_or_zero(inp: [str | int | None]):
try:
return int(inp)
except TypeError:
return 0
def time_string_to_datetime(inp: str) -> Optional[datetime]:
try:
return datetime.fromisoformat(inp)
except ValueError:
try:
# Support for the trailing "Z" was added in Python 3.11 -- strip it and see if it works now
return datetime.fromisoformat(inp[:-1])
except ValueError:
fci.Console.PrintWarning(f"Unable to parse '{str}' as a Python datetime")
return None
@dataclass
class AddonStats:
"""Statistics about an addon: not all stats apply to all addon types"""
last_update_time: datetime | None = None
date_created: datetime | None = None
stars: int = 0
open_issues: int = 0
forks: int = 0
license: str = ""
page_views_last_month: int = 0
@classmethod
def from_json(cls, json_dict: dict):
new_stats = AddonStats()
if "pushed_at" in json_dict:
new_stats.last_update_time = time_string_to_datetime(json_dict["pushed_at"])
if "created_at" in json_dict:
new_stats.date_created = time_string_to_datetime(json_dict["created_at"])
if "stargazers_count" in json_dict:
new_stats.stars = to_int_or_zero(json_dict["stargazers_count"])
if "forks_count" in json_dict:
new_stats.forks = to_int_or_zero(json_dict["forks_count"])
if "open_issues_count" in json_dict:
new_stats.open_issues = to_int_or_zero(json_dict["open_issues_count"])
if "license" in json_dict:
if json_dict["license"] != "NOASSERTION" and json_dict["license"] != "None":
new_stats.license = json_dict["license"] # Might be None or "NOASSERTION"
return new_stats

View File

@@ -1,189 +0,0 @@
IF (BUILD_GUI)
PYSIDE_WRAP_RC(AddonManager_QRC_SRCS Resources/AddonManager.qrc)
add_subdirectory(Widgets)
ENDIF (BUILD_GUI)
SET(AddonManager_SRCS
ALLOWED_PYTHON_PACKAGES.txt
Addon.py
AddonManager.py
AddonManager.ui
AddonManagerOptions.py
AddonManagerOptions.ui
AddonManagerOptions_AddCustomRepository.ui
AddonStats.py
Init.py
InitGui.py
NetworkManager.py
PythonDependencyUpdateDialog.ui
TestAddonManagerApp.py
add_toolbar_button_dialog.ui
addonmanager_cache.py
addonmanager_connection_checker.py
addonmanager_dependency_installer.py
addonmanager_devmode.py
addonmanager_devmode_add_content.py
addonmanager_devmode_license_selector.py
addonmanager_devmode_licenses_table.py
addonmanager_devmode_metadata_checker.py
addonmanager_devmode_people_table.py
addonmanager_devmode_person_editor.py
addonmanager_devmode_predictor.py
addonmanager_devmode_validators.py
addonmanager_firstrun.py
addonmanager_freecad_interface.py
addonmanager_git.py
addonmanager_installer.py
addonmanager_installer_gui.py
addonmanager_licenses.py
addonmanager_macro.py
addonmanager_macro_parser.py
addonmanager_metadata.py
addonmanager_package_details_controller.py
addonmanager_preferences_defaults.json
addonmanager_pyside_interface.py
addonmanager_python_deps_gui.py
addonmanager_readme_controller.py
addonmanager_uninstaller.py
addonmanager_uninstaller_gui.py
addonmanager_update_all_gui.py
addonmanager_utilities.py
addonmanager_workers_installation.py
addonmanager_workers_startup.py
addonmanager_workers_utility.py
change_branch.py
change_branch.ui
compact_view.py
composite_view.py
dependency_resolution_dialog.ui
developer_mode.ui
developer_mode_add_content.ui
developer_mode_advanced_freecad_versions.ui
developer_mode_copyright_info.ui
developer_mode_dependencies.ui
developer_mode_edit_dependency.ui
developer_mode_freecad_versions.ui
developer_mode_license.ui
developer_mode_licenses_table.ui
developer_mode_people.ui
developer_mode_people_table.ui
developer_mode_select_from_list.ui
developer_mode_tags.ui
expanded_view.py
first_run.ui
install_to_toolbar.py
loading.html
package_list.py
select_toolbar_dialog.ui
update_all.ui
)
IF (BUILD_GUI)
LIST(APPEND AddonManager_SRCS TestAddonManagerGui.py)
ENDIF (BUILD_GUI)
SOURCE_GROUP("" FILES ${AddonManager_SRCS})
SET(AddonManagerTests_SRCS
AddonManagerTest/__init__.py
AddonManagerTest/test_information.md
)
SET(AddonManagerTestsApp_SRCS
AddonManagerTest/app/__init__.py
AddonManagerTest/app/mocks.py
AddonManagerTest/app/test_addon.py
AddonManagerTest/app/test_addoncatalog.py
AddonManagerTest/app/test_cache.py
AddonManagerTest/app/test_dependency_installer.py
AddonManagerTest/app/test_freecad_interface.py
AddonManagerTest/app/test_git.py
AddonManagerTest/app/test_installer.py
AddonManagerTest/app/test_macro.py
AddonManagerTest/app/test_macro_parser.py
AddonManagerTest/app/test_metadata.py
AddonManagerTest/app/test_uninstaller.py
AddonManagerTest/app/test_utilities.py
)
SET(AddonManagerTestsGui_SRCS
AddonManagerTest/gui/__init__.py
AddonManagerTest/gui/gui_mocks.py
AddonManagerTest/gui/test_gui.py
AddonManagerTest/gui/test_installer_gui.py
AddonManagerTest/gui/test_python_deps_gui.py
AddonManagerTest/gui/test_uninstaller_gui.py
AddonManagerTest/gui/test_update_all_gui.py
AddonManagerTest/gui/test_widget_progress_bar.py
AddonManagerTest/gui/test_workers_startup.py
AddonManagerTest/gui/test_workers_utility.py
)
SET(AddonManagerTestsFiles_SRCS
AddonManagerTest/data/__init__.py
AddonManagerTest/data/addon_update_stats.json
AddonManagerTest/data/bundle_only.xml
AddonManagerTest/data/combination.xml
AddonManagerTest/data/corrupted_metadata.zip
AddonManagerTest/data/depends_on_all_workbenches.xml
AddonManagerTest/data/DoNothing.FCMacro
AddonManagerTest/data/git_submodules.txt
AddonManagerTest/data/good_package.xml
AddonManagerTest/data/icon_cache.zip
AddonManagerTest/data/icon_cache.zip.sha1
AddonManagerTest/data/macro_only.xml
AddonManagerTest/data/macro_template.FCStd
AddonManagerTest/data/MacrosRecipesWikiPage.zip
AddonManagerTest/data/metadata.zip
AddonManagerTest/data/missing_macro_metadata.FCStd
AddonManagerTest/data/other_only.xml
AddonManagerTest/data/prefpack_only.xml
AddonManagerTest/data/test_addon_with_fcmacro.zip
AddonManagerTest/data/test_github_style_repo.zip
AddonManagerTest/data/test_repo.zip
AddonManagerTest/data/test_simple_repo.zip
AddonManagerTest/data/test_version_detection.xml
AddonManagerTest/data/TestWorkbench.zip
AddonManagerTest/data/workbench_only.xml
)
SET(AddonManagerTests_ALL
${AddonManagerTests_SRCS}
${AddonManagerTestsApp_SRCS}
${AddonManagerTestsFiles_SRCS}
)
IF (BUILD_GUI)
LIST(APPEND AddonManagerTests_ALL ${AddonManagerTestsGui_SRCS})
ENDIF (BUILD_GUI)
ADD_CUSTOM_TARGET(AddonManager ALL
SOURCES ${AddonManager_SRCS} ${AddonManager_QRC_SRCS}
)
ADD_CUSTOM_TARGET(AddonManagerTests ALL
SOURCES ${AddonManagerTests_ALL}
)
fc_copy_sources(AddonManager "${CMAKE_BINARY_DIR}/Mod/AddonManager" ${AddonManager_SRCS})
fc_copy_sources(AddonManagerTests "${CMAKE_BINARY_DIR}/Mod/AddonManager" ${AddonManagerTests_ALL})
IF (BUILD_GUI)
fc_target_copy_resource(AddonManager
${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_BINARY_DIR}/Mod/AddonManager
AddonManager_rc.py)
ENDIF (BUILD_GUI)
INSTALL(
FILES
${AddonManager_SRCS}
${AddonManager_QRC_SRCS}
DESTINATION
Mod/AddonManager
)
INSTALL(FILES ${AddonManagerTests_SRCS} DESTINATION Mod/AddonManager/AddonManagerTest)
INSTALL(FILES ${AddonManagerTestsApp_SRCS} DESTINATION Mod/AddonManager/AddonManagerTest/app)
INSTALL(FILES ${AddonManagerTestsGui_SRCS} DESTINATION Mod/AddonManager/AddonManagerTest/gui)
INSTALL(FILES ${AddonManagerTestsFiles_SRCS} DESTINATION Mod/AddonManager/AddonManagerTest/data)

View File

@@ -1,8 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# FreeCAD init script of the AddonManager module
# (c) 2001 Juergen Riegel
# License LGPL
import FreeCAD
FreeCAD.__unit_test__ += ["TestAddonManagerApp"]

View File

@@ -1,13 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# AddonManager gui init module
# (c) 2001 Juergen Riegel
# License LGPL
import AddonManager
FreeCADGui.addLanguagePath(":/translations")
FreeCADGui.addCommand("Std_AddonMgr", AddonManager.CommandAddonManager())
import FreeCAD
FreeCAD.__unit_test__ += ["TestAddonManagerGui"]

View File

@@ -1,714 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 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/>. *
# * *
# ***************************************************************************
"""
#############################################################################
#
# ABOUT NETWORK MANAGER
#
# A wrapper around QNetworkAccessManager providing proxy-handling
# capabilities, and simplified access to submitting requests from any
# application thread.
#
#
# USAGE
#
# Once imported, this file provides access to a global object called
# AM_NETWORK_MANAGER. This is a QObject running on the main thread, but
# designed to be interacted with from any other application thread. It
# provides two principal methods: submit_unmonitored_get() and
# submit_monitored_get(). Use the unmonitored version for small amounts of
# data (suitable for caching in RAM, and without a need to show a progress
# bar during download), and the monitored version for larger amounts of data.
# Both functions take a URL, and return an integer index. That index allows
# tracking of the completed request by attaching to the signals completed(),
# progress_made(), and progress_complete(). All three provide, as the first
# argument to the signal, the index of the request the signal refers to.
# Code attached to those signals should filter them to look for the indices
# of the requests they care about. Requests may complete in any order.
#
# A secondary blocking interface is also provided, for very short network
# accesses: the blocking_get() function blocks until the network transmission
# is complete, directly returning a QByteArray object with the received data.
# Do not run on the main GUI thread!
"""
import threading
import os
import queue
import itertools
import tempfile
import sys
from typing import Dict, List, Optional
from urllib.parse import urlparse
try:
import FreeCAD
if FreeCAD.GuiUp:
import FreeCADGui
HAVE_FREECAD = True
translate = FreeCAD.Qt.translate
except ImportError:
# For standalone testing support working without the FreeCAD import
HAVE_FREECAD = False
from PySide import QtCore
if FreeCAD.GuiUp:
from PySide import QtWidgets
# This is the global instance of the NetworkManager that outside code
# should access
AM_NETWORK_MANAGER = None
HAVE_QTNETWORK = True
try:
from PySide import QtNetwork
except ImportError:
if HAVE_FREECAD:
FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
'Could not import QtNetwork -- it does not appear to be installed on your system. Your provider may have a package for this dependency (often called "python3-pyside2.qtnetwork")',
)
+ "\n"
)
else:
print("Could not import QtNetwork, unable to test this file.")
sys.exit(1)
HAVE_QTNETWORK = False
if HAVE_QTNETWORK:
# Added in Qt 5.15
if hasattr(QtNetwork.QNetworkRequest, "DefaultTransferTimeoutConstant"):
timeoutConstant = QtNetwork.QNetworkRequest.DefaultTransferTimeoutConstant
if hasattr(timeoutConstant, "value"):
# Qt 6 changed the timeout constant to have a 'value' attribute.
# The function setTransferTimeout does not accept
# DefaultTransferTimeoutConstant of type
# QtNetwork.QNetworkRequest.TransferTimeoutConstant any
# longer but only an int.
default_timeout = timeoutConstant.value
else:
# In Qt 5.15 we can use the timeoutConstant as is.
default_timeout = timeoutConstant
else:
default_timeout = 30000
class QueueItem:
"""A container for information about an item in the network queue."""
def __init__(self, index: int, request: QtNetwork.QNetworkRequest, track_progress: bool):
self.index = index
self.request = request
self.original_url = request.url()
self.track_progress = track_progress
class NetworkManager(QtCore.QObject):
"""A single global instance of NetworkManager is instantiated and stored as
AM_NETWORK_MANAGER. Outside threads should send GET requests to this class by
calling the submit_unmonitored_request() or submit_monitored_request() function,
as needed. See the documentation of those functions for details."""
# Connect to complete for requests with no progress monitoring (e.g. small amounts of data)
completed = QtCore.Signal(
int, int, QtCore.QByteArray
) # Index, http response code, received data (if any)
# Connect to progress_made and progress_complete for large amounts of data, which get buffered into a temp file
# That temp file should be deleted when your code is done with it
progress_made = QtCore.Signal(int, int, int) # Index, bytes read, total bytes (may be None)
progress_complete = QtCore.Signal(
int, int, os.PathLike
) # Index, http response code, filename
__request_queued = QtCore.Signal()
def __init__(self):
super().__init__()
self.counting_iterator = itertools.count()
self.queue = queue.Queue()
self.__last_started_index = 0
self.__abort_when_found: List[int] = []
self.replies: Dict[int, QtNetwork.QNetworkReply] = {}
self.file_buffers = {}
# We support an arbitrary number of threads using synchronous GET calls:
self.synchronous_lock = threading.Lock()
self.synchronous_complete: Dict[int, bool] = {}
self.synchronous_result_data: Dict[int, QtCore.QByteArray] = {}
# Make sure we exit nicely on quit
if QtCore.QCoreApplication.instance() is not None:
QtCore.QCoreApplication.instance().aboutToQuit.connect(self.__aboutToQuit)
# Create the QNAM on this thread:
self.QNAM = QtNetwork.QNetworkAccessManager()
self.QNAM.proxyAuthenticationRequired.connect(self.__authenticate_proxy)
self.QNAM.authenticationRequired.connect(self.__authenticate_resource)
self.QNAM.setRedirectPolicy(QtNetwork.QNetworkRequest.ManualRedirectPolicy)
qnam_cache = QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.CacheLocation)
os.makedirs(qnam_cache, exist_ok=True)
self.diskCache = QtNetwork.QNetworkDiskCache()
self.diskCache.setCacheDirectory(qnam_cache)
self.QNAM.setCache(self.diskCache)
self.monitored_connections: List[int] = []
self._setup_proxy()
# A helper connection for our blocking interface
self.completed.connect(self.__synchronous_process_completion)
# Set up our worker connection
self.__request_queued.connect(self.__setup_network_request)
def _setup_proxy(self):
"""Set up the proxy based on user preferences or prompts on command line"""
# Set up the proxy, if necessary:
if HAVE_FREECAD:
(
noProxyCheck,
systemProxyCheck,
userProxyCheck,
proxy_string,
) = self._setup_proxy_freecad()
else:
(
noProxyCheck,
systemProxyCheck,
userProxyCheck,
proxy_string,
) = self._setup_proxy_standalone()
if noProxyCheck:
pass
elif systemProxyCheck:
query = QtNetwork.QNetworkProxyQuery(
QtCore.QUrl("https://github.com/FreeCAD/FreeCAD")
)
proxy = QtNetwork.QNetworkProxyFactory.systemProxyForQuery(query)
if proxy and proxy[0]:
self.QNAM.setProxy(proxy[0]) # This may still be QNetworkProxy.NoProxy
elif userProxyCheck:
try:
parsed_url = urlparse(proxy_string)
host = parsed_url.hostname
port = parsed_url.port
scheme = (
"http" if parsed_url.scheme == "https" else parsed_url.scheme
) # There seems no https type: doc.qt.io/qt-6/qnetworkproxy.html#ProxyType-enum
except ValueError:
FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
"Failed to parse proxy URL '{}'",
).format(proxy_string)
+ "\n"
)
return
FreeCAD.Console.PrintMessage(f"Using proxy {scheme}://{host}:{port} \n")
if scheme == "http":
_scheme = QtNetwork.QNetworkProxy.HttpProxy
elif scheme == "socks5":
_scheme = QtNetwork.QNetworkProxy.Socks5Proxy
else:
FreeCAD.Console.PrintWarning(f"Unknown proxy scheme '{scheme}', using http. \n")
_scheme = QtNetwork.QNetworkProxy.HttpProxy
proxy = QtNetwork.QNetworkProxy(_scheme, host, port)
self.QNAM.setProxy(proxy)
def _setup_proxy_freecad(self):
"""If we are running within FreeCAD, this uses the config data to set up the proxy"""
noProxyCheck = True
systemProxyCheck = False
userProxyCheck = False
proxy_string = ""
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
noProxyCheck = pref.GetBool("NoProxyCheck", noProxyCheck)
systemProxyCheck = pref.GetBool("SystemProxyCheck", systemProxyCheck)
userProxyCheck = pref.GetBool("UserProxyCheck", userProxyCheck)
proxy_string = pref.GetString("ProxyUrl", "")
# Add some error checking to the proxy setup, since for historical reasons they
# are independent booleans, rather than an enumeration:
option_count = [noProxyCheck, systemProxyCheck, userProxyCheck].count(True)
if option_count != 1:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
"Parameter error: mutually exclusive proxy options set. Resetting to default.",
)
+ "\n"
)
noProxyCheck = True
systemProxyCheck = False
userProxyCheck = False
pref.SetBool("NoProxyCheck", noProxyCheck)
pref.SetBool("SystemProxyCheck", systemProxyCheck)
pref.SetBool("UserProxyCheck", userProxyCheck)
if userProxyCheck and not proxy_string:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
"Parameter error: user proxy indicated, but no proxy provided. Resetting to default.",
)
+ "\n"
)
noProxyCheck = True
userProxyCheck = False
pref.SetBool("NoProxyCheck", noProxyCheck)
pref.SetBool("UserProxyCheck", userProxyCheck)
return noProxyCheck, systemProxyCheck, userProxyCheck, proxy_string
def _setup_proxy_standalone(self):
"""If we are NOT running inside FreeCAD, prompt the user for proxy information"""
noProxyCheck = True
systemProxyCheck = False
userProxyCheck = False
proxy_string = ""
print("Please select a proxy type:")
print("1) No proxy")
print("2) Use system proxy settings")
print("3) Custom proxy settings")
result = input("Choice: ")
if result == "1":
pass
elif result == "2":
noProxyCheck = False
systemProxyCheck = True
elif result == "3":
noProxyCheck = False
userProxyCheck = True
proxy_string = input("Enter your proxy server (host:port): ")
else:
print(f"Got {result}, expected 1, 2, or 3.")
app.quit()
return noProxyCheck, systemProxyCheck, userProxyCheck, proxy_string
def __aboutToQuit(self):
"""Called when the application is about to quit. Not currently used."""
def __setup_network_request(self):
"""Get the next request off the queue and launch it."""
try:
item = self.queue.get_nowait()
if item:
if item.index in self.__abort_when_found:
self.__abort_when_found.remove(item.index)
return # Do not do anything with this item, it's been aborted...
if item.track_progress:
self.monitored_connections.append(item.index)
self.__launch_request(item.index, item.request)
except queue.Empty:
pass
def __launch_request(self, index: int, request: QtNetwork.QNetworkRequest) -> None:
"""Given a network request, ask the QNetworkAccessManager to begin processing it."""
reply = self.QNAM.get(request)
self.replies[index] = reply
self.__last_started_index = index
reply.finished.connect(self.__reply_finished)
reply.sslErrors.connect(self.__on_ssl_error)
if index in self.monitored_connections:
reply.readyRead.connect(self.__ready_to_read)
reply.downloadProgress.connect(self.__download_progress)
def submit_unmonitored_get(
self,
url: str,
timeout_ms: int = default_timeout,
) -> int:
"""Adds this request to the queue, and returns an index that can be used by calling code
in conjunction with the completed() signal to handle the results of the call. All data is
kept in memory, and the completed() call includes a direct handle to the bytes returned. It
is not called until the data transfer has finished and the connection is closed."""
current_index = next(self.counting_iterator) # A thread-safe counter
# Use a queue because we can only put things on the QNAM from the main event loop thread
self.queue.put(
QueueItem(
current_index, self.__create_get_request(url, timeout_ms), track_progress=False
)
)
self.__request_queued.emit()
return current_index
def submit_monitored_get(
self,
url: str,
timeout_ms: int = default_timeout,
) -> int:
"""Adds this request to the queue, and returns an index that can be used by calling code
in conjunction with the progress_made() and progress_completed() signals to handle the
results of the call. All data is cached to disk, and progress is reported periodically
as the underlying QNetworkReply reports its progress. The progress_completed() signal
contains a path to a temporary file with the stored data. Calling code should delete this
file when done with it (or move it into its final place, etc.)."""
current_index = next(self.counting_iterator) # A thread-safe counter
# Use a queue because we can only put things on the QNAM from the main event loop thread
self.queue.put(
QueueItem(
current_index, self.__create_get_request(url, timeout_ms), track_progress=True
)
)
self.__request_queued.emit()
return current_index
def blocking_get(
self,
url: str,
timeout_ms: int = default_timeout,
) -> Optional[QtCore.QByteArray]:
"""Submits a GET request to the QNetworkAccessManager and block until it is complete"""
current_index = next(self.counting_iterator) # A thread-safe counter
with self.synchronous_lock:
self.synchronous_complete[current_index] = False
self.queue.put(
QueueItem(
current_index, self.__create_get_request(url, timeout_ms), track_progress=False
)
)
self.__request_queued.emit()
while True:
if QtCore.QThread.currentThread().isInterruptionRequested():
return None
QtCore.QCoreApplication.processEvents()
with self.synchronous_lock:
if self.synchronous_complete[current_index]:
break
with self.synchronous_lock:
self.synchronous_complete.pop(current_index)
if current_index in self.synchronous_result_data:
return self.synchronous_result_data.pop(current_index)
return None
def __synchronous_process_completion(
self, index: int, code: int, data: QtCore.QByteArray
) -> None:
"""Check the return status of a completed process, and handle its returned data (if
any)."""
with self.synchronous_lock:
if index in self.synchronous_complete:
if code == 200:
self.synchronous_result_data[index] = data
else:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
"Addon Manager: Unexpected {} response from server",
).format(code)
+ "\n"
)
self.synchronous_complete[index] = True
@staticmethod
def __create_get_request(url: str, timeout_ms: int) -> QtNetwork.QNetworkRequest:
"""Construct a network request to a given URL"""
request = QtNetwork.QNetworkRequest(QtCore.QUrl(url))
request.setAttribute(
QtNetwork.QNetworkRequest.RedirectPolicyAttribute,
QtNetwork.QNetworkRequest.ManualRedirectPolicy,
)
request.setAttribute(QtNetwork.QNetworkRequest.CacheSaveControlAttribute, True)
request.setAttribute(
QtNetwork.QNetworkRequest.CacheLoadControlAttribute,
QtNetwork.QNetworkRequest.PreferNetwork,
)
if hasattr(request, "setTransferTimeout"):
# Added in Qt 5.15
# In Qt 5, the function setTransferTimeout seems to accept
# DefaultTransferTimeoutConstant of type
# PySide2.QtNetwork.QNetworkRequest.TransferTimeoutConstant,
# whereas in Qt 6, the function seems to only accept an
# integer.
request.setTransferTimeout(timeout_ms)
return request
def abort_all(self):
"""Abort ALL network calls in progress, including clearing the queue"""
for reply in self.replies.values():
if reply.abort().isRunning():
reply.abort()
while True:
try:
self.queue.get()
self.queue.task_done()
except queue.Empty:
break
def abort(self, index: int):
"""Abort a specific request"""
if index in self.replies and self.replies[index].isRunning():
self.replies[index].abort()
elif index < self.__last_started_index:
# It's still in the queue. Mark it for later destruction.
self.__abort_when_found.append(index)
def __authenticate_proxy(
self,
reply: QtNetwork.QNetworkProxy,
authenticator: QtNetwork.QAuthenticator,
):
"""If proxy authentication is required, attempt to authenticate. If the GUI is running this displays
a window asking for credentials. If the GUI is not running, it prompts on the command line.
"""
if HAVE_FREECAD and FreeCAD.GuiUp:
proxy_authentication = FreeCADGui.PySideUic.loadUi(
os.path.join(os.path.dirname(__file__), "proxy_authentication.ui")
)
# Show the right labels, etc.
proxy_authentication.labelProxyAddress.setText(f"{reply.hostName()}:{reply.port()}")
if authenticator.realm():
proxy_authentication.labelProxyRealm.setText(authenticator.realm())
else:
proxy_authentication.labelProxyRealm.hide()
proxy_authentication.labelRealmCaption.hide()
result = proxy_authentication.exec()
if result == QtWidgets.QDialogButtonBox.Ok:
authenticator.setUser(proxy_authentication.lineEditUsername.text())
authenticator.setPassword(proxy_authentication.lineEditPassword.text())
else:
username = input("Proxy username: ")
import getpass
password = getpass.getpass()
authenticator.setUser(username)
authenticator.setPassword(password)
def __authenticate_resource(
self,
_reply: QtNetwork.QNetworkReply,
_authenticator: QtNetwork.QAuthenticator,
):
"""Unused."""
def __on_ssl_error(self, reply: str, errors: List[str] = None):
"""Called when an SSL error occurs: prints the error information."""
if HAVE_FREECAD:
FreeCAD.Console.PrintWarning(
translate("AddonsInstaller", "Error with encrypted connection") + "\n:"
)
FreeCAD.Console.PrintWarning(reply)
if errors is not None:
for error in errors:
FreeCAD.Console.PrintWarning(error)
else:
print("Error with encrypted connection")
if errors is not None:
for error in errors:
print(error)
def __download_progress(self, bytesReceived: int, bytesTotal: int) -> None:
"""Monitors download progress and emits a progress_made signal"""
sender = self.sender()
if not sender:
return
for index, reply in self.replies.items():
if reply == sender:
self.progress_made.emit(index, bytesReceived, bytesTotal)
return
def __ready_to_read(self) -> None:
"""Called when data is available, this reads that data."""
sender = self.sender()
if not sender:
return
for index, reply in self.replies.items():
if reply == sender:
self.__data_incoming(index, reply)
return
def __data_incoming(self, index: int, reply: QtNetwork.QNetworkReply) -> None:
"""Read incoming data and attach it to a data object"""
if not index in self.replies:
# We already finished this reply, this is a vestigial signal
return
buffer = reply.readAll()
if not index in self.file_buffers:
f = tempfile.NamedTemporaryFile("wb", delete=False)
self.file_buffers[index] = f
else:
f = self.file_buffers[index]
try:
f.write(buffer.data())
except OSError as e:
if HAVE_FREECAD:
FreeCAD.Console.PrintError(f"Network Manager internal error: {str(e)}")
else:
print(f"Network Manager internal error: {str(e)}")
def __reply_finished(self) -> None:
"""Called when a reply has been completed: this makes sure the data has been read and
any notifications have been called."""
reply = self.sender()
if not reply:
# This can happen during a cancellation operation: silently do nothing
return
index = None
for key, value in self.replies.items():
if reply == value:
index = key
break
if index is None:
return
response_code = reply.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute)
redirect_codes = [301, 302, 303, 305, 307, 308]
if response_code in redirect_codes: # This is a redirect
timeout_ms = default_timeout
if hasattr(reply, "request"):
request = reply.request()
if hasattr(request, "transferTimeout"):
timeout_ms = request.transferTimeout()
new_url = reply.attribute(QtNetwork.QNetworkRequest.RedirectionTargetAttribute)
self.__launch_request(index, self.__create_get_request(new_url, timeout_ms))
return # The task is not done, so get out of this method now
if reply.error() != QtNetwork.QNetworkReply.NetworkError.OperationCanceledError:
# It this was not a timeout, make sure we mark the queue task done
self.queue.task_done()
if reply.error() == QtNetwork.QNetworkReply.NetworkError.NoError:
if index in self.monitored_connections:
# Make sure to read any remaining data
self.__data_incoming(index, reply)
self.monitored_connections.remove(index)
f = self.file_buffers[index]
f.close()
self.progress_complete.emit(index, response_code, f.name)
else:
data = reply.readAll()
self.completed.emit(index, response_code, data)
else:
FreeCAD.Console.PrintWarning(f"Request failed: {reply.error()} \n")
if index in self.monitored_connections:
self.progress_complete.emit(index, response_code, "")
else:
self.completed.emit(index, response_code, None)
self.replies.pop(index)
else: # HAVE_QTNETWORK is false:
class NetworkManager(QtCore.QObject):
"""A dummy class to enable an offline mode when the QtNetwork package is not yet installed"""
completed = QtCore.Signal(
int, int, bytes
) # Emitted as soon as the request is made, with a connection failed error
progress_made = QtCore.Signal(int, int, int) # Never emitted, no progress is made here
progress_complete = QtCore.Signal(
int, int, os.PathLike
) # Emitted as soon as the request is made, with a connection failed error
def __init__(self):
super().__init__()
self.monitored_queue = queue.Queue()
self.unmonitored_queue = queue.Queue()
def submit_unmonitored_request(self, _) -> int:
"""Returns a fake index that can be used for testing -- nothing is actually queued"""
current_index = next(itertools.count())
self.unmonitored_queue.put(current_index)
return current_index
def submit_monitored_request(self, _) -> int:
"""Returns a fake index that can be used for testing -- nothing is actually queued"""
current_index = next(itertools.count())
self.monitored_queue.put(current_index)
return current_index
def blocking_get(self, _: str) -> QtCore.QByteArray:
"""No operation - returns None immediately"""
return None
def abort_all(
self,
):
"""There is nothing to abort in this case"""
def abort(self, _):
"""There is nothing to abort in this case"""
def InitializeNetworkManager():
"""Called once at the beginning of program execution to create the appropriate manager object"""
global AM_NETWORK_MANAGER
if AM_NETWORK_MANAGER is None:
AM_NETWORK_MANAGER = NetworkManager()
if __name__ == "__main__":
app = QtCore.QCoreApplication()
InitializeNetworkManager()
count = 0
# For testing, create several network requests and send them off in quick succession:
# (Choose small downloads, no need for significant data)
urls = [
"https://api.github.com/zen",
"http://climate.ok.gov/index.php/climate/rainfall_table/local_data",
"https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/AIANNHA/MapServer",
]
def handle_completion(index: int, code: int, data):
"""Attached to the completion signal, prints diagnostic information about the network access"""
global count
if code == 200:
print(f"For request {index+1}, response was {data.size()} bytes.", flush=True)
else:
print(
f"For request {index+1}, request failed with HTTP result code {code}",
flush=True,
)
count += 1
if count >= len(urls):
print("Shutting down...", flush=True)
AM_NETWORK_MANAGER.requestInterruption()
AM_NETWORK_MANAGER.wait(5000)
app.quit()
AM_NETWORK_MANAGER.completed.connect(handle_completion)
for test_url in urls:
AM_NETWORK_MANAGER.submit_unmonitored_get(test_url)
app.exec_()
print("Done with all requests.")

View File

@@ -1,109 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PythonDependencyUpdateDialog</class>
<widget class="QDialog" name="PythonDependencyUpdateDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>528</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Manage Python Dependencies</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>The following Python packages have been installed locally by the Addon Manager to satisfy Addon dependencies. Installation location:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelInstallationPath">
<property name="text">
<string notr="true">placeholder for path</string>
</property>
<property name="textInteractionFlags">
<set>Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QTableWidget" name="tableWidget">
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<property name="columnCount">
<number>5</number>
</property>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Package name</string>
</property>
</column>
<column>
<property name="text">
<string>Installed version</string>
</property>
</column>
<column>
<property name="text">
<string>Available version</string>
</property>
</column>
<column>
<property name="text">
<string>Used by</string>
</property>
</column>
<column>
<property name="text">
<string/>
</property>
</column>
</widget>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>An asterisk (*) in the &quot;Used by&quot; column indicates an optional dependency. Note that Used by only records direct imports in the Addon. Other Python packages that those packages depend upon may have been installed as well.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="buttonUpdateAll">
<property name="text">
<string>Update all available</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,127 +0,0 @@
<RCC>
<qresource>
<file>icons/preferences-addon_manager.svg</file>
<file>icons/3D_Printing_Tools_workbench_icon.svg</file>
<file>icons/AddonMgrWithWarning.svg</file>
<file>icons/A2plus_workbench_icon.svg</file>
<file>icons/AirPlaneDesign_workbench_icon.svg</file>
<file>icons/ArchTextures_workbench_icon.svg</file>
<file>icons/Assembly4_workbench_icon.svg</file>
<file>icons/BCFPlugin_workbench_icon.svg</file>
<file>icons/BIMBots_workbench_icon.svg</file>
<file>icons/BIM_workbench_icon.svg</file>
<file>icons/BOLTSFC_workbench_icon.svg</file>
<file>icons/CADExchanger_workbench_icon.svg</file>
<file>icons/cadquery_module_workbench_icon.svg</file>
<file>icons/Cfd_workbench_icon.svg</file>
<file>icons/CfdOF_workbench_icon.svg</file>
<file>icons/CurvedShapes_workbench_icon.svg</file>
<file>icons/Curves_workbench_icon.svg</file>
<file>icons/Defeaturing_workbench_icon.svg</file>
<file>icons/DesignSPHysics_workbench_icon.svg</file>
<file>icons/dodo_workbench_icon.svg</file>
<file>icons/DynamicData_workbench_icon.svg</file>
<file>icons/EM_workbench_icon.svg</file>
<file>icons/ExplodedAssembly_workbench_icon.svg</file>
<file>icons/ExtMan_workbench_icon.svg</file>
<file>icons/fasteners_workbench_icon.svg</file>
<file>icons/flamingo_workbench_icon.svg</file>
<file>icons/FEM_FrontISTR_workbench_icon.svg</file>
<file>icons/GDML_workbench_icon.svg</file>
<file>icons/GDT_workbench_icon.svg</file>
<file>icons/FCGear_workbench_icon.svg</file>
<file>icons/Geomatics_workbench_icon.svg</file>
<file>icons/ImportNURBS_workbench_icon.svg</file>
<file>icons/InventorLoader_workbench_icon.svg</file>
<file>icons/kicadStepUpMod_workbench_icon.svg</file>
<file>icons/lattice2_workbench_icon.svg</file>
<file>icons/LCInterlocking_workbench_icon.svg</file>
<file>icons/Lithophane_workbench_icon.svg</file>
<file>icons/Maker_workbench_icon.svg</file>
<file>icons/Manipulator_workbench_icon.svg</file>
<file>icons/Marz_workbench_icon.svg</file>
<file>icons/MeshRemodel_workbench_icon.svg</file>
<file>icons/ModernUI_workbench_icon.svg</file>
<file>icons/MOOC_workbench_icon.svg</file>
<file>icons/MnesarcoUtils_workbench_icon.svg</file>
<file>icons/OSE3dPrinter_workbench_icon.svg</file>
<file>icons/Part-o-magic_workbench_icon.svg</file>
<file>icons/Plot_workbench_icon.svg</file>
<file>icons/POV-Ray-Rendering_workbench_icon.svg</file>
<file>icons/Pyramids-and-Polyhedrons_workbench_icon.svg</file>
<file>icons/pyrate_workbench_icon.svg</file>
<file>icons/Reinforcement_workbench_icon.svg</file>
<file>icons/Reporting_workbench_icon.svg</file>
<file>icons/Render_workbench_icon.svg</file>
<file>icons/Rocket_workbench_icon.svg</file>
<file>icons/sheetmetal_workbench_icon.svg</file>
<file>icons/slic3r-tools_workbench_icon.svg</file>
<file>icons/Ship_workbench_icon.svg</file>
<file>icons/Silk_workbench_icon.svg</file>
<file>icons/TaackPLM_workbench_icon.svg</file>
<file>icons/timber_workbench_icon.svg</file>
<file>icons/ThreadProfile_workbench_icon.svg</file>
<file>icons/WebTools_workbench_icon.svg</file>
<file>icons/workfeature_workbench_icon.svg</file>
<file>icons/yaml-workspace_workbench_icon.svg</file>
<file>icons/compact_view.svg</file>
<file>icons/composite_view.svg</file>
<file>icons/expanded_view.svg</file>
<file>icons/sort_ascending.svg</file>
<file>icons/sort_descending.svg</file>
<file>licenses/Apache-2.0.txt</file>
<file>licenses/BSD-2-Clause.txt</file>
<file>licenses/BSD-3-Clause.txt</file>
<file>licenses/CC0-1.0.txt</file>
<file>licenses/GPL-2.0-or-later.txt</file>
<file>licenses/GPL-3.0-or-later.txt</file>
<file>licenses/LGPL-2.1-or-later.txt</file>
<file>licenses/LGPL-3.0-or-later.txt</file>
<file>licenses/MIT.txt</file>
<file>licenses/MPL-2.0.txt</file>
<file>licenses/spdx.json</file>
<file>translations/AddonManager_af.qm</file>
<file>translations/AddonManager_ar.qm</file>
<file>translations/AddonManager_ca.qm</file>
<file>translations/AddonManager_cs.qm</file>
<file>translations/AddonManager_de.qm</file>
<file>translations/AddonManager_el.qm</file>
<file>translations/AddonManager_es-ES.qm</file>
<file>translations/AddonManager_eu.qm</file>
<file>translations/AddonManager_fi.qm</file>
<file>translations/AddonManager_fil.qm</file>
<file>translations/AddonManager_fr.qm</file>
<file>translations/AddonManager_gl.qm</file>
<file>translations/AddonManager_hr.qm</file>
<file>translations/AddonManager_hu.qm</file>
<file>translations/AddonManager_id.qm</file>
<file>translations/AddonManager_it.qm</file>
<file>translations/AddonManager_ja.qm</file>
<file>translations/AddonManager_kab.qm</file>
<file>translations/AddonManager_ko.qm</file>
<file>translations/AddonManager_lt.qm</file>
<file>translations/AddonManager_nl.qm</file>
<file>translations/AddonManager_no.qm</file>
<file>translations/AddonManager_pl.qm</file>
<file>translations/AddonManager_pt-BR.qm</file>
<file>translations/AddonManager_pt-PT.qm</file>
<file>translations/AddonManager_ro.qm</file>
<file>translations/AddonManager_ru.qm</file>
<file>translations/AddonManager_sk.qm</file>
<file>translations/AddonManager_sl.qm</file>
<file>translations/AddonManager_sr.qm</file>
<file>translations/AddonManager_sv-SE.qm</file>
<file>translations/AddonManager_tr.qm</file>
<file>translations/AddonManager_uk.qm</file>
<file>translations/AddonManager_val-ES.qm</file>
<file>translations/AddonManager_vi.qm</file>
<file>translations/AddonManager_zh-CN.qm</file>
<file>translations/AddonManager_zh-TW.qm</file>
<file>translations/AddonManager_es-AR.qm</file>
<file>translations/AddonManager_bg.qm</file>
<file>translations/AddonManager_ka.qm</file>
<file>translations/AddonManager_sr-CS.qm</file>
<file>translations/AddonManager_be.qm</file>
<file>translations/AddonManager_da.qm</file>
</qresource>
</RCC>

View File

@@ -1,179 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Scale_Mesh.svg">
<defs
id="defs2">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="36.112677 : -87.557014 : 1"
inkscape:vp_y="-4360.1598 : 2974.7292 : 0"
inkscape:vp_z="268.67971 : 140.87747 : 1"
inkscape:persp3d-origin="198.90457 : -5.0702273 : 1"
id="perspective851" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6"
inkscape:cx="-50.392857"
inkscape:cy="31.05133"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
units="px"
inkscape:snap-nodes="false" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06665)">
<flowRoot
xml:space="preserve"
id="flowRoot3725"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"><flowRegion
id="flowRegion3727"><rect
id="rect3729"
width="62.857143"
height="405.71429"
x="240"
y="362.51968" /></flowRegion><flowPara
id="flowPara3731" /></flowRoot> <flowRoot
xml:space="preserve"
id="flowRoot4572"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"><flowRegion
id="flowRegion4574"><rect
id="rect4576"
width="271.42856"
height="147.14285"
x="67.14286"
y="216.8054" /></flowRegion><flowPara
id="flowPara4578" /></flowRoot> <rect
style="fill:none;fill-opacity:1;stroke:none;stroke-width:148.89118958;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect834"
width="64"
height="64"
x="1.6348703e-006"
y="280.06665" />
<g
sodipodi:type="inkscape:box3d"
id="g853"
style="fill:#e5ff80;fill-opacity:1;stroke:none;stroke-width:37.79527664;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
inkscape:perspectiveID="#perspective851"
inkscape:corner0="1.2974359 : 0.051199999 : 0 : 1"
inkscape:corner7="0.70666667 : 0.040472381 : 0.29286865 : 1">
<path
sodipodi:type="inkscape:box3dside"
id="path855"
style="fill:#353564;fill-rule:evenodd;stroke:none;stroke-width:194.0941925;stroke-linejoin:round"
inkscape:box3dsidetype="6"
d="M 9.8014818,329.42585 30.160757,343.31602 57.128521,327.4728 39.071136,315.15311 Z"
points="30.160757,343.31602 57.128521,327.4728 39.071136,315.15311 9.8014818,329.42585 " />
<path
sodipodi:type="inkscape:box3dside"
id="path865"
style="fill:#e9e9ff;fill-rule:evenodd;stroke:none;stroke-width:194.0941925;stroke-linejoin:round"
inkscape:box3dsidetype="11"
d="m 39.071136,315.15311 0.874086,-34.41161 23.392499,15.95959 -6.2092,30.77171 z"
points="39.945222,280.7415 63.337721,296.70109 57.128521,327.4728 39.071136,315.15311 " />
<path
sodipodi:type="inkscape:box3dside"
id="path857"
style="fill:#4d4d9f;fill-rule:evenodd;stroke:none;stroke-width:194.0941925;stroke-linejoin:round"
inkscape:box3dsidetype="5"
d="M 9.8014818,329.42585 0.69376043,294.04969 39.945222,280.7415 39.071136,315.15311 Z"
points="0.69376043,294.04969 39.945222,280.7415 39.071136,315.15311 9.8014818,329.42585 " />
<path
sodipodi:type="inkscape:box3dside"
id="path863"
style="fill:#8ec98d;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:194.0941925;stroke-linejoin:round"
inkscape:box3dsidetype="13"
d="m 30.160757,343.31602 -2.060281,-30.56804 35.237245,-16.04689 -6.2092,30.77171 z"
points="28.100476,312.74798 63.337721,296.70109 57.128521,327.4728 30.160757,343.31602 " />
<path
sodipodi:type="inkscape:box3dside"
id="path861"
style="fill:#ee9bfc;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:194.0941925;stroke-linejoin:round"
inkscape:box3dsidetype="14"
d="M 0.69376043,294.04969 28.100476,312.74798 63.337721,296.70109 39.945222,280.7415 Z"
points="28.100476,312.74798 63.337721,296.70109 39.945222,280.7415 0.69376043,294.04969 " />
<path
sodipodi:type="inkscape:box3dside"
id="path859"
style="fill:#a7d9f0;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:194.0941925;stroke-linejoin:round"
inkscape:box3dsidetype="3"
d="M 9.8014818,329.42585 0.69376043,294.04969 28.100476,312.74798 30.160757,343.31602 Z"
points="0.69376043,294.04969 28.100476,312.74798 30.160757,343.31602 9.8014818,329.42585 " />
</g>
<path
style="fill:none;stroke:#000000;stroke-width:1.45151734;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 1.7257872,295.06561 28.19751,313.19314 30.187504,342.8281 10.522794,329.36192 Z"
id="path833"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 0.69376043,294.04969 39.945222,280.7415 63.337721,296.70109 28.100476,312.74798 v 0 0"
id="path835"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="m 57.128521,327.4728 6.2092,-30.77171"
id="path837"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 30.160757,343.31602 57.128521,327.4728"
id="path839"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="m 0.69376043,294.04969 62.64396057,2.6514"
id="path841"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="m 30.160757,343.31602 33.176964,-46.61493 v 0 0"
id="path843"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.47181559;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 30.709234,343.85948 1.9309977,295.29193"
id="path845"
inkscape:connector-curvature="0" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,300 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2"
width="64"
height="64"
viewBox="0 0 63.999999 64"
sodipodi:docname="airplaneWBiconV2.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)"
inkscape:export-filename="C:\Users\Rafael\bitmap.png"
inkscape:export-xdpi="300"
inkscape:export-ydpi="300">
<metadata
id="metadata8">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs6">
<linearGradient
inkscape:collect="always"
id="linearGradient913">
<stop
style="stop-color:#3465a4;stop-opacity:1;"
offset="0"
id="stop909" />
<stop
style="stop-color:#3465a4;stop-opacity:0;"
offset="1"
id="stop911" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient4739">
<stop
style="stop-color:#204a87;stop-opacity:1;"
offset="0"
id="stop4735" />
<stop
style="stop-color:#729fcf;stop-opacity:1"
offset="1"
id="stop4737" />
</linearGradient>
<linearGradient
id="linearGradient3797">
<stop
style="stop-color:#3465a4;stop-opacity:1;"
offset="0"
id="stop3799-3" />
<stop
style="stop-color:#729fcf;stop-opacity:1;"
offset="1"
id="stop3801" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3797"
id="linearGradient4687-1-6-6"
x1="18.145235"
y1="51.499367"
x2="44.398445"
y2="51.499367"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1,0,0,1.0981138,0.14321474,-55.839871)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3797"
id="linearGradient4695-7-8-7-0"
x1="0.18017258"
y1="36.248028"
x2="14.623517"
y2="36.248028"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-1,0,0,1,57.200391,-43.587124)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3797"
id="linearGradient4687-1-6-6-8"
x1="18.145235"
y1="51.499367"
x2="44.398445"
y2="51.499367"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1,0,0,1.0981138,45.686839,-56.465414)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3797"
id="linearGradient1065"
x1="26.11158"
y1="2.9369314"
x2="44.096264"
y2="2.9110429"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.2002734,0,0,1.2002734,-6.0365755,41.424104)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3797"
id="linearGradient4655"
x1="108"
y1="46"
x2="128"
y2="46"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-86.020077,-13.687753)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4739"
id="linearGradient995"
gradientUnits="userSpaceOnUse"
x1="39.971127"
y1="44.082279"
x2="26.592251"
y2="23.73741"
gradientTransform="translate(78.922405,4.6396909)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4739"
id="linearGradient949"
gradientUnits="userSpaceOnUse"
x1="18.702328"
y1="31.725029"
x2="45.765244"
y2="31.812008" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient913"
id="linearGradient915"
x1="43.590008"
y1="61.390144"
x2="22.400524"
y2="2.3465822"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1800"
inkscape:window-height="1096"
id="namedview4"
showgrid="true"
inkscape:zoom="4"
inkscape:cx="10.374285"
inkscape:cy="-2.7321467"
inkscape:current-layer="g4675"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-x="-9"
inkscape:window-y="214"
inkscape:window-maximized="1"
inkscape:snap-to-guides="true"
inkscape:snap-grids="false">
<sodipodi:guide
position="31.999999,59.249999"
orientation="1,0"
id="guide4620"
inkscape:locked="false" />
<sodipodi:guide
position="2.03125,61.3125"
orientation="1,0"
id="guide853"
inkscape:locked="false" />
<sodipodi:guide
position="62.004426,63.94897"
orientation="1,0"
id="guide855"
inkscape:locked="false" />
<sodipodi:guide
position="8.53125,2"
orientation="0,1"
id="guide857"
inkscape:locked="false" />
<sodipodi:guide
position="1.09375,62.000001"
orientation="0,1"
id="guide859"
inkscape:locked="false" />
<sodipodi:guide
position="44,31"
orientation="0,1"
id="guide861"
inkscape:locked="false" />
<inkscape:grid
type="xygrid"
id="grid863"
empspacing="2" />
<sodipodi:guide
position="41.125,18.125"
orientation="0,1"
id="guide916"
inkscape:locked="false" />
<sodipodi:guide
position="81.624999,50.124999"
orientation="0,1"
id="guide918"
inkscape:locked="false" />
<sodipodi:guide
position="4.6249999,67.125"
orientation="1,0"
id="guide1533"
inkscape:locked="false" />
<sodipodi:guide
position="59.875,71.124999"
orientation="1,0"
id="guide1535"
inkscape:locked="false" />
<sodipodi:guide
position="28.5,50.875"
orientation="1,0"
id="guide1537"
inkscape:locked="false" />
<sodipodi:guide
position="36.749999,67.625"
orientation="1,0"
id="guide1539"
inkscape:locked="false" />
<sodipodi:guide
position="-8.6249999,39.25"
orientation="0,1"
id="guide1541"
inkscape:locked="false" />
</sodipodi:namedview>
<g
id="g4675"
transform="matrix(1.0161989,0,0,1.0161989,-0.46489119,-0.38459578)">
<g
id="g1045"
transform="translate(27.100757,23.156998)" />
<path
style="fill:#34e0e2;stroke:#042a2a;stroke-width:1.96811864;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 60.471247,4.6806294 3.6892581,27.998455 18.531887,36.116006 Z"
id="path5174"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:#06989a;stroke:#042a2a;stroke-width:1.96811864;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 18.531887,36.116006 27.080367,59.750028 28.503171,40.673222 60.471247,4.6806294 Z"
id="path5178"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#042a2a;stroke:#042a2a;stroke-width:1.96811864;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 27.080367,59.750028 60.471247,4.6806294 28.503171,40.673222 Z"
id="path5180"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:#16d0d2;stroke:#042a2a;stroke-width:1.96811864;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 60.471247,4.6806294 28.503171,40.673222 58.606838,49.028083 Z"
id="path5176"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:#16d0d2;stroke:#34e0e2;stroke-width:2.00869703;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 57.899299,11.044025 -25.195091,28.366947 23.725688,6.58474 z"
id="path5176-2"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#34e0e2;stroke-width:1.96811867;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 53.810206,12.110056 -32.891771,24.698235 6.4519,17.963692"
id="path980"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;stroke:#042a2a;stroke-width:1.96811867;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 27.100887,59.176296 28.606846,40.423251"
id="path982"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#042a2a;stroke-width:1.96811867;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 44.903942,22.206144 57.418099,8.1263571"
id="path986"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -1,216 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg8"
inkscape:version="0.92.1 r15371"
sodipodi:docname="Workbench.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="-157.07953"
inkscape:cy="154.35889"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1366"
inkscape:window-height="705"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4485"
empspacing="2"
empcolor="#3f3fff"
empopacity="0.28627451"
color="#3f3fff"
opacity="0.09411765" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06665)">
<path
style="fill:none;fill-rule:evenodd;stroke:#171018;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 1.8520833,296.20623 H 14.552083 l 0,-10.05416 H 1.8520836 Z"
id="path4491"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#171018;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 1.8520836,286.15207 4.2333331,-4.7625 h 4.7625003 l 3.704166,4.7625 z"
id="path4493"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ad7fa8;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 2.38125,295.67707 h 11.641666 v -8.99584 H 2.38125 Z"
id="path4495"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#ffffff;fill-rule:evenodd;stroke:#171018;stroke-width:0.52916667;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none;fill-opacity:1"
d="m 3.4395833,296.20623 h 2.1166666 v -3.70416 H 3.4395833 Z"
id="path4499"
inkscape:connector-curvature="0" />
<path
style="fill:#75507b;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="m 3.7041666,286.94582 h 1.0583333 v 1.05833 H 3.7041666 Z"
id="path4501"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4503"
d="m 2.6458334,288.00415 h 1.0583333 v 1.05833 H 2.6458334 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 3.7041667,289.06249 H 4.7625 v 1.05833 H 3.7041667 Z"
id="path4505"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4507"
d="m 4.7625,288.00415 h 1.0583333 v 1.05833 H 4.7625 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 5.8208334,286.94582 h 1.0583333 v 1.05833 H 5.8208334 Z"
id="path4509"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4511"
d="m 2.6458334,290.12082 h 1.0583333 v 1.05833 H 2.6458334 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path4513"
d="m 7.9374999,286.94582 h 1.0583333 v 1.05833 H 7.9374999 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 6.8791667,288.00415 H 7.9375 v 1.05833 H 6.8791667 Z"
id="path4515"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4517"
d="M 3.7041667,291.17916 H 4.7625 v 1.05833 H 3.7041667 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 4.7625,290.12082 h 1.0583337 v 1.05833 H 4.7625 Z"
id="path4519"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4521"
d="m 5.8208337,289.06249 h 1.058333 v 1.05833 h -1.058333 z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path4525"
d="m 5.2916665,283.24165 h 1.0583333 v 1.05833 H 5.2916665 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 4.2333333,284.29998 h 1.0583333 v 1.05833 H 4.2333333 Z"
id="path4527"
inkscape:connector-curvature="0" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 6.35,284.29998 h 1.0583333 v 1.05833 H 6.35 Z"
id="path4529"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4531"
d="m 6.3500001,282.18332 h 1.0583333 v 1.05833 H 6.3500001 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path4533"
d="m 7.4083334,283.24165 h 1.0583333 v 1.05833 H 7.4083334 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 8.4666667,282.18332 H 9.525 v 1.05833 H 8.4666667 Z"
id="path4535"
inkscape:connector-curvature="0" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ad7fa8;stroke-width:0.5291667;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 2.9104166,285.6229 H 13.49375 l -2.910417,-3.70417 H 6.35 Z"
id="path4497"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 10.054167,286.94582 H 11.1125 v 1.05833 h -1.058333 z"
id="path4539"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4541"
d="m 8.9958333,288.00415 h 1.0583337 v 1.05833 H 8.9958333 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 5.8208333,291.17916 h 1.0583333 v 1.05833 H 5.8208333 Z"
id="path4543"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4545"
d="m 6.8791666,290.12082 h 1.0583337 v 1.05833 H 6.8791666 Z"
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#75507b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 7.9375003,289.06249 h 1.058333 v 1.05833 h -1.058333 z"
id="path4547"
inkscape:connector-curvature="0" />
<path
style="fill:none;fill-rule:evenodd;stroke:#171018;stroke-width:0.52916667;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="m 7.6729167,292.50207 h 2.1166664 v 1.5875 H 7.6729167 Z"
id="path4549"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4551"
d="m 10.847917,292.50207 h 2.116666 v 1.5875 h -2.116666 z"
style="fill:none;fill-rule:evenodd;stroke:#171018;stroke-width:0.52916667;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,411 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
id="svg3559"
version="1.1"
inkscape:version="0.92.3 (2405546, 2018-03-11)"
sodipodi:docname="Model.svg"
viewBox="0 0 64 64">
<defs
id="defs3561">
<linearGradient
id="linearGradient4383-1"
inkscape:collect="always">
<stop
id="stop69725"
offset="0"
style="stop-color:#c4a000;stop-opacity:1" />
<stop
id="stop69727"
offset="1"
style="stop-color:#fce94f;stop-opacity:1" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient4383">
<stop
style="stop-color:#3465a4;stop-opacity:1"
offset="0"
id="stop4385" />
<stop
style="stop-color:#729fcf;stop-opacity:1"
offset="1"
id="stop4387" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4383-1"
id="linearGradient4389"
x1="20.243532"
y1="37.588112"
x2="17.243532"
y2="27.588112"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.243533,-2.588112)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient73208"
id="linearGradient4399"
x1="48.714352"
y1="45.585785"
x2="44.714352"
y2="34.585785"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(1.2856487,1.4142136)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4383-3"
id="linearGradient4389-0"
x1="27.243532"
y1="54.588112"
x2="21.243532"
y2="30.588112"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.243533,-2.588112)" />
<linearGradient
inkscape:collect="always"
id="linearGradient4383-3">
<stop
style="stop-color:#3465a4;stop-opacity:1"
offset="0"
id="stop4385-1" />
<stop
style="stop-color:#729fcf;stop-opacity:1"
offset="1"
id="stop4387-2" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4393-9"
id="linearGradient4399-7"
x1="48.714352"
y1="45.585785"
x2="40.714352"
y2="24.585787"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(1.2856487,1.4142136)" />
<linearGradient
inkscape:collect="always"
id="linearGradient4393-9">
<stop
style="stop-color:#204a87;stop-opacity:1"
offset="0"
id="stop4395-8" />
<stop
style="stop-color:#3465a4;stop-opacity:1"
offset="1"
id="stop4397-1" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient73208"
id="linearGradient69042"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-12.714351,-17.585786)"
x1="48.714352"
y1="45.585785"
x2="44.714352"
y2="34.585785" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4383-1"
id="linearGradient69056"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-1.243533,-2.588112)"
x1="27.243532"
y1="54.588112"
x2="22.243532"
y2="40.588112" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4383"
id="linearGradient69709"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(21.756467,-5.588112)"
x1="20.243532"
y1="37.588112"
x2="17.243532"
y2="27.588112" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4383"
id="linearGradient69717"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(1.2856487,1.4142136)"
x1="50.714352"
y1="25.585787"
x2="48.714352"
y2="20.585787" />
<linearGradient
inkscape:collect="always"
id="linearGradient73208">
<stop
style="stop-color:#c4a000;stop-opacity:1"
offset="0"
id="stop73210" />
<stop
style="stop-color:#edd400;stop-opacity:1"
offset="1"
id="stop73212" />
</linearGradient>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="10"
inkscape:cx="8.35"
inkscape:cy="27.079831"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:document-units="px"
inkscape:grid-bbox="true"
inkscape:window-width="2160"
inkscape:window-height="1104"
inkscape:window-x="225"
inkscape:window-y="66"
inkscape:window-maximized="0"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid3007"
empspacing="4"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true"
spacingx="0.5"
spacingy="0.5" />
</sodipodi:namedview>
<metadata
id="metadata3564">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
<dc:title>Path-Stock</dc:title>
<dc:date>2015-07-04</dc:date>
<dc:relation>https://www.freecad.org/wiki/index.php?title=Artwork</dc:relation>
<dc:publisher>
<cc:Agent>
<dc:title>FreeCAD</dc:title>
</cc:Agent>
</dc:publisher>
<dc:identifier>FreeCAD/src/Mod/Path/Gui/Resources/icons/Path-Stock.svg</dc:identifier>
<dc:rights>
<cc:Agent>
<dc:title>FreeCAD LGPL2+</dc:title>
</cc:Agent>
</dc:rights>
<cc:license>https://www.gnu.org/copyleft/lesser.html</cc:license>
<dc:contributor>
<cc:Agent>
<dc:title>[agryson] Alexander Gryson</dc:title>
</cc:Agent>
</dc:contributor>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:label="Layer 1"
inkscape:groupmode="layer"
style="display:inline">
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69052"
d="m 9,35 0,-14 14,5 0,14 z"
style="fill:url(#linearGradient4389);fill-opacity:1.0;fill-rule:nonzero;stroke:#0b1521;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none" />
<path
style="fill:url(#linearGradient69056);fill-opacity:1.0;fill-rule:nonzero;stroke:#0b1521;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"
d="M 9,49 9,35 37,45 37,59 Z"
id="path4381"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:url(#linearGradient4399);fill-opacity:1.0;fill-rule:nonzero;stroke:#0b1521;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"
d="M 37,59 37,45 55,28 55,41 Z"
id="path4391"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#fce94f;fill-opacity:1;fill-rule:nonzero;stroke:#0b1521;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"
d="M 9,21 29,5 42,10 23,26 Z"
id="path4403"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#fce94f;stroke-width:2;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 11.008035,47.60627 11,38 l 24,8 0.0081,10.184812 z"
id="path4381-7"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#edd400;stroke-width:2;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 39.005041,54.16825 39,46 53,33 l 0.0021,7.176847 z"
id="path4391-0"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69038"
d="M 23,40 42,23 55,28 37,45 Z"
style="fill:#fce94f;fill-opacity:1;fill-rule:nonzero;stroke:#0b1521;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69040"
d="m 23,40 0,-14 8,-7 0,14 z"
style="fill:url(#linearGradient69042);fill-opacity:1.0;fill-rule:nonzero;stroke:#0b1521;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69044"
d="m 25,36 0,-9 4,-3.5 0,9 z"
style="fill:none;stroke:#edd400;stroke-width:2;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69048"
d="M 11.008,33.60627 11,24 l 10,3 0,10 z"
style="fill:none;stroke:#fce94f;stroke-width:2;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:url(#linearGradient69709);fill-opacity:1.0;fill-rule:nonzero;stroke:#302b00;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"
d="m 31,33 0,-14 13,5 0,14.5 z"
id="path69705"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#729fcf;stroke-width:2;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 33,32 0,-10 9,3 0,10.5 z"
id="path69707"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69715"
d="M 44,38.5 44,24 55,15 55,28 Z"
style="fill:url(#linearGradient69717);fill-opacity:1.0;fill-rule:nonzero;stroke:#302b00;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69719"
d="m 46,34 0,-9 7,-6 0,8 z"
style="fill:none;stroke:#729fcf;stroke-width:2;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="path69721"
d="m 31,19 11,-9 13,5 -11,9 z"
style="fill:#729fcf;fill-opacity:1;fill-rule:nonzero;stroke:#302b00;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none" />
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="temporal"
style="display:none;opacity:0.58800001"
sodipodi:insensitive="true">
<path
inkscape:connector-curvature="0"
id="path68967"
d="M 9,35 9,49"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
sodipodi:nodetypes="cc" />
<path
inkscape:connector-curvature="0"
id="path68971"
d="M 9,35 37,45"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
sodipodi:nodetypes="cc"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 55,28 0,13"
id="path68973"
inkscape:connector-curvature="0" />
<path
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 37,45 55,28"
id="path68977"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path68983"
d="M 23,40 23,26"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path68985"
d="m 29,5 13,5"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 23,26 42,10"
id="path68989"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 19,13 29,5"
id="path68993"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 55,15 -9,8"
id="path68997"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path69030"
d="M 42,23 42,10"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 42,23 14,5"
id="path69034"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path69036"
d="M 23,40 42,23"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path69711"
d="m 23,10 19,0"
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#ef2929;fill-rule:evenodd;stroke:#ef2929;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 34,17 0,13"
id="path69713"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="Cfd_workbench_icon.svg"
inkscape:version="1.0beta2 (2b71d25, 2019-12-03)"
version="1.1"
id="svg2816"
height="64px"
width="64px">
<defs
id="defs2818" />
<sodipodi:namedview
inkscape:document-rotation="0"
inkscape:window-maximized="1"
inkscape:window-y="61"
inkscape:window-x="0"
inkscape:window-height="1052"
inkscape:window-width="1920"
inkscape:grid-bbox="true"
inkscape:document-units="px"
showgrid="true"
inkscape:current-layer="layer8"
inkscape:cy="34.077045"
inkscape:cx="30.558985"
inkscape:zoom="10.825726"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base" />
<metadata
id="metadata2821">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 2"
id="layer8"
inkscape:groupmode="layer">
<path
sodipodi:nodetypes="cscsc"
inkscape:connector-curvature="0"
id="path3657"
d="M 61.095169,40.506926 C 39.817023,35.498558 36.885357,34.950109 17.577376,30.137198 -9.2686372,23.445279 13.357079,17.237473 11.337223,18.144965 c -3.2085292,0.389803 4.901242,-1.121067 9.635335,-0.398369 6.282158,0.959024 40.322493,22.708263 40.322493,22.708263"
style="display:inline;fill:#00ff00;fill-rule:evenodd;stroke:#000000;stroke-width:1.75748;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path3667"
d="M 6.7552618,34.820785 C 31.016687,42.543302 36.822807,41.477249 54.227408,47.648859"
style="display:inline;fill:none;fill-rule:evenodd;stroke:#0000ee;stroke-width:2.13543;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path3671"
d="m 49.568669,42.379789 4.658739,5.26907 v 0 l -4.991506,4.630397"
style="display:inline;fill:none;fill-rule:evenodd;stroke:#0000ee;stroke-width:1.96535;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path3667-3"
d="M 3.3627108,15.373641 C 12.801608,7.8011794 33.065789,12.824069 56.2526,24.488284"
style="display:inline;fill:none;fill-rule:evenodd;stroke:#ef0000;stroke-width:2.13543;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path3671-6"
d="m 52.78476,18.746343 5.329732,5.379334 v 0 l -5.710427,4.727294"
style="display:inline;fill:none;fill-rule:evenodd;stroke:#ef0000;stroke-width:1.87465;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,270 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
id="svg2869"
version="1.1"
viewBox="0 0 64 64"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2871">
<linearGradient
id="linearGradient29">
<stop
style="stop-color:#204a87;stop-opacity:1"
offset="0"
id="stop29" />
<stop
style="stop-color:#729fcf;stop-opacity:1"
offset="1"
id="stop30" />
</linearGradient>
<linearGradient
id="linearGradient24">
<stop
id="stop23"
offset="0"
style="stop-color:#204a87;stop-opacity:1" />
<stop
id="stop24"
offset="1"
style="stop-color:#729fcf;stop-opacity:1" />
</linearGradient>
<linearGradient
id="linearGradient22">
<stop
id="stop21"
offset="0"
style="stop-color:#4e9a06;stop-opacity:1" />
<stop
id="stop22"
offset="1"
style="stop-color:#8ae234;stop-opacity:1" />
</linearGradient>
<linearGradient
id="linearGradient5">
<stop
style="stop-color:#ef2929;stop-opacity:1;"
offset="0"
id="stop19" />
<stop
style="stop-color:#ef2929;stop-opacity:0;"
offset="1"
id="stop20" />
</linearGradient>
<linearGradient
id="swatch18">
<stop
style="stop-color:#ef2929;stop-opacity:1;"
offset="0"
id="stop18" />
</linearGradient>
<linearGradient
id="swatch15">
<stop
style="stop-color:#3d0000;stop-opacity:1;"
offset="0"
id="stop15" />
</linearGradient>
<linearGradient
id="linearGradient5-1">
<stop
style="stop-color:#ef2929;stop-opacity:1;"
offset="0"
id="stop5" />
<stop
style="stop-color:#ef2929;stop-opacity:0;"
offset="1"
id="stop6" />
</linearGradient>
<linearGradient
id="linearGradient3836-9">
<stop
style="stop-color:#a40000;stop-opacity:1"
offset="0"
id="stop3838-8" />
<stop
style="stop-color:#ef2929;stop-opacity:1"
offset="1"
id="stop3840-1" />
</linearGradient>
<radialGradient
xlink:href="#linearGradient11"
id="radialGradient13"
cx="39.502319"
cy="40.726604"
fx="39.502319"
fy="40.726604"
r="22.062311"
gradientTransform="matrix(1.7937293,1.017333,-1.0383284,1.824402,12.77456,-63.360877)"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="linearGradient11">
<stop
style="stop-color:#729fcf;stop-opacity:1"
offset="0"
id="stop13" />
<stop
style="stop-color:#204a87;stop-opacity:1"
offset="1"
id="stop11" />
</linearGradient>
<linearGradient
id="linearGradient3377-3">
<stop
id="stop3379-8"
offset="0"
style="stop-color:#faff2b;stop-opacity:1;" />
<stop
id="stop3381-3"
offset="1"
style="stop-color:#ffaa00;stop-opacity:1;" />
</linearGradient>
<linearGradient
id="linearGradient4387">
<stop
style="stop-color:#71b2f8;stop-opacity:1;"
offset="0"
id="stop4389" />
<stop
style="stop-color:#002795;stop-opacity:1;"
offset="1"
id="stop4391" />
</linearGradient>
<linearGradient
id="linearGradient3836-0-6-92-4-0">
<stop
style="stop-color:#a40000;stop-opacity:1"
offset="0"
id="stop3838-2-7-06-8-6" />
<stop
style="stop-color:#ef2929;stop-opacity:1"
offset="1"
id="stop3840-5-5-8-7-2" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient22"
id="linearGradient4516"
gradientUnits="userSpaceOnUse"
x1="14.577716"
y1="27.643585"
x2="50.611004"
y2="42.347919"
gradientTransform="matrix(0.91764667,0,0,0.91543299,7.4569649,-10.851524)" />
<linearGradient
id="linearGradient3354">
<stop
id="stop3356"
offset="0"
style="stop-color:#729fcf;stop-opacity:1" />
<stop
id="stop3358"
offset="1"
style="stop-color:#204a87;stop-opacity:1" />
</linearGradient>
<linearGradient
y2="31.92013"
x2="53.693264"
y1="45.023232"
x1="28.158932"
gradientTransform="matrix(0.91764667,0,0,0.91543299,5.5967063,-9.6079085)"
gradientUnits="userSpaceOnUse"
id="linearGradient18"
xlink:href="#linearGradient24" />
<linearGradient
gradientTransform="matrix(0.91764667,0,0,0.91543299,-34.434605,-11.375877)"
y2="34.816742"
x2="41.556858"
y1="47.53363"
x1="65.435036"
gradientUnits="userSpaceOnUse"
id="linearGradient14"
xlink:href="#linearGradient3354" />
<linearGradient
xlink:href="#linearGradient29"
id="linearGradient30"
x1="29.777794"
y1="35.716694"
x2="55.384754"
y2="14.091692"
gradientUnits="userSpaceOnUse" />
</defs>
<metadata
id="metadata2874">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:creator>
<cc:Agent>
<dc:title>[maxwxyz]</dc:title>
</cc:Agent>
</dc:creator>
<dc:relation>https://www.freecad.org/wiki/index.php?title=Artwork</dc:relation>
<dc:publisher>
<cc:Agent>
<dc:title>FreeCAD</dc:title>
</cc:Agent>
</dc:publisher>
<dc:identifier>FreeCAD/src/</dc:identifier>
<dc:rights>
<cc:Agent>
<dc:title>FreeCAD LGPL2+</dc:title>
</cc:Agent>
</dc:rights>
<dc:date>2024</dc:date>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer3"
style="display:inline">
<path
d="M 18.260795,61.000176 C 1.7864687,47.327689 1.5712439,37.276581 7.4738156,17.881577 10.514149,14.174495 15.337186,5.0777001 30.812411,3.2439598 11.081982,19.578959 16.471245,36.612767 22.896918,45.84763 c -4.058659,4.167818 -6.324466,8.717398 -4.636123,15.152546 z"
style="opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:evenodd;stroke:#0c1522;stroke-width:2.00001;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path12" />
<path
id="path16"
style="display:inline;opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:evenodd;stroke:#0c1522;stroke-width:2.00001;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 39.80712,40.886165 C 39.347502,18.750404 43.027643,14.637758 50.918334,7.7286519 54.736143,9.018553 59.074083,12.861105 60.054063,15.930397 53.803846,23.917301 52.515998,35.730898 57.174803,58.979264 54.485196,50.098341 51.074626,40.861606 39.80712,40.886165 Z" />
<path
id="path28"
style="display:inline;opacity:1;fill:url(#linearGradient30);fill-opacity:1;fill-rule:evenodd;stroke:#729fcf;stroke-width:2.00001;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 51.318359,10.046875 c 2.664611,1.226923 5.342254,3.630329 6.441407,5.619141 -4.704254,6.607763 -6.335888,15.411672 -4.847657,29.220703 -2.549632,-3.157876 -6.050466,-5.445581 -11.125,-5.908203 -0.197725,-18.706008 2.787826,-22.928537 9.53125,-28.931641 z" />
<path
id="path24"
style="display:inline;opacity:1;fill:#8ae234;fill-opacity:1;fill-rule:evenodd;stroke:#17230b;stroke-width:2.00001;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 22.896918,45.84763 C 10.507844,27.298201 19.418369,12.723127 30.812411,3.2439598 38.009021,2.2698045 45.343419,4.3228707 50.918334,7.7286519 40.938023,15.97348 39.70122,22.647624 39.80712,40.886165 34.254637,41.133337 27.673519,41.988373 22.896918,45.84763 Z" />
<path
id="path21"
style="opacity:1;fill:url(#linearGradient4516);fill-opacity:1;fill-rule:evenodd;stroke:#8ae234;stroke-width:1.93455;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 34.685547,5.0859375 c 4.3141,0.061386 8.542613,1.2198975 12.195312,3.0117188 C 38.840841,15.580985 37.559778,22.755428 37.558594,37.972656 33.070552,38.2911 27.950191,39.15602 23.683594,41.839844 14.12913,25.966693 21.656459,13.68295 31.611328,5.2363281 32.632653,5.1230644 33.659554,5.0713386 34.685547,5.0859375 Z"
transform="matrix(1.0338547,0,0,1.0338147,-1.0247137,-0.25384716)" />
<path
d="M 24.462891,6.7109375 C 11.291828,21.044915 15.087935,35.534635 20.701172,44.390625 18.167597,47.28992 16.422634,50.565513 16.210937,54.5 4.8313443,43.641922 4.991564,34.921511 9.9746094,18.4375 12.780553,14.921062 16.016896,9.4755874 24.462891,6.7109375 Z"
style="opacity:1;fill:url(#linearGradient14);fill-opacity:1;fill-rule:evenodd;stroke:#729fcf;stroke-width:1.93455;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path26"
transform="matrix(1.0338547,0,0,1.0338147,-1.0247137,-0.25384716)" />
<path
style="fill:none;stroke:#0c1522;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 8.4851839,51.269783 C 11.811877,31.098625 44.97231,22.579841 54.52943,40.463301"
id="path839" />
<path
style="fill:none;stroke:#0c1522;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 4.0012601,37.976551 C 14.369364,18.537691 43.536681,16.009382 54.707116,29.842375"
id="path841" />
<path
style="fill:none;stroke:#0c1522;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 5.5280638,24.90186 C 20.261449,10.062113 39.319651,5.3312343 56.764105,21.582244"
id="path843" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,197 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933312 16.933312"
version="1.1"
id="svg50"
sodipodi:docname="DesignSPHysics_workbench_icon_new_64x64_2.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<metadata
id="metadata54">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1853"
inkscape:window-height="1019"
id="namedview52"
showgrid="false"
inkscape:zoom="2.6074562"
inkscape:cx="-45.759252"
inkscape:cy="61.417337"
inkscape:window-x="1433"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg50" />
<defs
id="defs32">
<linearGradient
id="linearGradient1091"
inkscape:collect="always">
<stop
id="stop1087"
offset="0"
style="stop-color:#de2230;stop-opacity:1" />
<stop
id="stop1089"
offset="1"
style="stop-color:#2b6ab0;stop-opacity:1" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient1075">
<stop
style="stop-color:#983176;stop-opacity:1"
offset="0"
id="stop1071" />
<stop
style="stop-color:#2b6ab0;stop-opacity:1"
offset="1"
id="stop1073" />
</linearGradient>
<radialGradient
id="A"
cx="282.64999"
cy="29.149"
r="19.570999"
gradientTransform="matrix(0.61866,0.96665,-1.0332,0.66128,-327.28,-255.84)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#71b2f8"
offset="0"
id="stop2" />
<stop
stop-color="#002795"
offset="1"
id="stop4" />
</radialGradient>
<radialGradient
id="B"
cx="270.57999"
cy="33.900002"
r="19.570999"
gradientTransform="matrix(1.1149,0.27223,-0.75072,3.0746,-471.09,-148.33)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#ff6d0f"
offset="0"
id="stop7" />
<stop
stop-color="#ff1000"
offset="1"
id="stop9" />
</radialGradient>
<linearGradient
id="C"
x1="142.42599"
x2="226.73"
y1="162.099"
y2="162.099"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.06151372,0,0,0.06151372,12.90318,-2.121192)">
<stop
stop-color="#666"
stop-opacity=".847"
offset="0"
id="stop12" />
<stop
stop-color="#666"
stop-opacity=".25"
offset=".518"
id="stop14" />
<stop
stop-color="#666"
stop-opacity=".025"
offset=".776"
id="stop16" />
<stop
stop-color="#666"
stop-opacity="0"
offset="1"
id="stop18" />
</linearGradient>
<filter
id="D"
style="color-interpolation-filters:sRGB">
<feFlood
flood-color="#000"
flood-opacity=".498"
id="feFlood21" />
<feComposite
in2="SourceGraphic"
operator="in"
id="feComposite23" />
<feGaussianBlur
stdDeviation="2.5"
id="feGaussianBlur25" />
<feOffset
dx="0.3"
dy="0.8"
id="feOffset27"
result="result1" />
<feComposite
in="SourceGraphic"
in2="result1"
id="feComposite29" />
</filter>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1075"
id="linearGradient1077"
x1="12.879064"
y1="190.00041"
x2="127.98798"
y2="78.355347"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1091"
id="linearGradient1085"
x1="-20.224503"
y1="109.90421"
x2="53.218998"
y2="155.82506"
gradientUnits="userSpaceOnUse" />
</defs>
<g
id="g1069"
transform="matrix(0.10285649,0,0,0.10267995,1.0534131,-5.6318672)"
style="stroke-width:0.59856778">
<path
sodipodi:nodetypes="csczcc"
inkscape:connector-curvature="0"
id="path1061"
d="m 19.243778,189.86421 c 0,0 15.84417,-22.71955 9.352293,-54.45045 -5.996226,-29.30827 -20.8817476,-29.27105 -20.8817476,-29.27105 0,0 6.3886856,-26.330422 22.6624956,-19.823753 16.273809,6.506669 40.275125,77.872223 49.12365,119.176843 z"
style="fill:url(#linearGradient1085);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:2.5745616;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="ccscsc"
inkscape:connector-curvature="0"
id="path871"
d="m 33.57962,193.44157 73.18635,19.80337 c 0,0 -28.276859,-25.17999 -14.063259,-84.66656 7.749139,-32.431605 47.355879,-46.20786 47.355879,-46.20786 0,0 -25.23199,-34.985128 -48.419841,-13.860317 C 49.862564,106.56952 33.57962,193.44157 33.57962,193.44157 Z"
style="fill:url(#linearGradient1077);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:2.5745616;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg8"
sodipodi:docname="DynamicDataSVGLogo.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="-81.428571"
inkscape:cy="68.194187"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1001"
inkscape:window-x="1791"
inkscape:window-y="-9"
inkscape:window-maximized="1"
units="px" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06665)">
<image
y="280.21786"
x="0.60476506"
id="image4537"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAA3NCSVQICAjb4U/gAAAEmUlEQVRo
ge1az2vjRhiVvT0VSi67IOjKgeaQxRPQMZHZQntZJ9eS0R4CNbS3diFkDblapodCim32D0hLiy8Z
5bQ9+VSoidWEUFiQHJI2FNkpCBwIXkKPcQ+yFc9oRh4pcWyB32kszzfznma+HzN2otfrCXFGctIE
7oqZgEnjg2hmmqa5jbm5uTsy6Ha7xJihEPsVmAmYNBLR8kClUnEbqVTqjgxarZbbSCYpb/Pm5sZr
ZzIZt7G8vOw9jP0KzARMGhHzwDiwuLgY3GFhYcH/MPYrMBMwaWA+cHh46DYajYb3kBqex4HV1dUI
VrFfgZmASQPzAS/QXl1dBZudnJyMi1FIxH4FsGr08vLSbRwfHwebra2t0b9Q8vt5JRQDrxp99Kjz
548//fzOoXSCqIcg1TzKCrx4v5uTxQiGwXgl/kVnHwjMB7zie+QhIcJMY8J0+UCEhY0ioPbRV+PY
Qm/+zdx1C/FDzm2XBUEQhFTij60t1I42ig/lsjuqsPXifWpJ4xkWE3B+fu42zs7OvIeJRIKwGdN1
ajKZHD4B81qNg8pDIvYCpuhIubm5efvBKnL6FSZAUfpJdGVlxXvo3/HJZNK7FxIE4b48OBpiv4Wm
SoAVwSaKACLYSRGGoAMwP7CB+cDBwYHbODo68h4+2Jk4GqaaHA9mAiYNzAe828nr62u3Yder1d/r
rZZt24NO8/PzQurZs0/kbPSKtG0g1DAuLtptL4mI4u6uASFcL0BO96UIGIJdr1ar1bpN+ca2Bdu2
67WaIIryxsaIG1k/c4QMSu5zHMvRLUvXNAEAWCjwqqCdiU/ffrHxNY07BwLOxG2jVC7RuHOAfSb2
rcDpD8+f75xGmiUIbXSPx4ZhYAIeP/5NfUKyBwAW9goQ9NfUsvT9YlHTQ2VNo+RjL0mK+lpVJMm9
lXCcdx/+cxxyWEEgopCuqjpOHiLTNJHHXnD1ILO8nZND0C8ZOHklX6lU8op0m8RFUS4gs2cyNgob
QwJI+kAzTcQKCaKcK/NpIOlLaqWSVxj1B2BvdgZuBeg6QX9vVCSQcxwSjAZB/7U6oniCKIwETwDB
ny+QydlRbAj+ijrKwJ1c484FAwEkf8j3EkSFtRlckPwzfNeOYJ07m9FLCQDSnPaS9JSzZ6jOIM1L
gC4gneZewqcS/3kgRN804GRAF9BscsfjizZ/egrRt2lxMhgIwBVbVpNzonb7Iuhr/J2P6DxMoMlL
YCCA2HSETzPhGMHFDbHrCZ9mwtrnTsneFiLiji8p0+DUfhlV3xBxx5eUabCKLzXuLXzrA2Tw1dUl
lfkeut3u32+/36n5LpP/u2zh+PjTLHZsMEqvvvv1aKhDd4AB+6UlfvpEOU0zBlArFNaHqiHLsprF
z7/VO53AgUU592X/Et6p7fikinI2m5VlURz86a/TgZ91Xhb1Ec4LINrD65seDmbxw4CczTLPZXKu
PEDY3xOgxs7FEA0TJsMoRKbJm8efpOE3nEWpnNveZivFASAy0TpfX8G3An3gJTR1Fs10ezLkAojM
hxiWIaA/n4k0CAE2JwBQo1ALg3sdNuK/FqcHsb8X+h/s/sbLpcJe1gAAAABJRU5ErkJggg==
"
style="image-rendering:optimizeQuality"
preserveAspectRatio="none"
height="16.933332"
width="16.933332" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,602 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64px"
height="64px"
id="svg2816"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="EMWorkbench.svg"
inkscape:export-filename="C:\Users\ediloren\AppData\Roaming\FreeCAD\Mod\EM\Resources\EMWorkbench.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs2818">
<linearGradient
inkscape:collect="always"
id="linearGradient4289">
<stop
style="stop-color:#729fcf;stop-opacity:1"
offset="0"
id="stop4291" />
<stop
style="stop-color:#204a87;stop-opacity:1"
offset="1"
id="stop4293" />
</linearGradient>
<linearGradient
id="linearGradient4248">
<stop
id="stop4250"
offset="0"
style="stop-color:#a40000;stop-opacity:1" />
<stop
id="stop4252"
offset="1"
style="stop-color:#ef2929;stop-opacity:1" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient3789">
<stop
style="stop-color:#888a85;stop-opacity:1;"
offset="0"
id="stop3791" />
<stop
style="stop-color:#d3d7cf;stop-opacity:1"
offset="1"
id="stop3793" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient3781">
<stop
style="stop-color:#d3d7cf;stop-opacity:1;"
offset="0"
id="stop3783" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="stop3785" />
</linearGradient>
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 32 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="64 : 32 : 1"
inkscape:persp3d-origin="32 : 21.333333 : 1"
id="perspective2824" />
<inkscape:perspective
id="perspective3622"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3622-9"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3653"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3675"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3697"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3720"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3742"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3764"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3785"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3806"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3806-3"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective3835"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3781"
id="linearGradient3787"
x1="93.501396"
y1="-0.52792466"
x2="92.882462"
y2="-7.2011309"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3789"
id="linearGradient3795"
x1="140.23918"
y1="124.16501"
x2="137.60997"
y2="117.06711"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3781-6"
id="linearGradient3804-3"
gradientUnits="userSpaceOnUse"
x1="93.501396"
y1="-0.52792466"
x2="92.882462"
y2="-7.2011309" />
<linearGradient
inkscape:collect="always"
id="linearGradient3781-6">
<stop
style="stop-color:#d3d7cf;stop-opacity:1;"
offset="0"
id="stop3783-7" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="stop3785-5" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3789-5"
id="linearGradient3806-3"
gradientUnits="userSpaceOnUse"
x1="140.23918"
y1="124.16501"
x2="137.60997"
y2="117.06711" />
<linearGradient
inkscape:collect="always"
id="linearGradient3789-5">
<stop
style="stop-color:#888a85;stop-opacity:1;"
offset="0"
id="stop3791-6" />
<stop
style="stop-color:#d3d7cf;stop-opacity:1"
offset="1"
id="stop3793-2" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3781-0"
id="linearGradient3804-36"
gradientUnits="userSpaceOnUse"
x1="93.501396"
y1="-0.52792466"
x2="92.882462"
y2="-7.2011309" />
<linearGradient
inkscape:collect="always"
id="linearGradient3781-0">
<stop
style="stop-color:#d3d7cf;stop-opacity:1;"
offset="0"
id="stop3783-6" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="stop3785-2" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3789-1"
id="linearGradient3806-6"
gradientUnits="userSpaceOnUse"
x1="140.23918"
y1="124.16501"
x2="137.60997"
y2="117.06711" />
<linearGradient
inkscape:collect="always"
id="linearGradient3789-1">
<stop
style="stop-color:#888a85;stop-opacity:1;"
offset="0"
id="stop3791-8" />
<stop
style="stop-color:#d3d7cf;stop-opacity:1"
offset="1"
id="stop3793-7" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3781-8"
id="linearGradient3804-2"
gradientUnits="userSpaceOnUse"
x1="93.501396"
y1="-0.52792466"
x2="92.814743"
y2="-5.3353744" />
<linearGradient
inkscape:collect="always"
id="linearGradient3781-8">
<stop
style="stop-color:#d3d7cf;stop-opacity:1;"
offset="0"
id="stop3783-9" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="stop3785-7" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3789-12"
id="linearGradient3806-36"
gradientUnits="userSpaceOnUse"
x1="140.23918"
y1="124.16501"
x2="137.60997"
y2="117.06711" />
<linearGradient
inkscape:collect="always"
id="linearGradient3789-12">
<stop
style="stop-color:#888a85;stop-opacity:1;"
offset="0"
id="stop3791-9" />
<stop
style="stop-color:#d3d7cf;stop-opacity:1"
offset="1"
id="stop3793-3" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3781-03"
id="linearGradient3804-5"
gradientUnits="userSpaceOnUse"
x1="93.501396"
y1="-0.52792466"
x2="92.814743"
y2="-5.3353744" />
<linearGradient
inkscape:collect="always"
id="linearGradient3781-03">
<stop
style="stop-color:#d3d7cf;stop-opacity:1;"
offset="0"
id="stop3783-61" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="stop3785-0" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3789-2"
id="linearGradient3806-63"
gradientUnits="userSpaceOnUse"
x1="140.23918"
y1="124.16501"
x2="137.60997"
y2="117.06711" />
<linearGradient
inkscape:collect="always"
id="linearGradient3789-2">
<stop
style="stop-color:#888a85;stop-opacity:1;"
offset="0"
id="stop3791-0" />
<stop
style="stop-color:#d3d7cf;stop-opacity:1"
offset="1"
id="stop3793-6" />
</linearGradient>
<radialGradient
r="18.0625"
fy="41.625"
fx="25.1875"
cy="41.625"
cx="25.1875"
gradientTransform="matrix(1,0,0,0.32526,0,28.08607)"
gradientUnits="userSpaceOnUse"
id="radialGradient3169"
xlink:href="#linearGradient2269-0"
inkscape:collect="always" />
<linearGradient
inkscape:collect="always"
id="linearGradient2269-0">
<stop
offset="0"
id="stop2271-4"
style="stop-color:#000000;stop-opacity:1;" />
<stop
offset="1"
id="stop2273-87"
style="stop-color:#000000;stop-opacity:0;" />
</linearGradient>
<linearGradient
gradientTransform="matrix(0.49538883,0,0,0.49063187,2.3277017,31.102625)"
inkscape:collect="always"
xlink:href="#linearGradient3600"
id="linearGradient3606"
x1="51.037281"
y1="47.692612"
x2="14.872466"
y2="10.397644"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="linearGradient3600">
<stop
style="stop-color:#2e3436;stop-opacity:1"
offset="0"
id="stop3602" />
<stop
style="stop-color:#888a85;stop-opacity:1"
offset="1"
id="stop3604" />
</linearGradient>
<linearGradient
gradientTransform="matrix(0.49538883,0,0,0.49063187,30.327701,3.102625)"
inkscape:collect="always"
xlink:href="#linearGradient4248"
id="linearGradient3606-1"
x1="51.037281"
y1="47.692612"
x2="14.872466"
y2="10.397644"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3873"
id="linearGradient4043"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.87502198,0,0,0.34687969,-94.40985,36.126007)"
x1="126.79221"
y1="22.888617"
x2="131.8111"
y2="34.041721" />
<linearGradient
inkscape:collect="always"
id="linearGradient3873">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop3875" />
<stop
style="stop-color:#d3d7cf;stop-opacity:1"
offset="1"
id="stop3877" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3873"
id="linearGradient4221"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.91737886,0,0,0.22453536,-71.851019,11.452429)"
x1="127.49857"
y1="-0.16112821"
x2="129.43178"
y2="57.091461" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4289"
id="linearGradient4295"
x1="10.527967"
y1="36.787956"
x2="26.408852"
y2="53.650681"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="6.404658"
inkscape:cx="-27.433748"
inkscape:cy="16.593673"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:document-units="px"
inkscape:grid-bbox="true"
inkscape:snap-bbox="false"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:object-paths="true"
inkscape:object-nodes="true"
inkscape:window-width="1920"
inkscape:window-height="1018"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:snap-global="true"
borderlayer="true"
gridtolerance="10000"
inkscape:snap-perpendicular="false"
inkscape:snap-tangential="false"
inkscape:snap-grids="true"
inkscape:snap-nodes="false"
inkscape:snap-others="false"
inkscape:snap-to-guides="false">
<inkscape:grid
type="xygrid"
id="grid3005"
empspacing="2"
visible="true"
enabled="true"
snapvisiblegridlinesonly="false"
spacingx="1"
spacingy="1"
dotted="false" />
</sodipodi:namedview>
<metadata
id="metadata2821">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
<dc:creator>
<cc:Agent>
<dc:title>[triplus]</dc:title>
</cc:Agent>
</dc:creator>
<dc:title>ArchWorkbench</dc:title>
<dc:date>2016-02-26</dc:date>
<dc:relation>https://www.freecad.org/wiki/index.php?title=Artwork</dc:relation>
<dc:publisher>
<cc:Agent>
<dc:title>FreeCAD</dc:title>
</cc:Agent>
</dc:publisher>
<dc:identifier>FreeCAD/src/Mod/Arch/Resources/icons/ArchWorkbench.svg</dc:identifier>
<dc:rights>
<cc:Agent>
<dc:title>FreeCAD LGPL2+</dc:title>
</cc:Agent>
</dc:rights>
<cc:license>https://www.gnu.org/copyleft/lesser.html</cc:license>
<dc:contributor>
<cc:Agent>
<dc:title>[agryson] Alexander Gryson</dc:title>
</cc:Agent>
</dc:contributor>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:label="Layer 1"
inkscape:groupmode="layer">
<ellipse
cy="46"
cx="18"
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient3606);fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.91892004;stroke-miterlimit:4;stroke-dasharray:none;marker:none;enable-background:accumulate"
id="path2826"
rx="12.970181"
ry="12.845634" />
<ellipse
cy="46"
cx="18"
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient4295);stroke:#729fcf;stroke-width:1.865;stroke-miterlimit:4;stroke-dasharray:none;marker:none;enable-background:accumulate;fill-opacity:1"
id="path2826-4"
rx="11.067488"
ry="11.067487" />
<ellipse
cy="18"
cx="46"
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient3606-1);fill-opacity:1;fill-rule:evenodd;stroke:#280000;stroke-width:1.91892004;stroke-miterlimit:4;stroke-dasharray:none;marker:none;enable-background:accumulate"
id="path2826-5"
rx="12.970181"
ry="12.845634" />
<ellipse
cy="17.999998"
cx="46"
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:none;stroke:#ef2929;stroke-width:1.86502409;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker:none;enable-background:accumulate"
id="path2826-4-9"
rx="11.067488"
ry="11.067487" />
<rect
style="fill:url(#linearGradient4043);fill-opacity:1;stroke:#0b1521;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect3871"
width="12.686974"
height="4.2556629"
x="11.656513"
y="43.872169" />
<path
style="fill:url(#linearGradient4221);fill-opacity:1;stroke:#280000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 39.349662,19.221209 0,-2.754691 5.320444,0 0,-5.509383 2.66022,0 0,5.509383 5.320443,0 0,2.754691 -5.320443,0 0,5.509385 -2.66022,0 0,-5.509385 z"
id="rect3871-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
d="M 0,-0.01590846 64.328181,64.156136"
id="path4297"
inkscape:connector-curvature="0" />
<path
style="fill:none;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke-dashoffset:0"
d="M 0.15613636,19.969546 C 10.513182,21.53091 22.030268,24.831738 29.822046,33.241137 c 8.794633,7.685029 12.855226,20.609999 14.364544,30.44659"
id="path4299"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
d="M 0.15613636,27.9325 C 18.779553,28.17068 23.615656,30.723314 28.572955,35.114773 33.413181,40.111137 36.072347,46.132588 36.223636,64"
id="path4301"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke-dashoffset:0"
d="M 63.959135,44.197594 C 53.602089,42.63623 42.085003,39.335402 34.293225,30.926003 25.498592,23.240974 21.437999,10.316004 19.928681,0.47941339"
id="path4299-4"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#d3d7cf;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke-dashoffset:0"
d="M 63.959135,36.23464 C 45.335718,35.99646 40.499615,33.443826 35.542316,29.052367 30.70209,24.056003 28.042924,18.034552 27.891635,0.16714039"
id="path4301-2"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,112 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
id="svg4152"
version="1.1"
inkscape:version="0.91 r"
viewBox="0 0 64 64"
sodipodi:docname="WorkbenchIcon.svg">
<defs
id="defs4154" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.48613591"
inkscape:cx="-271.96604"
inkscape:cy="172.36689"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:document-units="px"
inkscape:grid-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="1028"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata4157">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:label="Layer 1"
inkscape:groupmode="layer">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4239"
width="9.256671"
height="14.52783"
x="-41.460148"
y="1.9454339"
transform="matrix(0.01127127,-0.99993648,0.99993648,0.01127127,0,0)" />
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4241"
width="14.142136"
height="7.4567623"
x="-32.460606"
y="5.545249"
transform="matrix(0.01127127,-0.99993648,0.99993648,0.01127127,0,0)" />
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4239-8"
width="9.256671"
height="14.52783"
x="3.7287047"
y="46.867786" />
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4241-4"
width="13.756441"
height="7.4567623"
x="12.728239"
y="50.467594" />
<rect
style="opacity:1;fill:#0039ff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4261"
width="34.113159"
height="38.23526"
x="28.497931"
y="2.160291" />
<circle
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000bff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:3, 1;stroke-dashoffset:0;stroke-opacity:1"
id="path4269"
cx="51.636368"
cy="13.100049"
r="5.818182" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ff9800;stroke-width:3.70000005;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:7.4, 3.7;stroke-dashoffset:0;stroke-opacity:1"
d="m 9.1281027,17.330948 0,-4.88546 42.5549703,-0.12857"
id="path4265"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ff9800;stroke-width:3.70000005;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:11.10000014, 3.70000005;stroke-dashoffset:0;stroke-opacity:1"
d="m 26.495713,54.4862 26.21588,-0.128565 -0.0377,-13.773797"
id="path4267"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,72 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="585.918px" height="585.918px" viewBox="0 0 585.918 585.918" style="enable-background:new 0 0 585.918 585.918;"
xml:space="preserve">
<g>
<path d="M132.22,274.777c-4.761,0.087-7.822,0.578-9.628,1.056v65.29c1.806,0.503,4.73,0.604,7.387,0.692
c19.625,0.813,32.749-10.33,32.749-34.897C162.876,285.549,150.905,274.463,132.22,274.777z"/>
<path d="M537.115,48.974h-177.39c-0.787,0-1.553,0.076-2.335,0.123V0L11.176,46.206v492.311l346.214,47.402v-50.583
c0.782,0.052,1.543,0.114,2.335,0.114h177.39c20.75,0,37.627-16.888,37.627-37.627V86.602
C574.742,65.859,557.865,48.974,537.115,48.974z M51.271,358.419c-10.767-0.536-19.717-4.146-24.458-7.801l3.872-16.205
c3.386,2.125,11.223,6.147,19.173,6.42c10.265,0.367,15.533-5.061,15.533-12.304c0-9.461-8.407-13.859-17.086-13.984l-7.93-0.117
v-15.789l7.535,0.01c6.596-0.137,15.101-2.929,15.101-11.044c0-5.75-4.203-9.96-12.526-9.827
c-6.797,0.108-13.905,3.538-17.302,5.881l-3.876-15.74c4.882-3.714,14.761-7.478,25.588-7.797
c18.342-0.557,28.741,10.089,28.741,23.42c0,10.352-5.278,18.437-16.003,22.53v0.294c10.441,2.162,19.003,11.119,19.003,23.886
C86.642,347.552,72.967,359.505,51.271,358.419z M170.134,348.089c-10.488,8.597-26.132,12.109-44.762,11.227
c-10.9-0.52-18.512-1.615-23.636-2.576v-96.448c7.557-1.527,17.551-2.564,28.248-2.887c18.186-0.559,30.251,2.575,39.848,9.827
c10.499,7.805,17.173,20.588,17.173,39.081C187.005,326.351,179.879,339.951,170.134,348.089z M299.285,366.387l-1.785-43.392
c-0.536-13.61-1.066-30.061-1.066-46.501l-0.537,0.01c-3.719,14.438-8.656,30.471-13.223,43.567l-14.192,43.072l-20.092-0.914
l-11.903-42.551c-3.591-12.746-7.315-28.14-9.927-42.204h-0.307c-0.657,14.546-1.129,31.164-1.93,44.565l-1.932,40.768
l-22.282-1.046l6.714-105.911l32.636-0.977l10.932,36.24c3.517,12.701,7.053,26.433,9.597,39.41l0.496,0.011
c3.238-12.735,7.157-27.319,10.932-39.673l12.494-37.32l35.665-1.065l6.828,115.192L299.285,366.387z M553.24,497.823
c0,8.887-7.238,16.127-16.125,16.127h-177.39c-0.797,0-1.563-0.117-2.335-0.231V324.34c4.729,6.604,9.422,13.689,15.019,21.553
c0.966-5.39,2.353-8.347,1.805-10.887c-2.771-13.139-4.828-26.624-9.458-39.124c-4.516-12.181-2.451-19.039,10.12-22.899
c8.588-2.639,16.893-6.187,25.312-9.332c-5.568,3.279-11.926,5.72-16.546,10.032c-8.011,7.46-5.663,17.841,4.263,21.625
c3.685,1.409,7.728,1.84,11.623,2.721c-0.898,1.202-1.791,2.405-2.688,3.602c7.232,6.384,14.467,12.746,21.679,19.15
c12.883,11.423,15.891-10.782,26.31-7.716c-3.348,5.846-7.591,11.369-9.868,17.589c-3.197,8.783-4.62,18.212-17.789,14.789
c-19.854-5.149-27.219-12.642-26.517-26.426c0.326-6.446-0.87-12.977-1.385-19.464c-4.378,7.611-4.269,14.761-6.11,21.364
c-3.896,13.959,0.986,21.763,13.343,29.322c23.077,14.111,44.7,30.626,66.973,46.06c4.599,3.201,9.439,6.068,14.158,9.07
c1.254-0.955,2.498-1.9,3.742-2.866c-1.938-5.06-4.966-10.006-5.553-15.233c-0.683-6.021,0.366-12.378,1.686-18.368
c0.246-1.112,6.838-2.577,7.768-1.507c3.974,4.568,7.139,9.9,10.063,15.255c0.729,1.323-0.557,3.749-0.688,4.525
c-4.394-2.277-8.666-4.494-16.067-8.346c9.009,20.608,15.117,21.926,25.375,8.702c3.622-4.651,5.386-10.697,8.388-15.891
c4.941-8.508,8.359-19.274,15.681-24.738c17.329-12.92,12.861-28.936,11.784-45.618c-15.652,2.792-28.021,18.919-44.44,8.021
c6.782-10.944,12.578-20.31,19.821-31.979c-5.921,0-7.895-0.407-9.627,0.071c-37.605,10.428-38.771,10.239-56.852-23.108
c-5.217-9.627-10.478-12.703-19.853-5.882c-3.233,2.349-7.002,3.951-10.525,5.893c22.483-24.331,23.086-27.098,9.014-56.131
c-3.701-7.639-8.105-15.168-13.439-21.712c-7.328-8.981-15.874-16.974-27.075-28.725c-3.47,18.954-3.906,33.35-8.926,45.922
c-5.814,14.583-12.954,28.688-20.519,42.536V70.717c0.771-0.113,1.532-0.242,2.335-0.242h177.39
c8.887,0,16.125,7.236,16.125,16.127V497.823z M406.813,303.394c19.476,13.984,25.135,11.333,25.135,11.333
c-5.24,5.664-12.997,0-12.997,0L406.813,303.394z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,329 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="68px"
height="68px"
id="svg2816"
version="1.1"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Inventor-WB.svg">
<defs
id="defs2818">
<linearGradient
id="linearGradient3602">
<stop
style="stop-color:#ff2600;stop-opacity:1;"
offset="0"
id="stop3604" />
<stop
style="stop-color:#ff5f00;stop-opacity:1;"
offset="1"
id="stop3606" />
</linearGradient>
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 32 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="64 : 32 : 1"
inkscape:persp3d-origin="32 : 21.333333 : 1"
id="perspective2824" />
<inkscape:perspective
id="perspective3618"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3602-7"
id="linearGradient3608-5"
x1="3.909091"
y1="14.363636"
x2="24.81818"
y2="14.363636"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="linearGradient3602-7">
<stop
style="stop-color:#c51900;stop-opacity:1;"
offset="0"
id="stop3604-1" />
<stop
style="stop-color:#ff5f00;stop-opacity:1;"
offset="1"
id="stop3606-3" />
</linearGradient>
<inkscape:perspective
id="perspective3677"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3602-5"
id="linearGradient3608-1"
x1="3.909091"
y1="14.363636"
x2="24.81818"
y2="14.363636"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="linearGradient3602-5">
<stop
style="stop-color:#c51900;stop-opacity:1;"
offset="0"
id="stop3604-9" />
<stop
style="stop-color:#ff5f00;stop-opacity:1;"
offset="1"
id="stop3606-9" />
</linearGradient>
<linearGradient
y2="14.363636"
x2="24.81818"
y1="14.363636"
x1="3.909091"
gradientUnits="userSpaceOnUse"
id="linearGradient3686"
xlink:href="#linearGradient3602-5"
inkscape:collect="always" />
<inkscape:perspective
id="perspective3717"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3602-58"
id="linearGradient3608-8"
x1="3.909091"
y1="14.363636"
x2="24.81818"
y2="14.363636"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="linearGradient3602-58">
<stop
style="stop-color:#c51900;stop-opacity:1;"
offset="0"
id="stop3604-2" />
<stop
style="stop-color:#ff5f00;stop-opacity:1;"
offset="1"
id="stop3606-2" />
</linearGradient>
<linearGradient
y2="14.363636"
x2="24.81818"
y1="14.363636"
x1="3.909091"
gradientUnits="userSpaceOnUse"
id="linearGradient3726"
xlink:href="#linearGradient3602-58"
inkscape:collect="always" />
<inkscape:perspective
id="perspective4410"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective4944"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective4966"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective5009"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective5165"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7581"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7606"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7638"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7660"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7704"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7730"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7762"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7783"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<inkscape:perspective
id="perspective7843"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" />
<filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter878"
x="-0.054005238"
width="1.1080105"
y="-0.035997672"
height="1.0719953">
<feGaussianBlur
inkscape:collect="always"
stdDeviation="0.94509399"
id="feGaussianBlur880" />
</filter>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="7.9999996"
inkscape:cx="13.164331"
inkscape:cy="35.428063"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:document-units="px"
inkscape:grid-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="1137"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
showguides="false" />
<metadata
id="metadata2821">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:label="Layer 1"
inkscape:groupmode="layer"
transform="matrix(0.53992752,0,0,0.54612395,15.182416,15.213398)"
style="stroke-width:1.84156334">
<g
id="g3755"
transform="translate(-2.1886387,-1.8655814)">
<path
inkscape:export-ydpi="121.55234"
inkscape:export-xdpi="121.55234"
inkscape:export-filename="D:\Inventor\InventorLoader\Icon.png"
sodipodi:nodetypes="ccccccccc"
inkscape:connector-curvature="0"
id="path6342"
d="m 18.836744,-24.017438 52.79118,-0.683828 V 4.374427 H 49.783297 v 56.431952 h 21.844627 c 0.02649,11.584862 -2.760418,24.960305 -3.640771,27.305783 -4.674513,0.606794 -22.603238,2.018094 -49.160272,1.820385 z"
style="opacity:0.98799995;fill:#cd4500;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:3.35235524px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
inkscape:export-ydpi="121.55234"
inkscape:export-xdpi="121.55234"
inkscape:export-filename="D:\Inventor\InventorLoader\Icon.png"
inkscape:connector-curvature="0"
id="polygon49"
d="m -3.0078826,4.374427 7.281542,-29.126169 H 18.836744 L 49.783297,4.374427 Z"
style="opacity:0.8;fill:#ff6000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:3.35235524px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
inkscape:export-ydpi="121.55234"
inkscape:export-xdpi="121.55234"
inkscape:export-filename="D:\Inventor\InventorLoader\Icon.png"
sodipodi:nodetypes="ccccc"
inkscape:connector-curvature="0"
id="polygon51"
d="M -4.8282686,60.806379 H 49.783297 L 18.826881,89.932547 H -4.8282686 Z"
style="opacity:0.8;fill:#ff6000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:3.35235524px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
transform="matrix(1.8203855,0,0,1.8203855,44.820058,23.99752)"
inkscape:export-ydpi="121.55234"
inkscape:export-xdpi="121.55234"
inkscape:export-filename="D:\Inventor\InventorLoader\Icon.png"
sodipodi:nodetypes="ccccccccccccccccccccccc"
inkscape:connector-curvature="0"
id="path58"
d="m 16.726477,-24.752292 -2,0.02539 v 13.947265 H 2.7264767 v 31 h 2 V -8.7796363 H 16.726477 Z m -40.5,13.972656 -0.5,1.9999997 h 9.998047 v -1.9999997 z m 38.449219,33 c -0.248643,5.820848 -1.515612,11.844764 -1.949219,13 -2.56787,0.333333 -12.41717232,1.108608 -27.005859,1 h -10.994141 v 2 h 12.994141 c 14.5886867,0.108608 24.437989,-0.666667 27.005859,-1 0.483608,-1.288451 2.01455,-8.636039 2,-15 z"
style="opacity:0.22099998;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1.84156334px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter878)" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,86 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="small_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.2"
inkscape:cx="32.49008"
inkscape:cy="36.240377"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid850" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06663)">
<path
style="fill:#666666;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 16.66875,280.33122 v 15.875 H 9.7895829 v -2.64584 H 11.90625 V 290.1208 H 9.7895829 v -3.70417 H 11.90625 v -3.175 H 9.7895829 v -2.91041 z"
id="path4555-1"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccc" />
<path
style="fill:#666666;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 1.3229167,280.33121 v 15.875 H 7.408334 v -2.91042 h 1.8520833 l -5e-7,-2.91042 H 7.408334 v -4.23333 h 1.8520833 v -2.64583 H 7.408334 v -3.175 z"
id="path4555-4-0"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccc" />
<path
style="fill:#005bff;stroke:#000000;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="m 15.875001,281.12496 0,15.61042 H 8.9958335 l 10e-8,-2.38126 h 2.1166674 v -3.43958 H 8.9958336 v -3.70417 h 2.1166674 v -3.175 H 8.9958336 v -2.91041 z"
id="path4555"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccc" />
<path
style="fill:#ff2124;stroke:#000000;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="m 0.52916686,281.12496 3.4e-7,15.61042 h 6.0854167 v -2.64584 h 1.8520834 l -4e-7,-2.91042 h -1.852083 v -4.23333 h 1.8520834 v -2.64583 H 6.6145839 v -3.175 z"
id="path4555-4"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,117 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg8"
inkscape:version="0.92.1 r15371"
sodipodi:docname="Workbench.svg">
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient4536">
<stop
style="stop-color:#75507b;stop-opacity:1;"
offset="0"
id="stop4532" />
<stop
style="stop-color:#ad7fa8;stop-opacity:1"
offset="1"
id="stop4534" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4536"
id="linearGradient4538"
x1="15.345834"
y1="294.88333"
x2="1.5875"
y2="282.18332"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6"
inkscape:cx="41.809024"
inkscape:cy="32.872782"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4485"
empspacing="2"
empcolor="#3f3fff"
empopacity="0.28627451"
color="#3f3fff"
opacity="0.09411765" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06665)">
<path
style="fill:none;stroke:#171018;stroke-width:0.5291667;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
d="m 0.79375,295.67707 4.9e-7,-14.2875 H 16.139583 v 14.2875 z"
id="path4487"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<path
style="fill:url(#linearGradient4538);stroke:#ad7fa8;stroke-width:0.52916667;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 1.3229166,295.1479 V 281.91873 H 15.610416 v 13.22917 z"
id="path4530"
inkscape:connector-curvature="0" />
<path
style="opacity:1;vector-effect:none;fill:#ad7fa8;fill-opacity:1;stroke:#171018;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path4542"
sodipodi:type="arc"
sodipodi:cx="3.96875"
sodipodi:cy="284.56458"
sodipodi:rx="1.5875"
sodipodi:ry="1.5875"
sodipodi:start="0"
sodipodi:end="6.2766054"
sodipodi:open="true"
d="m 5.55625,284.56458 a 1.5875,1.5875 0 0 1 -1.5848886,1.58749 1.5875,1.5875 0 0 1 -1.5901028,-1.58227 1.5875,1.5875 0 0 1 1.5796572,-1.59271 1.5875,1.5875 0 0 1 1.5952998,1.57704" />
<path
id="path4546"
style="fill:none;stroke:#171018;stroke-width:0.52916667;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="m 6.6145832,292.50207 5.2916668,-5.29167 2.645833,2.64583 m -12.170833,2.64584 4.2333332,-4.23334 2.1166667,2.11667" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

Some files were not shown because too many files have changed in this diff Show More