From cb1f6bffa83fab2f5e7bb8ee8ae26567da4525fd Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 7 Sep 2022 19:00:26 -0500 Subject: [PATCH] Addon Manager: DevMode content implementation --- src/App/Metadata.cpp | 227 ++++++- src/App/Metadata.h | 70 +- src/App/MetadataPy.xml | 180 ++++- src/App/MetadataPyImp.cpp | 635 ++++++++++++++++-- src/Mod/AddonManager/CMakeLists.txt | 4 +- src/Mod/AddonManager/addonmanager_devmode.py | 303 ++++++--- .../addonmanager_devmode_add_content.py | 160 ++++- .../addonmanager_devmode_license_selector.py | 2 +- .../addonmanager_devmode_predictor.py | 154 +++++ .../addonmanager_devmode_validators.py | 145 ++++ .../developer_mode_add_content.ui | 4 +- 11 files changed, 1658 insertions(+), 226 deletions(-) create mode 100644 src/Mod/AddonManager/addonmanager_devmode_predictor.py create mode 100644 src/Mod/AddonManager/addonmanager_devmode_validators.py diff --git a/src/App/Metadata.cpp b/src/App/Metadata.cpp index f2a2ad15da..ec24dd1602 100644 --- a/src/App/Metadata.cpp +++ b/src/App/Metadata.cpp @@ -207,7 +207,7 @@ std::string Metadata::classname() const return _classname; } -boost::filesystem::path App::Metadata::subdirectory() const +boost::filesystem::path Metadata::subdirectory() const { return _subdirectory; } @@ -217,12 +217,12 @@ std::vector Metadata::file() const return _file; } -Meta::Version App::Metadata::freecadmin() const +Meta::Version Metadata::freecadmin() const { return _freecadmin; } -Meta::Version App::Metadata::freecadmax() const +Meta::Version Metadata::freecadmax() const { return _freecadmax; } @@ -315,7 +315,7 @@ void Metadata::setClassname(const std::string& name) _classname = name; } -void App::Metadata::setSubdirectory(const boost::filesystem::path& path) +void Metadata::setSubdirectory(const boost::filesystem::path& path) { _subdirectory = path; } @@ -330,22 +330,22 @@ void Metadata::addContentItem(const std::string& tag, const Metadata& item) _content.insert(std::make_pair(tag, item)); } -void App::Metadata::setFreeCADMin(const Meta::Version& version) +void Metadata::setFreeCADMin(const Meta::Version& version) { _freecadmin = version; } -void App::Metadata::setFreeCADMax(const Meta::Version& version) +void Metadata::setFreeCADMax(const Meta::Version& version) { _freecadmax = version; } -void App::Metadata::addGenericMetadata(const std::string& tag, const Meta::GenericMetadata& genericMetadata) +void Metadata::addGenericMetadata(const std::string& tag, const Meta::GenericMetadata& genericMetadata) { _genericMetadata.insert(std::make_pair(tag, genericMetadata)); } -void App::Metadata::removeContentItem(const std::string& tag, const std::string& itemName) +void Metadata::removeContentItem(const std::string& tag, const std::string& itemName) { auto tagRange = _content.equal_range(tag); auto foundItem = std::find_if(tagRange.first, tagRange.second, [&itemName](const auto& check) -> bool { return itemName == check.second.name(); }); @@ -353,6 +353,119 @@ void App::Metadata::removeContentItem(const std::string& tag, const std::string& _content.erase(foundItem); } +void Metadata::removeMaintainer(const Meta::Contact &maintainer) +{ + auto new_end = std::remove(_maintainer.begin(), _maintainer.end(), maintainer); + _maintainer.erase(new_end, _maintainer.end()); +} + +void Metadata::removeLicense(const Meta::License &license) +{ + auto new_end = std::remove(_license.begin(), _license.end(), license); + _license.erase(new_end, _license.end()); +} + +void Metadata::removeUrl(const Meta::Url &url) +{ + auto new_end = std::remove(_url.begin(), _url.end(), url); + _url.erase(new_end, _url.end()); +} + +void Metadata::removeAuthor(const Meta::Contact &author) +{ + auto new_end = std::remove(_author.begin(), _author.end(), author); + _author.erase(new_end, _author.end()); +} + +void Metadata::removeDepend(const Meta::Dependency &dep) +{ + bool found = false; + for (const auto& check : _depend) { + if (dep == check) + found = true; + } + if (!found) + throw Base::RuntimeError("No match found for dependency to remove"); + auto new_end = std::remove(_depend.begin(), _depend.end(), dep); + _depend.erase(new_end, _depend.end()); +} + +void Metadata::removeConflict(const Meta::Dependency &dep) +{ + auto new_end = std::remove(_conflict.begin(), _conflict.end(), dep); + _conflict.erase(new_end, _conflict.end()); +} + +void Metadata::removeReplace(const Meta::Dependency &dep) +{ + auto new_end = std::remove(_replace.begin(), _replace.end(), dep); + _replace.erase(new_end, _replace.end()); +} + +void Metadata::removeTag(const std::string &tag) +{ + auto new_end = std::remove(_tag.begin(), _tag.end(), tag); + _tag.erase(new_end, _tag.end()); +} + +void Metadata::removeFile(const boost::filesystem::path &path) +{ + auto new_end = std::remove(_file.begin(), _file.end(), path); + _file.erase(new_end, _file.end()); +} + + +void Metadata::clearContent() +{ + _content.clear(); +} + +void Metadata::clearMaintainer() +{ + _maintainer.clear(); +} + +void Metadata::clearLicense() +{ + _license.clear(); +} + +void Metadata::clearUrl() +{ + _url.clear(); +} + +void Metadata::clearAuthor() +{ + _author.clear(); +} + +void Metadata::clearDepend() +{ + _depend.clear(); +} + +void Metadata::clearConflict() +{ + _conflict.clear(); +} + +void Metadata::clearReplace() +{ + _replace.clear(); +} + +void Metadata::clearTag() +{ + _tag.clear(); +} + +void Metadata::clearFile() +{ + _file.clear(); +} + + DOMElement* appendSimpleXMLNode(DOMElement* baseNode, const std::string& nodeName, const std::string& nodeContents) { @@ -376,6 +489,36 @@ void addAttribute(DOMElement* node, const std::string& key, const std::string& v node->setAttribute(XUTF8Str(key.c_str()).unicodeForm(), XUTF8Str(value.c_str()).unicodeForm()); } +void addAttribute(DOMElement *node, const std::string &key, bool value) +{ + if (value) + node->setAttribute(XUTF8Str(key.c_str()).unicodeForm(), XUTF8Str("True").unicodeForm()); + else + node->setAttribute(XUTF8Str(key.c_str()).unicodeForm(), XUTF8Str("False").unicodeForm()); +} + +void addAttribute(DOMElement *node, const std::string &key, Meta::DependencyType value) +{ + // Someday we should be able to change this to use reflection, but it's not yet + // available (using C++17) + std::string stringified("automatic"); + switch (value) { + case Meta::DependencyType::automatic: + stringified = "automatic"; + break; + case Meta::DependencyType::internal: + stringified = "internal"; + break; + case Meta::DependencyType::addon: + stringified = "addon"; + break; + case Meta::DependencyType::python: + stringified = "python"; + break; + } + node->setAttribute(XUTF8Str(key.c_str()).unicodeForm(), XUTF8Str(stringified.c_str()).unicodeForm()); +} + void addDependencyNode(DOMElement* root, const std::string& name, const Meta::Dependency& depend) { auto element = appendSimpleXMLNode(root, name, depend.package); @@ -386,6 +529,8 @@ void addDependencyNode(DOMElement* root, const std::string& name, const Meta::De addAttribute(element, "version_gte", depend.version_gte); addAttribute(element, "version_gt", depend.version_gt); addAttribute(element, "condition", depend.condition); + addAttribute(element, "optional", depend.optional); + addAttribute(element, "type", depend.dependencyType); } } @@ -489,7 +634,7 @@ bool Metadata::satisfies(const Meta::Dependency& dep) return true; } -bool App::Metadata::supportsCurrentFreeCAD() const +bool Metadata::supportsCurrentFreeCAD() const { static auto fcVersion = Meta::Version(); if (fcVersion == Meta::Version()) { @@ -683,6 +828,11 @@ Meta::Contact::Contact(const XERCES_CPP_NAMESPACE::DOMElement* e) email = StrXUTF8(emailAttribute).str; } +bool App::Meta::Contact::operator==(const Contact &rhs) const +{ + return name==rhs.name && email==rhs.email; +} + Meta::License::License(const std::string& name, fs::path file) : name(name), file(file) @@ -699,7 +849,16 @@ Meta::License::License(const XERCES_CPP_NAMESPACE::DOMElement* e) name = StrXUTF8(e->getTextContent()).str; } -Meta::Url::Url(const std::string& location, UrlType type) : +bool App::Meta::License::operator==(const License &rhs) const +{ + return name==rhs.name && file==rhs.file; +} + +App::Meta::Url::Url() : location(""), type(App::Meta::UrlType::website) +{ +} + +Meta::Url::Url(const std::string &location, UrlType type) : location(location), type(type) { @@ -728,7 +887,22 @@ Meta::Url::Url(const XERCES_CPP_NAMESPACE::DOMElement* e) location = StrXUTF8(e->getTextContent()).str; } -Meta::Dependency::Dependency(const XERCES_CPP_NAMESPACE::DOMElement* e) +bool App::Meta::Url::operator==(const Url &rhs) const +{ + if (type == UrlType::repository && branch != rhs.branch) + return false; + return type==rhs.type && location==rhs.location; +} + +App::Meta::Dependency::Dependency() : optional(false), dependencyType(App::Meta::DependencyType::automatic) +{ +} + +App::Meta::Dependency::Dependency(const std::string &pkg) : package(pkg), optional(false), dependencyType(App::Meta::DependencyType::automatic) +{ +} + +Meta::Dependency::Dependency(const XERCES_CPP_NAMESPACE::DOMElement *e) { version_lt = StrXUTF8(e->getAttribute(XUTF8Str("version_lt").unicodeForm())).str; version_lte = StrXUTF8(e->getAttribute(XUTF8Str("version_lte").unicodeForm())).str; @@ -736,10 +910,41 @@ Meta::Dependency::Dependency(const XERCES_CPP_NAMESPACE::DOMElement* e) version_gte = StrXUTF8(e->getAttribute(XUTF8Str("version_gte").unicodeForm())).str; version_gt = StrXUTF8(e->getAttribute(XUTF8Str("version_gt").unicodeForm())).str; condition = StrXUTF8(e->getAttribute(XUTF8Str("condition").unicodeForm())).str; + std::string opt_string = StrXUTF8(e->getAttribute(XUTF8Str("optional").unicodeForm())).str; + if (opt_string == "true" || opt_string == "True") // Support Python capitalization in this one case... + optional = true; + else + optional = false; + std::string type_string = StrXUTF8(e->getAttribute(XUTF8Str("type").unicodeForm())).str; + if (type_string == "automatic" || type_string == "") + dependencyType = Meta::DependencyType::automatic; + else if (type_string == "addon") + dependencyType = Meta::DependencyType::addon; + else if (type_string == "internal") + dependencyType = Meta::DependencyType::internal; + else if (type_string == "python") + dependencyType = Meta::DependencyType::python; + else { + auto message = std::string("Invalid dependency type \"") + type_string + "\""; + throw Base::XMLBaseException(message); + } package = StrXUTF8(e->getTextContent()).str; } +bool App::Meta::Dependency::operator==(const Dependency &rhs) const +{ + return package == rhs.package && + version_lt == rhs.version_lt && + version_lte == rhs.version_lte && + version_eq == rhs.version_eq && + version_gte == rhs.version_gte && + version_gt == rhs.version_gt && + condition == rhs.condition && + optional == rhs.optional && + dependencyType == rhs.dependencyType; +} + Meta::Version::Version() : major(0), minor(0), diff --git a/src/App/Metadata.h b/src/App/Metadata.h index d5159583c8..2dbce270c9 100644 --- a/src/App/Metadata.h +++ b/src/App/Metadata.h @@ -50,6 +50,7 @@ namespace App { explicit Contact(const XERCES_CPP_NAMESPACE::DOMElement* e); std::string name; //< Contact name - required std::string email; //< Contact email - may be optional + bool operator==(const Contact &rhs) const; }; /** @@ -65,6 +66,7 @@ namespace App { explicit License(const XERCES_CPP_NAMESPACE::DOMElement* e); std::string name; //< Short name of license, e.g. "LGPL2", "MIT", "Mozilla Public License", etc. boost::filesystem::path file; //< Optional path to the license file, relative to the XML file's location + bool operator==(const License &rhs) const; }; enum class UrlType { @@ -80,12 +82,13 @@ namespace App { * \brief A URL, including type information (e.g. website, repository, or bugtracker, in package.xml) */ struct AppExport Url { - Url() = default; + Url(); Url(const std::string& location, UrlType type); explicit Url(const XERCES_CPP_NAMESPACE::DOMElement* e); std::string location; //< The actual URL, including protocol UrlType type; //< What kind of URL this is std::string branch; //< If it's a repository, which branch to use + bool operator==(const Url &rhs) const; }; /** @@ -112,12 +115,25 @@ namespace App { bool operator!=(const Version&) const; }; + /** + * \enum DependencyType + * The type of dependency. + */ + enum class DependencyType + { + automatic, + internal, + addon, + python + }; + /** * \struct Dependency * \brief Another package that this package depends on, conflicts with, or replaces */ struct AppExport Dependency { - Dependency() = default; + Dependency(); + explicit Dependency(const std::string &pkg); explicit Dependency(const XERCES_CPP_NAMESPACE::DOMElement* e); std::string package; //< Required: must exactly match the contents of the "name" element in the referenced package's package.xml file. std::string version_lt; //< Optional: The dependency to the package is restricted to versions less than the stated version number. @@ -126,6 +142,9 @@ namespace App { std::string version_gte; //< Optional: The dependency to the package is restricted to versions greater or equal than the stated version number. std::string version_gt; //< Optional: The dependency to the package is restricted to versions greater than the stated version number. std::string condition; //< Optional: Conditional expression as documented in REP149. + bool optional;//< Optional: Whether this dependency is considered "optional" + DependencyType dependencyType; //< Optional: defaults to "automatic" + bool operator==(const Dependency &rhs) const; }; /** @@ -238,25 +257,46 @@ namespace App { void setName(const std::string& name); void setVersion(const Meta::Version& version); void setDescription(const std::string& description); - void addMaintainer(const Meta::Contact& maintainer); - void addLicense(const Meta::License& license); - void addUrl(const Meta::Url& url); - void addAuthor(const Meta::Contact& author); - void addDepend(const Meta::Dependency& dep); - void addConflict(const Meta::Dependency& dep); - void addReplace(const Meta::Dependency& dep); - void addTag(const std::string& tag); + void addMaintainer(const Meta::Contact &maintainer); + void addLicense(const Meta::License &license); + void addUrl(const Meta::Url &url); + void addAuthor(const Meta::Contact &author); + void addDepend(const Meta::Dependency &dep); + void addConflict(const Meta::Dependency &dep); + void addReplace(const Meta::Dependency &dep); + void addTag(const std::string &tag); void setIcon(const boost::filesystem::path& path); void setClassname(const std::string& name); void setSubdirectory(const boost::filesystem::path& path); - void addFile(const boost::filesystem::path& path); - void addContentItem(const std::string& tag, const Metadata& item); + void addFile(const boost::filesystem::path &path); + void addContentItem(const std::string &tag, const Metadata &item); void setFreeCADMin(const Meta::Version& version); void setFreeCADMax(const Meta::Version& version); - void addGenericMetadata(const std::string& tag, const Meta::GenericMetadata& genericMetadata); + void addGenericMetadata(const std::string &tag, const Meta::GenericMetadata &genericMetadata); - // Deleters (work in progress...) - void removeContentItem(const std::string& tag, const std::string& itemName); + // Deleters + void removeContentItem(const std::string &tag, const std::string &itemName); + void removeMaintainer(const Meta::Contact &maintainer); + void removeLicense(const Meta::License &license); + void removeUrl(const Meta::Url &url); + void removeAuthor(const Meta::Contact &author); + void removeDepend(const Meta::Dependency &dep); + void removeConflict(const Meta::Dependency &dep); + void removeReplace(const Meta::Dependency &dep); + void removeTag(const std::string &tag); + void removeFile(const boost::filesystem::path &path); + + // Utility functions to clear lists + void clearContent(); + void clearMaintainer(); + void clearLicense(); + void clearUrl(); + void clearAuthor(); + void clearDepend(); + void clearConflict(); + void clearReplace(); + void clearTag(); + void clearFile(); /** * Write the metadata to an XML file diff --git a/src/App/MetadataPy.xml b/src/App/MetadataPy.xml index 2f7f0e02a3..2847860864 100644 --- a/src/App/MetadataPy.xml +++ b/src/App/MetadataPy.xml @@ -14,11 +14,12 @@ RichCompare="false" FatherNamespace="Base"> + App.Metadata class.\n A Metadata object reads an XML-formatted package metadata file and provides -read-only access to its contents.\n +read and write access to its contents.\n The following constructors are supported:\n Metadata() Empty constructor.\n @@ -31,37 +32,37 @@ file : str\n XML file name. Metadata - + String representing the name of this item. - + String representing the version of this item in semantic triplet format. - + String representing the description of this item (text only, no markup allowed). - + List of maintainer objects with 'name' and 'email' string attributes. - + List of applicable licenses as objects with 'name' and 'file' string attributes. - + List of URLs as objects with 'location' and 'type' string attributes, where type is one of: @@ -73,14 +74,14 @@ is one of: - + List of author objects, each with a 'name' and a (potentially empty) 'email' string attribute. - + List of dependencies, as objects with the following attributes: * package @@ -106,53 +107,53 @@ string attribute. - + List of conflicts, format identical to dependencies. - + List of things this item is considered by its author to replace. The format is identical to dependencies. - + List of strings. - + Relative path to an icon file. - + String representing the name of the main Python class this item creates/represents. - + String representing the name of the subdirectory this content item is located in. If empty, the item is in a directory named the same as the content item. - + List of files associated with this item. The meaning of each file is implementation-defined. - + Dictionary of lists of content items: defined recursively, each item is itself a Metadata object. @@ -219,6 +220,151 @@ dictionary of strings, 'attributes'. They represent unrecognized simple XML tags in the metadata file. + + + + addContentItem(content_type,metadata)\n +Add a new content item of type 'content_type' with metadata 'metadata'. + + + + + + removeContentItem(content_type,name)\n +Remove the content item of type 'content_type' with name 'name'. + + + + + + + addMaintainer(name, email)\n +Add a new Maintainer. + + + + + + removeMaintainer(name, email)\n +Remove the Maintainer. + + + + + + addLicense(short_code,path)\n +Add a new License. + + + + + + removeLicense(short_code)\n +Remove the License. + + + + + + addUrl(url_type,url,branch)\n +Add a new Url or type 'url_type' (which should be one of 'repository', 'readme',\n +'bugtracker', 'documentation', or 'webpage') If type is 'repository' you\n +must also specify the 'branch' parameter. + + + + + + removeUrl(url_type,url)\n +Remove the Url. + + + + + + addAuthor(name, email)\n +Add a new Author with name 'name', and optionally email 'email'. + + + + + + removeAuthor(name, email)\n +Remove the Author. + + + + + + addDepend(name, kind, optional)\n +Add a new Dependency on package 'name' of kind 'kind' (optional, one of 'auto' (the default),\n +'internal', 'addon', or 'python'). + + + + + + removeDepend(name, kind)\n +Remove the Dependency on package 'name' of kind 'kind' (optional - if unspecified any\n +matching name is removed). + + + + + + addConflict(name, kind)\n +Add a new Conflict. See documentation for addDepend(). + + + + + + removeConflict(name, kind)\n +Remove the Conflict. See documentation for removeDepend(). + + + + + + addReplace(name)\n +Add a new Replace. + + + + + + removeReplace(name)\n +Remove the Replace. + + + + + + addTag(tag)\n +Add a new Tag. + + + + + + removeTag(tag)\n +Remove the Tag. + + + + + + addFile(filename)\n +Add a new File. + + + + + + removeFile(filename)\n +Remove the File. + + public: diff --git a/src/App/MetadataPyImp.cpp b/src/App/MetadataPyImp.cpp index 7651068a94..0a7e8d1033 100644 --- a/src/App/MetadataPyImp.cpp +++ b/src/App/MetadataPyImp.cpp @@ -50,13 +50,13 @@ std::string MetadataPy::representation() const return str.str(); } -PyObject* MetadataPy::PyMake(struct _typeobject*, PyObject*, PyObject*) // Python wrapper +PyObject *MetadataPy::PyMake(struct _typeobject *, PyObject *, PyObject *)// Python wrapper { return new MetadataPy(nullptr); } // constructor method -int MetadataPy::PyInit(PyObject* args, PyObject* /*kwd*/) +int MetadataPy::PyInit(PyObject *args, PyObject * /*kwd*/) { if (PyArg_ParseTuple(args, "")) { setTwinPointer(new Metadata()); @@ -65,7 +65,7 @@ int MetadataPy::PyInit(PyObject* args, PyObject* /*kwd*/) // Main class constructor -- takes a file path, loads the metadata from it PyErr_Clear(); - char* filename; + char *filename; if (PyArg_ParseTuple(args, "et", "utf-8", &filename)) { try { std::string utf8Name = std::string(filename); @@ -75,19 +75,19 @@ int MetadataPy::PyInit(PyObject* args, PyObject* /*kwd*/) setTwinPointer(md); return 0; } - catch (const Base::XMLBaseException& e) { + catch (const Base::XMLBaseException &e) { e.setPyException(); return -1; } - catch (const XMLException& toCatch) { - char* message = XMLString::transcode(toCatch.getMessage()); + catch (const XMLException &toCatch) { + char *message = XMLString::transcode(toCatch.getMessage()); std::string what = message; XMLString::release(&message); PyErr_SetString(Base::PyExc_FC_XMLBaseException, what.c_str()); return -1; } - catch (const DOMException& toCatch) { - char* message = XMLString::transcode(toCatch.getMessage()); + catch (const DOMException &toCatch) { + char *message = XMLString::transcode(toCatch.getMessage()); std::string what = message; XMLString::release(&message); PyErr_SetString(Base::PyExc_FC_XMLBaseException, what.c_str()); @@ -101,9 +101,9 @@ int MetadataPy::PyInit(PyObject* args, PyObject* /*kwd*/) // Copy constructor PyErr_Clear(); - PyObject* o; + PyObject *o; if (PyArg_ParseTuple(args, "O!", &(App::MetadataPy::Type), &o)) { - App::Metadata* a = static_cast(o)->getMetadataPtr(); + App::Metadata *a = static_cast(o)->getMetadataPtr(); setTwinPointer(new Metadata(*a)); return 0; } @@ -117,21 +117,52 @@ Py::Object MetadataPy::getName() const return Py::String(getMetadataPtr()->name()); } +void MetadataPy::setName(Py::Object args) +{ + const char *name = nullptr; + if (!PyArg_Parse(args.ptr(), "z", &name)) { + throw Py::Exception(); + } + if (name) + getMetadataPtr()->setName(name); + else + getMetadataPtr()->setName(""); +} + Py::Object MetadataPy::getVersion() const { return Py::String(getMetadataPtr()->version().str()); } +void MetadataPy::setVersion(Py::Object args) +{ + const char *name = nullptr; + if (!PyArg_Parse(args.ptr(), "z", &name)) + throw Py::Exception(); + if (name) + getMetadataPtr()->setVersion(App::Meta::Version(std::string(name))); + else + getMetadataPtr()->setVersion(App::Meta::Version()); +} + Py::Object MetadataPy::getDescription() const { return Py::String(getMetadataPtr()->description()); } +void MetadataPy::setDescription(Py::Object args) +{ + const char *description = nullptr; + if (!PyArg_Parse(args.ptr(), "s", &description)) + throw Py::Exception(); + getMetadataPtr()->setDescription(description); +} + Py::Object MetadataPy::getMaintainer() const { auto maintainers = getMetadataPtr()->maintainer(); Py::List pyMaintainers; - for (const auto& m : maintainers) { + for (const auto &m : maintainers) { Py::Dict pyMaintainer; pyMaintainer["name"] = Py::String(m.name); pyMaintainer["email"] = Py::String(m.email); @@ -140,11 +171,50 @@ Py::Object MetadataPy::getMaintainer() const return pyMaintainers; } +void MetadataPy::setMaintainer(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearMaintainer(); + Py::List maintainers(list); + for (const auto &m : maintainers) { + Py::Dict pyMaintainer(m); + std::string name = pyMaintainer["name"].str(); + std::string email = pyMaintainer["email"].str(); + getMetadataPtr()->addMaintainer(App::Meta::Contact(name, email)); + } +} + +PyObject *MetadataPy::addMaintainer(PyObject *args) +{ + const char *name = nullptr; + const char *email = nullptr; + if (!PyArg_ParseTuple(args, "ss", &name, &email)) + throw Py::Exception(); + getMetadataPtr()->addMaintainer(App::Meta::Contact(name, email)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeMaintainer(PyObject *args) +{ + const char *name = nullptr; + const char *email = nullptr; + if (!PyArg_ParseTuple(args, "ss", &name, &email)) + throw Py::Exception(); + getMetadataPtr()->removeMaintainer(App::Meta::Contact(name, email)); + Py_INCREF(Py_None); + return Py_None; +} + + Py::Object MetadataPy::getAuthor() const { auto authors = getMetadataPtr()->author(); Py::List pyAuthors; - for (const auto& a : authors) { + for (const auto &a : authors) { Py::Dict pyAuthor; pyAuthor["name"] = Py::String(a.name); pyAuthor["email"] = Py::String(a.email); @@ -153,11 +223,49 @@ Py::Object MetadataPy::getAuthor() const return pyAuthors; } +void MetadataPy::setAuthor(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearAuthor(); + Py::List authors(list); + for (const auto &a : authors) { + Py::Dict pyAuthor(a); + std::string name = pyAuthor["name"].str(); + std::string email = pyAuthor["email"].str(); + getMetadataPtr()->addAuthor(App::Meta::Contact(name, email)); + } +} + +PyObject *MetadataPy::addAuthor(PyObject *args) +{ + const char *name = nullptr; + const char *email = nullptr; + if (!PyArg_ParseTuple(args, "ss", &name, &email)) + throw Py::Exception(); + getMetadataPtr()->addAuthor(App::Meta::Contact(name, email)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeAuthor(PyObject *args) +{ + const char *name = nullptr; + const char *email = nullptr; + if (!PyArg_ParseTuple(args, "ss", &name, &email)) + throw Py::Exception(); + getMetadataPtr()->removeAuthor(App::Meta::Contact(name, email)); + Py_INCREF(Py_None); + return Py_None; +} + Py::Object MetadataPy::getLicense() const { auto licenses = getMetadataPtr()->license(); Py::List pyLicenses; - for (const auto& lic : licenses) { + for (const auto &lic : licenses) { Py::Dict pyLicense; pyLicense["name"] = Py::String(lic.name); pyLicense["file"] = Py::String(lic.file.string()); @@ -166,19 +274,57 @@ Py::Object MetadataPy::getLicense() const return pyLicenses; } +void MetadataPy::setLicense(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearLicense(); + Py::List licenses(list); + for (const auto &l : licenses) { + Py::Dict pyLicense(l); + std::string name = pyLicense["name"].str(); + std::string path = pyLicense["file"].str(); + getMetadataPtr()->addLicense(App::Meta::License(name, path)); + } +} + +PyObject *MetadataPy::addLicense(PyObject *args) +{ + const char *shortCode = nullptr; + const char *path = nullptr; + if (!PyArg_ParseTuple(args, "ss", &shortCode, &path)) + throw Py::Exception(); + getMetadataPtr()->addLicense(App::Meta::License(shortCode, path)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeLicense(PyObject *args) +{ + const char *shortCode = nullptr; + const char *path = nullptr; + if (!PyArg_ParseTuple(args, "ss", &shortCode, &path)) + throw Py::Exception(); + getMetadataPtr()->removeLicense(App::Meta::License(shortCode, path)); + Py_INCREF(Py_None); + return Py_None; +} + Py::Object MetadataPy::getUrls() const { - auto urls = getMetadataPtr()->url (); + auto urls = getMetadataPtr()->url(); Py::List pyUrls; - for (const auto& url : urls) { + for (const auto &url : urls) { Py::Dict pyUrl; pyUrl["location"] = Py::String(url.location); switch (url.type) { - case Meta::UrlType::website: pyUrl["type"] = Py::String("website"); break; - case Meta::UrlType::repository: pyUrl["type"] = Py::String("repository"); break; - case Meta::UrlType::bugtracker: pyUrl["type"] = Py::String("bugtracker"); break; - case Meta::UrlType::readme: pyUrl["type"] = Py::String("readme"); break; - case Meta::UrlType::documentation: pyUrl["type"] = Py::String("documentation"); break; + case Meta::UrlType::website: pyUrl["type"] = Py::String("website"); break; + case Meta::UrlType::repository: pyUrl["type"] = Py::String("repository"); break; + case Meta::UrlType::bugtracker: pyUrl["type"] = Py::String("bugtracker"); break; + case Meta::UrlType::readme: pyUrl["type"] = Py::String("readme"); break; + case Meta::UrlType::documentation: pyUrl["type"] = Py::String("documentation"); break; } if (url.type == Meta::UrlType::repository) pyUrl["branch"] = Py::String(url.branch); @@ -187,7 +333,93 @@ Py::Object MetadataPy::getUrls() const return pyUrls; } -Py::Object dependencyToPyObject(const Meta::Dependency& d) +void MetadataPy::setUrls(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearUrl(); + Py::List urls(list); + for (const auto &url : urls) { + Py::Dict pyUrl(url); + std::string location = pyUrl["location"].str(); + std::string typeAsString = pyUrl["type"].str(); + std::string branch = pyUrl["branch"].str(); + auto newUrl = App::Meta::Url(location, Meta::UrlType::website); + if (typeAsString == "website") { + newUrl.type = Meta::UrlType::website; + } + else if (typeAsString == "repository") { + newUrl.type = Meta::UrlType::repository; + newUrl.branch = branch; + } + else if (typeAsString == "bugtracker") { + newUrl.type = Meta::UrlType::bugtracker; + } + else if (typeAsString == "readme") { + newUrl.type = Meta::UrlType::readme; + } + else if (typeAsString == "documentation") { + newUrl.type = Meta::UrlType::documentation; + } + else { + PyErr_SetString(Base::PyExc_FC_GeneralError, "Unrecognized URL type"); + return; + } + getMetadataPtr()->addUrl(newUrl); + } +} + +App::Meta::Url urlFromStrings(const char* urlTypeCharStar, const char* link, const char* branch) +{ + std::string urlTypeString(urlTypeCharStar); + App::Meta::UrlType urlType; + if (urlTypeString == "repository") + urlType = App::Meta::UrlType::repository; + else if (urlTypeString == "bugtracker") + urlType = App::Meta::UrlType::bugtracker; + else if (urlTypeString == "documentation") + urlType = App::Meta::UrlType::documentation; + else if (urlTypeString == "readme") + urlType = App::Meta::UrlType::readme; + else if (urlTypeString == "website") + urlType = App::Meta::UrlType::website; + + App::Meta::Url url(link, urlType); + if (urlType == App::Meta::UrlType::repository) + url.branch = std::string(branch); + + return url; +} + +PyObject *MetadataPy::addUrl(PyObject *args) +{ + const char *urlTypeCharStar = nullptr; + const char *link = nullptr; + const char *branch = nullptr; + if (!PyArg_ParseTuple(args, "ss|s", &urlTypeCharStar, &link, &branch)) + throw Py::Exception(); + + getMetadataPtr()->addUrl(urlFromStrings(urlTypeCharStar, link, branch)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeUrl(PyObject *args) +{ + const char *urlTypeCharStar = nullptr; + const char *link = nullptr; + const char *branch = nullptr; + if (!PyArg_ParseTuple(args, "ss|s", &urlTypeCharStar, &link, &branch)) + throw Py::Exception(); + + getMetadataPtr()->removeUrl(urlFromStrings(urlTypeCharStar, link, branch)); + Py_INCREF(Py_None); + return Py_None; +} + +Py::Object dependencyToPyObject(const Meta::Dependency &d) { Py::Dict pyDependency; pyDependency["package"] = Py::String(d.package); @@ -197,90 +429,338 @@ Py::Object dependencyToPyObject(const Meta::Dependency& d) pyDependency["version_gt"] = Py::String(d.version_gt); pyDependency["version_gte"] = Py::String(d.version_gte); pyDependency["condition"] = Py::String(d.condition); + pyDependency["optional"] = Py::Boolean(d.optional); + switch (d.dependencyType) { + case App::Meta::DependencyType::automatic: + pyDependency["type"] = Py::String ("automatic"); + break; + case App::Meta::DependencyType::addon: + pyDependency["type"] = Py::String("addon"); + break; + case App::Meta::DependencyType::internal: + pyDependency["type"] = Py::String("internal"); + break; + case App::Meta::DependencyType::python: + pyDependency["type"] = Py::String("python"); + break; + } return pyDependency; } +Meta::Dependency pyObjectToDependency(const Py::Object &d) +{ + Py::Dict pyDependency(d); + Meta::Dependency result; + result.package = pyDependency["package"].str(); + if (pyDependency.hasKey("version_lt")) + result.version_lt = pyDependency["version_lt"].str(); + if (pyDependency.hasKey("version_lte")) + result.version_lte = pyDependency["version_lte"].str(); + if (pyDependency.hasKey("version_eq")) + result.version_eq = pyDependency["version_eq"].str(); + if (pyDependency.hasKey("version_gt")) + result.version_gt = pyDependency["version_gt"].str(); + if (pyDependency.hasKey("version_gte")) + result.version_gte = pyDependency["version_gte"].str(); + if (pyDependency.hasKey("condition")) + result.condition = pyDependency["condition"].str(); + if (pyDependency.hasKey("optional")) + result.optional = Py::Boolean(pyDependency["optional"]).as_bool(); + if (pyDependency.hasKey("type")) { + if (pyDependency["type"].str() == Py::String("automatic")) + result.dependencyType = App::Meta::DependencyType::automatic; + else if (pyDependency["type"].str() == Py::String("internal")) + result.dependencyType = App::Meta::DependencyType::internal; + else if (pyDependency["type"].str() == Py::String("addon")) + result.dependencyType = App::Meta::DependencyType::addon; + else if (pyDependency["type"].str() == Py::String("python")) + result.dependencyType = App::Meta::DependencyType::python; + } + return result; +} + Py::Object MetadataPy::getDepend() const { auto dependencies = getMetadataPtr()->depend(); Py::List pyDependencies; - for (const auto& d : dependencies) { + for (const auto &d : dependencies) { pyDependencies.append(dependencyToPyObject(d)); } return pyDependencies; } +void MetadataPy::setDepend(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearDepend(); + Py::List deps(list); + for (const auto &dep : deps) { + Py::Dict pyDep(dep); + getMetadataPtr()->addDepend(pyObjectToDependency(pyDep)); + } +} + +PyObject *MetadataPy::addDepend(PyObject *args) +{ + PyObject *dictionary = nullptr; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &dictionary)) + throw Py::Exception(); + Py::Dict pyDep(dictionary); + getMetadataPtr()->addDepend(pyObjectToDependency(pyDep)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeDepend(PyObject *args) +{ + PyObject *dictionary = nullptr; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &dictionary)) + throw Py::Exception(); + Py::Dict pyDep(dictionary); + getMetadataPtr()->removeDepend(pyObjectToDependency(pyDep)); + Py_INCREF(Py_None); + return Py_None; +} + Py::Object MetadataPy::getConflict() const { auto dependencies = getMetadataPtr()->conflict(); Py::List pyDependencies; - for (const auto& d : dependencies) { + for (const auto &d : dependencies) { pyDependencies.append(dependencyToPyObject(d)); } return pyDependencies; } +void MetadataPy::setConflict(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearConflict(); + Py::List deps(list); + for (const auto &dep : deps) { + Py::Dict pyDep(dep); + getMetadataPtr()->addConflict(pyObjectToDependency(pyDep)); + } +} + +PyObject *MetadataPy::addConflict(PyObject *args) +{ + PyObject *dictionary = nullptr; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &dictionary)) + throw Py::Exception(); + Py::Dict pyDep(dictionary); + getMetadataPtr()->addConflict(pyObjectToDependency(pyDep)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeConflict(PyObject *args) +{ + PyObject *dictionary = nullptr; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &dictionary)) + throw Py::Exception(); + Py::Dict pyDep(dictionary); + getMetadataPtr()->removeConflict(pyObjectToDependency(pyDep)); + Py_INCREF(Py_None); + return Py_None; +} + Py::Object MetadataPy::getReplace() const { auto dependencies = getMetadataPtr()->replace(); Py::List pyDependencies; - for (const auto& d : dependencies) { + for (const auto &d : dependencies) { pyDependencies.append(dependencyToPyObject(d)); } return pyDependencies; } +void MetadataPy::setReplace(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearReplace(); + Py::List deps(list); + for (const auto &dep : deps) { + Py::Dict pyDep(dep); + getMetadataPtr()->addReplace(pyObjectToDependency(pyDep)); + } +} + +PyObject *MetadataPy::addReplace(PyObject *args) +{ + PyObject *dictionary = nullptr; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &dictionary)) + throw Py::Exception(); + Py::Dict pyDep(dictionary); + getMetadataPtr()->addReplace(pyObjectToDependency(pyDep)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeReplace(PyObject *args) +{ + PyObject *dictionary = nullptr; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &dictionary)) + throw Py::Exception(); + Py::Dict pyDep(dictionary); + getMetadataPtr()->removeReplace(pyObjectToDependency(pyDep)); + Py_INCREF(Py_None); + return Py_None; +} + // Tag, icon, classname, file Py::Object MetadataPy::getTag() const { auto tags = getMetadataPtr()->tag(); Py::List pyTags; - for (const auto& t : tags) { + for (const auto &t : tags) { pyTags.append(Py::String(t)); } return pyTags; } +void MetadataPy::setTag(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearTag(); + Py::List tags(list); + for (const auto &tag : tags) { + Py::String pyTag(tag); + getMetadataPtr()->addTag(pyTag.as_std_string()); + } +} + +PyObject *MetadataPy::addTag(PyObject *args) +{ + const char *tag = nullptr; + if (!PyArg_ParseTuple(args, "s", &tag)) + throw Py::Exception(); + getMetadataPtr()->addTag(tag); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeTag(PyObject *args) +{ + const char *tag = nullptr; + if (!PyArg_ParseTuple(args, "s", &tag)) + throw Py::Exception(); + getMetadataPtr()->removeTag(tag); + Py_INCREF(Py_None); + return Py_None; +} + Py::Object MetadataPy::getIcon() const { return Py::String(getMetadataPtr()->icon().string()); } +void MetadataPy::setIcon(Py::Object args) +{ + const char *name; + if (!PyArg_Parse(args.ptr(), "s", &name)) + throw Py::Exception(); + getMetadataPtr()->setIcon(name); +} + Py::Object MetadataPy::getClassname() const { return Py::String(getMetadataPtr()->classname()); } +void MetadataPy::setClassname(Py::Object args) +{ + const char *name; + if (!PyArg_Parse(args.ptr(), "s", &name)) + throw Py::Exception(); + getMetadataPtr()->setClassname(name); +} + Py::Object MetadataPy::getSubdirectory() const { return Py::String(getMetadataPtr()->subdirectory().string()); } +void MetadataPy::setSubdirectory(Py::Object args) +{ + const char *name; + if (!PyArg_Parse(args.ptr(), "s", &name)) + throw Py::Exception(); + getMetadataPtr()->setSubdirectory(name); +} + Py::Object MetadataPy::getFile() const { auto files = getMetadataPtr()->file(); Py::List pyFiles; - for (const auto& f : files) { + for (const auto &f : files) { pyFiles.append(Py::String(f.string())); } return pyFiles; } +void MetadataPy::setFile(Py::Object args) +{ + PyObject *list = nullptr; + if (!PyArg_Parse(args.ptr(), "O!", &PyList_Type, &list)) + throw Py::Exception(); + + getMetadataPtr()->clearTag(); + Py::List files(list); + for (const auto &file : files) { + Py::String pyFile(file); + getMetadataPtr()->addFile(pyFile.as_std_string()); + } +} + +PyObject *MetadataPy::addFile(PyObject *args) +{ + const char *file = nullptr; + if (!PyArg_ParseTuple(args, "s", &file)) + throw Py::Exception(); + getMetadataPtr()->addFile(file); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeFile(PyObject *args) +{ + const char *file = nullptr; + if (!PyArg_ParseTuple(args, "s", &file)) + throw Py::Exception(); + getMetadataPtr()->removeFile(file); + Py_INCREF(Py_None); + return Py_None; +} + + Py::Object MetadataPy::getContent() const { auto content = getMetadataPtr()->content(); std::set keys; - for (const auto& item : content) { + for (const auto &item : content) { keys.insert(item.first); } // For the Python, we'll use a dictionary of lists to store the content components: Py::Dict pyContent; - for (const auto& key : keys) { + for (const auto &key : keys) { Py::List pyContentForKey; auto elements = content.equal_range(key); - for (auto & element = elements.first; element != elements.second; ++element) { + for (auto &element = elements.first; element != elements.second; ++element) { auto contentMetadataItem = new MetadataPy(new Metadata(element->second)); pyContentForKey.append(Py::asObject(contentMetadataItem)); } @@ -289,19 +769,38 @@ Py::Object MetadataPy::getContent() const return pyContent; } -PyObject* MetadataPy::getGenericMetadata(PyObject* args) +void MetadataPy::setContent(Py::Object arg) { - const char* name; + PyObject *obj = nullptr; + if (!PyArg_Parse(arg.ptr(), "O!", &PyList_Type, &obj)) + throw Py::Exception(); + + getMetadataPtr()->clearContent(); + Py::Dict outerDict(obj); + for (const auto &pyContentType : outerDict) { + auto contentType = Py::String(pyContentType.first).as_std_string(); + auto contentList = Py::List(pyContentType.second); + for (const auto& contentItem : contentList) { + auto item = static_cast(contentItem.ptr()); + getMetadataPtr()->addContentItem(contentType, *(item->getMetadataPtr())); + } + } + +} + +PyObject *MetadataPy::getGenericMetadata(PyObject *args) +{ + const char *name; if (!PyArg_ParseTuple(args, "s", &name)) return nullptr; auto gm = (*getMetadataPtr())[name]; Py::List pyGenericMetadata; - for (const auto& item : gm) { + for (const auto &item : gm) { Py::Dict pyItem; pyItem["contents"] = Py::String(item.contents); Py::Dict pyAttributes; - for (const auto& attribute : item.attributes) { + for (const auto &attribute : item.attributes) { pyAttributes[attribute.first] = Py::String(attribute.second); } pyItem["attributes"] = pyAttributes; @@ -317,12 +816,14 @@ Py::Object MetadataPy::getFreeCADMin() const void MetadataPy::setFreeCADMin(Py::Object args) { - char* version = nullptr; - PyObject* p = args.ptr(); - if (!PyArg_ParseTuple(p, "s", &version)) - return; - - getMetadataPtr()->setFreeCADMin(App::Meta::Version(version)); + char *version = nullptr; + PyObject *p = args.ptr(); + if (!PyArg_Parse(p, "z", &version)) + throw Py::Exception(); + if (version) + getMetadataPtr()->setFreeCADMin(App::Meta::Version(version)); + else + getMetadataPtr()->setFreeCADMin(App::Meta::Version()); } Py::Object MetadataPy::getFreeCADMax() const @@ -332,15 +833,18 @@ Py::Object MetadataPy::getFreeCADMax() const void MetadataPy::setFreeCADMax(Py::Object args) { - char* version = nullptr; - PyObject* p = args.ptr(); - if (!PyArg_ParseTuple(p, "s", &version)) - return; + char *version = nullptr; + PyObject *p = args.ptr(); + if (!PyArg_Parse(p, "z", &version)) + throw Py::Exception(); - getMetadataPtr()->setFreeCADMax(App::Meta::Version(version)); + if (version) + getMetadataPtr()->setFreeCADMax(App::Meta::Version(version)); + else + getMetadataPtr()->setFreeCADMax(App::Meta::Version()); } -PyObject* MetadataPy::getFirstSupportedFreeCADVersion(PyObject* p) +PyObject *MetadataPy::getFirstSupportedFreeCADVersion(PyObject *p) { if (!PyArg_ParseTuple(p, "")) return nullptr; @@ -351,7 +855,7 @@ PyObject* MetadataPy::getFirstSupportedFreeCADVersion(PyObject* p) auto content = getMetadataPtr()->content(); auto result = App::Meta::Version(); - for (const auto& item : content) { + for (const auto &item : content) { auto minVersion = item.second.freecadmin(); if (minVersion != App::Meta::Version()) if (result == App::Meta::Version() || minVersion < result) @@ -366,7 +870,7 @@ PyObject* MetadataPy::getFirstSupportedFreeCADVersion(PyObject* p) } } -PyObject* MetadataPy::getLastSupportedFreeCADVersion(PyObject* p) +PyObject *MetadataPy::getLastSupportedFreeCADVersion(PyObject *p) { if (!PyArg_ParseTuple(p, "")) return nullptr; @@ -377,7 +881,7 @@ PyObject* MetadataPy::getLastSupportedFreeCADVersion(PyObject* p) auto content = getMetadataPtr()->content(); auto result = App::Meta::Version(); - for (const auto& item : content) { + for (const auto &item : content) { auto maxVersion = item.second.freecadmax(); if (maxVersion != App::Meta::Version()) if (result == App::Meta::Version() || maxVersion > result) @@ -392,7 +896,7 @@ PyObject* MetadataPy::getLastSupportedFreeCADVersion(PyObject* p) } } -PyObject* MetadataPy::supportsCurrentFreeCAD(PyObject* p) +PyObject *MetadataPy::supportsCurrentFreeCAD(PyObject *p) { if (!PyArg_ParseTuple(p, "")) return nullptr; @@ -401,12 +905,41 @@ PyObject* MetadataPy::supportsCurrentFreeCAD(PyObject* p) return Py::new_reference_to(Py::Boolean(result)); } -PyObject* MetadataPy::getCustomAttributes(const char* /*attr*/) const +PyObject* MetadataPy::addContentItem(PyObject* arg) +{ + char *contentType = nullptr; + PyObject *contentItem = nullptr; + if (!PyArg_ParseTuple(arg, "sO!", &contentType, &(App::MetadataPy::Type), &contentItem)) + return nullptr; + + if (!contentItem || !contentType) + return nullptr; + auto item = static_cast(contentItem); + getMetadataPtr()->addContentItem(contentType, *(item->getMetadataPtr())); + + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::removeContentItem(PyObject *arg) +{ + char *contentType = nullptr; + char *contentName = nullptr; + if (!PyArg_ParseTuple(arg, "ss", &contentType, &contentName)) + return nullptr; + if (contentType && contentName) + getMetadataPtr()->removeContentItem(contentType, contentName); + + Py_INCREF(Py_None); + return Py_None; +} + +PyObject *MetadataPy::getCustomAttributes(const char * /*attr*/) const { return nullptr; } -int MetadataPy::setCustomAttributes(const char* /*attr*/, PyObject* /*obj*/) +int MetadataPy::setCustomAttributes(const char * /*attr*/, PyObject * /*obj*/) { return 0; } diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 658beb7672..6e7fd0480e 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -9,9 +9,11 @@ SET(AddonManager_SRCS AddonManager.ui addonmanager_devmode.py addonmanager_devmode_add_content.py - addonmanager_devmode_license_selector.py addonmanager_devmode_dependencies.py + addonmanager_devmode_license_selector.py addonmanager_devmode_person_editor.py + addonmanager_devmode_predictor.py + addonmanager_devmode_validators.py addonmanager_git.py addonmanager_macro.py addonmanager_utilities.py diff --git a/src/Mod/AddonManager/addonmanager_devmode.py b/src/Mod/AddonManager/addonmanager_devmode.py index 050ca28f08..66a0164930 100644 --- a/src/Mod/AddonManager/addonmanager_devmode.py +++ b/src/Mod/AddonManager/addonmanager_devmode.py @@ -27,24 +27,25 @@ import os import FreeCAD import FreeCADGui -from PySide2.QtWidgets import QFileDialog, QTableWidgetItem +from PySide2.QtWidgets import QFileDialog, QTableWidgetItem, QListWidgetItem, QDialog from PySide2.QtGui import ( QIcon, - QValidator, - QRegularExpressionValidator, QPixmap, ) -from PySide2.QtCore import QRegularExpression, Qt +from PySide2.QtCore import Qt from addonmanager_git import GitManager from addonmanager_devmode_license_selector import LicenseSelector from addonmanager_devmode_person_editor import PersonEditor from addonmanager_devmode_add_content import AddContent +from addonmanager_devmode_validators import NameValidator, VersionValidator translate = FreeCAD.Qt.translate # pylint: disable=too-few-public-methods +ContentTypeRole = Qt.UserRole +ContentIndexRole = Qt.UserRole + 1 class AddonGitInterface: """Wrapper to handle the git calls needed by this class""" @@ -66,94 +67,20 @@ class AddonGitInterface: return AddonGitInterface.git_manager.get_branches(self.path) return [] + @property + def committers(self): + """The commiters to this repo, in the last ten commits""" + if self.git_exists: + return AddonGitInterface.git_manager.get_last_committers(self.path, 10) + return [] -class NameValidator(QValidator): - """Simple validator to exclude characters that are not valid in filenames.""" + @property + def authors(self): + """The commiters to this repo, in the last ten commits""" + if self.git_exists: + return AddonGitInterface.git_manager.get_last_authors(self.path, 10) + return [] - invalid = '/\\?%*:|"<>' - - def validate(self, value: str, _: int): - """Check the value against the validator""" - for char in value: - if char in NameValidator.invalid: - return QValidator.Invalid - return QValidator.Acceptable - - def fixup(self, value: str) -> str: - """Remove invalid characters from value""" - result = "" - for char in value: - if char not in NameValidator.invalid: - result += char - return result - - -class SemVerValidator(QRegularExpressionValidator): - """Implements the officially-recommended regex validator for Semantic version numbers.""" - - # https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string - semver_re = QRegularExpression( - r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" - + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" - + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" - + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" - ) - - def __init__(self): - super().__init__() - self.setRegularExpression(SemVerValidator.semver_re) - - @classmethod - def check(cls, value: str) -> bool: - """Returns true if value validates, and false if not""" - return cls.semver_re.match(value).hasMatch() - - -class CalVerValidator(QRegularExpressionValidator): - """Implements a basic regular expression validator that makes sure an entry corresponds - to a CalVer version numbering standard.""" - - calver_re = QRegularExpression( - r"^(?P[1-9]\d{3})\.(?P[0-9]{1,2})\.(?P0|[0-9]{0,2})" - + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" - + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" - + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" - ) - - def __init__(self): - super().__init__() - self.setRegularExpression(CalVerValidator.calver_re) - - @classmethod - def check(cls, value: str) -> bool: - """Returns true if value validates, and false if not""" - return cls.calver_re.match(value).hasMatch() - - -class VersionValidator(QValidator): - """Implements the officially-recommended regex validator for Semantic version numbers, and a - decent approximation of the same thing for CalVer-style version numbers.""" - - def __init__(self): - super().__init__() - self.semver = SemVerValidator() - self.calver = CalVerValidator() - - def validate(self, value: str, position: int): - """Called for validation, returns a tuple of the validation state, the value, and the - position.""" - semver_result = self.semver.validate(value, position) - calver_result = self.calver.validate(value, position) - - if semver_result[0] == QValidator.Acceptable: - return semver_result - if calver_result[0] == QValidator.Acceptable: - return calver_result - if semver_result[0] == QValidator.Intermediate: - return semver_result - if calver_result[0] == QValidator.Intermediate: - return calver_result - return (QValidator.Invalid, value, position) class DeveloperMode: @@ -174,6 +101,8 @@ class DeveloperMode: self.current_mod: str = "" self.git_interface = None self.has_toplevel_icon = False + self.metadata = None + self._setup_dialog_signals() self.dialog.displayNameLineEdit.setValidator(NameValidator()) @@ -202,7 +131,9 @@ class DeveloperMode: """Show the main dev mode dialog""" if parent: self.dialog.setParent(parent) - self.dialog.exec() + result = self.dialog.exec() + if result == QDialog.Accepted: + self._sync_metadata_to_ui() def _populate_dialog(self, path_to_repo): """Populate this dialog using the best available parsing of the contents of the repo at @@ -215,7 +146,6 @@ class DeveloperMode: self._scan_for_git_info(self.current_mod) metadata_path = os.path.join(path_to_repo, "package.xml") - metadata = None if os.path.exists(metadata_path): try: self.metadata = FreeCAD.Metadata(metadata_path) @@ -239,19 +169,21 @@ class DeveloperMode: + "\n\n" ) - if self.metadata: - self.dialog.displayNameLineEdit.setText(self.metadata.Name) - self.dialog.descriptionTextEdit.setPlainText(self.metadata.Description) - self.dialog.versionLineEdit.setText(self.metadata.Version) + self._clear_all_fields() - self._populate_people_from_metadata(self.metadata) - self._populate_licenses_from_metadata(self.metadata) - self._populate_urls_from_metadata(self.metadata) - self._populate_contents_from_metadata(self.metadata) + if not self.metadata: + self._predict_metadata() - self._populate_icon_from_metadata(self.metadata) - else: - self._populate_without_metadata() + self.dialog.displayNameLineEdit.setText(self.metadata.Name) + self.dialog.descriptionTextEdit.setPlainText(self.metadata.Description) + self.dialog.versionLineEdit.setText(self.metadata.Version) + + self._populate_people_from_metadata(self.metadata) + self._populate_licenses_from_metadata(self.metadata) + self._populate_urls_from_metadata(self.metadata) + self._populate_contents_from_metadata(self.metadata) + + self._populate_icon_from_metadata(self.metadata) def _populate_people_from_metadata(self, metadata): """Use the passed metadata object to populate the maintainers and authors""" @@ -305,6 +237,7 @@ class DeveloperMode: ) def _add_license_row(self, row: int, name: str, path: str): + """ Add a row to the table of licenses """ self.dialog.licensesTableWidget.insertRow(row) self.dialog.licensesTableWidget.setItem(row, 0, QTableWidgetItem(name)) self.dialog.licensesTableWidget.setItem(row, 1, QTableWidgetItem(path)) @@ -349,6 +282,7 @@ class DeveloperMode: contents = metadata.Content self.dialog.contentsListWidget.clear() for content_type in contents: + counter = 0 for item in contents[content_type]: contents_string = f"[{content_type}] " info = [] @@ -378,7 +312,11 @@ class DeveloperMode: ) contents_string += ", ".join(info) - self.dialog.contentsListWidget.addItem(contents_string) + item = QListWidgetItem (contents_string) + item.setData(ContentTypeRole, content_type) + item.setData(ContentIndexRole, counter) + self.dialog.contentsListWidget.addItem(item) + counter += 1 def _populate_icon_from_metadata(self, metadata): """Use the passed metadata object to populate the icon fields""" @@ -407,9 +345,16 @@ class DeveloperMode: self.dialog.iconDisplayLabel.setPixmap(icon_data.pixmap(32, 32)) self.dialog.iconPathLineEdit.setText(icon) - def _populate_without_metadata(self): + def _predict_metadata(self): """If there is no metadata, try to guess at values for it""" - self._clear_all_fields() + self.metadata = FreeCAD.Metadata() + self._predict_author_info() + self._predict_name() + self._predict_description() + self._predict_contents() + self._predict_icon() + self._predict_urls() + self._predict_license() def _scan_for_git_info(self, path): """Look for branch availability""" @@ -436,6 +381,57 @@ class DeveloperMode: self.dialog.licensesTableWidget.setRowCount(0) self.dialog.peopleTableWidget.setRowCount(0) + def _predict_author_info(self): + """ Look at the git commit history and attempt to discern maintainer and author + information.""" + + self.git_interface = AddonGitInterface(path) + if self.git_interface.git_exists: + committers = self.git_interface.get_last_committers() + else: + return + + # This is a dictionary keyed to the author's name (which can be many different + # things, depending on the author) containing two fields, "email" and "count". It + # is common for there to be multiple entries representing the same human being, + # so a passing attempt is made to reconcile: + for key in committers: + emails = committers[key]["email"] + if "GitHub" in key: + # Robotic merge commit (or other similar), ignore + continue + # Does any other committer share any of these emails? + for other_key in committers: + if other_key == key: + continue + other_emails = committers[other_key]["email"] + for other_email in other_emails: + if other_email in emails: + # There is overlap in the two email lists, so this is probably the + # same author, with a different name (username, pseudonym, etc.) + if not committers[key]["aka"]: + committers[key]["aka"] = set() + committers[key]["aka"].add(other_key) + committers[key]["count"] += committers[other_key]["count"] + committers[key]["email"].combine(committers[other_key]["email"]) + committers.remove(other_key) + break + maintainers = [] + for name,info in committers.items(): + if info["aka"]: + for other_name in info["aka"]: + # Heuristic: the longer name is more likely to be the actual legal name + if len(other_name) > len(name): + name = other_name + # There is no logical basis to choose one email address over another, so just + # take the first one + email = info["email"][0] + commit_count = info["count"] + maintainers.append( {"name":name,"email":email,"count":commit_count} ) + + # Sort by count of commits + maintainers.sort(lambda i:i["count"],reverse=True) + def _setup_dialog_signals(self): """Set up the signal and slot connections for the main dialog.""" @@ -467,14 +463,54 @@ class DeveloperMode: self.dialog.contentsListWidget.itemSelectionChanged.connect(self._content_selection_changed) self.dialog.contentsListWidget.itemDoubleClicked.connect(self._edit_content) - # Finally, populate the combo boxes, etc. self._populate_combo() if self.dialog.pathToAddonComboBox.currentIndex() != -1: self._populate_dialog(self.dialog.pathToAddonComboBox.currentText()) + # Disable all of the "Remove" buttons until something is selected self.dialog.removeLicenseToolButton.setDisabled(True) self.dialog.removePersonToolButton.setDisabled(True) + self.dialog.removeContentItemToolButton.setDisabled(True) + + def _sync_metadata_to_ui(self): + """ Take the data from the UI fields and put it into the stored metadata + object. Only overwrites known data fields: unknown metadata will be retained. """ + self.metadata.Name = self.dialog.displayNameLineEdit.text() + self.metadata.Description = self.descriptionTextEdit.text() + self.metadata.Version = self.dialog.versionLineEdit.text() + self.metadata.Icon = self.dialog.iconPathLineEdit.text() + + url = {} + url["website"] = self.dialog.websiteURLLineEdit.text() + url["repository"] = self.dialog.repositoryURLLineEdit.text() + url["bugtracker"] = self.dialog.bugtrackerURLLineEdit.text() + url["readme"] = self.dialog.readmeURLLineEdit.text() + url["documentation"] = self.dialog.documentationURLLineEdit.text() + self.metadata.setUrl(url) + + # Licenses: + licenses = [] + for row in range(self.dialog.licensesTableWidget.rowCount()): + license = {} + license["name"] = self.dialog.licensesTableWidget.item(row,0).text() + license["file"] = self.dialog.licensesTableWidget.item(row,1).text() + licenses.append(license) + self.metadata.setLicense(licenses) + + # Maintainers: + maintainers = [] + authors = [] + for row in range(self.dialog.peopleTableWidget.rowCount()): + person = {} + person["name"] = self.dialog.peopleTableWidget.item(row,1).text() + person["email"] = self.dialog.peopleTableWidget.item(row,2).text() + if self.dialog.peopleTableWidget.item(row,0).data(Qt.UserRole) == "maintainer": + maintainers.append(person) + elif self.dialog.peopleTableWidget.item(row,0).data(Qt.UserRole) == "author": + authors.append(person) + + # Content: ############################################################################################### # DIALOG SLOTS @@ -553,6 +589,7 @@ class DeveloperMode: recent_mods_group.SetString(entry_name, mod) def _person_selection_changed(self): + """ Callback: the current selection in the peopleTableWidget changed """ items = self.dialog.peopleTableWidget.selectedItems() if items: self.dialog.removePersonToolButton.setDisabled(False) @@ -560,6 +597,7 @@ class DeveloperMode: self.dialog.removePersonToolButton.setDisabled(True) def _license_selection_changed(self): + """ Callback: the current selection in the licensesTableWidget changed """ items = self.dialog.licensesTableWidget.selectedItems() if items: self.dialog.removeLicenseToolButton.setDisabled(False) @@ -567,6 +605,7 @@ class DeveloperMode: self.dialog.removeLicenseToolButton.setDisabled(True) def _add_license_clicked(self): + """ Callback: The Add License button was clicked """ license_selector = LicenseSelector(self.current_mod) short_code, path = license_selector.exec() if short_code: @@ -575,6 +614,7 @@ class DeveloperMode: ) def _remove_license_clicked(self): + """ Callback: the Remove License button was clicked """ items = self.dialog.licensesTableWidget.selectedIndexes() if items: # We only support single-selection, so can just pull the row # from @@ -582,6 +622,7 @@ class DeveloperMode: self.dialog.licensesTableWidget.removeRow(items[0].row()) def _edit_license(self, item): + """ Callback: a license row was double-clicked """ row = item.row() short_code = self.dialog.licensesTableWidget.item(row, 0).text() path = self.dialog.licensesTableWidget.item(row, 1).text() @@ -592,12 +633,14 @@ class DeveloperMode: self._add_license_row(row, short_code, path) def _add_person_clicked(self): + """ Callback: the Add Person button was clicked """ dlg = PersonEditor() person_type, name, email = dlg.exec() if person_type and name: self._add_person_row(row, person_type, name, email) def _remove_person_clicked(self): + """ Callback: the Remove Person button was clicked """ items = self.dialog.peopleTableWidget.selectedIndexes() if items: # We only support single-selection, so can just pull the row # from @@ -605,6 +648,7 @@ class DeveloperMode: self.dialog.peopleTableWidget.removeRow(items[0].row()) def _edit_person(self, item): + """ Callback: a row in the peopleTableWidget was double-clicked """ row = item.row() person_type = self.dialog.peopleTableWidget.item(row, 0).data(Qt.UserRole) name = self.dialog.peopleTableWidget.item(row, 1).text() @@ -621,14 +665,51 @@ class DeveloperMode: def _add_content_clicked(self): + """ Callback: The Add Content button was clicked """ dlg = AddContent(self.current_mod, self.metadata) - dlg.exec() + singleton = False + if self.dialog.contentsListWidget.count() == 0: + singleton = True + content_type,new_metadata = dlg.exec(singleton=singleton) + if content_type and new_metadata: + self.metadata.addContentItem(content_type, new_metadata) + self._populate_contents_from_metadata(self.metadata) def _remove_content_clicked(self): - pass + """ Callback: the remove content button was clicked """ + + item = self.dialog.contentsListWidget.currentItem() + if not item: + return + content_type = item.data(ContentTypeRole) + content_index = item.data(ContentIndexRole) + if self.metadata.Content[content_type] and content_index < len(self.metadata.Content[content_type]): + content_name = self.metadata.Content[content_type][content_index].Name + self.metadata.removeContentItem(content_type,content_name) + self._populate_contents_from_metadata(self.metadata) def _content_selection_changed(self): - pass + """ Callback: the selected content item changed """ + items = self.dialog.contentsListWidget.selectedItems() + if items: + self.dialog.removeContentItemToolButton.setDisabled(False) + else: + self.dialog.removeContentItemToolButton.setDisabled(True) def _edit_content(self, item): - pass + """ Callback: a content row was double-clicked """ + dlg = AddContent(self.current_mod, self.metadata) + + content_type = item.data(ContentTypeRole) + content_index = item.data(ContentIndexRole) + + content = self.metadata.Content + metadata = content[content_type][content_index] + old_name = metadata.Name + new_type, new_metadata = dlg.exec(content_type, metadata, len(content) == 1) + if new_type and new_metadata: + self.metadata.removeContentItem(content_type, old_name) + self.metadata.addContentItem(new_type, new_metadata) + self._populate_contents_from_metadata(self.metadata) + + diff --git a/src/Mod/AddonManager/addonmanager_devmode_add_content.py b/src/Mod/AddonManager/addonmanager_devmode_add_content.py index ca959e063c..7a3386547b 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_add_content.py +++ b/src/Mod/AddonManager/addonmanager_devmode_add_content.py @@ -35,6 +35,8 @@ from PySide2.QtWidgets import QDialog, QLayout, QFileDialog, QTableWidgetItem from PySide2.QtGui import QIcon from PySide2.QtCore import Qt +from addonmanager_devmode_validators import VersionValidator, NameValidator, PythonIdentifierValidator + # pylint: disable=too-few-public-methods translate = FreeCAD.Qt.translate @@ -77,6 +79,24 @@ class AddContent: self._freecad_versions_clicked ) + self.dialog.versionLineEdit.setValidator(VersionValidator()) + self.dialog.prefPackNameLineEdit.setValidator(NameValidator()) + self.dialog.displayNameLineEdit.setValidator(NameValidator()) + self.dialog.workbenchClassnameLineEdit.setValidator(PythonIdentifierValidator()) + + self.dialog.addPersonToolButton.setIcon( + QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) + ) + self.dialog.removePersonToolButton.setIcon( + QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) + ) + self.dialog.addLicenseToolButton.setIcon( + QIcon.fromTheme("add", QIcon(":/icons/list-add.svg")) + ) + self.dialog.removeLicenseToolButton.setIcon( + QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg")) + ) + def exec( self, content_kind: str = "workbench", @@ -87,7 +107,8 @@ class AddContent: is accepted, or None if it is rejected. This metadata object represents a single new content item. It's returned as a tuple with the object type as the first component, and the metadata object itself as the second.""" - self.metadata = metadata + if metadata: + self.metadata = FreeCAD.Metadata(metadata) # Deep copy self.dialog.singletonCheckBox.setChecked(singleton) if singleton: # This doesn't happen automatically the first time @@ -104,12 +125,15 @@ class AddContent: self.dialog.addonKindComboBox.setCurrentIndex(index) if metadata: self._populate_dialog(metadata) + + self.dialog.removeLicenseToolButton.setDisabled(True) + self.dialog.removePersonToolButton.setDisabled(True) self.dialog.layout().setSizeConstraint(QLayout.SetFixedSize) result = self.dialog.exec() if result == QDialog.Accepted: return self._generate_metadata() - return None + return None, None def _populate_dialog(self, metadata: FreeCAD.Metadata) -> None: """Fill in the dialog with the details from the passed metadata object""" @@ -117,9 +141,11 @@ class AddContent: if addon_kind == "workbench": self.dialog.workbenchClassnameLineEdit.setText(metadata.Classname) elif addon_kind == "macro": - pass + files = self.metadata.File + if files: + self.dialog.macroFileLineEdit.setText(files[0]) elif addon_kind == "preferencepack": - pass + self.dialog.prefPackNameLineEdit.setText(self.metadata.Name) else: raise RuntimeError("Invalid data found for selection") @@ -140,6 +166,12 @@ class AddContent: else: self.dialog.subdirectoryLineEdit.setText("") + self.dialog.displayNameLineEdit.setText(metadata.Name) + self.dialog.descriptionTextEdit.setPlainText(metadata.Description) + self.dialog.versionLineEdit.setText(metadata.Version) + + #TODO: Add people and licenses + def _set_icon(self, icon_relative_path): """Load the icon and display it, and its path, in the dialog.""" icon_path = os.path.join( @@ -161,7 +193,56 @@ class AddContent: def _generate_metadata(self) -> Tuple[str, FreeCAD.Metadata]: """Create and return a new metadata object based on the contents of the dialog.""" - return ("workbench", FreeCAD.Metadata()) + + if not self.metadata: + self.metadata = FreeCAD.Metadata() + + ########################################################################################## + # Required data: + current_data:str = self.dialog.addonKindComboBox.currentData() + if current_data == "preferencepack": + self.metadata.Name = self.dialog.prefPackNameLineEdit.text() + elif self.dialog.displayNameLineEdit.text(): + self.metadata.Name = self.dialog.displayNameLineEdit.text() + + if current_data == "workbench": + self.metadata.Classname = self.dialog.workbenchClassnameLineEdit.text() + elif current_data == "macro": + self.metadata.File = [self.dialog.macroFileLineEdit.text()] + ########################################################################################## + + # Early return if this is the only addon + if self.dialog.singletonCheckBox.isChecked(): + return (current_data, self.metadata) + + self.metadata.Icon = self.dialog.iconLineEdit.text() + + # Otherwise, process the rest of the metadata (display name is already done) + self.metadata.Description = self.dialog.descriptionTextEdit.document().toPlainText() + self.metadata.Version = self.dialog.versionLineEdit.text() + + maintainers = [] + authors = [] + for row in range(self.dialog.peopleTableWidget.rowCount()): + person_type = self.dialog.peopleTableWidget.item(row, 0).data() + name = self.dialog.peopleTableWidget.item(row, 1).text() + email = self.dialog.peopleTableWidget.item(row, 2).text() + if person_type == "maintainer": + maintainers.append({"name":name,"email":email}) + elif person_type == "author": + authors.append({"name":name,"email":email}) + self.metadata.Maintainer = maintainers + self.metadata.Author = authors + + licenses = [] + for row in range(self.dialog.licensesTableWidget.rowCount()): + license = {} + license["name"] = self.dialog.licensesTableWidget.item(row, 0).text + license["file"] = self.dialog.licensesTableWidget.item(row, 1).text() + licenses.append(license) + self.metadata.License = licenses + + return (self.dialog.addonKindComboBox.currentData(), self.metadata) ############################################################################################### # DIALOG SLOTS @@ -180,6 +261,9 @@ class AddContent: dir=start_dir, ) + if not new_icon_path: + return + base_path = self.path_to_addon.replace("/", os.path.sep) icon_path = new_icon_path.replace("/", os.path.sep) @@ -192,6 +276,7 @@ class AddContent: ) return self._set_icon(new_icon_path[len(base_path) :]) + self.metadata.Icon = new_icon_path[len(base_path) :] def _browse_for_subdirectory_clicked(self): """Callback: when the "Browse..." button for the subdirectory field is clicked""" @@ -205,6 +290,8 @@ class AddContent: ), dir=start_dir, ) + if not new_subdir_path: + return if new_subdir_path[-1] != "/": new_subdir_path += "/" @@ -238,20 +325,27 @@ class AddContent: def _tags_clicked(self): """Show the tag editor""" tags = [] + if not self.metadata: + self.metadata = FreeCAD.Metadata() if self.metadata: tags = self.metadata.Tag dlg = EditTags(tags) new_tags = dlg.exec() + self.metadata.Tag = new_tags def _freecad_versions_clicked(self): """Show the FreeCAD version editor""" + if not self.metadata: + self.metadata = FreeCAD.Metadata() dlg = EditFreeCADVersions() - dlg.exec() + dlg.exec(self.metadata) def _dependencies_clicked(self): """Show the dependencies editor""" + if not self.metadata: + self.metadata = FreeCAD.Metadata() dlg = EditDependencies() - dlg.exec() + result = dlg.exec(self.metadata) class EditTags: @@ -304,20 +398,34 @@ class EditDependencies: self.dialog.removeDependencyToolButton.setDisabled(True) - def exec(self): + def exec(self, metadata:FreeCAD.Metadata): """Execute the dialog""" - self.dialog.exec() + self.metadata = FreeCAD.Metadata(metadata) # Make a copy, in case we cancel + row = 0 + for dep in self.metadata.Depend: + dep_type = dep["type"] + dep_name = dep["package"] + dep_optional = dep["optional"] + self._add_row(row, dep_type, dep_name, dep_optional) + row += 1 + result = self.dialog.exec() + if result == QDialog.Accepted: + metadata.Depend = self.metadata.Depend def _add_dependency_clicked(self): """Callback: The add button was clicked""" dlg = EditDependency() dep_type, dep_name, dep_optional = dlg.exec() - row = self.dialog.tableWidget.rowCount() - self._add_row(row, dep_type, dep_name, dep_optional) + if dep_name: + row = self.dialog.tableWidget.rowCount() + self._add_row(row, dep_type, dep_name, dep_optional) + self.metadata.addDepend({"package":dep_name, "type":dep_type, "optional":dep_optional}) + def _add_row(self, row, dep_type, dep_name, dep_optional): """Utility function to add a row to the table.""" translations = { + "automatic": translate("AddonsInstaller", "Automatic"), "workbench": translate("AddonsInstaller", "Workbench"), "addon": translate("AddonsInstaller", "Addon"), "python": translate("AddonsInstaller", "Python"), @@ -338,6 +446,10 @@ class EditDependencies: items = self.dialog.tableWidget.selectedItems() if items: row = items[0].row() + dep_type = self.dialog.tableWidget.item(row, 0).data(Qt.UserRole) + dep_name = self.dialog.tableWidget.item(row, 1).text() + dep_optional = bool(self.dialog.tableWidget.item(row, 2)) + self.metadata.removeDepend({"package":dep_name, "type":dep_type, "optional":dep_optional}) self.dialog.tableWidget.removeRow(row) def _edit_dependency(self, item): @@ -347,8 +459,10 @@ class EditDependencies: dep_type = self.dialog.tableWidget.item(row, 0).data(Qt.UserRole) dep_name = self.dialog.tableWidget.item(row, 1).text() dep_optional = bool(self.dialog.tableWidget.item(row, 2)) - dep_type, dep_name, dep_optional = dlg.exec(dep_type, dep_name, dep_optional) + new_dep_type, new_dep_name, new_dep_optional = dlg.exec(dep_type, dep_name, dep_optional) if dep_type and dep_name: + self.metadata.removeDepend({"package":dep_name, "type":dep_type, "optional":dep_optional}) + self.metadata.addDepend({"package":new_dep_name, "type":new_dep_type, "optional":new_dep_optional}) self.dialog.tableWidget.removeRow(row) self._add_row(row, dep_type, dep_name, dep_optional) @@ -384,9 +498,8 @@ class EditDependency: self._dependency_selection_changed ) - self.dialog.typeComboBox.setCurrentIndex( - 2 - ) # Expect mostly Python dependencies... + # Expect mostly Python dependencies... + self.dialog.typeComboBox.setCurrentIndex(2) def exec( self, dep_type="", dep_name="", dep_optional=False @@ -488,9 +601,22 @@ class EditFreeCADVersions: ) ) - def exec(self): + def exec(self, metadata:FreeCAD.Metadata): """Execute the dialog""" - self.dialog.exec() + if metadata.FreeCADMin != "0.0.0": + self.dialog.minVersionLineEdit.setText(metadata.FreeCADMin) + if metadata.FreeCADMax != "0.0.0": + self.dialog.maxVersionLineEdit.setText(metadata.FreeCADMax) + result = self.dialog.exec() + if result == QDialog.Accepted: + if self.dialog.minVersionLineEdit.text(): + metadata.FreeCADMin = self.dialog.minVersionLineEdit.text() + else: + metadata.FreeCADMin = None + if self.dialog.maxVersionLineEdit.text(): + metadata.FreeCADMax = self.dialog.maxVersionLineEdit.text() + else: + metadata.FreeCADMax = None class EditAdvancedVersions: diff --git a/src/Mod/AddonManager/addonmanager_devmode_license_selector.py b/src/Mod/AddonManager/addonmanager_devmode_license_selector.py index 5a9bcb37be..ca2e2c1df6 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_license_selector.py +++ b/src/Mod/AddonManager/addonmanager_devmode_license_selector.py @@ -60,7 +60,7 @@ class LicenseSelector: "https://creativecommons.org/choose/zero/", ), "GPLv2": ( - "GNU Lesser General Public License version 2", + "GNU General Public License version 2", "https://opensource.org/licenses/GPL-2.0", ), "GPLv3": ( diff --git a/src/Mod/AddonManager/addonmanager_devmode_predictor.py b/src/Mod/AddonManager/addonmanager_devmode_predictor.py new file mode 100644 index 0000000000..8a9eb9f810 --- /dev/null +++ b/src/Mod/AddonManager/addonmanager_devmode_predictor.py @@ -0,0 +1,154 @@ +# *************************************************************************** +# * * +# * Copyright (c) 2022 FreeCAD Project Association * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +""" Class to guess metadata based on folder contents. Note that one of the functions +of this file is to guess the license being applied to the new software package based +in its contents. It is up to the user to make the final determination about whether +the selected license is the correct one, and inclusion here shouldn't be construed as +endorsement of any particular license. In addition, the inclusion of those text strings +does not imply a modification to the license for THIS software, which is licensed +under the LGPLv2.1 license (as stated above).""" + +import os + +import FreeCAD +from addonmanager_git import initialize_git, GitFailed + +class Predictor: + """ Guess the appropriate metadata to apply to a project based on various parameters + found in the supplied directory. """ + + def __init__(self): + self.path = None + self.metadata = FreeCAD.Metadata() + self.license_data = None + self.license_file = "" + + def predict_metadata(self, path:os.PathLike) -> FreeCAD.Metadata: + """ Create a predicted Metadata object based on the contents of the passed-in directory """ + self.path = path + self._predict_author_info() + self._predict_name() + self._predict_description() + self._predict_contents() + self._predict_icon() + self._predict_urls() + self._predict_license() + + def _predict_author_info(self): + """ Predict the author and maintainer info based on git history """ + + def _predict_name(self): + """ Predict the name based on the local path name and/or the contents of a + README.md file. """ + + def _predict_description(self): + """ Predict the description based on the contents of a README.md file. """ + + def _predict_contents(self): + """ Predict the contents based on the contents of the directory. """ + + def _predict_icon(self): + """ Predict the icon based on either a class which defines an Icon member, or + the contents of the local directory structure. """ + + def _predict_urls(self): + """ Predict the URLs based on git settings """ + + def _predict_license(self): + """ Predict the license based on any existing license file. """ + + # These are processed in order, so the BSD 3 clause must come before the 2, for example, because + # the only difference between them is the additional clause. + known_strings = { + "Apache-2.0": ( + "Apache License, Version 2.0", + "Apache License\nVersion 2.0, January 2004", + ), + "BSD-3-Clause": ( + "The 3-Clause BSD License", + "3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission." + ), + "BSD-2-Clause": ( + "The 2-Clause BSD License", + "2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution." + ), + "CC0v1": ( + "CC0 1.0 Universal", + "voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms" + ), + "GPLv2": ( + "GNU General Public License version 2", + "GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991" + ), + "GPLv3": ( + "GNU General Public License version 3", + "The GNU General Public License is a free, copyleft license for software and other kinds of works." + ), + "LGPLv2.1": ( + "GNU Lesser General Public License version 2.1", + "GNU Lesser General Public License\nVersion 2.1, February 1999" + ), + "LGPLv3": ( + "GNU Lesser General Public License version 3", + "GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007" + ), + "MIT": ( + "The MIT License", + "including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software", + ), + "MPL-2.0": ( + "Mozilla Public License 2.0", + "https://opensource.org/licenses/MPL-2.0", + ), + } + self._load_license() + if self.license_data: + for license, test_data in known_strings.items(): + if license in self.license_data: + self.metadata.License = {"name":license,"file":self.license_file} + return + for test_text in test_data: + if test_text in self.license_data + self.metadata.License = {"name":license,"file":self.license_file} + return + + def _load_readme(self): + """ Load in any existing readme """ + valid_names = ["README.md", "README.txt", "README"] + for name in valid_names: + full_path = os.path.join(self.path,name) + if os.path.exists(full_path): + with open(full_path,"r",encoding="utf-8") as f: + self.readme_data = f.read() + return + + def _load_license(self): + """ Load in any existing license """ + valid_names = ["LICENSE", "LICENCE", "COPYING","LICENSE.txt", "LICENCE.txt", "COPYING.txt"] + for name in valid_names: + full_path = os.path.join(self.path,name) + if os.path.exists(full_path): + with open(full_path,"r",encoding="utf-8") as f: + self.license_data = f.read() + self.license_file = name + return \ No newline at end of file diff --git a/src/Mod/AddonManager/addonmanager_devmode_validators.py b/src/Mod/AddonManager/addonmanager_devmode_validators.py new file mode 100644 index 0000000000..7aacb46dc9 --- /dev/null +++ b/src/Mod/AddonManager/addonmanager_devmode_validators.py @@ -0,0 +1,145 @@ +# *************************************************************************** +# * * +# * Copyright (c) 2022 FreeCAD Project Association * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +""" Validators used for various line edits """ + +import keyword + +from PySide2.QtGui import ( + QValidator, + QRegularExpressionValidator, +) +from PySide2.QtCore import QRegularExpression + +def isidentifier(ident: str) -> bool: + + if not ident.isidentifier(): + return False + + if keyword.iskeyword(ident): + return False + + return True + +class NameValidator(QValidator): + """Simple validator to exclude characters that are not valid in filenames.""" + + invalid = '/\\?%*:|"<>' + + def validate(self, value: str, _: int): + """Check the value against the validator""" + for char in value: + if char in NameValidator.invalid: + return QValidator.Invalid + return QValidator.Acceptable + + def fixup(self, value: str) -> str: + """Remove invalid characters from value""" + result = "" + for char in value: + if char not in NameValidator.invalid: + result += char + return result + + +class PythonIdentifierValidator(QValidator): + """ Validates whether input is a valid Python identifier. """ + + def validate(self, value: str, _: int): + if not value: + return QValidator.Intermediate + + if not value.isidentifier(): + return QValidator.Invalid # Includes an illegal character of some sort + + if keyword.iskeyword(value): + return QValidator.Intermediate # They can keep typing and it might become valid + + return QValidator.Acceptable + + +class SemVerValidator(QRegularExpressionValidator): + """Implements the officially-recommended regex validator for Semantic version numbers.""" + + # https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + semver_re = QRegularExpression( + r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + ) + + def __init__(self): + super().__init__() + self.setRegularExpression(SemVerValidator.semver_re) + + @classmethod + def check(cls, value: str) -> bool: + """Returns true if value validates, and false if not""" + return cls.semver_re.match(value).hasMatch() + + +class CalVerValidator(QRegularExpressionValidator): + """Implements a basic regular expression validator that makes sure an entry corresponds + to a CalVer version numbering standard.""" + + calver_re = QRegularExpression( + r"^(?P[1-9]\d{3})\.(?P[0-9]{1,2})\.(?P0|[0-9]{0,2})" + + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + ) + + def __init__(self): + super().__init__() + self.setRegularExpression(CalVerValidator.calver_re) + + @classmethod + def check(cls, value: str) -> bool: + """Returns true if value validates, and false if not""" + return cls.calver_re.match(value).hasMatch() + + +class VersionValidator(QValidator): + """Implements the officially-recommended regex validator for Semantic version numbers, and a + decent approximation of the same thing for CalVer-style version numbers.""" + + def __init__(self): + super().__init__() + self.semver = SemVerValidator() + self.calver = CalVerValidator() + + def validate(self, value: str, position: int): + """Called for validation, returns a tuple of the validation state, the value, and the + position.""" + semver_result = self.semver.validate(value, position) + calver_result = self.calver.validate(value, position) + + if semver_result[0] == QValidator.Acceptable: + return semver_result + if calver_result[0] == QValidator.Acceptable: + return calver_result + if semver_result[0] == QValidator.Intermediate: + return semver_result + if calver_result[0] == QValidator.Intermediate: + return calver_result + return (QValidator.Invalid, value, position) \ No newline at end of file diff --git a/src/Mod/AddonManager/developer_mode_add_content.ui b/src/Mod/AddonManager/developer_mode_add_content.ui index 21b5b6dfa7..68c5acf29f 100644 --- a/src/Mod/AddonManager/developer_mode_add_content.ui +++ b/src/Mod/AddonManager/developer_mode_add_content.ui @@ -73,7 +73,7 @@ - 1 + 0 @@ -263,7 +263,7 @@ - + true