diff --git a/src/licensedcode/detection.py b/src/licensedcode/detection.py index 34cbe582e6..2922c0443e 100644 --- a/src/licensedcode/detection.py +++ b/src/licensedcode/detection.py @@ -1465,6 +1465,56 @@ def use_referenced_license_expression(referenced_license_expression, license_det return True + +def combine_expressions_with_exception_handling(matches, licensing): + """ + Combine license expressions from matches, using WITH for license+exception pairs + and AND for other combinations. + + Returns a combined LicenseExpression or None. + """ + if not matches: + return None + + from licensedcode.cache import get_licenses_db + + license_db = get_licenses_db() + expressions = [match.rule.license_expression for match in matches] + + all_keys = set() + for expr in expressions: + keys = licensing.license_keys(expr) + all_keys.update(keys) + + exceptions = set() + regular_licenses = set() + + for key in all_keys: + license_obj = license_db.get(key) + if license_obj and license_obj.is_exception: + exceptions.add(key) + else: + regular_licenses.add(key) + + if len(regular_licenses) == 1 and len(exceptions) >= 1: + base_license = list(regular_licenses)[0] + exception_expr = combine_expressions( + expressions=list(exceptions), + relation='AND', + unique=True, + licensing=licensing + ) + combined = licensing.parse(f"{base_license} WITH {exception_expr}") + return combined + + return combine_expressions( + expressions=expressions, + relation='AND', + unique=True, + licensing=licensing + ) + + def get_detected_license_expression( analysis, license_matches=None, @@ -1591,10 +1641,7 @@ def get_detected_license_expression( if TRACE: logger_debug(f'matches_for_expression: {matches_for_expression}', f'detection_log: {detection_log}') - combined_expression = combine_expressions( - expressions=[match.rule.license_expression for match in matches_for_expression], - licensing=get_licensing(), - ) + combined_expression = combine_expressions_with_exception_handling(matches_for_expression, get_licensing()) if TRACE or TRACE_ANALYSIS: logger_debug(f'combined_expression {combined_expression}') diff --git a/tests/licensedcode/test_spdx_exception_fix.py b/tests/licensedcode/test_spdx_exception_fix.py new file mode 100644 index 0000000000..099a081e75 --- /dev/null +++ b/tests/licensedcode/test_spdx_exception_fix.py @@ -0,0 +1,39 @@ +import os +from licensedcode.detection import get_detected_license_expression +from licensedcode.detection import DetectionCategory +from licensedcode.match import LicenseMatch +from licensedcode.models import Rule +from licensedcode.cache import get_licensing + +def test_gpl_with_gcc_exception_uses_with_operator(): + """ + Test that GPL-3.0 and GCC-exception are combined with WITH instead of AND + """ + licensing = get_licensing() + + gpl_rule = Rule( + license_expression='gpl-3.0', + text='GPL 3.0 text', + ) + + gcc_exception_rule = Rule( + license_expression='gcc-exception-3.1', + text='GCC exception text', + ) + + gpl_match = LicenseMatch(rule=gpl_rule, qspan=(0, 10), ispan=(0, 10)) + gcc_match = LicenseMatch(rule=gcc_exception_rule, qspan=(11, 20), ispan=(11, 20)) + + matches = [gpl_match, gcc_match] + + detection_log, combined_expression = get_detected_license_expression( + analysis=DetectionCategory.UNKNOWN_MATCH.value, + license_matches=matches, + ) + + assert 'WITH' in combined_expression + assert 'gpl-3.0 WITH gcc-exception-3.1' == combined_expression + +if __name__ == '__main__': + test_gpl_with_gcc_exception_uses_with_operator() + print("Test passed!")