FreeCAD
286 строк · 12.3 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022 FreeCAD Project Association *
5# * *
6# * This file is part of FreeCAD. *
7# * *
8# * FreeCAD is free software: you can redistribute it and/or modify it *
9# * under the terms of the GNU Lesser General Public License as *
10# * published by the Free Software Foundation, either version 2.1 of the *
11# * License, or (at your option) any later version. *
12# * *
13# * FreeCAD is distributed in the hope that it will be useful, but *
14# * WITHOUT ANY WARRANTY; without even the implied warranty of *
15# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
16# * Lesser General Public License for more details. *
17# * *
18# * You should have received a copy of the GNU Lesser General Public *
19# * License along with FreeCAD. If not, see *
20# * <https://www.gnu.org/licenses/>. *
21# * *
22# ***************************************************************************
23
24""" Class to guess metadata based on folder contents. Note that one of the functions
25of this file is to guess the license being applied to the new software package based
26in its contents. It is up to the user to make the final determination about whether
27the selected license is the correct one, and inclusion here shouldn't be construed as
28endorsement of any particular license. In addition, the inclusion of those text strings
29does not imply a modification to the license for THIS software, which is licensed
30under the LGPLv2.1 license (as stated above)."""
31
32import datetime
33import os
34
35import FreeCAD
36from addonmanager_git import initialize_git, GitManager
37from addonmanager_utilities import get_readme_url
38
39translate = FreeCAD.Qt.translate
40
41# pylint: disable=too-few-public-methods
42
43
44class AddonSlice:
45"""A tiny class to implement duck-typing for the URL-parsing utility functions"""
46
47def __init__(self, url, branch):
48self.url = url
49self.branch = branch
50
51
52class Predictor:
53"""Guess the appropriate metadata to apply to a project based on various parameters
54found in the supplied directory."""
55
56def __init__(self):
57self.path = None
58self.metadata = FreeCAD.Metadata()
59self.license_data = None
60self.readme_data = None
61self.license_file = ""
62self.git_manager: GitManager = initialize_git()
63if not self.git_manager:
64raise Exception("Cannot use Developer Mode without git installed")
65
66def predict_metadata(self, path: str) -> FreeCAD.Metadata:
67"""Create a predicted Metadata object based on the contents of the passed-in directory"""
68if not os.path.isdir(path):
69return None
70self.path = path
71self._predict_author_info()
72self._predict_name()
73self._predict_description()
74self._predict_contents()
75self._predict_icon()
76self._predict_urls()
77self._predict_license()
78self._predict_version()
79
80return self.metadata
81
82def _predict_author_info(self):
83"""Look at the git commit history and attempt to discern maintainer and author
84information."""
85
86committers = self.git_manager.get_last_committers(self.path)
87
88# This is a dictionary keyed to the author's name (which can be many
89# things, depending on the author) containing two fields, "email" and "count". It
90# is common for there to be multiple entries representing the same human being,
91# so a passing attempt is made to reconcile:
92filtered_committers = {}
93for key, committer in committers.items():
94if "github" in key.lower():
95# Robotic merge commit (or other similar), ignore
96continue
97# Does any other committer share any of these emails?
98for other_key, other_committer in committers.items():
99if other_key == key:
100continue
101for other_email in other_committer["email"]:
102if other_email in committer["email"]:
103# There is overlap in the two email lists, so this is probably the
104# same author, with a different name (username, pseudonym, etc.)
105if not committer["aka"]:
106committer["aka"] = set()
107committer["aka"].add(other_key)
108committer["count"] += other_committer["count"]
109committer["email"].combine(other_committer["email"])
110committers.pop(other_key)
111break
112filtered_committers[key] = committer
113maintainers = []
114for name, info in filtered_committers.items():
115if "aka" in info:
116for other_name in info["aka"]:
117# Heuristic: the longer name is more likely to be the actual legal name
118if len(other_name) > len(name):
119name = other_name
120# There is no logical basis to choose one email address over another, so just
121# take the first one
122email = info["email"][0]
123commit_count = info["count"]
124maintainers.append({"name": name, "email": email, "count": commit_count})
125
126# Sort by count of commits
127maintainers.sort(key=lambda i: i["count"], reverse=True)
128
129self.metadata.Maintainer = maintainers
130
131def _predict_name(self):
132"""Predict the name based on the local path name and/or the contents of a
133README.md file."""
134
135normed_path = self.path.replace(os.path.sep, "/")
136path_components = normed_path.split("/")
137final_path_component = path_components[-1]
138predicted_name = final_path_component.replace("/", "")
139self.metadata.Name = predicted_name
140
141def _predict_description(self):
142"""Predict the description based on the contents of a README.md file."""
143self._load_readme()
144
145if not self.readme_data:
146return
147
148lines = self.readme_data.split("\n")
149description = ""
150for line in lines:
151if "#" in line:
152continue # Probably not a line of description
153if "![" in line:
154continue # An image link, probably separate from any description
155if not line and description:
156break # We're done: this is a blank line, and we've read some data already
157if description:
158description += " "
159description += line
160
161if description:
162self.metadata.Description = description
163
164def _predict_contents(self):
165"""Predict the contents based on the contents of the directory."""
166
167def _predict_icon(self):
168"""Predict the icon based on either a class which defines an Icon member, or
169the contents of the local directory structure."""
170
171def _predict_urls(self):
172"""Predict the URLs based on git settings"""
173
174branch = self.git_manager.current_branch(self.path)
175remote = self.git_manager.get_remote(self.path)
176
177addon = AddonSlice(remote, branch)
178readme = get_readme_url(addon)
179
180self.metadata.addUrl("repository", remote, branch)
181self.metadata.addUrl("readme", readme)
182
183def _predict_license(self):
184"""Predict the license based on any existing license file."""
185
186# These are processed in order, so the BSD 3 clause must come before the 2, for example,
187# because the only difference between them is the additional clause.
188known_strings = {
189"Apache-2.0": (
190"Apache License, Version 2.0",
191"Apache License\nVersion 2.0, January 2004",
192),
193"BSD-3-Clause": (
194"The 3-Clause BSD License",
195"3. Neither the name of the copyright holder nor the names of its contributors \
196may be used to endorse or promote products derived from this software without \
197specific prior written permission.",
198),
199"BSD-2-Clause": (
200"The 2-Clause BSD License",
201"2. Redistributions in binary form must reproduce the above copyright notice, \
202this list of conditions and the following disclaimer in the documentation and/or \
203other materials provided with the distribution.",
204),
205"CC0v1": (
206"CC0 1.0 Universal",
207"voluntarily elects to apply CC0 to the Work and publicly distribute the Work \
208under its terms",
209),
210"GPLv2": (
211"GNU General Public License version 2",
212"GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991",
213),
214"GPLv3": (
215"GNU General Public License version 3",
216"The GNU General Public License is a free, copyleft license for software and \
217other kinds of works.",
218),
219"LGPLv2.1": (
220"GNU Lesser General Public License version 2.1",
221"GNU Lesser General Public License\nVersion 2.1, February 1999",
222),
223"LGPLv3": (
224"GNU Lesser General Public License version 3",
225"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
226),
227"MIT": (
228"The MIT License",
229"including without limitation the rights to use, copy, modify, merge, publish, \
230distribute, sublicense, and/or sell copies of the Software",
231),
232"MPL-2.0": (
233"Mozilla Public License 2.0",
234"https://opensource.org/licenses/MPL-2.0",
235),
236}
237self._load_license()
238if self.license_data:
239for shortcode, test_data in known_strings.items():
240if shortcode.lower() in self.license_data.lower():
241self.metadata.addLicense(shortcode, self.license_file)
242return
243for test_text in test_data:
244# Do the comparison without regard to whitespace or capitalization
245if (
246"".join(test_text.split()).lower()
247in "".join(self.license_data.split()).lower()
248):
249self.metadata.addLicense(shortcode, self.license_file)
250return
251
252def _predict_version(self):
253"""Default to a CalVer style set to today's date"""
254year = datetime.date.today().year
255month = datetime.date.today().month
256day = datetime.date.today().day
257version_string = f"{year}.{month:>02}.{day:>02}"
258self.metadata.Version = version_string
259
260def _load_readme(self):
261"""Load in any existing readme"""
262valid_names = ["README.md", "README.txt", "README"]
263for name in valid_names:
264full_path = os.path.join(self.path, name)
265if os.path.exists(full_path):
266with open(full_path, encoding="utf-8") as f:
267self.readme_data = f.read()
268return
269
270def _load_license(self):
271"""Load in any existing license"""
272valid_names = [
273"LICENSE",
274"LICENCE",
275"COPYING",
276"LICENSE.txt",
277"LICENCE.txt",
278"COPYING.txt",
279]
280for name in valid_names:
281full_path = os.path.join(self.path.replace("/", os.path.sep), name)
282if os.path.isfile(full_path):
283with open(full_path, encoding="utf-8") as f:
284self.license_data = f.read()
285self.license_file = name
286return
287