@@ -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):
691899def 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