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 diff --git a/Makefile b/Makefile index 79b9465..016273a 100644 --- a/Makefile +++ b/Makefile @@ -10,13 +10,14 @@ 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: 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: " @@ -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 diff --git a/licomp_toolkit/__main__.py b/licomp_toolkit/__main__.py index 8f25527..3449457 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 @@ -64,7 +65,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 +141,17 @@ def outbound_candidate(self, args): return formatter.format_licomp_licenses(candidates), ReturnCodes.LICOMP_OK.value, None + def display_compatibility(self, args): + 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, + {'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) @@ -183,7 +194,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 +214,17 @@ 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_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) parser_ob.add_argument("license_expression") 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 diff --git a/licomp_toolkit/format.py b/licomp_toolkit/format.py index 31e1a77..fb2d1cb 100644 --- a/licomp_toolkit/format.py +++ b/licomp_toolkit/format.py @@ -13,24 +13,47 @@ 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 + + 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 +67,13 @@ 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, 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) + +class YamlLicompToolkitFormatter(LicompToolkitFormatter): def format_compatibilities(self, compat): return yaml.safe_dump(compat, indent=4) @@ -58,7 +87,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, settings={}): + 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 +194,108 @@ 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 + if not value: + value = compat + else: + if compat != value: + same = False + + if same: + line = _line_map.get(value, '') + 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, settings={}): + # 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()) + + 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(' 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 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 + + lines.append(self._license_license_compat(outbound, inbound, display_compats[outbound][inbound], display_compats[inbound][outbound])) + finished[outbound][inbound] = True + finished[inbound][outbound] = True + + lines.append('}') + return '\n'.join(lines) 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, 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..8008b94 --- /dev/null +++ b/tests/shell/test_display_compat.sh @@ -0,0 +1,77 @@ +#!/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 +TEST_HANDLER=false + +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 + if [ "$TEST_HANDLER" = "true" ] + then + $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 + 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 +if [ "$TEST_HANDLER" = "true" ] +then + is_pdf tmp.pdf +fi + 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()