diff --git a/.reuse/dep5 b/.reuse/dep5 index ec76bbb..38c2d78 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -27,3 +27,6 @@ Files: README.md docs/*.md Copyright: Henrik Sandklef License: GPL-3.0-or-later +Files: tests/policy/license-policy.json tests/policy/bad-license-policy.json +Copyright: Henrik Sandklef +License: GPL-3.0-or-later diff --git a/devel/licomp-toolkit b/devel/licomp-toolkit index f061b27..3c28ce8 100755 --- a/devel/licomp-toolkit +++ b/devel/licomp-toolkit @@ -14,6 +14,13 @@ fi if [ "$1" = "" ] then ARGS="verify -il MIT -ol \"MIT OR X11\"" +else + ARGS="" + for arg in "$@" + do + ARGS="$ARGS \"$arg\"" + done fi -echo PYTHONPATH=${EXTRA_PYTHONPATH}:${PYTHONPATH} ${SCRIPT_DIR}/licomp_toolkit/__main__.py $* $ARGS | bash + +echo PYTHONPATH=${EXTRA_PYTHONPATH}:${PYTHONPATH} ${SCRIPT_DIR}/licomp_toolkit/__main__.py $ARGS | bash diff --git a/licomp_toolkit/__main__.py b/licomp_toolkit/__main__.py index d96bbb1..68317e8 100755 --- a/licomp_toolkit/__main__.py +++ b/licomp_toolkit/__main__.py @@ -4,21 +4,28 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +import json +from json.decoder import JSONDecodeError import logging import sys from licomp.interface import LicompException +from licomp_toolkit.return_codes import LicompToolkitReturnCodes from licomp_toolkit.toolkit import LicompToolkit from licomp_toolkit.toolkit import ExpressionExpressionChecker from licomp_toolkit.format import LicompToolkitFormatter from licomp_toolkit.config import cli_name from licomp_toolkit.config import description +from licomp_toolkit.config import module_name +from licomp_toolkit.config import licomp_toolkit_file_version 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_toolkit.utils import resources_to_use +from licomp_toolkit.utils import default_resources +from licomp_toolkit.license_policy import LicensePolicyHandler from licomp.main_base import LicompParser from licomp.interface import UseCase @@ -43,6 +50,47 @@ def validate(self, args): LicompToolkitSchemaChecker().validate_file(args.file_name, deep=True) return None, ReturnCodes.LICOMP_OK.value, None + def _read_report_file(self, report_file): + try: + with open(report_file) as fp: + report = json.load(fp) + meta = report['meta'] + meta_OK = meta['tool'] == module_name + meta_OK = meta['file_version'] == licomp_toolkit_file_version + file_OK = meta['file'] == 'verification' + if not (meta_OK and file_OK): + err_msg = f'File "{report_file}" not in Licomp Toolkit\'s license policy format.' + err_code = LicompToolkitReturnCodes.LICOMP_TOOLKIT_INVALID_FILE.value + return None, err_code, err_msg + return report, ReturnCodes.LICOMP_OK.value, None + except (FileNotFoundError, JSONDecodeError): + err_msg = f'File "{report_file}" not found or not in JSON format' + err_code = LicompToolkitReturnCodes.LICOMP_TOOLKIT_INVALID_FILE.value + return None, err_code, err_msg + except (KeyError): + err_msg = f'File "{report_file}" not in Licomp Toolkit\'s license policy format.' + err_code = LicompToolkitReturnCodes.LICOMP_TOOLKIT_INVALID_FILE.value + return None, err_code, err_msg + + def apply_license_policy(self, args): + report, err_code, err_msg = self._read_report_file(args.report_file) + if err_code != ReturnCodes.LICOMP_OK.value: + return None, err_code, err_msg + if args.resources: + logging.warning(f'User specified resources are ignored. Using the resources as specified in the report file ("{args.report_file}").') + resources = report['resources'] + usecase = report['usecase'] + provisioning = report['provisioning'] + lph = LicensePolicyHandler(policy_file=args.license_policy_file, + resources=resources, + usecase=usecase, + provisioning=provisioning) + policy_report = lph.apply_policy(report, ignore_missing=True) + ret_code = compatibility_status_to_returncode(policy_report['compatibility']) + formatter = LicompToolkitFormatter.formatter(self.args.output_format) + formatted_report = formatter.format_policy_report(report, verbose=args.verbose) + return formatted_report, ret_code, False + def verify(self, args): formatter = LicompToolkitFormatter.formatter(self.args.output_format) try: @@ -63,7 +111,20 @@ def verify(self, args): detailed_report=detailed_report) ret_code = compatibility_status_to_returncode(compatibilities['compatibility']) - return formatter.format_compatibilities(compatibilities), ret_code, False + if args.apply_license_policy: + if args.license_policy_file: + lph = LicensePolicyHandler(policy_file=args.license_policy_file) + else: + resources, unsupported = resources_to_use(args) + if unsupported: + return f'Resource(s) {", ".join(unsupported)} is/are not supported', ReturnCodes.LICOMP_UNSUPPORTED_RESOURCE.value, True + lph = LicensePolicyHandler(resources=resources, + usecase=args.usecase, + provisioning=args.provisioning) + policy_report = lph.apply_policy(compatibilities) + return formatter.format_policy_report(policy_report, verbose=args.verbose), ret_code, False + else: + return formatter.format_compatibilities(compatibilities, verbose=args.verbose), ret_code, False except LicompException as e: return e, e.return_code.value, True except FlameException as e: @@ -125,11 +186,14 @@ def outbound_candidate(self, args): if args.all_licenses: licenses_to_check = self.licomp_toolkit.supported_licenses() + resources, unsupported = resources_to_use(args) + if unsupported: + return f'Resource(s) {", ".join(unsupported)} is/are not supported', ReturnCodes.LICOMP_UNSUPPORTED_RESOURCE.value, True candidates = suggester.compat_licenses(args.license_expression, args.usecase, args.provisioning, licenses_to_check, - args.resources) + resources) if args.least_compatible: candidates.reverse() @@ -139,10 +203,13 @@ def outbound_candidate(self, args): def display_compatibility(self, args): display_compat = DisplayCompatibility(self.licomp_toolkit) + resources, unsupported = resources_to_use(args) + if unsupported: + return f'Resource(s) {", ".join(unsupported)} is/are not supported', ReturnCodes.LICOMP_UNSUPPORTED_RESOURCE.value, True compats = display_compat.display_compatibility(args.licenses, UseCase.string_to_usecase(args.usecase), Provisioning.string_to_provisioning(args.provisioning), - args.resources) + resources) formatter = LicompToolkitFormatter.formatter(args.output_format) formatted = formatter.format_display_compatibilities(compats, {'discard_unsupported': args.discard_unsupported_licenses}) @@ -154,7 +221,7 @@ def versions(self, args): def _working_return_code(return_code): - return return_code >= 0 and return_code < ReturnCodes.LICOMP_LAST_SUCCESSFUL_CODE.value + return return_code >= 0 and return_code < LicompToolkitReturnCodes.LICOMP_TOOLKIT_LAST_ERROR_CODE.value def main(): logging.debug("Licomp Toolkit") @@ -171,7 +238,7 @@ def main(): parser.add_argument('-r', '--resources', type=str, action='append', - help='use specified licomp resource. For a list use the commands \'supported-resources\'. Use \'all\' to use all', + help=f'use specified licomp resource. For a list use the commands \'supported-resources\'. Use \'all\' to use all. Default: {", ".join(default_resources())}', default=[]) parser.add_argument('-nv', '--no-verbose', @@ -179,6 +246,10 @@ def main(): help='keep compatibility report as short as possible', default=[]) + parser_v = subparsers.choices['verify'] + parser_v.add_argument("--apply-license-policy", action='store_true', help='Apply license policy', default=False) + parser_v.add_argument("--license-policy-file", type=str, help='License policy file. Defaults to use default license policy.', default=None) + # Commands parser_si = subparsers.add_parser('simplify', help='Normalize and simplify a license expression') parser_si.set_defaults(which="simplify", func=lct_parser.simplify) @@ -226,6 +297,12 @@ def main(): 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) + # Command: apply policy + parser_sr = subparsers.add_parser('apply-license-policy', help='') + parser_sr.set_defaults(which="apply_license_policy", func=lct_parser.apply_license_policy) + parser_sr.add_argument('--license-policy-file', '-lpf', type=str, help='License policy file', default=None) + parser_sr.add_argument("report_file", type=str) + res, code, err, func = lct_parser.run_noexit() if _working_return_code(code): if res: diff --git a/licomp_toolkit/config.py b/licomp_toolkit/config.py index 51a87f4..73c3968 100644 --- a/licomp_toolkit/config.py +++ b/licomp_toolkit/config.py @@ -4,6 +4,7 @@ licomp_toolkit_version = '0.5.18' my_supported_api_version = '0.5' +licomp_toolkit_file_version = '0.5' cli_name = 'licomp-toolkit' module_name = 'licomp_toolkit' diff --git a/licomp_toolkit/data/reply_schema.json b/licomp_toolkit/data/reply_schema.json index 916f275..34b98ad 100644 --- a/licomp_toolkit/data/reply_schema.json +++ b/licomp_toolkit/data/reply_schema.json @@ -4,6 +4,23 @@ "title" : "Licomp Toolkit Reply", "type" : "object", "properties" : { + "meta": { + "type" : "object", + "properties" : { + "status" : { + "type": "string", + "description" : "" + }, + "tool" : { + "type": "string", + "description" : "" + }, + "file_version" : { + "type": "string", + "description" : "" + } + } + }, "compatibility_report": { "type" : "object", "$ref": "#/$defs/compatibility_object" @@ -78,13 +95,13 @@ "$ref": "#/$defs/compatibility", "description" : "The inbound license expression." }, - "compatibility_type": { - "enum": [ "license" ], - "description": "Describing what is being checked. Can be either expression or license. In this case the value is \"license\"." - }, "compatibility_check": { "$ref": "#/$defs/compatibility_check" }, + "check_class": { + "type": "string", + "description": "Names the class/function used to determine. For development purposes" + }, "inbound_license": { "$ref": "#/$defs/license", "description" : "The inbound license expression." @@ -123,7 +140,7 @@ ] } }, - "required": [ "compatibility", "compatibility_type", "compatibility_check", "inbound_license" , "outbound_license", "compatibility_object", "compatibility_details"], + "required": [ "compatibility", "compatibility_check", "inbound_license" , "outbound_license", "compatibility_object", "compatibility_details"], "additionalProperties" : false }, { @@ -132,13 +149,13 @@ "$ref": "#/$defs/compatibility", "description" : "The inbound license expression." }, - "compatibility_type": { - "enum": [ "expression" ], - "description": "Describing what is being checked. Can be either expression or license. In this case the value is \"expression\"." - }, "compatibility_check": { "$ref": "#/$defs/compatibility_check" }, + "check_class": { + "type": "string", + "description": "Names the class/function used to determine. For development purposes" + }, "inbound_license": { "$ref": "#/$defs/license", "description" : "The inbound license expression." @@ -166,7 +183,7 @@ "description": "The operands for the operator. The operands can be either a license or an operator." } }, - "required": [ "compatibility", "compatibility_type", "compatibility_check", "inbound_license" , "outbound_license" , "operator", "operands" ], + "required": [ "compatibility", "compatibility_check", "inbound_license" , "outbound_license" , "operator", "operands" ], "additionalProperties" : false } ] @@ -255,7 +272,7 @@ "pattern": "^[0-9].[0-9](.[0-9]){0,1}$", "description" : "The api version of the program providing the reply" }, - "resource_name1" : { + "resource_name" : { "type" : "string", "minLength": 1, "description" : "The name of the program providing the reply, e.g. licomp-osadl" @@ -281,7 +298,7 @@ "description" : "A URL pointing to the project page (or similar) for the program providing the resulting compatibility." } }, - "required" : [ "status", "status_details", "outbound", "inbound", "usecase", "provisioning", "modification", "compatibility_status", "explanation", "api_version", "resource_name1", "resource_version", "resource_disclaimer", "data_url", "resource_url"], + "required" : [ "status", "status_details", "outbound", "inbound", "usecase", "provisioning", "modification", "compatibility_status", "explanation", "api_version", "resource_name", "resource_version", "resource_disclaimer", "data_url", "resource_url"], "additionalProperties" : false } } diff --git a/licomp_toolkit/exception.py b/licomp_toolkit/exception.py new file mode 100644 index 0000000..7eae0e1 --- /dev/null +++ b/licomp_toolkit/exception.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +class LicompToolkitException(Exception): + + def __init__(self, message, error_code, orig_exception=None): + self.message = message + super().__init__(self.message) + self.error_code = error_code + self.original_exception = orig_exception diff --git a/licomp_toolkit/expr_parser.py b/licomp_toolkit/expr_parser.py index a5750d8..b72d021 100644 --- a/licomp_toolkit/expr_parser.py +++ b/licomp_toolkit/expr_parser.py @@ -12,8 +12,6 @@ OR = "OR" COMPATIBILITY_TYPE = 'compatibility_type' -COMPATIBILITY_OUTBOUND_LICENSE = 'outbound_license' -COMPATIBILITY_INBOUND_LICENSE = 'inbound_license' class LicenseExpressionParser(): @@ -127,7 +125,7 @@ def __parse_expression(self, expression): elif self.__is_close(expression): return "" - raise Exception("Bottom reached") + raise Exception("Bottom reached in __parse_expression (expr_parser.py)") def to_string(self, parsed_license): license_type = parsed_license['compatibility_type'] diff --git a/licomp_toolkit/format.py b/licomp_toolkit/format.py index c25e1f1..b58b5b8 100644 --- a/licomp_toolkit/format.py +++ b/licomp_toolkit/format.py @@ -18,7 +18,7 @@ def formatter(fmt): if fmt.lower() == 'dot': return DotLicompToolkitFormatter() - def format_compatibilities(self, compat): + def format_compatibilities(self, compat, verbose=False): raise Exception(f'{self.__class__.__name__} cannot format compatibilities.') def _pre_format_display_compatibilities(self, compats): @@ -55,7 +55,7 @@ def format_licomp_versions(self, licomp_versions): class JsonLicompToolkitFormatter(LicompToolkitFormatter): - def format_compatibilities(self, compat): + def format_compatibilities(self, compat, verbose=False): return json.dumps(compat, indent=4) def format_licomp_resources(self, licomp_resources): @@ -67,17 +67,23 @@ 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, settings={}): + def format_display_compatibilities(self, compats, settings={}): # noqa: B006 # 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) + def format_policy_report(self, report, verbose=False): + return json.dumps(report, indent=4) + class YamlLicompToolkitFormatter(LicompToolkitFormatter): - def format_compatibilities(self, compat): + def format_compatibilities(self, compat, verbose=False): return yaml.safe_dump(compat, indent=4) + def format_policy_report(self, report, verbose=False): + return yaml.safe_dump(report, indent=4) + def format_licomp_resources(self, licomp_resources): return yaml.safe_dump(licomp_resources, indent=4) @@ -87,7 +93,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, settings={}): + def format_display_compatibilities(self, compats, settings={}): # noqa: B006 display_compats = self._pre_format_display_compatibilities(compats) return yaml.safe_dump(display_compats, indent=4) @@ -139,51 +145,109 @@ def __statuses(self, statuses, indent=''): return output - def _format_compat(self, compat): + def _format_compat_value(self, compat): + return {'yes': 'compatible'}.get(compat, 'incompatible') + + def _format_compat_pref(self, compat, pref_lic=None): PAREN_OPEN = '(' - PAREN_START = ')' - return f'{PAREN_OPEN}{compat}{PAREN_START}' + PAREN_CLOSE = ')' + compat_string = self._format_compat_value(compat) + if pref_lic: + return f'{PAREN_OPEN}{compat_string}, {pref_lic}{PAREN_CLOSE}' + else: + return f'{PAREN_OPEN}{compat_string}{PAREN_CLOSE}' - def format_compatibilities_object(self, compat_object, indent=''): + def format_compatibilities_general(self, compat_object, indent='', policy_report=False, preferred_license=None): compatibility_check = compat_object["compatibility_check"] output = [] if compatibility_check == "outbound-license -> inbound-license": - if not compat_object["compatibility_object"]: - pass - else: - compat_object = compat_object["compatibility_object"] + compat_object = compat_object details = compat_object["compatibility_details"] summary = details["summary"] + preferred_info = 'no' + if policy_report and preferred_license == compat_object["policy_check"]["inbound"]["preferences"]["license"]: + preferred_info = 'yes' + output.append(f'{indent}{compat_object["outbound_license"]} -> {compat_object["inbound_license"]}') + if policy_report: + output.append(f'{indent} preferred: {preferred_info}') + output.append(f'{indent} compatibility: {compat_object["compatibility"]}') + output.append(f'{indent} compatibility details: ') + output += self.__compatibility_statuses(summary['compatibility_statuses'], f'{indent} ') - output.append(f'{indent}{compat_object["outbound_license"]} -> {compat_object["inbound_license"]} {self._format_compat(compat_object["compatibility"])}') - output.append(f'{indent} compatibility: {compat_object["compatibility"]}') - output.append(f'{indent} compatibility details:') - output += self.__compatibility_statuses(summary['compatibility_statuses'], f'{indent} ') if compatibility_check == "outbound-license -> inbound-expression": - operator = compat_object["compatibility_object"]["operator"] - output.append(f'{indent}{operator} {self._format_compat(compat_object["compatibility"])}') - for operand in compat_object["compatibility_object"]["operands"]: - res = self.format_compatibilities_object(operand['compatibility_object'], indent=f'{indent} ') - output.append(res) + operator = compat_object["operator"] + inner_output = [] + preferred_info = 'no' + if policy_report and preferred_license == compat_object['policy_check']['inbound']['preferences']['license']: + preferred_info = 'yes' + for operand in compat_object["operands"]: + res = self.format_compatibilities_general(operand['compatibility_object'], indent=f'{indent} ', policy_report=policy_report, preferred_license=preferred_license) + inner_output.append(res) + if policy_report: + output.append(f'{indent}{operator} {self._format_compat_pref(compat_object["compatibility"], preferred_license)}') + output.append(f'{indent} check: {compatibility_check}') + output.append(f'{indent} outbound: {compat_object["outbound_license"]}') + output.append(f'{indent} inbound: {compat_object["inbound_license"]}') + output.append(f'{indent} comaptibility: {compat_object["compatibility"]}') + output.append(f'{indent} preferred: {preferred_info}') + output.append(f'{indent} details:') + output += inner_output if compatibility_check == "outbound-expression -> inbound-license": operator = compat_object["operator"] - output.append(f'{indent}{operator} {self._format_compat(compat_object["compatibility"])}') + output.append(f'{indent}{operator} {self._format_compat_pref(compat_object["compatibility"])}') for operand in compat_object["operands"]: - res = self.format_compatibilities_object(operand['compatibility_object'], indent=f'{indent} ') + res = self.format_compatibilities_general(operand['compatibility_object'], indent=f'{indent} ', policy_report=policy_report, preferred_license=preferred_license) output.append(res) if compatibility_check == "outbound-expression -> inbound-expression": operator = compat_object["operator"] compat = compat_object["compatibility"] - output.append(f'{indent}{operator} {self._format_compat(compat)}') + preferred_info = 'no' + if policy_report and preferred_license == compat_object['policy_check']['inbound']['preferences']['license']: + preferred_info = 'yes' + output.append(f'{indent}{operator} {self._format_compat_pref(compat)}') + output.append(f'{indent} check: {compatibility_check}') + output.append(f'{indent} outbound: {compat_object["outbound_license"]}') + output.append(f'{indent} inbound: {compat_object["inbound_license"]}') + output.append(f'{indent} comaptibility: {compat_object["compatibility"]}') + output.append(f'{indent} preferred: {preferred_info}') + output.append(f'{indent} details:') for operand in compat_object['operands']: - res = self.format_compatibilities_object(operand['compatibility_object'], indent=f'{indent} ') + res = self.format_compatibilities_general(operand['compatibility_object'], indent=f'{indent} ', policy_report=policy_report, preferred_license=preferred_license) output.append(f'{res}') return "\n".join(output) - def format_compatibilities(self, compat): + def format_compatibilities_object(self, compat_object): + return self.format_compatibilities_general(compat_object, indent='') + + def format_policy_report(self, report, verbose=False): + output = [] + preferred_inbound = report['compatibility_report']['policy_check']['inbound']['preferences']['license'] + preferred_outbound = report['compatibility_report']['policy_check']['outbound']['preferences']['license'] + + policy_info = report['meta']['policy_type'] + if policy_info == 'policy_file': + policy_info = f'{policy_info} {report["meta"]["policy_file"]}' + + output.append(f'outbound: {report["outbound"]}') + output.append(f'inbound: {report["inbound"]}') + res = ', '.join(report['resources']) + output.append(f'resources: {res}') + output.append(f'provisioning: {report["provisioning"]}') + output.append(f'usecase: {report["usecase"]}') + output.append(f'policy: {policy_info}') + output.append(f'compatibility: {report["compatibility"]}') + output.append(f'preferred inbound: {preferred_inbound}') + output.append(f'preferred outbound: {preferred_outbound}') + + if verbose: + output.append('report:') + output.append(self.format_compatibilities_general(report["compatibility_report"], indent=' ', policy_report=True, preferred_license=preferred_inbound)) + return "\n".join(output) + + def format_compatibilities(self, compat, verbose=False): output = [] output.append(f'outbound: {compat["outbound"]}') output.append(f'inbound: {compat["inbound"]}') @@ -191,8 +255,9 @@ def format_compatibilities(self, compat): output.append(f'provisioning: {compat["provisioning"]}') output.append(f'usecase: {compat["usecase"]}') output.append(f'compatibility: {compat["compatibility"]}') - output.append('report:') - output.append(self.format_compatibilities_object(compat["compatibility_report"], ' ')) + if verbose: + output.append('report:') + output.append(self.format_compatibilities_general(compat["compatibility_report"], ' ', policy_report=False)) return "\n".join(output) @@ -204,13 +269,6 @@ def format_licomp_versions(self, licomp_versions): 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()) @@ -231,7 +289,7 @@ def _compat_line_color(self, compats): } _color_map = { 'yes': 'darkgreen', - 'no': 'darkred' + 'no': 'darkred', } same = True value = None @@ -256,20 +314,15 @@ def _compat_line_color(self, compats): 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) + BR_START = '[' + BR_END = ']' if out_line == in_line and out_color == in_color: - return (f' "{outbound}" -> "{inbound}" [dir="both" color="{out_color}" {out_line}]') + return (f' "{outbound}" -> "{inbound}" {BR_START}dir="both" color="{out_color}" {out_line}{BR_END}') 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) + return '\n'.join([f' "{outbound}" -> "{inbound}" {BR_START}color="{out_color}" {out_line}{BR_END}', + f' "{inbound}" -> "{outbound}" {BR_START}color="{in_color}" {in_line}{BR_END}']) + + def format_display_compatibilities(self, compats, settings={}): # noqa: B006 display_compats = self._pre_format_display_compatibilities(compats) licenses = list(display_compats.keys()) diff --git a/licomp_toolkit/license_policy.py b/licomp_toolkit/license_policy.py new file mode 100644 index 0000000..ede43c7 --- /dev/null +++ b/licomp_toolkit/license_policy.py @@ -0,0 +1,564 @@ +# SPDX-FileCopyrightText: 2024 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from collections import OrderedDict + +from functools import cmp_to_key +import json +import logging + +from licomp_toolkit.toolkit import LicompToolkit +from licomp.interface import UseCase +from licomp.interface import Provisioning +from licomp.interface import Modification + +class LicensePolicyException(Exception): + + def __init__(self, message): + self.message = message + super().__init__(self.message) + + +class LicensePolicy: + + def __init__(self, policy_file): + data = self._read_json_file(policy_file) + self.policy_meta = data['meta'] + self.policy = data['policy'] + + def _read_json_data(self, data): + return json.load(data) + + def _read_json_file(self, file_name): + with open(file_name) as fp: + return self._read_json_data(fp) + + def allowed(self): + return self.policy['allowed'] + + def avoided(self): + return self.policy['avoided'] + + def denied(self): + return self.policy['denied'] + + def meta(self): + return self.policy_meta + + def list_presence(self, lic, ignore_missing=False): + + if lic in self.allowed(): + return 1, self.allowed().index(lic) + if lic in self.avoided(): + return 2, self.avoided().index(lic) + if lic in self.denied(): + return 3, self.denied().index(lic) + + if ignore_missing: + return None, None + raise LicensePolicyException(f'License "{lic}" is not present in any of the policy\'s lists. Licenses: {self.allowed()}, {self.avoided()} ') + + def list_nr_to_name(self, nr): + return { + 1: 'allowed', + 2: 'avoided', + 3: 'denied'}.get(nr, '') + + def list_name_to_nr(self, nr): + return { + 'allowed': 1, + 'avoided': 2, + 'denied': 3}.get(nr, None) + + def compare_preferences(self, lic1, lic2, ignore_missing=False): + return self.compare_preferences_general(lic1, lic2, ignore_missing=ignore_missing) + + def compare_preferences_expressions(self, lic1, lic2, key, ignore_missing=False): + return self.compare_preferences_general(lic1, lic2, key, ignore_missing=ignore_missing) + + def compare_preferences_general(self, lic1, lic2, key=None, ignore_missing=False): + """ + + returns + * negative if lic1 is more preferred than lic2 + * None if both licenses are denied + raises + * LicensePolicyException if at least one license is not listed (if ignore_missing, then both licenses need to be not listed to raise exception) + """ + logging.debug(f'compare_preferences {lic1}, {lic2}, {key}, {ignore_missing}') + if key: + list_key = key.replace('_license', '') + lic1_name = lic1[list_key]['license'] + lic2_name = lic2[list_key]['license'] + if lic1[list_key]['type'] == 'license': + lic1_list, lic1_index = self.list_presence(lic1[list_key]['license'], ignore_missing=False) + else: + lic1_list_name = lic1[list_key]['preferences']['license_list'] + lic1_list = self.list_name_to_nr(lic1_list_name) + lic1_index = lic1[list_key]['preferences']['license_index'] + + if lic2[list_key]['type'] == 'license': + lic2_list, lic2_index = self.list_presence(lic2[list_key]['license'], ignore_missing=False) + else: # license-expression + lic2_list_name = lic2[list_key]['preferences']['license_list'] + lic2_list = self.list_name_to_nr(lic2_list_name) + lic2_index = lic2[list_key]['preferences']['license_index'] + else: + lic1_name = lic1 + lic2_name = lic2 + lic1_list, lic1_index = self.list_presence(lic1, ignore_missing=ignore_missing) + lic2_list, lic2_index = self.list_presence(lic2, ignore_missing=ignore_missing) + + logging.debug(f'compare_preferences: "{lic1_list}", "{lic1_index}" "{lic2_list}", "{lic2_index}"') + + if (not lic1_list) and (not lic2_list): + if ignore_missing: + logging.debug(f'compare_preferences({lic1_name}, {lic2_name}, {ignore_missing}): ignore since both None') + return None + + raise LicensePolicyException(f'License "{lic1_name}" and/or "{lic2_name}" is not present in any of the policy\'s lists.') + + if lic1_list == lic2_list: + # if both are denied, return None to indicate "error" + if lic1_list == 3: + return None + return lic1_index - lic2_index + + if not lic1_list: + return 1 + if not lic2_list: + return -1 + + return lic1_list - lic2_list + + def most_preferred(self, lic1, lic2, ignore_missing=False): + pref = self.compare_preferences(lic1, lic2, ignore_missing) + if pref is None: + return None + if pref < 0: + return lic1 + return lic2 + + def OBSOLETE_preferred_score_ignore_missing(self, lic1, lic2): + pref = self.compare_preferences(lic1, lic2, ignore_missing=True) + if pref is None: + return 10000 + if pref < 0: + return -1 + if lic1 == lic2: + return 0 + return 1 + + def preferred_score_licenses(self, lic1, lic2, key): + logging.debug(f'preferred_score_licenses {lic1}, {lic2}, {key}') + pref = self.compare_preferences_expressions(lic1, lic2, key) + if pref < 0: + return -1 + if lic1 == lic2: + return 0 + return 1 + + def preferred_score_inbounds(self, lic1, lic2): + logging.debug(f'preferred_score_inbounds {lic1}, {lic2}') + return self.preferred_score_licenses(lic1, lic2, 'inbound_license') + + def preferred_score_outbounds(self, lic1, lic2): + logging.debug(f'preferred_score_outbounds {lic1}, {lic2}') + return self.preferred_score_licenses(lic1, lic2, 'outbound_license') + + def least_preferred(self, lic1, lic2, ignore_missing=False): + most_preferred = self.most_preferred(lic1, lic2, ignore_missing) + if most_preferred == lic1: + return lic2 + return lic1 + +class DefaultLicensePolicy(LicensePolicy): + + def __init__(self, resources, usecase, provisioning): + self.lt = LicompToolkit() + self.__licenses(resources, usecase, provisioning) + license_order = self.__order(resources, usecase, provisioning) + self.policy_meta = {} + self.policy = { + 'allowed': license_order, + 'avoided': [], + 'denied': [], + } + + def __order(self, resources, usecase, provisioning): + scores = {} + for lic in self.licenses: + scores[lic] = 0 + for out_license in self.licenses: + for in_license in self.licenses: + for resource in self.resources: + compat = resource.outbound_inbound_compatibility(out_license, + in_license, + UseCase.string_to_usecase(usecase), + Provisioning.string_to_provisioning(provisioning), + Modification.UNMODIFIED) + if compat['compatibility_status'] == 'yes': + scores[in_license] += 1 + + scores_dict = OrderedDict(sorted(scores.items(), key=lambda x: x[1], reverse=True)) + return [x for (x, y) in scores_dict.items()] + + def __licenses(self, resources, usecase, provisioning): + self.resources = [] + self.licenses = [] + + logging.debug(f'__licenses {resources}, {usecase}, {provisioning}') + for resource in resources: + for licomp_resource in self.lt.licomp_resources(): + if resource == licomp_resource: + self.licenses += self.lt.licomp_resources()[licomp_resource].supported_licenses() + self.resources.append(self.lt.licomp_resources()[licomp_resource]) + logging.debug(f'__licenses {resources}, {usecase}, {provisioning} ==> {self.licenses}') + + +class LicensePolicyHandler: + + def __init__(self, policy_file=None, resources=None, usecase=None, provisioning=None): + logging.debug("LicensePolicyHandler()") + if policy_file: + self.policy = LicensePolicy(policy_file) + self.policy_type = 'policy_file' + self.policy_file = policy_file + else: + self.policy = DefaultLicensePolicy(resources, usecase, provisioning) + self.policy_type = 'default' + self.policy_file = None + + def __is_license_expression(self, lic): + CONTAINS_AND = 'AND' in lic + CONTAINS_OR = 'OR' in lic + return CONTAINS_AND or CONTAINS_OR + + def usable_license(self, lic, key): + if key in lic: + license_name = lic[key]['license'] + else: + license_name = lic + if self.__is_license_expression(license_name): + # should have been checked already, so skip + policy_ok = True + else: + policy_ok = license_name in (self.policy.allowed() + self.policy.avoided()) + compat_ok = lic['compatibility'] == 'yes' + + ret = policy_ok and compat_ok + logging.debug(f'usable_license {lic} | policy_ok: {policy_ok}, compat_ok: {compat_ok} ===> {ret}') + + return ret + + def unusable_licenses(self, licenses, key): + problematic_licenses = [] + for lic in licenses: + license_name = lic[key]['license'] + if self.__is_license_expression(license_name): + # should have been checked already, so skip + policy_ok = True + else: + policy_ok = license_name in (self.policy.allowed() + self.policy.avoided()) + compat_ok = lic['compatibility'] == 'yes' + + ret = policy_ok and compat_ok + logging.debug(f'unusable_license {lic} | policy_ok: {policy_ok}, compat_ok: {compat_ok} ===> {ret}') + if not ret: + problematic_licenses.append(lic) + return len(problematic_licenses) > 0 + + def scored_general(self, licenses, operator, key): + compare_function = { + 'inbound': self.policy.preferred_score_inbounds, + 'outbound': self.policy.preferred_score_outbounds, + }[key] + if operator == "OR": + logging.debug(f'preferred_licenses {licenses}, {operator}') + filtered_licenses = [] + unusable_licenses = [] + for lic in licenses: + if self.usable_license(lic, key): + filtered_licenses.append(lic) + else: + print("UNUSABLE: " + str(key)) + print("UNUSABLE: " + str(lic)) + unusable_licenses.append(lic[key]) + sorted_licenses = sorted(filtered_licenses, key=cmp_to_key(compare_function)) + return sorted_licenses, unusable_licenses + elif operator == "AND": + if self.unusable_licenses(licenses, key): + sorted_licenses = [] + unusable_licenses = [x[key] for x in licenses] + else: + sorted_licenses = sorted(licenses, key=cmp_to_key(compare_function)) + unusable_licenses = [] + + return sorted_licenses, unusable_licenses + else: + raise Exception(f'scored_general got a bad operator "{operator}"') + + def scored_inbounds(self, inbounds, operator): + return self.scored_general(inbounds, operator, 'inbound') + + def scored_outbounds(self, outbounds, operator): + return self.scored_general(outbounds, operator, 'outbound') + + def __check_policy_check(self, reply): + keys = [ + 'license', + 'type', + 'preferences:license', + 'preferences:license_list', + 'preferences:license_index', + ] + for key in keys: + in_value = reply['inbound'] + out_value = reply['outbound'] + for sub_key in key.split(':'): + assert sub_key in in_value # noqa: S101 + assert sub_key in out_value # noqa: S101 + in_value = in_value[sub_key] + out_value = out_value[sub_key] + + assert 'compatibility' in reply # noqa: S101 + assert 'unusable' in reply # noqa: S101 + + def __pack_policy_check(self, outbound_pref, inbound_pref, compat, unusable): + return { + 'outbound': outbound_pref, + 'inbound': inbound_pref, + 'compatibility': compat, + 'unusable': unusable, + } + + def __pack_policy_unusable(self, unusables): + return unusables + + def __pack_policy_preferences(self, license_string, license_list, license_index): + return { + 'license': license_string, + 'license_list': license_list, + 'license_index': license_index, + } + + def __pack_policy_check_license(self, license_name, license_type, pref_lic, pref_lic_list, pref_lic_idx): + return { + 'license': license_name, + 'type': license_type, + 'preferences': { + 'license': pref_lic, + 'license_list': pref_lic_list, + 'license_index': pref_lic_idx, + }, + } + + def __apply_to_compat_object(self, compat_object, ignore_missing=False, indent=0): + """ + Specification of the policy_check format + + define license_data: + + license (str) + license_type (str: license | license-expression) + preferrences { + license + license_list + license_index + } + + unusable [ + { license (str), + reason (str) + } + ] + + inbound (license_data) + outbound (license_data) + compatibility (str) + + """ + unusable = [] + if 'outbound-expression' in compat_object['compatibility_check']: + operator = compat_object['operator'] + outbounds = [] + for operand in compat_object['operands']: + operand['compatibility_object']['TEST'] = "Liverpool" + self.__apply_to_compat_object(operand['compatibility_object'], indent + 4) + self.__check_policy_check(operand['compatibility_object']['policy_check']) + + outbounds.append(operand['compatibility_object']['policy_check']) + + scored_outbounds, unusable = self.scored_outbounds(outbounds, operator) + + outbound_list = [] + outbound_list_index = -1 + preferred_inbound_license = None + compatibility = 'no' + + if len(scored_outbounds) > 0: + if operator == 'AND': + outbound_name = ' AND '.join([x['outbound']['preferences']['license'] for x in scored_outbounds]) + outbound_pref = scored_outbounds[-1] + else: + outbound_name = scored_outbounds[0]['outbound']['preferences']['license'] + outbound_pref = scored_outbounds[0] + + outbound_list = outbound_pref['outbound']['preferences']['license_list'] + outbound_list_index = outbound_pref['outbound']['preferences']['license_index'] + inbound_prefs = outbound_pref['inbound']['preferences'] + preferred_inbound_license = inbound_prefs['license'] + preferred_inbound_license_list = inbound_prefs['license_list'] + preferred_inbound_license_index = inbound_prefs['license_index'] + + compatibility = 'yes' + else: + outbound_name = None + outbound_list = None + outbound_list_index = None + + preferred_inbound_license = None + preferred_inbound_license_list = None + preferred_inbound_license_index = None + + compatibility = 'no' + if 'inbound-expression' in compat_object['compatibility_check']: + inbound_license_type = 'license-expression' + else: + inbound_license_type = 'license' + + policy_check_inbound_license = self.__pack_policy_check_license( + compat_object['inbound_license'], + inbound_license_type, + preferred_inbound_license, + preferred_inbound_license_list, + preferred_inbound_license_index, + ) + policy_check_outbound_license = self.__pack_policy_check_license( + compat_object['outbound_license'], + 'license-expression', + outbound_name, + outbound_list, + outbound_list_index, + ) + unusable = self.__pack_policy_unusable(unusable) + policy_license_object = self.__pack_policy_check( + policy_check_outbound_license, + policy_check_inbound_license, + compatibility, + unusable, + ) + + compat_object['policy_check'] = policy_license_object + + elif 'outbound-license' in compat_object['compatibility_check']: + + if 'inbound-expression' in compat_object['compatibility_check']: + inner_compat_object = compat_object + operator = inner_compat_object['operator'] + inbounds = [] + for operand in inner_compat_object['operands']: + self.__apply_to_compat_object(operand['compatibility_object'], indent + 4) + self.__check_policy_check(operand['compatibility_object']['policy_check']) + inbounds.append(operand['compatibility_object']['policy_check']) + + scored_inbounds, unusable = self.scored_inbounds(inbounds, operator) + inbound_list = '' + inbound_list_index = -1 + preferred_inbound_license = None + + if len(scored_inbounds) > 0: + if operator == 'AND': + # use last (least preferred) license + inbound_list = scored_inbounds[-1]['inbound']['preferences']['license_list'] + inbound_list_index = scored_inbounds[-1]['inbound']['preferences']['license_index'] + preferred_inbound_license = ' AND '.join([x['inbound']['preferences']['license'] for x in scored_inbounds]) + else: + # use first (most preferred) license + inbound_list = scored_inbounds[0]['inbound']['preferences']['license_list'] + inbound_list_index = scored_inbounds[0]['inbound']['preferences']['license_index'] + preferred_inbound_license = scored_inbounds[0]['inbound']['preferences']['license'] + + out_lic = inner_compat_object["outbound_license"] + out_list_nr, out_index = self.policy.list_presence(out_lic, ignore_missing=True) + out_list_name = self.policy.list_nr_to_name(out_list_nr) + + policy_check_inbound_license = self.__pack_policy_check_license( + inner_compat_object['inbound_license'], + 'license-expression', + preferred_inbound_license, + inbound_list, + inbound_list_index, + ) + policy_check_outbound_license = self.__pack_policy_check_license( + out_lic, + 'license', + out_lic, + out_list_name, + out_index, + ) + packed_unusable = self.__pack_policy_unusable(unusable) + policy_license_object = self.__pack_policy_check( + policy_check_outbound_license, + policy_check_inbound_license, + compat_object['compatibility'], + packed_unusable) + inner_compat_object['policy_check'] = policy_license_object + + if 'inbound-license' in compat_object['compatibility_check']: + + in_lic = compat_object['inbound_license'] + in_list_nr, in_list_index = self.policy.list_presence(in_lic, ignore_missing=True) + in_list_name = self.policy.list_nr_to_name(in_list_nr) + + out_lic = compat_object["outbound_license"] + out_list_nr, out_index = self.policy.list_presence(out_lic, ignore_missing=True) + out_list_name = self.policy.list_nr_to_name(out_list_nr) + + policy_check_inbound_license = self.__pack_policy_check_license( + in_lic, + 'license', + in_lic, + in_list_name, + in_list_index, + ) + policy_check_outbound_license = self.__pack_policy_check_license( + out_lic, + 'license', + out_lic, + out_list_name, + out_index, + ) + packed_unusable = self.__pack_policy_unusable(unusable) + policy_license_object = self.__pack_policy_check( + policy_check_outbound_license, + policy_check_inbound_license, + compat_object['compatibility'], + packed_unusable, + ) + compat_object['policy_check'] = policy_license_object + + else: + raise Exception("We should not be here") + return None + + def apply_policy(self, compat_report, ignore_missing=False): + if not self.policy: + logging.debug('apply_policy, no policy. Creating default one') + resources = compat_report['resources'] + usecase = compat_report['usecase'] + provisioning = compat_report['provisioning'] + self.policy = DefaultLicensePolicy(resources, usecase, provisioning) + self.policy_type = 'default' + self.policy_file = None + + top_object = compat_report['compatibility_report'] + logging.debug("apply_policy") + self.__apply_to_compat_object(top_object, + ignore_missing=ignore_missing) + self.__check_policy_check(top_object['policy_check']) + meta = compat_report['meta'] + meta['policy_type'] = self.policy_type + meta['policy_file'] = self.policy_file + return compat_report diff --git a/licomp_toolkit/return_codes.py b/licomp_toolkit/return_codes.py new file mode 100644 index 0000000..b39a7dd --- /dev/null +++ b/licomp_toolkit/return_codes.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2026 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from enum import Enum + +from licomp.return_codes import ReturnCodes + +class LicompToolkitReturnCodes(Enum): + LICOMP_TOOLKIT_INVALID_FILE = ReturnCodes.LICOMP_LAST_ERROR_CODE.value + 1 + + LICOMP_TOOLKIT_LAST_ERROR_CODE = ReturnCodes.LICOMP_LAST_ERROR_CODE.value + 100 diff --git a/licomp_toolkit/schema_checker.py b/licomp_toolkit/schema_checker.py index 142c0b5..42a5855 100644 --- a/licomp_toolkit/schema_checker.py +++ b/licomp_toolkit/schema_checker.py @@ -43,7 +43,7 @@ def __validate_deeply(self, compat): if compat_check == 'outbound-expression -> inbound-license' or compat_check == 'outbound-expression -> inbound-expression': compat_object = compat elif compat['compatibility_check'] == 'outbound-license -> inbound-expression': - compat_object = compat['compatibility_object'] + compat_object = compat else: raise LicompException("Validation failed. Invalid state: " + compat_check) diff --git a/licomp_toolkit/toolkit.py b/licomp_toolkit/toolkit.py index ae3f7ab..37c77fe 100644 --- a/licomp_toolkit/toolkit.py +++ b/licomp_toolkit/toolkit.py @@ -23,7 +23,9 @@ from licomp_toolkit.config import disclaimer from licomp_toolkit.config import licomp_toolkit_version +from licomp_toolkit.config import licomp_toolkit_file_version from licomp_toolkit.config import cli_name +from licomp_toolkit.config import module_name from licomp_toolkit.expr_parser import LicenseExpressionParser from licomp_toolkit.expr_parser import COMPATIBILITY_TYPE @@ -92,6 +94,12 @@ def licomp_resources_long(self): _resources.append(self.licomp_resource_long(resource)) return _resources + def licomp_resources_short(self): + _resources = [] + for resource in self.licomp_resources().values(): + _resources.append(f'{resource.name()}:{resource.version()}') + return _resources + def _resource_type(self, resource): if self._resource_is_standard(resource): return 'standard' @@ -114,7 +122,7 @@ def __summarize_compatibility(self, compatibilities, outbound, inbound, usecase, logging.debug(f': {compat["resource_name"]}') self.__add_to_list(statuses, compat['status'], compat) self.__add_to_list(compats, compat['compatibility_status'], compat) - compatibilities["summary"]["resources"] = self.licomp_resources_long() + compatibilities["summary"]["resources"] = self.licomp_resources_short() compatibilities["summary"]["outbound"] = outbound compatibilities["summary"]["inbound"] = inbound compatibilities["summary"]["usecase"] = UseCase.usecase_to_string(usecase) @@ -245,8 +253,8 @@ def check_compatibility(self, detailed_report=True): compat_object = { - COMPATIBILITY_TYPE: parsed_expression[COMPATIBILITY_TYPE], - 'compatibility_check': 'outbound-expression -> inbound-license', + 'compatibility_check': f'outbound-{self.le_parser.parse_license_expression(outbound)["compatibility_type"]} -> inbound-{parsed_expression["compatibility_type"]}', + 'check_class': __class__.__name__, } if parsed_expression[COMPATIBILITY_TYPE] == 'license': @@ -344,6 +352,13 @@ def __init__(self): def __parsed_expression_to_name(self, parsed_expression): return parsed_expression[parsed_expression[COMPATIBILITY_TYPE]] + def meta_information(self): + return { + 'tool': module_name, + 'file': 'verification', + 'file_version': licomp_toolkit_file_version, + } + def check_compatibility(self, outbound, inbound, usecase, provisioning, resources=None, detailed_report=True): # Check usecase @@ -396,6 +411,7 @@ def check_compatibility(self, outbound, inbound, usecase, provisioning, resource resources, detailed_report) return { + 'meta': self.meta_information(), 'inbound': str(inbound), 'outbound': str(outbound), 'usecase': usecase, @@ -417,27 +433,34 @@ def __check_compatibility(self, outbound_type = outbound_parsed[COMPATIBILITY_TYPE] compat_object = { - COMPATIBILITY_TYPE: outbound_type, 'inbound_license': self.le_parser.to_string(inbound_parsed), 'outbound_license': self.le_parser.to_string(outbound_parsed), + 'check_class': __class__.__name__, } if outbound_type == 'license': - compat_object['compatibility_check'] = f'outbound-license -> inbound-{inbound_parsed["compatibility_type"]}' + if False: + compat_object['compatibility_check'] = f'outbound-license -> inbound-{inbound_parsed["compatibility_type"]} HERE?' + outbound_parsed_license = outbound_parsed['license'] + + compat = self.le_checker.check_compatibility(outbound_parsed_license, + inbound_parsed, + usecase, + provisioning, + resources, + detailed_report) + compat_object['compatibility'] = compat['compatibility'] + compat_object['compatibility_object'] = compat + compat_object['compatibility_details'] = None + outbound_parsed_license = outbound_parsed['license'] - # Check if: - # outbound license - # is compatible with - # inbound license compat = self.le_checker.check_compatibility(outbound_parsed_license, inbound_parsed, usecase, provisioning, resources, detailed_report) - compat_object['compatibility'] = compat['compatibility'] - compat_object['compatibility_object'] = compat - compat_object['compatibility_details'] = None + compat_object = compat elif outbound_type == 'expression': compat_object['compatibility_details'] = None diff --git a/licomp_toolkit/utils.py b/licomp_toolkit/utils.py index 8306539..348f372 100644 --- a/licomp_toolkit/utils.py +++ b/licomp_toolkit/utils.py @@ -54,14 +54,20 @@ def _inc_map(_map, _name): def resource_avilable(resource, licomp_toolkit): return resource in licomp_toolkit.licomp_resources().keys() +def default_resources(): + return ['licomp_osadl', 'licomp_reclicense'] + + def resources_to_use(args): lt = LicompToolkit() resources = args.resources - new_resources = [] - unsupported = [] - if args.resources == ['all']: + new_resources = set() + unsupported = set() + if 'all' in args.resources: new_resources = list(lt.licomp_resources().keys()) return new_resources, [] + if not args.resources: + return default_resources(), [] for resource in resources: if 'licomp' not in resource: resource = f'licomp_{resource}' @@ -69,7 +75,7 @@ def resources_to_use(args): resource = resource.replace('-', '_') if not resource_avilable(resource, lt): - unsupported.append(resource) + unsupported.add(resource) else: - new_resources.append(resource) - return new_resources, unsupported + new_resources.add(resource) + return list(new_resources), list(unsupported) diff --git a/licomp_toolkit_test.tmp b/licomp_toolkit_test.tmp index e101afb..28f1d68 100644 --- a/licomp_toolkit_test.tmp +++ b/licomp_toolkit_test.tmp @@ -1,6 +1,5 @@ digraph depends { graph [label="License Compatibility Graph (library)" labelloc=t] node [shape=plaintext] - "MIT" -> "BSD-3-Clause" [color="darkblue" style="dotted"] - "BSD-3-Clause" -> "MIT" [color="darkgreen" ] + "MIT" -> "BSD-3-Clause" [dir="both" color="darkgreen" ] } diff --git a/tests/policy/license-policy.json b/tests/policy/license-policy.json new file mode 100644 index 0000000..4a757d0 --- /dev/null +++ b/tests/policy/license-policy.json @@ -0,0 +1,22 @@ +{ + "meta": { + "tool": "licomp-toolkit", + "file": "license-policy-file" + }, + "policy": { + "allowed": [ + "MIT", + "BSD-3-Clause", + "GPL-2.0-only", + "LGPL-2.1-only", + "0BSD" + ], + "avoided": [ + "BSD-4-Clause" + ], + "denied": [ + "BSD-2-Clause-Patent" + ] + } +} + diff --git a/tests/python/test_expr_expr.py b/tests/python/test_expr_expr.py index d52c0a8..f1e4378 100644 --- a/tests/python/test_expr_expr.py +++ b/tests/python/test_expr_expr.py @@ -29,8 +29,8 @@ def _compat_status(report): return report['compatibility_report']['compatibility'] -def _compat_type(report): - return report['compatibility_report']['compatibility_type'] +def _compat_check(report): + return report['compatibility_report']['compatibility_check'] # # license compat with license @@ -42,7 +42,8 @@ def test_lic_lic_compat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'yes' - assert _compat_type(compat_report) == 'license' + assert 'inbound-license' in _compat_check(compat_report) + assert 'outbound-license' in _compat_check(compat_report) # MIT -> GPL-2.0-only -> are NOT compatible def test_lic_lic_incompat(): @@ -50,7 +51,8 @@ def test_lic_lic_incompat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'no' - assert _compat_type(compat_report) == 'license' + assert 'inbound-license' in _compat_check(compat_report) + assert 'outbound-license' in _compat_check(compat_report) # # license compat with expression @@ -62,7 +64,8 @@ def test_lic_expr_compat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'yes' - assert _compat_type(compat_report) == 'license' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-license' in _compat_check(compat_report) # GPL-2.0-only -> MIT AND Apache-2.0 are NOT compatible def test_lic_expr_incompat(): @@ -70,7 +73,9 @@ def test_lic_expr_incompat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'no' - assert _compat_type(compat_report) == 'license' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-license' in _compat_check(compat_report) # # expression compat with license @@ -81,7 +86,8 @@ def test_expr_lic_compat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'yes' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-license' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) # GPL-2.0-only AND BSD-3-Clause -> Apache-2.0 are NOT compatible def test_expr_lic_incompat(): @@ -89,7 +95,8 @@ def test_expr_lic_incompat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'no' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-license' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) # # expression compat with expression @@ -101,7 +108,8 @@ def test_expr_expr_compat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'yes' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) # GPL-2.0-only AND BSD-3-Clause -> Apache-2.0 AND ISC are NOT compatible def test_expr_expr_incompat(): @@ -109,7 +117,8 @@ def test_expr_expr_incompat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'no' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) @@ -125,7 +134,8 @@ def test_expr_expr_large_compat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'yes' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) def test_expr_expr_large_incompat(): @@ -135,7 +145,8 @@ def test_expr_expr_large_incompat(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'no' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) @@ -149,7 +160,8 @@ def test_expr_expr_with_1(): provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'no' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) def test_expr_expr_with_2(): @@ -159,7 +171,8 @@ def test_expr_expr_with_2(): usecase=UseCase.usecase_to_string(UseCase.LIBRARY), provisioning=Provisioning.provisioning_to_string(Provisioning.BIN_DIST)) assert _compat_status(compat_report) == 'no' - assert _compat_type(compat_report) == 'expression' + assert 'inbound-expression' in _compat_check(compat_report) + assert 'outbound-expression' in _compat_check(compat_report) diff --git a/tests/python/test_policy.py b/tests/python/test_policy.py new file mode 100644 index 0000000..896c007 --- /dev/null +++ b/tests/python/test_policy.py @@ -0,0 +1,290 @@ +# SPDX-FileCopyrightText: 2026 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import pytest +import logging +import sys + +from licomp_toolkit.license_policy import LicensePolicy +from licomp_toolkit.license_policy import DefaultLicensePolicy +from licomp_toolkit.license_policy import LicensePolicyException +from licomp_toolkit.license_policy import LicensePolicyHandler +from licomp_toolkit.toolkit import ExpressionExpressionChecker +from licomp.interface import UseCase +from licomp.interface import Provisioning + +TEST_POLICY_FILE = 'tests/policy/license-policy.json' + +policy = LicensePolicy(TEST_POLICY_FILE) +policy_handler = LicensePolicyHandler(TEST_POLICY_FILE) +default_policy_handler = LicensePolicyHandler( + resources = ['licomp_reclicense'], + usecase = 'library', + provisioning = 'binary-distribution') +expr_checker = ExpressionExpressionChecker() + +default_policy = DefaultLicensePolicy( + ['licomp_reclicense'], + 'library', + 'binary-distribution') + + +def test_policy_allowed(): + assert "MIT" in policy.allowed() + assert "BSD-3-Clause" in policy.allowed() + + assert "BSD-4-Clause" not in policy.allowed() + assert "BSD-2-Clause-Patent" not in policy.allowed() + +def test_policy_avoided(): + assert "MIT" not in policy.avoided() + assert "BSD-3-Clause" not in policy.avoided() + + assert "BSD-4-Clause" in policy.avoided() + + assert "BSD-2-Clause-Patent" not in policy.avoided() + +def test_policy_denied(): + assert "MIT" not in policy.denied() + assert "BSD-3-Clause" not in policy.denied() + + assert "BSD-4-Clause" not in policy.denied() + + assert "BSD-2-Clause-Patent" in policy.denied() + +def test_meta(): + assert policy.meta() != None + +def _test_expr_expr_library_bin(outbound, inbound, policy_file=None): + compats = expr_checker.check_compatibility(outbound, + inbound, + 'library', + 'binary-distribution') + return compats + + +def test_policy_preferences_allowed(): + assert policy.compare_preferences('MIT', 'BSD-3-Clause') < 0 + assert policy.compare_preferences('BSD-3-Clause', 'MIT') > 0 + assert policy.compare_preferences('BSD-3-Clause', 'BSD-3-Clause') == 0 + +def test_policy_preferences_allowed_avoided(): + assert policy.compare_preferences('MIT', 'BSD-4-Clause') < 0 + assert policy.compare_preferences('BSD-4-Clause', 'MIT') > 0 + assert policy.compare_preferences('BSD-4-Clause', 'BSD-4-Clause') == 0 + +def test_policy_preferences_allowed_denied(): + assert policy.compare_preferences('BSD-3-Clause', 'BSD-3-Clause') == 0 + assert policy.compare_preferences('MIT', 'BSD-2-Clause-Patent') < 0 + assert policy.compare_preferences('BSD-2-Clause-Patent', 'MIT') > 0 + assert policy.compare_preferences('BSD-2-Clause-Patent', 'BSD-2-Clause-Patent') == None + +def test_policy_preferences_allowed_denied_ignore(): + assert policy.compare_preferences('MIT', 'BSD-2-Clause-Patent', ignore_missing=True) < 0 + assert policy.compare_preferences('BSD-2-Clause-Patent', 'BSD-2-Clause-Patent', ignore_missing=True) == None + +def test_policy_preferences_allowed_denied_names(): + assert policy.most_preferred('MIT', 'BSD-3-Clause') == 'MIT' + assert policy.most_preferred('MIT', 'BSD-2-Clause-Patent') == 'MIT' + assert policy.most_preferred('BSD-2-Clause-Patent', 'BSD-3-Clause') == 'BSD-3-Clause' + assert policy.most_preferred('BSD-2-Clause-Patent', 'BSD-2-Clause-Patent') == None + +def test_policy_preferences_allowed_denied_names_ignore(): + assert policy.most_preferred('BSD-2-Clause-Patent', 'BSD-3-Clause', ignore_missing=True) == 'BSD-3-Clause' + assert policy.most_preferred('BSD-2-Clause-Patent', 'BSD-2-Clause-Patent', ignore_missing=True) == None + +def OBSOLETE_test_policy_preferred_score_ignore_missing(): + assert policy.preferred_score_ignore_missing('MIT', 'BSD-3-Clause') == -1 + assert policy.preferred_score_ignore_missing('BSD-3-Clause', 'BSD-3-Clause') == 0 + assert policy.preferred_score_ignore_missing('BSD-3-Clause', 'MIT') == 1 + +def OBSOLETE_test_policy_sorted(): + assert policy.preferred_score_ignore_missing('MIT', 'BSD-3-Clause') == -1 + assert policy.preferred_score_ignore_missing('BSD-3-Clause', 'BSD-3-Clause') == 0 + assert policy.preferred_score_ignore_missing('BSD-3-Clause', 'MIT') == 1 + assert policy.preferred_score_ignore_missing('BSD-3-Clause', 'GPL-2.0-or-later') == -1 + from functools import cmp_to_key + inbounds = ['MIT', 'GPL-2.0-or-later', 'BSD-3-Clause'] + sorted_inbounds = sorted(inbounds, key=cmp_to_key(policy.preferred_score_ignore_missing)) + print("TEST: " + str(inbounds)) + print("TEST: " + str(sorted_inbounds)) + assert sorted_inbounds == ['MIT', 'BSD-3-Clause', 'GPL-2.0-or-later'] + + +def test_policy_preferences_raises(): + with pytest.raises(LicensePolicyException) as e_info: + policy.most_preferred('MIT2', 'GPL-2.0-or-later2') + with pytest.raises(LicensePolicyException) as e_info: + policy.most_preferred('MIT2', 'GPL-2.0-or-later2', ignore_missing=False) + +def test_policy_preferences_raises_ignore(): + policy.most_preferred('MIT2', 'GPL-2.0-or-later2', ignore_missing=True) == None + +# BRING BACK - RENAME +def _test_expr_expr_1(): + compat_report = _test_expr_expr_library_bin('MIT OR 0BSD', 'MIT OR (BSD-3-Clause AND MIT)') + #compat_report = _test_expr_expr_library_bin('MIT', 'MIT') + #print("cr: " + str(compat_report)) + policy_report = policy_handler.apply(compat_report) + print("pr: " + str(policy_report)) + assert False + + +def test_default_policy_listed(): + assert "MIT" in default_policy.allowed() + assert "BSD-3-Clause" in default_policy.allowed() + + assert "BSD-4-Clause" in default_policy.allowed() + assert "BSD-2-Clause-Patent" in default_policy.allowed() + + assert len(default_policy.avoided()) == 0 + assert len(default_policy.denied()) == 0 + +def test_default_policy_preference(): + assert default_policy.compare_preferences('MIT', 'GPL-2.0-or-later') < 0 + assert default_policy.compare_preferences('GPL-2.0-or-later', 'MIT') > 0 + assert default_policy.compare_preferences('MIT', 'MIT') == 0 + +def test_default_policy_preferences_allowed_denied_names(): + assert default_policy.most_preferred('BSD-3-Clause', 'BSD-3-Clause') == 'BSD-3-Clause' + assert default_policy.most_preferred('MIT', 'BSD-3-Clause') == 'MIT' + assert default_policy.most_preferred('MIT', 'GPL-2.0-or-later') == 'MIT' + + +def test_default_policy_preferences_raises(): + with pytest.raises(LicensePolicyException) as e_info: + default_policy.most_preferred('MIT2', 'GPL-2.0-or-later2') + +def test_preferred_score_inbounds(): + OBSD_MIT = {'outbound': {'license': 'MIT', 'type': 'license', 'preferences': {'license': 'MIT', 'license_list': 'allowed', 'license_index': 3}}, 'inbound': {'license': '0BSD', 'type': 'license', 'preferences': {'license': '0BSD', 'license_list': 'allowed', 'license_index': 2}}, 'compatibility': 'yes', 'unusable': {'unusable': []}} + #{'check_type': 'inbound', 'inbound_license': '0BSD', 'outbound_license': 'MIT', 'inbound_license_type': 'license', 'outbound_license_type': 'license', 'compatibility': 'yes', 'inbound_list': 'allowed', 'inbound_list_index': 3} + + ISC_MIT = {'outbound': {'license': 'MIT', 'type': 'license', 'preferences': {'license': 'MIT', 'license_list': 'allowed', 'license_index': 3}}, 'inbound': {'license': 'ISC', 'type': 'license', 'preferences': {'license': 'ISC', 'license_list': 'allowed', 'license_index': 4}}, 'compatibility': 'yes', 'unusable': {'unusable': []}} + #{'check_type': 'inbound', 'inbound_license': 'ISC', 'outbound_license': 'MIT', 'inbound_license_type': 'license', 'outbound_license_type': 'license', 'compatibility': 'yes', 'inbound_list': 'allowed', 'inbound_list_index': 7} + + assert default_policy.preferred_score_inbounds(OBSD_MIT, ISC_MIT) < 0 + assert default_policy.preferred_score_inbounds(ISC_MIT, OBSD_MIT) > 0 + assert default_policy.preferred_score_inbounds(ISC_MIT, ISC_MIT) == 0 + +def test_scored_inbounds_zerobsd_isc(): + inbounds = [ + { + "outbound": { + "license": "MIT", + "type": "license", + "preferences": { + "license": "MIT", + "license_list": "allowed", + "license_index": 0 + } + }, + "inbound": { + "license": "0BSD", + "type": "license", + "preferences": { + "license": "0BSD", + "license_list": "allowed", + "license_index": 4 + } + }, + "compatibility": "yes", + "unusable": [] + }, + { + "outbound": { + "license": "MIT", + "type": "license", + "preferences": { + "license": "MIT", + "license_list": "allowed", + "license_index": 0 + } + }, + "inbound": { + "license": "ISC", + "type": "license", + "preferences": { + "license": "ISC", + "license_list": "", + "license_index": None + } + }, + "compatibility": "yes", + "unusable": [] + } + ] + + scored_inbounds, unusable = default_policy_handler.scored_inbounds(inbounds, "OR") + assert len(scored_inbounds) == 2 + assert len(scored_inbounds[0]['inbound']) == 3 + assert scored_inbounds[0]['inbound']['license'] == '0BSD' + assert scored_inbounds[1]['inbound']['license'] == 'ISC' + + +def test_scored_inbounds_isc_zerobsd(): + inbounds = [ + { + "outbound": { + "license": "MIT", + "type": "license", + "preferences": { + "license": "MIT", + "license_list": "allowed", + "license_index": 0 + } + }, + "inbound": { + "license": "ISC", + "type": "license", + "preferences": { + "license": "ISC", + "license_list": "", + "license_index": None + } + }, + "compatibility": "yes", + "unusable": [] + }, + { + "outbound": { + "license": "MIT", + "type": "license", + "preferences": { + "license": "MIT", + "license_list": "allowed", + "license_index": 0 + } + }, + "inbound": { + "license": "0BSD", + "type": "license", + "preferences": { + "license": "0BSD", + "license_list": "allowed", + "license_index": 4 + } + }, + "compatibility": "yes", + "unusable": [] + } + ] + + scored_inbounds, unusable = default_policy_handler.scored_inbounds(inbounds, "OR") + assert len(scored_inbounds) == 2 + assert len(scored_inbounds[0]['inbound']) == 3 + assert scored_inbounds[0]['inbound']['license'] == '0BSD' + assert scored_inbounds[1]['inbound']['license'] == 'ISC' + + +def test_usable_license(): + LIC = {'outbound': {'license': 'MIT', 'type': 'license', 'preferences': {'license': 'MIT', 'license_list': 'allowed', 'license_index': 3}}, 'inbound': {'license': 'BSD-3-Clause', 'type': 'license', 'preferences': {'license': 'BSD-3-Clause', 'license_list': 'allowed', 'license_index': 2}}, 'compatibility': 'no', 'unusable': {'unusable': []}} + + assert not policy_handler.usable_license(LIC, 'inbound') + assert not policy_handler.usable_license(LIC, 'outbound') + + LIC['compatibility'] = 'yes' + assert policy_handler.usable_license(LIC, 'inbound') + assert policy_handler.usable_license(LIC, 'outbound') + diff --git a/tests/python/test_policy_expr.py b/tests/python/test_policy_expr.py new file mode 100644 index 0000000..4701a4d --- /dev/null +++ b/tests/python/test_policy_expr.py @@ -0,0 +1,311 @@ +# SPDX-FileCopyrightText: 2026 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import json +import pytest +import logging +import sys + +from licomp_toolkit.license_policy import LicensePolicy +from licomp_toolkit.license_policy import DefaultLicensePolicy +from licomp_toolkit.license_policy import LicensePolicyException +from licomp_toolkit.license_policy import LicensePolicyHandler +from licomp_toolkit.toolkit import ExpressionExpressionChecker +from licomp.interface import UseCase +from licomp.interface import Provisioning + +TEST_POLICY_FILE = 'tests/policy/license-policy.json' + +policy = LicensePolicy(TEST_POLICY_FILE) +policy_handler = LicensePolicyHandler(TEST_POLICY_FILE) +default_policy_handler = LicensePolicyHandler( + resources = ['licomp_reclicense'], + usecase = 'library', + provisioning = 'binary-distribution') +expr_checker = ExpressionExpressionChecker() + +default_policy = DefaultLicensePolicy( + ['licomp_reclicense'], + 'library', + 'binary-distribution') + +def _test_expr_expr_library_bin(outbound, inbound): + report = expr_checker.check_compatibility(outbound, + inbound, + 'library', + 'binary-distribution') + policy_report = policy_handler.apply_policy(report) + policy_report_default = default_policy_handler.apply_policy(report) + + return report, policy_report, policy_report_default + + + +def test_lic_lic(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT', + 'BSD-3-Clause' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == 'MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == 'MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-3-Clause' + + + +def test_lic_expr_and(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT', + 'BSD-3-Clause AND 0BSD' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause AND 0BSD' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == 'MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == '0BSD AND BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause AND 0BSD' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == 'MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == '0BSD AND BSD-3-Clause' + + +def test_lic_expr_or(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT', + 'BSD-3-Clause OR 0BSD' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause OR 0BSD' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == 'MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == '0BSD' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause OR 0BSD' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == 'MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == '0BSD' + + +def test_expr_lic_or(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT OR 0BSD', + 'BSD-3-Clause' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT OR 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT OR 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-3-Clause' + + +def test_expr_lic_and(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT AND 0BSD', + 'BSD-3-Clause' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-3-Clause' + + +def test_expr_expr_or(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT OR 0BSD', + 'BSD-2-Clause OR BSD-3-Clause' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT OR 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause OR BSD-3-Clause' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT OR 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause OR BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause' + + +def test_expr_expr_and(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT AND 0BSD', + 'BSD-2-Clause AND BSD-3-Clause' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND BSD-3-Clause' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause AND BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND BSD-3-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause AND BSD-3-Clause' + +def test_expr_expr_incompat(): + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT AND 0BSD', + 'BSD-2-Clause AND GPL-2.0-or-later' + ) + + assert report['compatibility_report']['compatibility'] == 'no' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND GPL-2.0-or-later' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == None + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == None + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND GPL-2.0-or-later' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == None + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == None + +def test_expr_expr_bad_license_or(): + # X11 not supported by licomp_reclicense, but we have and OR so should be compatible + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT AND 0BSD', + 'BSD-2-Clause OR X11' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause OR X11' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause OR X11' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause' + +def test_expr_expr_bad_license_and(): + # X11 not supported by licomp_reclicense + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT AND 0BSD', + 'BSD-2-Clause AND X11' + ) + + assert report['compatibility_report']['compatibility'] == 'no' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND X11' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == None + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == None + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND X11' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == None + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == None + +def test_expr_expr_no_license_or(): + # MONKEYWRENCH is not a license + # ... and nor supported by licomp_reclicense + # ... and not in a policy list + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT AND 0BSD', + 'BSD-2-Clause OR MONKEYWRENCH' + ) + + assert report['compatibility_report']['compatibility'] == 'yes' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause OR MONKEYWRENCH' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause OR MONKEYWRENCH' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == '0BSD AND MIT' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == 'BSD-2-Clause' + + +def test_expr_expr_no_license_and(): + # MONKEYWRENCH is not a license + # ... and nor supported by licomp_reclicense + # ... and not in a policy list + + report, policy_report, policy_report_default = _test_expr_expr_library_bin( + 'MIT AND 0BSD', + 'BSD-2-Clause AND MONKEYWRENCH' + ) + + assert report['compatibility_report']['compatibility'] == 'no' + + assert policy_report['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND MONKEYWRENCH' + + assert policy_report['compatibility_report']['policy_check']['outbound']['preferences']['license'] == None + assert policy_report['compatibility_report']['policy_check']['inbound']['preferences']['license'] == None + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['license'] == 'MIT AND 0BSD' + assert policy_report_default['compatibility_report']['policy_check']['inbound']['license'] == 'BSD-2-Clause AND MONKEYWRENCH' + + assert policy_report_default['compatibility_report']['policy_check']['outbound']['preferences']['license'] == None + assert policy_report_default['compatibility_report']['policy_check']['inbound']['preferences']['license'] == None + +# with pytest.raises(LicensePolicyException): diff --git a/tests/shell/test-cli.sh b/tests/shell/test-cli.sh index c0c5f69..b14786f 100755 --- a/tests/shell/test-cli.sh +++ b/tests/shell/test-cli.sh @@ -6,7 +6,7 @@ LT_VERSION=$(grep licomp_toolkit_version licomp_toolkit/config.py | cut -d = -f 2 | sed "s,[' ]*,,g") -EXTRACT_COMPAT=".compatibility_report.compatibility_object.compatibility_details" +EXTRACT_COMPAT=".compatibility_report.compatibility_details" if [ "$1" == "--local" ] then diff --git a/tests/shell/test_policy.sh b/tests/shell/test_policy.sh new file mode 100755 index 0000000..1b1aa4b --- /dev/null +++ b/tests/shell/test_policy.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: 2025 Henrik Sandklef +# +# SPDX-License-Identifier: GPL-3.0-or-later + +if [ "$1" == "--local" ] +then + export IMPLEMENTATIONS=../licomp:../licomp-dwheeler:../licomp-hermione:../licomp-osadl:../licomp-reclicense:../licomp-proprietary::../licomp-gnuguide:. + shift +fi +comment_file_presence() +{ + EXPECTED="$1" + REPORT=$2 + PRESENT=$(grep -c -e "$EXPECTED" $REPORT) + ACTUAL=$(grep -e "$EXPECTED" $REPORT) + MSG="$3" + if [ $PRESENT -ne 1 ] + then + echo "ERROR" + echo "Values differ" + echo " Expected: $EXPECTED" + #echo " Actual: $ACTUAL" + echo " Message: $MSG" + echo " Reproduce: grep -e \"$EXPECTED\" $REPORT" + exit 1 + fi + +} + + +licomp-toolkit-verify() +{ + INBOUND="$1" + OUTBOUND="$2" + + PYTHONPATH=$IMPLEMENTATIONS::. python3 licomp_toolkit/__main__.py --verbose $RESOURCE_ARGS verify -il "$INBOUND" -ol "$OUTBOUND" +} + +licomp-toolkit-apply() +{ + OUTPUT_ARGS="$1" + REPORT="$2" + + PYTHONPATH=$IMPLEMENTATIONS::. python3 licomp_toolkit/__main__.py --verbose $RESOURCE_ARGS $OUTPUT_ARGS apply-license-policy $REPORT +} + +# +# Text output +# +licomp-toolkit-verify MIT MIT > report.json +licomp-toolkit-apply " -of text" report.json > policy-report.txt +comment_file_presence "preferred inbound:[ ]*MIT" policy-report.txt "test 1.1" +comment_file_presence "preferred outbound:[ ]*MIT" policy-report.txt "test 1.2" +comment_file_presence "^compatibility:[ ]*yes" policy-report.txt "test 1.5" +comment_file_presence "compatibility details:" policy-report.txt "test 1.6" + +licomp-toolkit-verify MIT "MIT OR LGPL-2.1-only" > report.json +licomp-toolkit-apply " -of text" report.json > policy-report.txt +comment_file_presence "preferred inbound:[ ]*MIT" policy-report.txt "test 2.1" +comment_file_presence "preferred outbound:[ ]*MIT" policy-report.txt "test 2.2" +comment_file_presence "^compatibility:[ ]*yes" policy-report.txt "test 2.5" +