diff --git a/src/licensedcode/required_phrases.py b/src/licensedcode/required_phrases.py index f99af2c09d..1dc93a153f 100644 --- a/src/licensedcode/required_phrases.py +++ b/src/licensedcode/required_phrases.py @@ -346,7 +346,7 @@ def get_updatable_rules_by_expression(license_expression=None, simple_expression # filter rules to keep only updatable rules for expression, rules in rules_by_expression.items(): if simple_expression: - license_keys = licensing.license_keys(license_expression) + license_keys = licensing.license_keys(expression) if len(license_keys) != 1: continue @@ -441,7 +441,7 @@ def add_license_attributes_as_required_phrases_to_rules_text( } for rule in rules: - for field_name, required_phrase_texts in license_fields_mapping_by_order.values(): + for field_name, required_phrase_texts in license_fields_mapping_by_order.items(): for required_phrase_text in required_phrase_texts: debug = False if rule.identifier in TRACE_REQUIRED_PHRASE_FOR_RULES: @@ -593,6 +593,202 @@ def update_rules_using_license_attributes( dry_run=dry_run, ) + +def get_required_phrases_by_key(rules_by_expression, licenses_by_key): + """ + Return required phrase candidates grouped by license key. + Only collect from required phrase rules with a single non generic key. + """ + licensing = Licensing() + required_phrases_by_key = {} + required_phrases_by_expression = collect_is_required_phrase_from_rules( + rules_by_expression=rules_by_expression, + ) + + for expression, required_phrases in required_phrases_by_expression.items(): + license_keys = licensing.license_keys(expression, unique=True) + if len(license_keys) != 1: + continue + + license_key = license_keys[0] + if licenses_by_key[license_key].is_generic: + continue + + if required_phrases: + required_phrases_by_key[license_key] = required_phrases + + return required_phrases_by_key + + +def _get_required_phrase_matches(rule, license_keys, required_phrases_by_key): + """ + Return one non overlapping required phrase match for every license key. + Prefer phrases already marked in the rule and return None if no complete match exists. + """ + existing_spans = get_existing_required_phrase_spans(rule.text) + unavailable_spans = existing_spans + get_ignorable_spans(rule) + matches_by_key = {} + + for license_key in license_keys: + marked_matches = [] + new_matches = [] + + for candidate in required_phrases_by_key.get(license_key, []): + phrase_spans = find_phrase_spans_in_text( + rule.text, + candidate.required_phrase_text, + ) + marked_spans = [ + span + for span in phrase_spans + if any(span in existing for existing in existing_spans) + ] + if marked_spans: + marked_matches.extend( + (candidate, True, [span]) + for span in marked_spans + ) + continue + + spans_to_add = list( + get_non_overlapping_spans( + old_required_phrase_spans=unavailable_spans, + new_required_phrase_spans=phrase_spans, + ) + ) + if spans_to_add: + new_matches.append((candidate, False, spans_to_add)) + + matches_by_key[license_key] = marked_matches + new_matches + if not matches_by_key[license_key]: + return + + def find_matches(remaining_keys, matched_spans): + if not remaining_keys: + return [] + + license_key = remaining_keys[0] + for required_phrase, is_marked, phrase_spans in matches_by_key[license_key]: + if any( + span.overlap(matched) + for span in phrase_spans + for matched in matched_spans + ): + continue + + remaining_matches = find_matches( + remaining_keys=remaining_keys[1:], + matched_spans=matched_spans + phrase_spans, + ) + if remaining_matches is not None: + return [ + (required_phrase, is_marked), + *remaining_matches, + ] + + return find_matches( + remaining_keys=license_keys, + matched_spans=[], + ) + + +def add_required_phrases_to_composite_rules( + rules, + license_keys, + required_phrases_by_key, + write_phrase_source=False, + dry_run=False, +): + """ + Add existing required phrases to rules when every license key has a matching phrase. + """ + for rule in rules: + matched_required_phrases = _get_required_phrase_matches( + rule=rule, + license_keys=license_keys, + required_phrases_by_key=required_phrases_by_key, + ) + if not matched_required_phrases: + continue + + original_text = rule.text + original_source = rule.source + updated = False + + for required_phrase, is_marked in matched_required_phrases: + if is_marked: + continue + + source = rule.source + if write_phrase_source: + phrase_source = required_phrase.rule.identifier + source = f"{source} {phrase_source}" if source else phrase_source + + added = add_required_phrase_to_rule( + rule=rule, + required_phrase=required_phrase.required_phrase_text, + source=source, + dry_run=True, + ) + if not added: + rule.text = original_text + rule.source = original_source + updated = False + break + + updated = True + + if updated and not dry_run: + rule.dump(rules_data_dir) + + +def update_composite_rules_using_required_phrases( + license_expression=None, + write_phrase_source=False, + verbose=False, + dry_run=False, +): + """ + Collect existing required phrases from single license key rules and add them to composite rules + only when every non generic license key has a non overlapping match. + """ + licensing = Licensing() + licenses_by_key = get_licenses_db() + rules_by_expression = get_base_rules_by_expression() + required_phrases_by_key = get_required_phrases_by_key( + rules_by_expression=rules_by_expression, + licenses_by_key=licenses_by_key, + ) + updatable_rules_by_expression = get_updatable_rules_by_expression( + license_expression=license_expression, + simple_expression=False, + ) + + for expression, updatable_rules in updatable_rules_by_expression.items(): + license_keys = licensing.license_keys(expression, unique=True) + if len(license_keys) < 2: + continue + + license_keys = [ + license_key + for license_key in license_keys + if not licenses_by_key[license_key].is_generic + ] + if not license_keys: + continue + + if verbose: + click.echo(f'Annotating required phrases for expression: {expression}') + + add_required_phrases_to_composite_rules( + rules=updatable_rules, + license_keys=license_keys, + required_phrases_by_key=required_phrases_by_key, + write_phrase_source=write_phrase_source, + dry_run=dry_run, + ) + + #################################################################################################### # # Inject new required phrase in rules @@ -617,7 +813,8 @@ def delete_required_phrase_rules_source_debug(rules_data_dir): is_flag=True, default=False, help="Propagate existing required phrases from other rules to all selected rules. " - "Mutually exclusive with --from-license-attributes.", + "Mutually exclusive with --from-license-attributes and --composite-rules.", + conflicting_options=["from_license_attributes", "composite_rules"], cls=PluggableCommandLineOption, ) @click.option( @@ -626,7 +823,18 @@ def delete_required_phrase_rules_source_debug(rules_data_dir): is_flag=True, default=False, help="Propagate license attributes as required phrases to all selected rules. " - "Mutually exclusive with --from-other-rule.", + "Mutually exclusive with --from-other-rules and --composite-rules.", + conflicting_options=["from_other_rules", "composite_rules"], + cls=PluggableCommandLineOption, +) +@click.option( + "-c", + "--composite-rules", + is_flag=True, + default=False, + help="Add required phrases to composite (with multiple license keys) rules " + "using existing required phrases.", + conflicting_options=["from_other_rules", "from_license_attributes"], cls=PluggableCommandLineOption, ) @click.option( @@ -691,6 +899,7 @@ def delete_required_phrase_rules_source_debug(rules_data_dir): def add_required_phrases( from_other_rules, from_license_attributes, + composite_rules, license_expression, validate, reindex, @@ -702,6 +911,12 @@ def add_required_phrases( """ Update license detection rules with new "required phrases" to improve rules detection accuracy. """ + update_modes = (from_other_rules, from_license_attributes, composite_rules) + if sum(update_modes) > 1: + raise click.UsageError( + "Options --from-other-rules, --from-license-attributes, and --composite-rules " + "are mutually exclusive." + ) if delete_phrase_source: click.echo('Deleting rules phrase source debug data.') @@ -726,6 +941,15 @@ def add_required_phrases( verbose=verbose, ) + elif composite_rules: + click.echo('Updating composite rules from required phrases.') + update_composite_rules_using_required_phrases( + license_expression=license_expression, + write_phrase_source=write_phrase_source, + dry_run=dry_run, + verbose=verbose, + ) + validate_and_reindex(validate, reindex, verbose) diff --git a/tests/licensedcode/test_required_phrases.py b/tests/licensedcode/test_required_phrases.py index 860ccc70f5..bc016ef04a 100644 --- a/tests/licensedcode/test_required_phrases.py +++ b/tests/licensedcode/test_required_phrases.py @@ -7,18 +7,27 @@ # See https://aboutcode.org for more information about nexB OSS projects. # +from types import SimpleNamespace from unittest import TestCase as TestCaseClass import pytest +from click.testing import CliRunner +from licensedcode import required_phrases from licensedcode.models import InvalidRule from licensedcode.models import Rule -from licensedcode.required_phrases import update_rules_using_is_required_phrases_rules -from licensedcode.required_phrases import update_rules_using_license_attributes from licensedcode.required_phrases import IsRequiredPhrase +from licensedcode.required_phrases import add_license_attributes_as_required_phrases_to_rules_text from licensedcode.required_phrases import add_required_phrase_markers -from licensedcode.spans import Span +from licensedcode.required_phrases import add_required_phrases +from licensedcode.required_phrases import add_required_phrases_to_composite_rules from licensedcode.required_phrases import find_phrase_spans_in_text +from licensedcode.required_phrases import get_required_phrases_by_key +from licensedcode.required_phrases import get_updatable_rules_by_expression +from licensedcode.required_phrases import update_composite_rules_using_required_phrases +from licensedcode.required_phrases import update_rules_using_is_required_phrases_rules +from licensedcode.required_phrases import update_rules_using_license_attributes +from licensedcode.spans import Span from licensedcode.tokenize import get_existing_required_phrase_spans @@ -180,3 +189,616 @@ def test_update_rules_using_is_required_phrases_rules(self): @pytest.mark.scanslow def test_update_rules_using_license_attributes(self): update_rules_using_license_attributes(verbose=True, dry_run=True) + + +def make_required_phrase_rule(expression, text, identifier): + return SimpleNamespace( + license_expression=expression, + text=text, + identifier=identifier, + is_required_phrase=True, + ) + + +def make_license(is_generic=False): + return SimpleNamespace(is_generic=is_generic) + + +class TestRequiredPhrasesByKey: + + def test_collects_single_key_required_phrases_longest_first(self): + rules_by_expression = { + "mit": [ + make_required_phrase_rule("mit", "MIT", "mit_1.RULE"), + make_required_phrase_rule("mit", "MIT License", "mit_2.RULE"), + SimpleNamespace(is_required_phrase=False), + ], + } + licenses_by_key = {"mit": make_license()} + + required_phrases_by_key = get_required_phrases_by_key( + rules_by_expression=rules_by_expression, + licenses_by_key=licenses_by_key, + ) + + assert [ + phrase.required_phrase_text + for phrase in required_phrases_by_key["mit"] + ] == ["MIT License", "MIT"] + + def test_skips_composite_source_expressions(self): + rules_by_expression = { + "mit AND apache-2.0": [ + make_required_phrase_rule( + "mit AND apache-2.0", + "MIT and Apache", + "mit_and_apache_1.RULE", + ), + ], + } + licenses_by_key = { + "mit": make_license(), + "apache-2.0": make_license(), + } + + required_phrases_by_key = get_required_phrases_by_key( + rules_by_expression=rules_by_expression, + licenses_by_key=licenses_by_key, + ) + + assert required_phrases_by_key == {} + + def test_skips_generic_license_keys(self): + rules_by_expression = { + "unknown": [ + make_required_phrase_rule("unknown", "Unknown License", "unknown_1.RULE"), + ], + } + licenses_by_key = {"unknown": make_license(is_generic=True)} + + required_phrases_by_key = get_required_phrases_by_key( + rules_by_expression=rules_by_expression, + licenses_by_key=licenses_by_key, + ) + + assert required_phrases_by_key == {} + + +class TestCompositeRequiredPhrases: + + required_phrases_by_key = { + "mit": [ + IsRequiredPhrase( + rule=make_required_phrase_rule("mit", "MIT License", "mit_1.RULE"), + required_phrase_text="MIT License", + ), + ], + "apache-2.0": [ + IsRequiredPhrase( + rule=make_required_phrase_rule( + "apache-2.0", + "Apache License", + "apache-2.0_1.RULE", + ), + required_phrase_text="Apache License", + ), + ], + "bsd-new": [ + IsRequiredPhrase( + rule=make_required_phrase_rule("bsd-new", "BSD License", "bsd-new_1.RULE"), + required_phrase_text="BSD License", + ), + ], + } + + def test_marks_each_key_when_all_required_phrases_match(self): + rule = Rule( + license_expression="mit AND apache-2.0", + identifier="mit_and_apache-2.0_test.RULE", + text="Licensed under the MIT License and the Apache License.", + is_license_notice=True, + ) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=self.required_phrases_by_key, + dry_run=True, + ) + + assert "{{MIT License}}" in rule.text + assert "{{Apache License}}" in rule.text + + def test_leaves_rule_unchanged_when_one_key_does_not_match(self): + text = "Licensed under the MIT License." + rule = Rule( + license_expression="mit AND apache-2.0", + identifier="mit_and_apache-2.0_test.RULE", + text=text, + is_license_notice=True, + ) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=self.required_phrases_by_key, + dry_run=True, + ) + + assert rule.text == text + assert rule.source is None + + def test_marks_three_key_rule(self): + rule = Rule( + license_expression="mit AND apache-2.0 AND bsd-new", + identifier="three_key_test.RULE", + text="MIT License, Apache License, and BSD License apply.", + is_license_notice=True, + ) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0", "bsd-new"], + required_phrases_by_key=self.required_phrases_by_key, + dry_run=True, + ) + + assert "{{MIT License}}" in rule.text + assert "{{Apache License}}" in rule.text + assert "{{BSD License}}" in rule.text + + def test_keeps_existing_marker_and_marks_the_other_key(self): + rule = Rule( + license_expression="mit AND apache-2.0", + identifier="existing_marker_test.RULE", + text="Licensed under the {{MIT License}} and the Apache License.", + is_license_notice=True, + ) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=self.required_phrases_by_key, + dry_run=True, + ) + + assert rule.text.count("{{MIT License}}") == 1 + assert "{{Apache License}}" in rule.text + + def test_keeps_marked_occurrence_of_partly_marked_phrase(self): + rule = Rule( + license_expression="mit AND apache-2.0", + identifier="partly_marked_phrase_test.RULE", + text=( + "The {{MIT License}} applies to one part and the MIT License applies " + "to another part under the Apache License." + ), + is_license_notice=True, + ) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=self.required_phrases_by_key, + dry_run=True, + ) + + assert rule.text.count("{{MIT License}}") == 1 + assert "{{Apache License}}" in rule.text + + def test_prefers_existing_marker_over_unmarked_candidate(self): + text = ( + "Licensed under {{Apache License}} {{or the MIT License}} " + "(LICENSE.mit)." + ) + rule = Rule( + license_expression="mit OR apache-2.0", + identifier="existing_markers_test.RULE", + text=text, + is_license_notice=True, + ) + required_phrases_by_key = dict(self.required_phrases_by_key) + required_phrases_by_key["mit"] = [ + IsRequiredPhrase( + rule=make_required_phrase_rule("mit", "License: MIT", "mit_1.RULE"), + required_phrase_text="License: MIT", + ), + self.required_phrases_by_key["mit"][0], + ] + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=required_phrases_by_key, + dry_run=True, + ) + + assert rule.text == text + + def test_writes_once_after_all_required_phrases_are_added(self, tmp_path, monkeypatch): + rule = Rule( + license_expression="mit AND apache-2.0", + identifier="write_once_test.RULE", + text="Licensed under the MIT License and the Apache License.", + is_license_notice=True, + ) + original_dump = Rule.dump + dump_calls = [] + + def dump(rule, rules_data_dir): + dump_calls.append(rule.identifier) + original_dump(rule, rules_data_dir) + + monkeypatch.setattr(Rule, "dump", dump) + monkeypatch.setattr(required_phrases, "rules_data_dir", str(tmp_path)) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=self.required_phrases_by_key, + write_phrase_source=True, + ) + + saved_rule = Rule.from_file(str(tmp_path / rule.identifier)) + assert dump_calls == [rule.identifier] + assert "{{MIT License}}" in saved_rule.text + assert "{{Apache License}}" in saved_rule.text + assert saved_rule.source == "mit_1.RULE apache-2.0_1.RULE" + + def test_dry_run_does_not_write_rule(self, monkeypatch): + rule = Rule( + license_expression="mit AND apache-2.0", + identifier="dry_run_test.RULE", + text="Licensed under the MIT License and the Apache License.", + is_license_notice=True, + ) + + def dump(*args, **kwargs): + pytest.fail("Rule.dump() called during a dry run") + + monkeypatch.setattr(Rule, "dump", dump) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=self.required_phrases_by_key, + dry_run=True, + ) + + assert "{{MIT License}}" in rule.text + assert "{{Apache License}}" in rule.text + + def test_rolls_back_when_a_required_phrase_cannot_be_added(self, monkeypatch): + text = "Licensed under the MIT License and the Apache License." + source = "existing.RULE" + rule = Rule( + license_expression="mit AND apache-2.0", + identifier="rollback_test.RULE", + text=text, + source=source, + is_license_notice=True, + ) + original_add_required_phrase = required_phrases.add_required_phrase_to_rule + calls = [] + + def add_required_phrase(*args, **kwargs): + calls.append(kwargs["required_phrase"]) + if len(calls) == 2: + return False + return original_add_required_phrase(*args, **kwargs) + + monkeypatch.setattr( + required_phrases, + "add_required_phrase_to_rule", + add_required_phrase, + ) + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["mit", "apache-2.0"], + required_phrases_by_key=self.required_phrases_by_key, + write_phrase_source=True, + dry_run=True, + ) + + assert calls == ["MIT License", "Apache License"] + assert rule.text == text + assert rule.source == source + + def test_uses_next_candidate_when_first_candidate_overlaps(self): + rule = Rule( + license_expression="gpl-2.0 AND gpl-2.0-plus", + identifier="overlapping_candidate_test.RULE", + text=( + "GNU General Public License version 2, or any later version." + ), + is_license_notice=True, + ) + required_phrases_by_key = { + "gpl-2.0": [ + IsRequiredPhrase( + rule=make_required_phrase_rule( + "gpl-2.0", + "GNU General Public License version 2", + "gpl-2.0_1.RULE", + ), + required_phrase_text="GNU General Public License version 2", + ), + ], + "gpl-2.0-plus": [ + IsRequiredPhrase( + rule=make_required_phrase_rule( + "gpl-2.0-plus", + "General Public License version 2", + "gpl-2.0-plus_1.RULE", + ), + required_phrase_text="General Public License version 2", + ), + IsRequiredPhrase( + rule=make_required_phrase_rule( + "gpl-2.0-plus", + "any later version", + "gpl-2.0-plus_2.RULE", + ), + required_phrase_text="any later version", + ), + ], + } + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["gpl-2.0", "gpl-2.0-plus"], + required_phrases_by_key=required_phrases_by_key, + dry_run=True, + ) + + assert "{{GNU General Public License version 2}}" in rule.text + assert "{{any later version}}" in rule.text + + def test_retries_candidate_selected_for_earlier_key(self): + rule = Rule( + license_expression="license-a AND license-b", + identifier="candidate_backtracking_test.RULE", + text="Alpha Long License and Backup Terms.", + is_license_notice=True, + ) + required_phrases_by_key = { + "license-a": [ + IsRequiredPhrase( + rule=make_required_phrase_rule( + "license-a", + "Alpha Long License", + "license-a_1.RULE", + ), + required_phrase_text="Alpha Long License", + ), + IsRequiredPhrase( + rule=make_required_phrase_rule( + "license-a", + "Backup Terms", + "license-a_2.RULE", + ), + required_phrase_text="Backup Terms", + ), + ], + "license-b": [ + IsRequiredPhrase( + rule=make_required_phrase_rule( + "license-b", + "Long License", + "license-b_1.RULE", + ), + required_phrase_text="Long License", + ), + ], + } + + add_required_phrases_to_composite_rules( + rules=[rule], + license_keys=["license-a", "license-b"], + required_phrases_by_key=required_phrases_by_key, + dry_run=True, + ) + + assert "{{Long License}}" in rule.text + assert "{{Backup Terms}}" in rule.text + assert "{{Alpha Long License}}" not in rule.text + + +class TestCompositeRequiredPhrasesCommand: + + def test_update_composite_rules_uses_single_key_required_phrases(self, monkeypatch): + required_rules = { + "mit": [make_required_phrase_rule("mit", "MIT License", "mit_1.RULE")], + "apache-2.0": [ + make_required_phrase_rule( + "apache-2.0", + "Apache License", + "apache-2.0_1.RULE", + ), + ], + } + target = Rule( + license_expression="mit AND apache-2.0", + identifier="mit_and_apache-2.0_test.RULE", + text="Licensed under the MIT License and the Apache License.", + is_license_notice=True, + ) + licenses_by_key = { + "mit": make_license(), + "apache-2.0": make_license(), + } + + monkeypatch.setattr(required_phrases, "get_licenses_db", lambda: licenses_by_key) + monkeypatch.setattr( + required_phrases, + "get_base_rules_by_expression", + lambda license_expression=None: required_rules, + ) + monkeypatch.setattr( + required_phrases, + "get_updatable_rules_by_expression", + lambda license_expression=None, simple_expression=True: { + "mit AND apache-2.0": [target], + }, + ) + + update_composite_rules_using_required_phrases(dry_run=True) + + assert "{{MIT License}}" in target.text + assert "{{Apache License}}" in target.text + + def test_update_composite_rules_skips_generic_keys(self, monkeypatch): + required_rules = { + "mit": [make_required_phrase_rule("mit", "MIT License", "mit_1.RULE")], + "unknown": [ + make_required_phrase_rule("unknown", "Unknown License", "unknown_1.RULE"), + ], + } + target = Rule( + license_expression="mit AND unknown", + identifier="mit_and_unknown_test.RULE", + text="Licensed under the MIT License and an Unknown License.", + is_license_notice=True, + ) + licenses_by_key = { + "mit": make_license(), + "unknown": make_license(is_generic=True), + } + + monkeypatch.setattr(required_phrases, "get_licenses_db", lambda: licenses_by_key) + monkeypatch.setattr( + required_phrases, + "get_base_rules_by_expression", + lambda license_expression=None: required_rules, + ) + monkeypatch.setattr( + required_phrases, + "get_updatable_rules_by_expression", + lambda license_expression=None, simple_expression=True: { + "mit AND unknown": [target], + }, + ) + + update_composite_rules_using_required_phrases(dry_run=True) + + assert "{{MIT License}}" in target.text + assert "{{Unknown License}}" not in target.text + + def test_composite_cli_calls_the_composite_update(self, monkeypatch): + called = [] + + def update(**kwargs): + called.append(kwargs) + + monkeypatch.setattr( + required_phrases, + "update_composite_rules_using_required_phrases", + update, + ) + + result = CliRunner().invoke(add_required_phrases, ["--composite-rules", "--dry-run"]) + + assert result.exit_code == 0 + assert called == [{ + "license_expression": None, + "write_phrase_source": False, + "dry_run": True, + "verbose": False, + }] + + @pytest.mark.parametrize( + "update_options", + [ + ["--from-other-rules", "--from-license-attributes"], + ["--from-other-rules", "--composite-rules"], + ["--from-license-attributes", "--composite-rules"], + [ + "--from-other-rules", + "--from-license-attributes", + "--composite-rules", + ], + ], + ) + def test_cli_rejects_multiple_update_modes(self, update_options, monkeypatch): + def fail(*args, **kwargs): + pytest.fail("An update handler was called for conflicting options") + + monkeypatch.setattr( + required_phrases, + "update_rules_using_is_required_phrases_rules", + fail, + ) + monkeypatch.setattr( + required_phrases, + "update_rules_using_license_attributes", + fail, + ) + monkeypatch.setattr( + required_phrases, + "update_composite_rules_using_required_phrases", + fail, + ) + monkeypatch.setattr(required_phrases, "validate_and_reindex", fail) + + result = CliRunner().invoke( + add_required_phrases, + [*update_options, "--dry-run"], + ) + + assert result.exit_code == 2 + assert "are mutually exclusive" in result.output + + +class TestLicenseAttributePrerequisite: + + def test_license_attribute_fields_are_used(self): + license_object = SimpleNamespace( + key="mit", + name="MIT License", + short_name="MIT License", + spdx_license_key="MIT", + other_spdx_license_keys=[], + ) + rule = Rule( + license_expression="mit", + identifier="mit_test.RULE", + text="Licensed under the MIT License.", + is_license_notice=True, + ) + + add_license_attributes_as_required_phrases_to_rules_text( + license_object=license_object, + rules=[rule], + dry_run=True, + ) + + assert "{{MIT License}}" in rule.text + + def test_simple_expression_filter_uses_each_expression(self, monkeypatch): + single_rule = Rule( + license_expression="mit", + identifier="mit_test.RULE", + text="MIT License", + is_license_notice=True, + ) + composite_rule = Rule( + license_expression="mit AND apache-2.0", + identifier="composite_test.RULE", + text="MIT License and Apache License", + is_license_notice=True, + ) + + monkeypatch.setattr(required_phrases, "get_index", lambda: None) + monkeypatch.setattr( + required_phrases, + "get_base_rules_by_expression", + lambda license_expression=None: { + "mit": [single_rule], + "mit AND apache-2.0": [composite_rule], + }, + ) + + rules_by_expression = get_updatable_rules_by_expression(simple_expression=True) + + assert list(rules_by_expression) == ["mit"]