From 66f1c3e67c2f96767f7c0d4d201ec7caaf063b00 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Mon, 6 Oct 2025 19:30:36 +0200 Subject: [PATCH 1/3] new file for suggesting outbound license --- licomp_toolkit/suggester.py | 162 ++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 licomp_toolkit/suggester.py diff --git a/licomp_toolkit/suggester.py b/licomp_toolkit/suggester.py new file mode 100644 index 0000000..0851ca1 --- /dev/null +++ b/licomp_toolkit/suggester.py @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: 2025 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import logging +import re +import traceback + +from licomp_toolkit.toolkit import LicompToolkit +from licomp_toolkit.toolkit import ExpressionExpressionChecker +from licomp.interface import Provisioning +from licomp.interface import UseCase + +from flame.license_db import FossLicenses + +class OutboundSuggester: + """ + OutboundSuggester suggests outbound candidate licenses from a + license expression + + If you have a license expression like this "BSD-3-Clause AND MIT + AND GPL-2.0-only", typically the result of your dependencies' + licenses, and want to know which outbound license you can chose. + + """ + def __init__(self): + self._compatibility_rankings = {} + self.lt = LicompToolkit() + self.le = ExpressionExpressionChecker() + self.flame = FossLicenses() + + def compatibility_rankings(self, usecase, provisioning): + """ Returns a dict with ranked licenses based on your usecase/provisioning, e.g. + + { + 'GPL3': {'count': 2, 'valids': '1', 'index': 69}, + 'MIT': {'count': 108, 'valids': '1', 'index': 169} + } + + count - how many other licenses the license is compatible with (primary sort) + valids - how many valid resources provided a 'yes' answer (secondary sort) + index - index for the licenses, the higher index the more compatible (with other licenses) + """ + if usecase in self._compatibility_rankings: + if provisioning in self._compatibility_rankings[usecase]: + return self._compatibility_rankings[usecase][provisioning] + else: + self._compatibility_rankings[usecase] = {} + + # + # identify compatiblities + lic_map = {} + # loop through every license (against themselves) + for in_lic in self.lt.supported_licenses(): + for out_lic in self.lt.supported_licenses(): + # get compat + ret = self.lt.outbound_inbound_compatibility(out_lic, + in_lic, + UseCase.string_to_usecase(usecase), + Provisioning.string_to_provisioning(provisioning)) + results = ret['summary']['results'] + valid_results = results['nr_valid'] + # only increase the 'count' if the answers from the resources are only 'yes' + yes = results.get('yes', {'count': 0, 'percent': 0.0}) + inc = 0 + if yes['percent'] == 100: + inc = 1 + + lic_map[in_lic] = { + 'count': lic_map.get(in_lic, {'count': 0})['count'] + inc, + 'valids': valid_results, + } + + # decorate (to sort list) + decorated = [(lic_map[lic]['count'], lic_map[lic]['valids'], i, lic) for i, lic in enumerate(lic_map)] + # sort after count, valids (in tuple) + decorated.sort() + # undecorate + ordered_lic = [lic for count, valids, i, lic in decorated] + + # create new dict - use license name as key + ordered_lic_map = {} + for i, lic in enumerate(ordered_lic): + ordered_lic_map[lic] = lic_map[lic] + ordered_lic_map[lic]['index'] = i + + # store foar later (re)use + self._compatibility_rankings[usecase][provisioning] = ordered_lic_map + + return self._compatibility_rankings[usecase][provisioning] + + def licenses(self, license_expr): + """Returns a list of licenses in a license expression. + + If you have a license expression like this: + "(MIT OR Apache-2.0) AND MIT AND GPL-2.0-only WITH Classpath-exception-2.0" + + you get a list like this: + ["MIT", "Apache-2.0", "GPL-2.0-only WITH Classpath-exception-2.0"] + + """ + normalized = self.flame.expression_license(license_expr, update_dual=False)['identified_license'] + + with_fixed = normalized.replace(' WITH ', '-WITH-') + + splits = [x.replace('-WITH-', ' WITH ') for x in re.split(r' AND | OR |\(|\)', with_fixed) if len(x) > 1] + + return splits + + def compat_licenses(self, license_expr, usecase, provisioning, licenses_to_check=None, resources=None): + """Returns a list of licenses that are compatible with a license expression + + If you have a license expression like this: + "(MIT OR Apache-2.0) AND MIT AND GPL-2.0-only" + + you get a list like this of licenses that are compatible with + the expression listed in order of ranked compatibility. If you + don't provide any licenses to check the functions defaults to + looking at the licenses in the expressions itself. This you will get a list like this: + + ["GPL-2.0-only"] + + """ + compats = [] + if not licenses_to_check: + licenses_to_check = self.licenses(license_expr) + + license_expression_parsed = self.flame.expression_license(license_expr, update_dual=False)['identified_license'] + for lic in licenses_to_check: + logging.debug(f' check licenses: {lic}') + try: + ret = self.le.check_compatibility(lic, + license_expression_parsed, + usecase, + provisioning, + resources) + except Exception as e: + logging.debug(f'Exception caught: {e}') + logging.debug(traceback.format_exc()) + if ret['compatibility'] == 'yes': + compats.append(lic) + logging.debug(f' appending: {lic}') + + # decorate compat list with indices (from rankings) + decorated = [(self.__compat_index(lic, usecase, provisioning), lic) for lic in compats] + + # sort and reverse (to get the most compatible first) + decorated.sort() + decorated.reverse() + + # undecorate + sorted_compats = [lic for index, lic in decorated] + + return sorted_compats + + def __compat_index(self, lic, usecase, provisioning): + # returns the ranking index for a license, + # given usecase and provisioning + + # initialize the ranking + self.compatibility_rankings(usecase, provisioning) + return self._compatibility_rankings[usecase][provisioning][lic]['index'] From 64f1052c5be4fe580430ac0af54a865bf4643f6e Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Mon, 6 Oct 2025 19:31:09 +0200 Subject: [PATCH 2/3] add command outbound-candidate --- licomp_toolkit/__main__.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/licomp_toolkit/__main__.py b/licomp_toolkit/__main__.py index 73952c9..c88a2c7 100755 --- a/licomp_toolkit/__main__.py +++ b/licomp_toolkit/__main__.py @@ -16,6 +16,7 @@ from licomp_toolkit.config import description from licomp_toolkit.config import epilog from licomp_toolkit.schema_checker import LicompToolkitSchemaChecker +from licomp_toolkit.suggester import OutboundSuggester from licomp.main_base import LicompParser from licomp.interface import UseCase @@ -122,6 +123,21 @@ def supports_usecase(self, args): except KeyError: return None, f'Use case "{args.usecase}" not supported. Supported use cases: {self.supported_usecases(args)[0]}' + def outbound_candidate(self, args): + suggester = OutboundSuggester() + licenses_to_check = None + if args.all_licenses: + licenses_to_check = self.licomp_toolkit.supported_licenses() + + candidates = suggester.compat_licenses(args.license_expression, + args.usecase, + args.provisioning, + licenses_to_check, + args.resources) + formatter = LicompToolkitFormatter.formatter(args.output_format) + + return formatter.format_licomp_licenses(candidates), ReturnCodes.LICOMP_OK.value, None + def supports_provisioning(self, args): try: provisioning = Provisioning.string_to_provisioning(args.provisioning) @@ -184,6 +200,14 @@ def main(): parser_sp.set_defaults(which="supports_provisioning", func=lct_parser.supports_provisioning) parser_sp.add_argument("provisioning") + parser_ob = subparsers.add_parser('outbound-candidate', help='Identify outbound candidates to a license expression') + parser_ob.set_defaults(which='outbound_candidate', func=lct_parser.outbound_candidate) + parser_ob.add_argument("license_expression") + parser_ob.add_argument('-al', '--all-licenses', + action='store_true', + help='Use all known licenses to identify outbound candidates', + default=False) + # Command: list versions (of all toolkit and licomp resources) parser_sr = subparsers.add_parser('versions', help='Output version of licomp-toolkit and all the licomp resources') parser_sr.set_defaults(which="versions", func=lct_parser.versions) From 1999b43573dd96303646608b1fac11b3a725a7a2 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Mon, 6 Oct 2025 19:31:28 +0200 Subject: [PATCH 3/3] add tests for outbound suggest/candidate --- tests/python/test_suggest_outbound.py | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/python/test_suggest_outbound.py diff --git a/tests/python/test_suggest_outbound.py b/tests/python/test_suggest_outbound.py new file mode 100644 index 0000000..b1bd1df --- /dev/null +++ b/tests/python/test_suggest_outbound.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2025 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# +# Tests of OutboundSuggester +# + +from licomp_toolkit.suggester import OutboundSuggester + +usecase = 'library' +provisioning = 'binary-distribution' +suggester = OutboundSuggester() + + +def test_compat_permissives(): + + license_list = suggester.compat_licenses("(MIT AND ISC OR BSD-3-Clause AND Unlicense)", "library", "binary-distribution") + assert 'MIT' in license_list + assert 'ISC' in license_list + assert 'BSD-3-Clause' in license_list + assert 'Unlicense' in license_list + + assert license_list[0] == 'MIT' + +def test_compat_mixed_list(): + license_list = suggester.compat_licenses("(MIT AND GPL-2.0-or-later)", "library", "binary-distribution") + assert 'GPL-2.0-or-later' in license_list + assert 'GPL-3.0-or-later' not in license_list + assert 'AGPL-3.0-or-later' not in license_list + +def test_compat_mixed_list(): + license_list = suggester.compat_licenses("(MIT AND GPL-2.0-or-later)", "library", "binary-distribution", ["GPL-3.0-only", "GPL-2.0-only", "GPL-2.0-or-later", "AGPL-3.0-or-later"]) + assert 'GPL-2.0-or-later' in license_list + assert 'GPL-3.0-only' in license_list + assert 'AGPL-3.0-or-later' in license_list + +def _ranked_index(ranked, lic): + return ranked[lic]['index'] + +def test_ranked(): + ranked = suggester.compatibility_rankings(usecase, provisioning) + # make sure the ranked index is higher the more permissive the license + assert _ranked_index(ranked, "BSD-3-Clause") > _ranked_index(ranked, "LGPL-2.1-or-later") + assert _ranked_index(ranked, "BSD-3-Clause") > _ranked_index(ranked, "GPL-2.0-or-later") + assert _ranked_index(ranked, "BSD-3-Clause") == _ranked_index(ranked, "BSD-3-Clause") + assert _ranked_index(ranked, "LGPL-2.1-or-later") > _ranked_index(ranked, "GPL-2.0-or-later")