Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions licomp_toolkit/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
162 changes: 162 additions & 0 deletions licomp_toolkit/suggester.py
Original file line number Diff line number Diff line change
@@ -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']
47 changes: 47 additions & 0 deletions tests/python/test_suggest_outbound.py
Original file line number Diff line number Diff line change
@@ -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")