Skip to content

Commit a8beda9

Browse files
Refactor composite required phrase annotation
1 parent 66c8d64 commit a8beda9

2 files changed

Lines changed: 851 additions & 5 deletions

File tree

src/licensedcode/required_phrases.py

Lines changed: 226 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,202 @@ def update_rules_using_license_attributes(
593593
dry_run=dry_run,
594594
)
595595

596+
597+
def get_required_phrases_by_key(rules_by_expression, licenses_by_key):
598+
"""
599+
Return required phrase candidates grouped by license key.
600+
Only collect from required phrase rules with a single non generic key.
601+
"""
602+
licensing = Licensing()
603+
required_phrases_by_key = {}
604+
required_phrases_by_expression = collect_is_required_phrase_from_rules(
605+
rules_by_expression=rules_by_expression,
606+
)
607+
608+
for expression, required_phrases in required_phrases_by_expression.items():
609+
license_keys = licensing.license_keys(expression, unique=True)
610+
if len(license_keys) != 1:
611+
continue
612+
613+
license_key = license_keys[0]
614+
if licenses_by_key[license_key].is_generic:
615+
continue
616+
617+
if required_phrases:
618+
required_phrases_by_key[license_key] = required_phrases
619+
620+
return required_phrases_by_key
621+
622+
623+
def _get_required_phrase_matches(rule, license_keys, required_phrases_by_key):
624+
"""
625+
Return one non overlapping required phrase match for every license key.
626+
Prefer phrases already marked in the rule and return None if no complete match exists.
627+
"""
628+
existing_spans = get_existing_required_phrase_spans(rule.text)
629+
unavailable_spans = existing_spans + get_ignorable_spans(rule)
630+
matches_by_key = {}
631+
632+
for license_key in license_keys:
633+
marked_matches = []
634+
new_matches = []
635+
636+
for candidate in required_phrases_by_key.get(license_key, []):
637+
phrase_spans = find_phrase_spans_in_text(
638+
rule.text,
639+
candidate.required_phrase_text,
640+
)
641+
marked_spans = [
642+
span
643+
for span in phrase_spans
644+
if any(span in existing for existing in existing_spans)
645+
]
646+
if marked_spans:
647+
marked_matches.extend(
648+
(candidate, True, [span])
649+
for span in marked_spans
650+
)
651+
continue
652+
653+
spans_to_add = list(
654+
get_non_overlapping_spans(
655+
old_required_phrase_spans=unavailable_spans,
656+
new_required_phrase_spans=phrase_spans,
657+
)
658+
)
659+
if spans_to_add:
660+
new_matches.append((candidate, False, spans_to_add))
661+
662+
matches_by_key[license_key] = marked_matches + new_matches
663+
if not matches_by_key[license_key]:
664+
return
665+
666+
def find_matches(remaining_keys, matched_spans):
667+
if not remaining_keys:
668+
return []
669+
670+
license_key = remaining_keys[0]
671+
for required_phrase, is_marked, phrase_spans in matches_by_key[license_key]:
672+
if any(
673+
span.overlap(matched)
674+
for span in phrase_spans
675+
for matched in matched_spans
676+
):
677+
continue
678+
679+
remaining_matches = find_matches(
680+
remaining_keys=remaining_keys[1:],
681+
matched_spans=matched_spans + phrase_spans,
682+
)
683+
if remaining_matches is not None:
684+
return [
685+
(required_phrase, is_marked),
686+
*remaining_matches,
687+
]
688+
689+
return find_matches(
690+
remaining_keys=license_keys,
691+
matched_spans=[],
692+
)
693+
694+
695+
def add_required_phrases_to_composite_rules(
696+
rules,
697+
license_keys,
698+
required_phrases_by_key,
699+
write_phrase_source=False,
700+
dry_run=False,
701+
):
702+
"""
703+
Add existing required phrases to rules when every license key has a matching phrase.
704+
"""
705+
for rule in rules:
706+
matched_required_phrases = _get_required_phrase_matches(
707+
rule=rule,
708+
license_keys=license_keys,
709+
required_phrases_by_key=required_phrases_by_key,
710+
)
711+
if not matched_required_phrases:
712+
continue
713+
714+
original_text = rule.text
715+
original_source = rule.source
716+
updated = False
717+
718+
for required_phrase, is_marked in matched_required_phrases:
719+
if is_marked:
720+
continue
721+
722+
source = rule.source
723+
if write_phrase_source:
724+
phrase_source = required_phrase.rule.identifier
725+
source = f"{source} {phrase_source}" if source else phrase_source
726+
727+
added = add_required_phrase_to_rule(
728+
rule=rule,
729+
required_phrase=required_phrase.required_phrase_text,
730+
source=source,
731+
dry_run=True,
732+
)
733+
if not added:
734+
rule.text = original_text
735+
rule.source = original_source
736+
updated = False
737+
break
738+
739+
updated = True
740+
741+
if updated and not dry_run:
742+
rule.dump(rules_data_dir)
743+
744+
745+
def update_composite_rules_using_required_phrases(
746+
license_expression=None,
747+
write_phrase_source=False,
748+
verbose=False,
749+
dry_run=False,
750+
):
751+
"""
752+
Collect existing required phrases from single license key rules and add them to composite rules
753+
only when every non generic license key has a non overlapping match.
754+
"""
755+
licensing = Licensing()
756+
licenses_by_key = get_licenses_db()
757+
rules_by_expression = get_base_rules_by_expression()
758+
required_phrases_by_key = get_required_phrases_by_key(
759+
rules_by_expression=rules_by_expression,
760+
licenses_by_key=licenses_by_key,
761+
)
762+
updatable_rules_by_expression = get_updatable_rules_by_expression(
763+
license_expression=license_expression,
764+
simple_expression=False,
765+
)
766+
767+
for expression, updatable_rules in updatable_rules_by_expression.items():
768+
license_keys = licensing.license_keys(expression, unique=True)
769+
if len(license_keys) < 2:
770+
continue
771+
772+
license_keys = [
773+
license_key
774+
for license_key in license_keys
775+
if not licenses_by_key[license_key].is_generic
776+
]
777+
if not license_keys:
778+
continue
779+
780+
if verbose:
781+
click.echo(f'Annotating required phrases for expression: {expression}')
782+
783+
add_required_phrases_to_composite_rules(
784+
rules=updatable_rules,
785+
license_keys=license_keys,
786+
required_phrases_by_key=required_phrases_by_key,
787+
write_phrase_source=write_phrase_source,
788+
dry_run=dry_run,
789+
)
790+
791+
596792
####################################################################################################
597793
#
598794
# Inject new required phrase in rules
@@ -617,7 +813,8 @@ def delete_required_phrase_rules_source_debug(rules_data_dir):
617813
is_flag=True,
618814
default=False,
619815
help="Propagate existing required phrases from other rules to all selected rules. "
620-
"Mutually exclusive with --from-license-attributes.",
816+
"Mutually exclusive with --from-license-attributes and --composite-rules.",
817+
conflicting_options=["from_license_attributes", "composite_rules"],
621818
cls=PluggableCommandLineOption,
622819
)
623820
@click.option(
@@ -626,7 +823,18 @@ def delete_required_phrase_rules_source_debug(rules_data_dir):
626823
is_flag=True,
627824
default=False,
628825
help="Propagate license attributes as required phrases to all selected rules. "
629-
"Mutually exclusive with --from-other-rule.",
826+
"Mutually exclusive with --from-other-rules and --composite-rules.",
827+
conflicting_options=["from_other_rules", "composite_rules"],
828+
cls=PluggableCommandLineOption,
829+
)
830+
@click.option(
831+
"-c",
832+
"--composite-rules",
833+
is_flag=True,
834+
default=False,
835+
help="Add required phrases to composite (with multiple license keys) rules "
836+
"using existing required phrases.",
837+
conflicting_options=["from_other_rules", "from_license_attributes"],
630838
cls=PluggableCommandLineOption,
631839
)
632840
@click.option(
@@ -691,6 +899,7 @@ def delete_required_phrase_rules_source_debug(rules_data_dir):
691899
def add_required_phrases(
692900
from_other_rules,
693901
from_license_attributes,
902+
composite_rules,
694903
license_expression,
695904
validate,
696905
reindex,
@@ -702,6 +911,12 @@ def add_required_phrases(
702911
"""
703912
Update license detection rules with new "required phrases" to improve rules detection accuracy.
704913
"""
914+
update_modes = (from_other_rules, from_license_attributes, composite_rules)
915+
if sum(update_modes) > 1:
916+
raise click.UsageError(
917+
"Options --from-other-rules, --from-license-attributes, and --composite-rules "
918+
"are mutually exclusive."
919+
)
705920

706921
if delete_phrase_source:
707922
click.echo('Deleting rules phrase source debug data.')
@@ -726,6 +941,15 @@ def add_required_phrases(
726941
verbose=verbose,
727942
)
728943

944+
elif composite_rules:
945+
click.echo('Updating composite rules from required phrases.')
946+
update_composite_rules_using_required_phrases(
947+
license_expression=license_expression,
948+
write_phrase_source=write_phrase_source,
949+
dry_run=dry_run,
950+
verbose=verbose,
951+
)
952+
729953
validate_and_reindex(validate, reindex, verbose)
730954

731955

0 commit comments

Comments
 (0)