From 2a12c79778acab3d70a4ba3918a955ae3b971fbe Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Tue, 16 Dec 2025 10:03:19 +0100 Subject: [PATCH 01/16] add format of display compatiblities --- licomp_toolkit/format.py | 147 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 10 deletions(-) diff --git a/licomp_toolkit/format.py b/licomp_toolkit/format.py index 31e1a77..b089cc8 100644 --- a/licomp_toolkit/format.py +++ b/licomp_toolkit/format.py @@ -13,24 +13,48 @@ def formatter(fmt): return JsonLicompToolkitFormatter() if fmt.lower() == 'text': return TextLicompToolkitFormatter() - if fmt.lower() == 'yaml': - return YamlLicompToolkitFormatter() - if fmt.lower() == 'yml': + if fmt.lower() == 'yaml' or fmt.lower() == 'yml': return YamlLicompToolkitFormatter() + if fmt.lower() == 'dot': + return DotLicompToolkitFormatter() def format_compatibilities(self, compat): - return None + raise Exception(f'{self.__class__.__name__} cannot format compatibilities.') + + def _pre_format_display_compatibilities(self, compats): + licenses = list(compats.keys()) + finished = {} + for outbound in licenses: + finished[outbound] = {} + for inbound in licenses: + finished[outbound][inbound] = False + + lines = {} + + for outbound in licenses: + for inbound in licenses: + if finished[outbound][inbound] and finished[inbound][outbound]: + continue + if outbound == inbound: + finished[outbound][inbound] = ['yes'] + finished[inbound][outbound] = ['yes'] + else: + outbound_compat = compats[outbound][inbound]['summary']['compatibility_statuses'] + inbound_compat = compats[inbound][outbound]['summary']['compatibility_statuses'] + finished[outbound][inbound] = list(outbound_compat.keys()) + finished[inbound][outbound] = list(inbound_compat.keys()) + return finished def format_licomp_resources(self, licomp_resources): - return None + raise Exception(f'{self.__class__.__name__} cannot format licomp resources.') def format_licomp_licenses(self, licomp_licenses): - return None + raise Exception(f'{self.__class__.__name__} cannot format licomp licenses.') def format_licomp_versions(self, licomp_versions): - return None + raise Exception(f'{self.__class__.__name__} cannot format licomp versions.') -class JsonLicompToolkitFormatter(): +class JsonLicompToolkitFormatter(LicompToolkitFormatter): def format_compatibilities(self, compat): return json.dumps(compat, indent=4) @@ -44,7 +68,11 @@ def format_licomp_licenses(self, licomp_licenses): def format_licomp_versions(self, licomp_versions): return json.dumps(licomp_versions, indent=4) -class YamlLicompToolkitFormatter(): + def format_display_compatibilities(self, compats): + display_compats = self._pre_format_display_compatibilities(compats) + return json.dumps(display_compats, indent=4) + +class YamlLicompToolkitFormatter(LicompToolkitFormatter): def format_compatibilities(self, compat): return yaml.safe_dump(compat, indent=4) @@ -58,7 +86,11 @@ def format_licomp_licenses(self, licomp_licenses): def format_licomp_versions(self, licomp_versions): return yaml.safe_dump(licomp_versions, indent=4) -class TextLicompToolkitFormatter(): + def format_display_compatibilities(self, compats): + display_compats = self._pre_format_display_compatibilities(compats) + return yaml.safe_dump(display_compats, indent=4) + +class TextLicompToolkitFormatter(LicompToolkitFormatter): def format_licomp_resources(self, licomp_resources): return "\n".join(licomp_resources) @@ -161,3 +193,98 @@ def format_licomp_versions(self, licomp_versions): for k, v in licomp_versions['licomp-resources'].items(): res.append(f'{k}: {v}') return '\n'.join(res) + + def format_display_compatibilities(self, compats): + # possible compats are: + # no (red) + # yes (green) + # depends (yellow) + # unsupported (yellow) + # unknown (yellow) + # mixed (yellow) + display_compats = self._pre_format_display_compatibilities(compats) + licenses = list(display_compats.keys()) + + lines = [] + for outbound in licenses: + for inbound in licenses: + lines.append(f'{outbound:30s} {"---->":10s} {inbound:30s}: {", ".join(display_compats[outbound][inbound])}') + return '\n'.join(lines) + +class DotLicompToolkitFormatter(LicompToolkitFormatter): + + def _compat_line_color(self, compats): + _line_map = { + 'unknown': 'style="dotted"', + 'depends': 'style="dotted"', + 'unsupported': 'style="dotted"', + 'mixed': 'style="dotted"', + } + _color_map = { + 'yes': 'darkgreen', + 'no': 'darkred' + } + same = True + value = None + for compat in compats: + if compat == 'unsupported': + continue + #print(compat) + if not value: + value = compat + else: + if compat != value: + same = False + + if same: + line = _line_map.get(value, '') + color = _color_map.get(value, 'darkblue') + else: + line = _line_map['mixed'] + color = 'darkblue' + + return line, color + + def _license_license_compat(self, outbound, inbound, outbound_compat, inbound_compat): + out_line, out_color = self._compat_line_color(outbound_compat) + in_line, in_color = self._compat_line_color(inbound_compat) + + if out_line == in_line and out_color == in_color: + return (f' "{outbound}" -> "{inbound}" [dir="both" color="{out_color}" {out_line}]') + else: + return '\n'.join([f' "{outbound}" -> "{inbound}" [color="{out_color}" {out_line}]', + f' "{inbound}" -> "{outbound}" [color="{in_color}" {in_line}]']) + + def format_display_compatibilities(self, compats): + # possible compats are: + # no (red) + # yes (green) + # depends (yellow) + # unsupported (yellow) + # unknown (yellow) + # mixed (yellow) + display_compats = self._pre_format_display_compatibilities(compats) + licenses = list(display_compats.keys()) + + lines = [] + finished = {} + usecase = compats[licenses[0]][licenses[0]]['compatibilities'][0]['usecase'] + lines.append('digraph depends {') + lines.append(f' graph [label="License Compatibility Graph ({usecase})" labelloc=t]') + lines.append(' node [shape=plaintext]') + for outbound in licenses: + finished[outbound] = {} + for inbound in licenses: + if inbound == outbound: + continue + if not display_compats[outbound][inbound]: + #print("skip.... since " + str(display_compats[outbound][inbound])) + continue + + #print(f'{outbound} ---> {inbound}') + lines.append(self._license_license_compat(outbound, inbound, display_compats[outbound][inbound], display_compats[inbound][outbound])) + display_compats[outbound][inbound] = False + display_compats[inbound][outbound] = False + + lines.append('}') + return '\n'.join(lines) From 68cabf2d49539fb88a5b4e5f6ec0810c7879262f Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Tue, 16 Dec 2025 10:05:47 +0100 Subject: [PATCH 02/16] add display compatiblities --- licomp_toolkit/__main__.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/licomp_toolkit/__main__.py b/licomp_toolkit/__main__.py index 8f25527..5a88f6b 100755 --- a/licomp_toolkit/__main__.py +++ b/licomp_toolkit/__main__.py @@ -64,7 +64,6 @@ def verify(self, args): new_resources.append(resource) if unsupported: return f'Resource(s) {", ".join(unsupported)} is/are not supported', ReturnCodes.LICOMP_UNSUPPORTED_RESOURCE.value, True - resources = new_resources expr_checker = ExpressionExpressionChecker() compatibilities = expr_checker.check_compatibility(self.__normalize_license(args.out_license), @@ -141,6 +140,23 @@ def outbound_candidate(self, args): return formatter.format_licomp_licenses(candidates), ReturnCodes.LICOMP_OK.value, None + def display_compatibility(self, args): + compats = {} + resources=['licomp_reclicense'] + #resources = None + for outbound in args.licenses: + compats[outbound] = {} + for inbound in args.licenses: + ret = self.licomp_toolkit.outbound_inbound_compatibility(outbound, + inbound, + UseCase.LIBRARY, + Provisioning.BIN_DIST, + resources) + compats[outbound][inbound] = ret + formatter = LicompToolkitFormatter.formatter(args.output_format) + formatted = formatter.format_display_compatibilities(compats) + return formatted, ReturnCodes.LICOMP_OK.value, None + def supports_provisioning(self, args): try: provisioning = Provisioning.string_to_provisioning(args.provisioning) @@ -183,7 +199,7 @@ def main(): help='keep compatibility report as short as possible', default=[]) - # Command: list supported + # Commands parser_si = subparsers.add_parser('simplify', help='Normalize and simplify a license expression') parser_si.set_defaults(which="simplify", func=lct_parser.simplify) parser_si.add_argument("license", type=str, nargs="+", help='License expression to simplify') @@ -203,6 +219,13 @@ def main(): parser_sp.set_defaults(which="supports_provisioning", func=lct_parser.supports_provisioning) parser_sp.add_argument("provisioning") + parser_dc = subparsers.add_parser('display-compatibility', help='Display the compatibility between the supplied licenses') + parser_dc.set_defaults(which="display_compatibility", func=lct_parser.display_compatibility) + parser_dc.add_argument("licenses", + type=str, + nargs='+', + default=[]) + 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") From 0ca0aa5b8864bfd4658913a444a702d9d4f081e0 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Tue, 16 Dec 2025 10:06:17 +0100 Subject: [PATCH 03/16] force string conversion --- licomp_toolkit/toolkit.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/licomp_toolkit/toolkit.py b/licomp_toolkit/toolkit.py index 6a89312..faf2f3d 100644 --- a/licomp_toolkit/toolkit.py +++ b/licomp_toolkit/toolkit.py @@ -368,7 +368,6 @@ def check_compatibility(self, outbound, inbound, usecase, provisioning, resource inbound_parsed = self.le_parser.parse_license_expression(inbound) outbound_parsed = self.le_parser.parse_license_expression(outbound) - compatibility_object = self.__check_compatibility(outbound_parsed, inbound_parsed, usecase_obj, @@ -376,8 +375,8 @@ def check_compatibility(self, outbound, inbound, usecase, provisioning, resource resources, detailed_report) return { - 'inbound': inbound, - 'outbound': outbound, + 'inbound': str(inbound), + 'outbound': str(outbound), 'usecase': usecase, 'resources': resources, 'provisioning': provisioning, From 289091c436769549c2324451021be59d22652c25 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Wed, 17 Dec 2025 11:02:24 +0100 Subject: [PATCH 04/16] use dedicated class for displaying compats --- licomp_toolkit/__main__.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/licomp_toolkit/__main__.py b/licomp_toolkit/__main__.py index 5a88f6b..70cf524 100755 --- a/licomp_toolkit/__main__.py +++ b/licomp_toolkit/__main__.py @@ -17,6 +17,7 @@ from licomp_toolkit.config import epilog from licomp_toolkit.schema_checker import LicompToolkitSchemaChecker from licomp_toolkit.suggester import OutboundSuggester +from licomp_toolkit.display_compatibility import DisplayCompatibility from licomp.main_base import LicompParser from licomp.interface import UseCase @@ -141,20 +142,14 @@ def outbound_candidate(self, args): return formatter.format_licomp_licenses(candidates), ReturnCodes.LICOMP_OK.value, None def display_compatibility(self, args): - compats = {} - resources=['licomp_reclicense'] - #resources = None - for outbound in args.licenses: - compats[outbound] = {} - for inbound in args.licenses: - ret = self.licomp_toolkit.outbound_inbound_compatibility(outbound, - inbound, - UseCase.LIBRARY, - Provisioning.BIN_DIST, - resources) - compats[outbound][inbound] = ret + display_compat = DisplayCompatibility(self.licomp_toolkit) + compats = display_compat.display_compatibility(args.licenses, + UseCase.string_to_usecase(args.usecase), + Provisioning.string_to_provisioning(args.provisioning), + args.resources) formatter = LicompToolkitFormatter.formatter(args.output_format) - formatted = formatter.format_display_compatibilities(compats) + formatted = formatter.format_display_compatibilities(compats, + { 'discard_unsupported': args.discard_unsupported_licenses }) return formatted, ReturnCodes.LICOMP_OK.value, None def supports_provisioning(self, args): @@ -225,6 +220,10 @@ def main(): type=str, nargs='+', default=[]) + parser_dc.add_argument('-dul', '--discard-unsupported-licenses', + action='store_true', + help='Discard unsupported licenses from the output', + default=False) 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) From 7aa4bd022e2a33bb457440336f064f4930afca0f Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 10:51:52 +0100 Subject: [PATCH 05/16] fix lint issues --- licomp_toolkit/__main__.py | 4 ++-- licomp_toolkit/format.py | 49 +++++++++++++++++++++++--------------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/licomp_toolkit/__main__.py b/licomp_toolkit/__main__.py index 70cf524..3449457 100755 --- a/licomp_toolkit/__main__.py +++ b/licomp_toolkit/__main__.py @@ -149,9 +149,9 @@ def display_compatibility(self, args): args.resources) formatter = LicompToolkitFormatter.formatter(args.output_format) formatted = formatter.format_display_compatibilities(compats, - { 'discard_unsupported': args.discard_unsupported_licenses }) + {'discard_unsupported': args.discard_unsupported_licenses}) return formatted, ReturnCodes.LICOMP_OK.value, None - + def supports_provisioning(self, args): try: provisioning = Provisioning.string_to_provisioning(args.provisioning) diff --git a/licomp_toolkit/format.py b/licomp_toolkit/format.py index b089cc8..a86d05f 100644 --- a/licomp_toolkit/format.py +++ b/licomp_toolkit/format.py @@ -28,9 +28,7 @@ def _pre_format_display_compatibilities(self, compats): finished[outbound] = {} for inbound in licenses: finished[outbound][inbound] = False - - lines = {} - + for outbound in licenses: for inbound in licenses: if finished[outbound][inbound] and finished[inbound][outbound]: @@ -43,6 +41,7 @@ def _pre_format_display_compatibilities(self, compats): inbound_compat = compats[inbound][outbound]['summary']['compatibility_statuses'] finished[outbound][inbound] = list(outbound_compat.keys()) finished[inbound][outbound] = list(inbound_compat.keys()) + return finished def format_licomp_resources(self, licomp_resources): @@ -68,7 +67,9 @@ def format_licomp_licenses(self, licomp_licenses): def format_licomp_versions(self, licomp_versions): return json.dumps(licomp_versions, indent=4) - def format_display_compatibilities(self, compats): + def format_display_compatibilities(self, compats, settings={}): + # settings + # discard_unsupported: True - will remove unsupported licenses from the output display_compats = self._pre_format_display_compatibilities(compats) return json.dumps(display_compats, indent=4) @@ -86,7 +87,7 @@ def format_licomp_licenses(self, licomp_licenses): def format_licomp_versions(self, licomp_versions): return yaml.safe_dump(licomp_versions, indent=4) - def format_display_compatibilities(self, compats): + def format_display_compatibilities(self, compats, settings={}): display_compats = self._pre_format_display_compatibilities(compats) return yaml.safe_dump(display_compats, indent=4) @@ -204,7 +205,7 @@ def format_display_compatibilities(self, compats): # mixed (yellow) display_compats = self._pre_format_display_compatibilities(compats) licenses = list(display_compats.keys()) - + lines = [] for outbound in licenses: for inbound in licenses: @@ -212,7 +213,7 @@ def format_display_compatibilities(self, compats): return '\n'.join(lines) class DotLicompToolkitFormatter(LicompToolkitFormatter): - + def _compat_line_color(self, compats): _line_map = { 'unknown': 'style="dotted"', @@ -229,7 +230,6 @@ def _compat_line_color(self, compats): for compat in compats: if compat == 'unsupported': continue - #print(compat) if not value: value = compat else: @@ -238,24 +238,23 @@ def _compat_line_color(self, compats): if same: line = _line_map.get(value, '') - color = _color_map.get(value, 'darkblue') + color = _color_map.get(value, 'yellow') else: line = _line_map['mixed'] color = 'darkblue' return line, color - + def _license_license_compat(self, outbound, inbound, outbound_compat, inbound_compat): out_line, out_color = self._compat_line_color(outbound_compat) in_line, in_color = self._compat_line_color(inbound_compat) - if out_line == in_line and out_color == in_color: return (f' "{outbound}" -> "{inbound}" [dir="both" color="{out_color}" {out_line}]') else: return '\n'.join([f' "{outbound}" -> "{inbound}" [color="{out_color}" {out_line}]', f' "{inbound}" -> "{outbound}" [color="{in_color}" {in_line}]']) - def format_display_compatibilities(self, compats): + def format_display_compatibilities(self, compats, settings={}): # possible compats are: # no (red) # yes (green) @@ -265,26 +264,38 @@ def format_display_compatibilities(self, compats): # mixed (yellow) display_compats = self._pre_format_display_compatibilities(compats) licenses = list(display_compats.keys()) - + + discard_unsupported = settings.get('discard_unsupported') + lines = [] finished = {} usecase = compats[licenses[0]][licenses[0]]['compatibilities'][0]['usecase'] lines.append('digraph depends {') - lines.append(f' graph [label="License Compatibility Graph ({usecase})" labelloc=t]') + lines.append(f' graph [label="License Compatibility Graph {usecase})" labelloc=t]') lines.append(' node [shape=plaintext]') for outbound in licenses: finished[outbound] = {} for inbound in licenses: + if inbound not in finished: + finished[inbound] = {} if inbound == outbound: continue - if not display_compats[outbound][inbound]: - #print("skip.... since " + str(display_compats[outbound][inbound])) + + if display_compats[outbound][inbound] == []: + if discard_unsupported: + continue + if display_compats[inbound][outbound] == []: + if discard_unsupported: + continue + + if finished[outbound].get(inbound, False): + continue + elif finished[inbound].get(outbound, False): continue - #print(f'{outbound} ---> {inbound}') lines.append(self._license_license_compat(outbound, inbound, display_compats[outbound][inbound], display_compats[inbound][outbound])) - display_compats[outbound][inbound] = False - display_compats[inbound][outbound] = False + finished[outbound][inbound] = True + finished[inbound][outbound] = True lines.append('}') return '\n'.join(lines) From a25b5d31a3845fc1141936a78852116d3688a809 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 10:52:09 +0100 Subject: [PATCH 06/16] use python 3.11 for flake8 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 79b9465..e0b10db 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ build: rm -fr build && python3 setup.py sdist lint: - PYTHONPATH=. flake8 licomp_toolkit + PYTHONPATH=. python3.11 -m flake8 licomp_toolkit check_version: @echo -n "Checking api versions: " From 4479d9485e33536f132d6b06a862b1b1e460d341 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 12:26:56 +0100 Subject: [PATCH 07/16] add class to prepare data for compat graph --- licomp_toolkit/display_compatibility.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 licomp_toolkit/display_compatibility.py diff --git a/licomp_toolkit/display_compatibility.py b/licomp_toolkit/display_compatibility.py new file mode 100644 index 0000000..0336dff --- /dev/null +++ b/licomp_toolkit/display_compatibility.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2024 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +class DisplayCompatibility: + + def __init__(self, licomp_toolkit): + self.licomp_toolkit = licomp_toolkit + + def display_compatibility(self, licenses, usecase, provisioning, resources): + compats = {} + for outbound in licenses: + compats[outbound] = {} + for inbound in licenses: + ret = self.licomp_toolkit.outbound_inbound_compatibility(outbound, + inbound, + usecase, + provisioning, + resources) + compats[outbound][inbound] = ret + return compats From 933fcda979bd45db6c3d9c481dac97abe04cc903 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 12:27:12 +0100 Subject: [PATCH 08/16] test display compat output --- tests/python/test_display_compat.py | 147 ++++++++++++++++++++++++++++ tests/shell/test_display_compat.sh | 72 ++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 tests/python/test_display_compat.py create mode 100755 tests/shell/test_display_compat.sh diff --git a/tests/python/test_display_compat.py b/tests/python/test_display_compat.py new file mode 100644 index 0000000..d81acab --- /dev/null +++ b/tests/python/test_display_compat.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: 2025 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# +# Tests of DisplayCompatibility +# + +from licomp_toolkit.toolkit import LicompToolkit +from licomp_toolkit.display_compatibility import DisplayCompatibility +from licomp_toolkit.format import LicompToolkitFormatter +from licomp.interface import UseCase +from licomp.interface import Provisioning + +import json +import re + +usecase = UseCase.LIBRARY +provisioning = Provisioning.BIN_DIST +licomp_toolkit = LicompToolkit() +display_compat = DisplayCompatibility(licomp_toolkit) +dot_formatter = LicompToolkitFormatter.formatter('dot') +json_formatter = LicompToolkitFormatter.formatter('json') + + +# +# json output +# +def test_json_display_mit_bsd3(): + licenses = ['MIT', 'BSD-3-Clause'] + resources = ['licomp_reclicense'] + + compats = display_compat.display_compatibility(licenses, + usecase, + provisioning, + resources) + formatted = json.loads(json_formatter.format_display_compatibilities(compats)) + + assert formatted['MIT']['MIT'] == ['yes'] + assert formatted['MIT']['BSD-3-Clause'] == ['yes'] + assert formatted['BSD-3-Clause']['MIT'] == ['yes'] + assert formatted['BSD-3-Clause']['BSD-3-Clause'] == ['yes'] + + +def test_json_display_apache_gpl2_reclicense(): + licenses = ['Apache-2.0', 'GPL-2.0-only'] + resources = ['licomp_reclicense'] + compats = display_compat.display_compatibility(licenses, + usecase, + provisioning, + resources) + formatted = json.loads(json_formatter.format_display_compatibilities(compats)) + assert formatted['Apache-2.0']['Apache-2.0'] == ['yes'] + assert formatted['Apache-2.0']['GPL-2.0-only'] == ['no'] + assert formatted['GPL-2.0-only']['Apache-2.0'] == ['no'] + assert formatted['GPL-2.0-only']['GPL-2.0-only'] == ['yes'] + + +def test_json_display_apache_gpl2_osadl(): + licenses = ['Apache-2.0', 'GPL-2.0-only'] + resources = ['licomp_osadl'] + compats = display_compat.display_compatibility(licenses, + UseCase.SNIPPET, + provisioning, + resources) + formatted = json.loads(json_formatter.format_display_compatibilities(compats)) + assert formatted['Apache-2.0']['Apache-2.0'] == ['yes'] + assert formatted['Apache-2.0']['GPL-2.0-only'] == ['no'] + assert formatted['GPL-2.0-only']['Apache-2.0'] == ['no'] + assert formatted['GPL-2.0-only']['GPL-2.0-only'] == ['yes'] + + +# +# dot output +# + +def _build_expr(license1, license2, first_expr): + return '"' + license1 + '"[ ]*->[ ]*"' + license2 + r'"[ ]*\[[ ]*' + str(first_expr) + +def test_display_mit_bsd3(): + licenses = ['MIT', 'BSD-3-Clause'] + resources = ['licomp_reclicense'] + + compats = display_compat.display_compatibility(licenses, + usecase, + provisioning, + resources) + formatted = dot_formatter.format_display_compatibilities(compats) + + assert re.search(_build_expr('MIT', 'BSD-3-Clause', 'dir="both"'), formatted) + +def test_display_apache_bsd3(): + licenses = ['Apache-2.0', 'BSD-3-Clause'] + resources = ['licomp_reclicense'] + + compats = display_compat.display_compatibility(licenses, + usecase, + provisioning, + resources) + formatted = dot_formatter.format_display_compatibilities(compats) + + assert re.search(_build_expr('Apache-2.0', 'BSD-3-Clause', 'dir="both"'), formatted) + +def test_display_apache_gpl2_reclicense(): + licenses = ['Apache-2.0', 'GPL-2.0-only'] + resources = ['licomp_reclicense'] + compats = display_compat.display_compatibility(licenses, + usecase, + provisioning, + resources) + formatted = dot_formatter.format_display_compatibilities(compats) + assert re.search(_build_expr('Apache-2.0', 'GPL-2.0-only', 'dir="both" color="darkred"'), formatted) + +def test_display_apache_gpl2_osadl(): + licenses = ['Apache-2.0', 'GPL-2.0-only'] + resources = ['licomp_osadl'] + compats = display_compat.display_compatibility(licenses, + UseCase.SNIPPET, + provisioning, + resources) + formatted = dot_formatter.format_display_compatibilities(compats) + assert re.search(_build_expr('Apache-2.0', 'GPL-2.0-only', 'dir="both"[ ]*color="darkred"'), formatted) + +# +# test unsupported output +# +def test_display_apache_gpl2_osadl_unsupported_1(): + licenses = ['Apache-2.0', 'GPL-2.0-only', 'BSD-Santa-Clause'] + resources = ['licomp_osadl'] + compats = display_compat.display_compatibility(licenses, + UseCase.SNIPPET, + provisioning, + resources) + formatted = dot_formatter.format_display_compatibilities(compats) + # By default, unsupported should be part of the output + assert re.search(_build_expr('Apache-2.0', 'BSD-Santa-Clause', 'dir="both"[ ]*color="yellow"'), formatted) + assert re.search(_build_expr('GPL-2.0-only', 'BSD-Santa-Clause', 'dir="both"[ ]*color="yellow"'), formatted) + + # False: unsupported should be part of the output + formatted = dot_formatter.format_display_compatibilities(compats, {'discard_unsupported': False}) + assert re.search(_build_expr('Apache-2.0', 'BSD-Santa-Clause', 'dir="both"[ ]*color="yellow"'), formatted) + assert re.search(_build_expr('GPL-2.0-only', 'BSD-Santa-Clause', 'dir="both"[ ]*color="yellow"'), formatted) + + # True: unsupported should NOT be part of the output + formatted = dot_formatter.format_display_compatibilities(compats, {'discard_unsupported': True}) + assert not 'BSD-Santa-Clause' in formatted + diff --git a/tests/shell/test_display_compat.sh b/tests/shell/test_display_compat.sh new file mode 100755 index 0000000..777f233 --- /dev/null +++ b/tests/shell/test_display_compat.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: 2025 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +if [ "$1" == "--local" ] +then + IMPLEMENTATIONS=../licomp:../licomp-dwheeler:../licomp-hermione:../licomp-osadl:../licomp-reclicense:../licomp-proprietary::../licomp-gnuguide:. + shift +fi + +TMP_FILE=licomp_toolkit_test.tmp + +run_lt() +{ + PYTHONPATH=$IMPLEMENTATIONS:${PYTHONPATH}:. python3 licomp_toolkit/__main__.py $* +} + +err() +{ + echo "$*" 1>&2 +} + +check_ret() +{ + LICENSES="$1" + FORMAT=$2 + HANDLER="$3" + EXP_RET=$4 + printf "%-50s" "-of $FORMAT display-compatibility $LICENSES: " + run_lt -of $FORMAT display-compatibility $LICENSES > $TMP_FILE + $HANDLER $TMP_FILE > /dev/null 2>&1 + ACT_RET=$? + + if [ $ACT_RET -ne $EXP_RET ] + then + + err "ERROR" + err " * command: display-compatibility $LICENSES" + err " * format: $FORMAT" + err " * expected: $EXP_RET" + err " * actual: $ACT_RET" + exit 1 + fi + echo OK +} + +is_pdf() +{ + PDF_FILE=$1 + IS_PDF=$(file $PDF_FILE | grep PDF | wc -l) + printf "%-50s " "is pdf: " + if [ $IS_PDF -eq 0 ] + then + err + err "$PDF_FILE not in PDF format" + exit 1 + else + echo OK + fi + + +} + +check_ret "MIT BSD-3-Clause" "json" "jq ." 0 +check_ret "MIT BSD-3-Clause" "json" "dot $TMP_FILE -Tpdf -o tmp.pdf" 1 + +check_ret "MIT BSD-3-Clause" "dot" "jq ." 5 +check_ret "MIT BSD-3-Clause" "dot" "dot $TMP_FILE -Tpdf -o tmp.pdf" 0 +is_pdf tmp.pdf + From 62869d1598a8bf02807bec67b39d56ff0e26a7cc Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:19:14 +0100 Subject: [PATCH 09/16] update tests --- tests/shell/test_validate.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/shell/test_validate.sh b/tests/shell/test_validate.sh index 12fbc11..e4a2851 100755 --- a/tests/shell/test_validate.sh +++ b/tests/shell/test_validate.sh @@ -62,9 +62,11 @@ compatibles() validate_reply "BSD-3-Clause OR MIT" "BSD-2-Clause AND ISC" 0 0 validate_reply "BSD-3-Clause OR GPL-2.0-only" "BSD-2-Clause AND ISC" 0 0 validate_reply "BSD-3-Clause OR GPL-2.0-only" "BSD-2-Clause AND Apache-2.0" 0 0 - validate_reply "BSD-2-Clause OR Apache-2.0" "GPL-2.0-only" 0 0 - validate_reply "Apache-2.0" "GPL-3.0-only" 0 0 -# validate_reply "GPL-3.0-only" "Apache-2.0" 0 0 + validate_reply "BSD-2-Clause OR Apache-2.0" "GPL-2.0-only" 0 0 + # 9 is mixed + validate_reply "Apache-2.0" "GPL-3.0-only" 9 0 + # 9 is no + validate_reply "GPL-3.0-only" "Apache-2.0" 2 0 } incompatibles() From bf5923e8a130b3ce65fd6e65b103d535d8135ae0 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:19:28 +0100 Subject: [PATCH 10/16] fix title --- licomp_toolkit/format.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/licomp_toolkit/format.py b/licomp_toolkit/format.py index a86d05f..fb2d1cb 100644 --- a/licomp_toolkit/format.py +++ b/licomp_toolkit/format.py @@ -271,7 +271,7 @@ def format_display_compatibilities(self, compats, settings={}): finished = {} usecase = compats[licenses[0]][licenses[0]]['compatibilities'][0]['usecase'] lines.append('digraph depends {') - lines.append(f' graph [label="License Compatibility Graph {usecase})" labelloc=t]') + lines.append(f' graph [label="License Compatibility Graph ({usecase})" labelloc=t]') lines.append(' node [shape=plaintext]') for outbound in licenses: finished[outbound] = {} From d901ae806223e7b6974bba859167971baa65070c Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:19:54 +0100 Subject: [PATCH 11/16] add all test scripts --- .github/workflows/push.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 9891f4a..7d270d9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -44,6 +44,9 @@ jobs: - name: CLI check run: | tests/shell/test-cli.sh + tests/shell/test_display_compat.sh + tests/shell/test_returns.sh + tests/shell/test_validate.sh generate: runs-on: ubuntu-latest From 8aaf6c75dcce9d68dd8e92c01f3ec23709515ec9 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:20:14 +0100 Subject: [PATCH 12/16] remove test artefacts, add test scripts --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index e0b10db..016273a 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ clean: rm -fr licomp_toolkit/__pycache__ rm -fr tests/python/__pycache__ rm -fr .pytest_cache + rm -f licomp_toolkit_test.tmp tmp.pdf .PHONY: build build: @@ -34,10 +35,13 @@ unit-test-local-verbose: cli-test: tests/shell/test-cli.sh tests/shell/test_returns.sh + tests/shell/test_display_compat.sh + tests/shell/test_validate.sh cli-test-local: tests/shell/test-cli.sh --local tests/shell/test_returns.sh --local + tests/shell/test_display_compat.sh --local test: unit-test cli-test From 5d8c4cdc261578b5edf790a4598eba1e56c775fe Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:25:08 +0100 Subject: [PATCH 13/16] install jq --- .github/workflows/push.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 7d270d9..adb62d1 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -43,6 +43,7 @@ jobs: make test - name: CLI check run: | + apt install jq tests/shell/test-cli.sh tests/shell/test_display_compat.sh tests/shell/test_returns.sh From 4833a2d444e3192ac16e6f8e8f7c01180e116457 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:27:01 +0100 Subject: [PATCH 14/16] install jq in generate --- .github/workflows/push.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index adb62d1..b390e0d 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -59,6 +59,7 @@ jobs: # Something to generate files - run: | + apt install jq pip install -r requirements.txt pip install -r requirements-dev.txt make test From c307d79eb3e9829dc8587f08179971e8255f1fb3 Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:29:09 +0100 Subject: [PATCH 15/16] remove apt installs --- .github/workflows/push.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index b390e0d..7d270d9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -43,7 +43,6 @@ jobs: make test - name: CLI check run: | - apt install jq tests/shell/test-cli.sh tests/shell/test_display_compat.sh tests/shell/test_returns.sh @@ -59,7 +58,6 @@ jobs: # Something to generate files - run: | - apt install jq pip install -r requirements.txt pip install -r requirements-dev.txt make test From 4334a0cc127091d9d28fc2b9fd9b204ea3f64ecf Mon Sep 17 00:00:00 2001 From: Henrik Sandklef Date: Thu, 18 Dec 2025 15:32:23 +0100 Subject: [PATCH 16/16] remove tests with tools not in python image --- tests/shell/test_display_compat.sh | 33 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/tests/shell/test_display_compat.sh b/tests/shell/test_display_compat.sh index 777f233..8008b94 100755 --- a/tests/shell/test_display_compat.sh +++ b/tests/shell/test_display_compat.sh @@ -11,6 +11,7 @@ then fi TMP_FILE=licomp_toolkit_test.tmp +TEST_HANDLER=false run_lt() { @@ -30,18 +31,21 @@ check_ret() EXP_RET=$4 printf "%-50s" "-of $FORMAT display-compatibility $LICENSES: " run_lt -of $FORMAT display-compatibility $LICENSES > $TMP_FILE - $HANDLER $TMP_FILE > /dev/null 2>&1 - ACT_RET=$? - - if [ $ACT_RET -ne $EXP_RET ] + if [ "$TEST_HANDLER" = "true" ] then + $HANDLER $TMP_FILE > /dev/null 2>&1 + ACT_RET=$? - err "ERROR" - err " * command: display-compatibility $LICENSES" - err " * format: $FORMAT" - err " * expected: $EXP_RET" - err " * actual: $ACT_RET" - exit 1 + if [ $ACT_RET -ne $EXP_RET ] + then + + err "ERROR" + err " * command: display-compatibility $LICENSES" + err " * format: $FORMAT" + err " * expected: $EXP_RET" + err " * actual: $ACT_RET" + exit 1 + fi fi echo OK } @@ -59,8 +63,6 @@ is_pdf() else echo OK fi - - } check_ret "MIT BSD-3-Clause" "json" "jq ." 0 @@ -68,5 +70,8 @@ check_ret "MIT BSD-3-Clause" "json" "dot $TMP_FILE -Tpdf -o tmp.pdf" 1 check_ret "MIT BSD-3-Clause" "dot" "jq ." 5 check_ret "MIT BSD-3-Clause" "dot" "dot $TMP_FILE -Tpdf -o tmp.pdf" 0 -is_pdf tmp.pdf - +if [ "$TEST_HANDLER" = "true" ] +then + is_pdf tmp.pdf +fi +