This is an automated email from the ASF dual-hosted git repository. slachiewicz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/maven-remote-resources-plugin.git
commit c2ab5ad812052eaeab6c420f18c2ba797dec8ca8 Author: Sylwester Lachiewicz <[email protected]> AuthorDate: Wed Oct 28 09:45:57 2026 +0000 Group dependencies by the license an ASF distribution would choose A dependency with several licenses may be used under any one of them, and an ASF distribution names only the one it chose, preferring Category A to B to X. Choices that resolve to nothing no longer leave the dependency unresolved. --- .../project/src/main/resources/DEPENDENCIES.vm | 2 + .../plugin/resources/remote/SpdxLicenses.java | 214 ++++++++++++++++++--- .../plugin/resources/remote/license-categories.txt | 65 +++++++ src/site/markdown/usage.md.vm | 4 +- .../plugin/resources/remote/SpdxLicensesTest.java | 55 +++++- .../resources/remote/it/ITSpdxLicenseId.java | 25 ++- 6 files changed, 320 insertions(+), 45 deletions(-) diff --git a/src/it/resources/spdx-license-id/project/src/main/resources/DEPENDENCIES.vm b/src/it/resources/spdx-license-id/project/src/main/resources/DEPENDENCIES.vm index 6ebd36b..428baa9 100644 --- a/src/it/resources/spdx-license-id/project/src/main/resources/DEPENDENCIES.vm +++ b/src/it/resources/spdx-license-id/project/src/main/resources/DEPENDENCIES.vm @@ -17,6 +17,8 @@ Override: #if ($projectName)$projectName#else${project.name}#end #foreach( $dependency in $projects ) $dependency.artifactId expression: $!spdx.expression($dependency) +#set( $preferred = $spdx.preferred($dependency) ) +$dependency.artifactId preferred:#if( $preferred ) $preferred $!spdx.category($preferred)#end #foreach( $license in $dependency.licenses ) #set( $id = $spdx.id($license) ) $dependency.artifactId: #if( $id )$id#else$license.name#end diff --git a/src/main/java/org/apache/maven/plugin/resources/remote/SpdxLicenses.java b/src/main/java/org/apache/maven/plugin/resources/remote/SpdxLicenses.java index 9dfbc2b..ec6699e 100644 --- a/src/main/java/org/apache/maven/plugin/resources/remote/SpdxLicenses.java +++ b/src/main/java/org/apache/maven/plugin/resources/remote/SpdxLicenses.java @@ -53,7 +53,11 @@ import org.apache.maven.project.MavenProject; * A project that declares several licenses may be used under any one of them, so * <code>$!spdx.expression($project)</code> joins their IDs with <code>OR</code>. * <p> - * To list each license once, <code>$spdx.group($projects)</code> groups dependencies by SPDX expression, + * An ASF distribution names one chosen license for such a project: <code>$!spdx.preferred($project)</code> + * picks it by <a href="https://www.apache.org/legal/resolved.html">ASF license category</a>, which + * <code>$!spdx.category($id)</code> returns. + * <p> + * To list each license once, <code>$spdx.group($projects)</code> groups dependencies by preferred license, * <code>$spdx.ids($projects)</code> returns every ID they use, and <code>$spdx.unresolved($projects)</code> * returns the dependencies that match nothing. * @@ -63,6 +67,8 @@ public class SpdxLicenses { private static final String RESOURCE = "license-aliases.txt"; + private static final String CATEGORIES = "license-categories.txt"; + private static final Pattern EXPRESSION_TOKEN = Pattern.compile("\\(|\\)|[^\\s()]+"); private static final Pattern LICENSE_REF = Pattern.compile("LicenseRef-[A-Za-z0-9.-]+"); @@ -81,6 +87,9 @@ public class SpdxLicenses { /** Normalised URL to ID or expression. */ private final Map<String, String> urls = new HashMap<>(); + /** Lower-case ID or expression to ASF category. */ + private final Map<String, String> categories = new HashMap<>(); + private final List<LicenseMapping> mappings; SpdxLicenses() { @@ -89,33 +98,42 @@ public class SpdxLicenses { SpdxLicenses(List<LicenseMapping> mappings) { this.mappings = new ArrayList<>(mappings); - InputStream in = SpdxLicenses.class.getResourceAsStream(RESOURCE); + for (String[] fields : read(RESOURCE)) { + String id = fields[0].trim(); + if (id.indexOf(' ') < 0) { + ids.put(id.toLowerCase(Locale.ROOT), id); + } + if (fields.length > 1) { + String alias = fields[1].trim(); + if (alias.startsWith("http://") || alias.startsWith("https://")) { + put(urls, normaliseUrl(alias), id); + } else { + put(names, normaliseName(alias), id); + } + } + } + for (String[] fields : read(CATEGORIES)) { + categories.put(fields[0].trim().toLowerCase(Locale.ROOT), fields[1].trim()); + } + } + + private static List<String[]> read(String resource) { + InputStream in = SpdxLicenses.class.getResourceAsStream(resource); if (in == null) { - throw new IllegalStateException("Missing resource " + RESOURCE); + throw new IllegalStateException("Missing resource " + resource); } + List<String[]> lines = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { - if (line.trim().isEmpty() || line.startsWith("#")) { - continue; - } - String[] fields = line.split("\t", 2); - String id = fields[0].trim(); - if (id.indexOf(' ') < 0) { - ids.put(id.toLowerCase(Locale.ROOT), id); - } - if (fields.length > 1) { - String alias = fields[1].trim(); - if (alias.startsWith("http://") || alias.startsWith("https://")) { - put(urls, normaliseUrl(alias), id); - } else { - put(names, normaliseName(alias), id); - } + if (!line.trim().isEmpty() && !line.startsWith("#")) { + lines.add(line.split("\t", 2)); } } } catch (IOException e) { throw new UncheckedIOException(e); } + return lines; } private static void put(Map<String, String> map, String key, String id) { @@ -172,18 +190,154 @@ public class SpdxLicenses { } /** - * Groups projects by their SPDX license expression, leaving out the projects whose expression is unknown. + * Returns the license to use a project under: of the choices its licenses resolve to, the first one in + * ASF Category A, else in Category B, else without a known category, else in Category X. Choices that + * resolve to nothing are skipped, as are the alternatives of an <code>OR</code> expression that lose. + * + * @param project a project, typically a dependency + * @return the SPDX ID or expression of the chosen license, or {@code null} when none of its licenses resolve + */ + public String preferred(MavenProject project) { + List<License> licenses = project == null ? null : project.getLicenses(); + if (licenses == null) { + return null; + } + String best = null; + int bestRank = Integer.MAX_VALUE; + for (License license : licenses) { + String id = id(license); + if (id == null) { + continue; + } + for (String choice : alternatives(id)) { + int rank = rank(category(choice)); + if (rank < bestRank) { + best = choice; + bestRank = rank; + } + } + } + return best; + } + + /** + * Returns the <a href="https://www.apache.org/legal/resolved.html">ASF license category</a> of an SPDX ID or + * expression: <code>A</code>, <code>B</code> or <code>X</code>. An <code>OR</code> expression takes the best + * category of its alternatives, an <code>AND</code> expression the worst of its parts. + * + * @param expression an SPDX ID or expression, for example from <code>$spdx.preferred($dependency)</code> + * @return the category, or {@code null} when it is not known + */ + public String category(String expression) { + if (isBlank(expression)) { + return null; + } + String e = stripParentheses(expression.trim()); + String category = categories.get(e.toLowerCase(Locale.ROOT)); + if (category != null) { + return category; + } + List<String> alternatives = split(e, " OR "); + if (alternatives.size() > 1) { + String best = null; + for (String alternative : alternatives) { + String c = category(alternative); + best = rank(c) < rank(best) ? c : best; + } + return best; + } + List<String> parts = split(e, " AND "); + if (parts.size() > 1) { + String worst = "A"; + for (String part : parts) { + String c = category(part); + worst = rank(c) > rank(worst) ? c : worst; + } + return worst; + } + int with = e.toUpperCase(Locale.ROOT).indexOf(" WITH "); + return with > 0 ? categories.get(e.substring(0, with).toLowerCase(Locale.ROOT)) : null; + } + + /** A, B, unknown, X. */ + private static int rank(String category) { + if ("A".equals(category)) { + return 0; + } + if ("B".equals(category)) { + return 1; + } + return "X".equals(category) ? 3 : 2; + } + + /** Returns the alternatives of an <code>OR</code> expression, each without enclosing parentheses. */ + private static List<String> alternatives(String expression) { + List<String> result = new ArrayList<>(); + for (String alternative : split(stripParentheses(expression), " OR ")) { + String a = stripParentheses(alternative); + if (split(a, " OR ").size() > 1) { + result.addAll(alternatives(a)); + } else { + result.add(a); + } + } + return result; + } + + /** Splits at the operator where it is outside parentheses. */ + private static List<String> split(String expression, String operator) { + List<String> parts = new ArrayList<>(); + int depth = 0; + int start = 0; + for (int i = 0; i < expression.length(); i++) { + char c = expression.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (depth == 0 && expression.regionMatches(true, i, operator, 0, operator.length())) { + parts.add(expression.substring(start, i).trim()); + start = i + operator.length(); + i = start - 1; + } + } + parts.add(expression.substring(start).trim()); + return parts; + } + + private static String stripParentheses(String expression) { + String e = expression.trim(); + while (e.startsWith("(") && e.endsWith(")") && encloses(e)) { + e = e.substring(1, e.length() - 1).trim(); + } + return e; + } + + /** Whether the opening parenthesis at the start closes at the end. */ + private static boolean encloses(String e) { + int depth = 0; + for (int i = 0; i < e.length(); i++) { + depth += e.charAt(i) == '(' ? 1 : e.charAt(i) == ')' ? -1 : 0; + if (depth == 0) { + return i == e.length() - 1; + } + } + return false; + } + + /** + * Groups projects by their preferred license, leaving out the projects whose licenses resolve to nothing. * * @param projects projects, typically <code>$projects</code> - * @return the projects for each expression, sorted by expression + * @return the projects for each preferred license, sorted by SPDX ID */ public Map<String, List<MavenProject>> group(List<MavenProject> projects) { Map<String, List<MavenProject>> groups = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); if (projects != null) { for (MavenProject project : projects) { - String expression = expression(project); - if (expression != null) { - groups.computeIfAbsent(expression, k -> new ArrayList<>()).add(project); + String preferred = preferred(project); + if (preferred != null) { + groups.computeIfAbsent(preferred, k -> new ArrayList<>()).add(project); } } } @@ -191,8 +345,8 @@ public class SpdxLicenses { } /** - * Returns every SPDX license and exception ID that the projects' expressions use, each one once, so that a - * template can add each license text once. + * Returns every SPDX license and exception ID that the projects' preferred licenses use, each one once, so + * that a template can add each license text once. * * @param projects projects, typically <code>$projects</code> * @return the IDs, sorted @@ -203,9 +357,7 @@ public class SpdxLicenses { Matcher m = EXPRESSION_TOKEN.matcher(expression); while (m.find()) { String token = m.group(); - if (!token.equalsIgnoreCase("AND") - && !token.equalsIgnoreCase("OR") - && !token.equalsIgnoreCase("WITH")) { + if (!isOperator(token) && !token.equals("(") && !token.equals(")")) { result.add(token); } } @@ -214,16 +366,16 @@ public class SpdxLicenses { } /** - * Returns the projects whose SPDX license expression is unknown. + * Returns the projects that have no preferred license. * * @param projects projects, typically <code>$projects</code> - * @return the projects that declare no license or a license that matches nothing + * @return the projects that declare no license, or only licenses that match nothing */ public List<MavenProject> unresolved(List<MavenProject> projects) { List<MavenProject> result = new ArrayList<>(); if (projects != null) { for (MavenProject project : projects) { - if (expression(project) == null) { + if (preferred(project) == null) { result.add(project); } } diff --git a/src/main/resources/org/apache/maven/plugin/resources/remote/license-categories.txt b/src/main/resources/org/apache/maven/plugin/resources/remote/license-categories.txt new file mode 100644 index 0000000..2d05426 --- /dev/null +++ b/src/main/resources/org/apache/maven/plugin/resources/remote/license-categories.txt @@ -0,0 +1,65 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# ASF license categories of the IDs in license-aliases.txt, from +# https://www.apache.org/legal/resolved.html. Category A may be included in ASF +# products, Category B in binary form only and labelled, Category X not at all. +# +# Each line holds an SPDX ID or expression, a tab, and A, B or X. An ID that is +# missing here has no known category. + +0BSD A +Apache-1.1 A +Apache-2.0 A +BSD-2-Clause A +BSD-3-Clause A +BSL-1.0 A +CC0-1.0 A +ISC A +MIT A +MIT-0 A +UPL-1.0 A +Unlicense A + +CDDL-1.0 B +CDDL-1.1 B +CPL-1.0 B +EPL-1.0 B +EPL-2.0 B +MPL-1.0 B +MPL-1.1 B +MPL-2.0 B +GPL-2.0-only WITH Classpath-exception-2.0 B + +AGPL-3.0-only X +AGPL-3.0-or-later X +GPL-2.0 X +GPL-2.0+ X +GPL-2.0-only X +GPL-2.0-or-later X +GPL-3.0 X +GPL-3.0+ X +GPL-3.0-only X +GPL-3.0-or-later X +LGPL-2.1 X +LGPL-2.1+ X +LGPL-2.1-only X +LGPL-2.1-or-later X +LGPL-3.0 X +LGPL-3.0+ X +LGPL-3.0-only X +LGPL-3.0-or-later X diff --git a/src/site/markdown/usage.md.vm b/src/site/markdown/usage.md.vm index c2d81ba..6b082fe 100644 --- a/src/site/markdown/usage.md.vm +++ b/src/site/markdown/usage.md.vm @@ -131,7 +131,9 @@ A mapping matches a spelling wherever it appears, like `licenseMerges` in the [L A dependency that declares several licenses may be used under any one of them. `${esc.d}spdx.expression(${esc.d}dependency)` returns one SPDX license expression for the whole dependency, joining the IDs with `OR`, for example `CDDL-1.1 OR (GPL-2.0-only WITH Classpath-exception-2.0)`. It returns null when the dependency declares no license or when one of its licenses matches nothing, because a partial expression would leave out a choice. -To list each license once rather than once per dependency, group the dependencies by SPDX expression. `${esc.d}spdx.ids(${esc.d}projects)` returns every license and exception ID in use, each one once, so a template can add each license text once. `${esc.d}spdx.unresolved(${esc.d}projects)` returns the dependencies that `${esc.d}spdx` could not resolve, which still need their declared licenses listed: +An ASF distribution states the one license it uses a dependency under: "include only the license that you have chosen. Prefer Category A to Category B to Category X" ([ASF 3rd Party License Policy](https://www.apache.org/legal/resolved.html)). `${esc.d}spdx.preferred(${esc.d}dependency)` makes that choice. Of the licenses that resolve, it returns the first one in Category A, else in Category B, else one without a known category, else in Category X; licenses that resolve to nothing are sk [...] + +To list each license once rather than once per dependency, `${esc.d}spdx.group(${esc.d}projects)` groups the dependencies by preferred license. `${esc.d}spdx.ids(${esc.d}projects)` returns every license and exception ID in use, each one once, so a template can add each license text once. `${esc.d}spdx.unresolved(${esc.d}projects)` returns the dependencies without a preferred license, which still need their declared licenses listed: ``` #[[#foreach( $group in $spdx.group($projects).entrySet() ) diff --git a/src/test/java/org/apache/maven/plugin/resources/remote/SpdxLicensesTest.java b/src/test/java/org/apache/maven/plugin/resources/remote/SpdxLicensesTest.java index 47fcb51..39ef612 100644 --- a/src/test/java/org/apache/maven/plugin/resources/remote/SpdxLicensesTest.java +++ b/src/test/java/org/apache/maven/plugin/resources/remote/SpdxLicensesTest.java @@ -139,16 +139,63 @@ class SpdxLicensesTest { Map<String, List<MavenProject>> groups = spdx.group(projects); assertEquals( - Arrays.asList( - "Apache-2.0", "Apache-2.0 OR MIT", "CDDL-1.1 OR (GPL-2.0-only WITH Classpath-exception-2.0)"), + Arrays.asList("Apache-2.0", "CDDL-1.1"), Arrays.asList(groups.keySet().toArray())); - assertEquals(Arrays.asList(a, b), groups.get("Apache-2.0")); + assertEquals(Arrays.asList(a, b, e), groups.get("Apache-2.0")); + assertEquals(Arrays.asList(c), groups.get("CDDL-1.1")); assertEquals( - Arrays.asList("Apache-2.0", "CDDL-1.1", "Classpath-exception-2.0", "GPL-2.0-only", "MIT"), + Arrays.asList("Apache-2.0", "CDDL-1.1"), Arrays.asList(spdx.ids(projects).toArray())); assertEquals(Arrays.asList(d), spdx.unresolved(projects)); } + @Test + void preferredSkipsUnresolvedChoices() { + // javassist 3.30.2-GA and rocksdbjni 9.7.3 as their POMs declare them + assertEquals( + "Apache-2.0", + spdx.preferred(project( + license("MPL 1.1", "https://www.mozilla.org/en-US/MPL/1.1/"), + license("LGPL 2.1", "https://www.gnu.org/licenses/lgpl-2.1.html"), + license("Apache License 2.0", "https://www.apache.org/licenses/LICENSE-2.0")))); + assertEquals( + "Apache-2.0", + spdx.preferred(project( + license("Apache License 2.0", "http://www.apache.org/licenses/LICENSE-2.0.html"), + license("GNU General Public License, version 2", "http://www.gnu.org/licenses/gpl-2.0.html")))); + assertNull(spdx.preferred(project(license("Public Domain", null)))); + assertNull(spdx.preferred(project())); + assertNull(spdx.preferred(null)); + } + + @Test + void preferredFollowsCategory() { + assertEquals("MPL-1.1", spdx.preferred(project(license("LGPL-2.1-only", null), license("MPL 1.1", null)))); + assertEquals("Apache-2.0", spdx.preferred(project(license("EPL-2.0 OR Apache-2.0", null)))); + assertEquals("MIT", spdx.preferred(project(license("(MIT OR Apache-2.0)", null)))); + assertEquals("GPL-2.0-only", spdx.preferred(project(license("GPL-2.0-only", null)))); + assertEquals( + "LicenseRef-Widget", + spdx.preferred(project(license("GPL-3.0-only", null), license("LicenseRef-Widget", null)))); + assertEquals( + "GPL-2.0-only WITH Classpath-exception-2.0", + spdx.preferred(project(license("GPL-3.0-only", null), license("GPL2 w/ CPE", null)))); + } + + @Test + void category() { + assertEquals("A", spdx.category("Apache-2.0")); + assertEquals("B", spdx.category("EPL-2.0")); + assertEquals("X", spdx.category("LGPL-2.1-or-later")); + assertEquals("B", spdx.category("GPL-2.0-only WITH Classpath-exception-2.0")); + assertEquals("X", spdx.category("GPL-3.0-only WITH Classpath-exception-2.0")); + assertEquals("A", spdx.category("(MIT OR GPL-2.0-only)")); + assertEquals("B", spdx.category("MIT AND EPL-2.0")); + assertNull(spdx.category("MIT AND Zlib")); + assertNull(spdx.category("Zlib")); + assertNull(spdx.category(null)); + } + private static License license(String name, String url) { License license = new License(); license.setName(name); diff --git a/src/test/java/org/apache/maven/plugin/resources/remote/it/ITSpdxLicenseId.java b/src/test/java/org/apache/maven/plugin/resources/remote/it/ITSpdxLicenseId.java index c65ea2c..423d44a 100644 --- a/src/test/java/org/apache/maven/plugin/resources/remote/it/ITSpdxLicenseId.java +++ b/src/test/java/org/apache/maven/plugin/resources/remote/it/ITSpdxLicenseId.java @@ -62,18 +62,25 @@ public class ITSpdxLicenseId extends AbstractIT { content); assertTrue(content.contains("f-mapped: BSD-3-Clause\n"), content); - assertTrue(content.contains("Group Apache-2.0: a-name b-url\n"), content); - assertTrue(content.contains("Group (Apache-2.0 OR EPL-2.0): c-expression\n"), content); + assertTrue(content.contains("c-expression preferred: Apache-2.0 A\n"), content); + assertTrue(content.contains("e-dual preferred: CDDL-1.1 B\n"), content); + assertTrue(content.contains("d-unknown preferred:\n"), content); + + assertTrue(content.contains("Group Apache-2.0: a-name b-url c-expression\n"), content); + assertTrue(content.contains("Group CDDL-1.1: e-dual\n"), content); assertTrue(content.contains("Unresolved: d-unknown\n"), content); - assertEquals(1, content.split("License text: Apache-2.0\n", -1).length - 1, content); - assertTrue(content.contains("License text: Classpath-exception-2.0\n"), content); + assertEquals( + "License text: Apache-2.0\nLicense text: BSD-3-Clause\nLicense text: CDDL-1.1\n", + content.replaceAll("(?m)^(?!License text: ).*\n", ""), + content); + assertTrue(content.contains("Org Example Org:\n License Apache-2.0: a-name c-expression\n"), content); assertTrue( - content.contains("Org Example Org:\n" - + " License (Apache-2.0 OR EPL-2.0): c-expression\n" - + " License Apache-2.0: a-name\n"), + content.contains("Org an unknown organization:\n" + + " License Apache-2.0: b-url\n" + + " License BSD-3-Clause: f-mapped\n" + + " License CDDL-1.1: e-dual\n" + + " Unresolved d-unknown\n"), content); - assertTrue(content.contains("Org an unknown organization:\n License Apache-2.0: b-url\n"), content); - assertTrue(content.contains(" Unresolved d-unknown\n"), content); } }
