8484 UnmarshallingError ,
8585)
8686from .icon_names import ICON_SET , LG_ICON_SET
87- from .n import FileId , SerializableType , TocTreeDirectiveEntry
87+ from .n import ComposableOption , FileId , SerializableType , TocTreeDirectiveEntry
8888from .page import Page , PendingTask
8989from .page_database import PageDatabase
9090from .postprocess import Postprocessor , PostprocessorResult
91+ from .specparser import Composable
9192from .target_database import ProjectInterface , TargetDatabase
9293from .types import (
9394 AssociatedProduct ,
9697 ProjectConfig ,
9798 StaticAsset ,
9899)
99- from .util import RST_EXTENSIONS
100+ from .util import RST_EXTENSIONS , split_option_str
100101
101102NO_CHILDREN = (n .SubstitutionReference ,)
102103MULTIPLE_FORWARD_SLASHES = re .compile (r"([\/])\1" )
@@ -579,6 +580,9 @@ def dispatch_departure(self, node: tinydocutils.nodes.Node) -> None:
579580 popped .options ["id" ] = html5_id
580581 popped .children = [n .Section ((node .get_line (),), popped .children )]
581582
583+ elif isinstance (popped , n .ComposableDirective ):
584+ self .handle_composable (popped )
585+
582586 def handle_facet (self , node : rstparser .directive , line : int ) -> None :
583587 if "values" not in node ["options" ] or "name" not in node ["options" ]:
584588 return
@@ -690,7 +694,9 @@ def handle_wayfinding(self, node: n.Directive) -> None:
690694 if child .name == expected_child_desc_name :
691695 valid_desc = child
692696 continue
693- self .check_valid_option_id (child , expected_options_dict , used_ids )
697+ self .check_valid_option_id (
698+ child .options .get ("id" ), child , expected_options_dict , used_ids
699+ )
694700 except ChildValidationError :
695701 continue
696702
@@ -763,13 +769,13 @@ def check_valid_child(
763769
764770 def check_valid_option_id (
765771 self ,
772+ option_id : str | None ,
766773 child : n .Directive ,
767774 expected_options : Dict [str , Any ],
768775 used_ids : Set [str ],
769776 ) -> None :
770777 """Ensures that a child directive has a unique option "id" that is correctly defined."""
771778
772- option_id = child .options .get ("id" )
773779 if not option_id :
774780 # Don't append diagnostic since docutils should already
775781 # complain about missing ID option
@@ -803,7 +809,9 @@ def handle_method_selector(self, node: n.Directive) -> None:
803809 self .check_valid_child (node , child , {expected_child_name })
804810 # check_valid_child verifies that the child is a directive
805811 assert isinstance (child , n .Directive )
806- self .check_valid_option_id (child , expected_options_dict , used_ids )
812+ self .check_valid_option_id (
813+ child .options .get ("id" ), child , expected_options_dict , used_ids
814+ )
807815 except ChildValidationError :
808816 continue
809817
@@ -854,6 +862,207 @@ def handle_method_option(self, node: n.Directive) -> None:
854862 if target_idx >= 0 :
855863 node .children .insert (0 , node .children .pop (target_idx ))
856864
865+ def handle_composable (self , node : n .ComposableDirective ) -> None :
866+ """Handles composable directive(s) and its children composable content. Translates string options to lists for consumption"""
867+
868+ # first convert specified options from str -> List[str]
869+ option_ids_as_string = (
870+ node .options ["options" ] if "options" in node .options else ""
871+ )
872+ default_ids_as_string = (
873+ node .options ["defaults" ] if "defaults" in node .options else ""
874+ )
875+ option_ids : List [str ] = split_option_str (option_ids_as_string )
876+ default_ids : List [str ] = split_option_str (default_ids_as_string )
877+
878+ # expect at least 1 option_ids
879+ if len (option_ids ) < 1 :
880+ self .diagnostics .append (
881+ InvalidChildCount (
882+ "composable-tutorial" , "option_ids" , "at least one" , node .start [0 ]
883+ )
884+ )
885+
886+ # get the expected composable options from the spec
887+ spec_composables = specparser .Spec .get ().composables
888+ spec_composables_dict = {
889+ expected_option .id : expected_option for expected_option in spec_composables
890+ }
891+
892+ # validate the specified :options: and :defaults:
893+ used_ids : Set [str ] = set ()
894+ composable_options = []
895+ default_ids_dict : Dict [str , str ] = {}
896+ for index in range (len (option_ids )):
897+ option_id = option_ids [index ]
898+ try :
899+ self .check_valid_option_id (
900+ option_id , node , spec_composables_dict , used_ids
901+ )
902+ composable_from_spec = next (
903+ (option for option in spec_composables if option .id == option_id ),
904+ None ,
905+ )
906+ if not composable_from_spec :
907+ self .diagnostics .append (
908+ UnknownOptionId (
909+ "composable-tutorial" ,
910+ option_id ,
911+ [
912+ spec_composable .id
913+ for spec_composable in spec_composables
914+ ],
915+ node .start [0 ],
916+ )
917+ )
918+ continue
919+ specified_default_id = default_ids [index ]
920+ allowed_values_dict = {
921+ option .id : option for option in composable_from_spec .options
922+ }
923+ self .check_valid_option_id (
924+ specified_default_id , node , allowed_values_dict , set ()
925+ )
926+ default_ids_dict [option_id ] = (
927+ specified_default_id
928+ or composable_from_spec .default
929+ or composable_from_spec .options [0 ].id
930+ )
931+
932+ composable_option : ComposableOption = {
933+ "value" : composable_from_spec .id ,
934+ "text" : composable_from_spec .title ,
935+ "default" : specified_default_id
936+ or composable_from_spec .default
937+ or "" ,
938+ "dependencies" : composable_from_spec .dependencies or [],
939+ "selections" : [],
940+ }
941+ composable_options .append (composable_option )
942+
943+ except ChildValidationError :
944+ continue
945+
946+ # add to used ids for no repeats
947+ used_ids .add (option_id )
948+
949+ # validate the expected children and options
950+ valid_children : List [n .ComposableContent ] = []
951+ default_values_found = False
952+ for child in node .children :
953+ try :
954+ self .check_valid_child (node , child , {"selected-content" })
955+ assert isinstance (child , n .ComposableContent )
956+ self .handle_composable_content (child , spec_composables )
957+ valid_children .append (child )
958+
959+ # populate parent composable-tutorial with selections used by children
960+ for option_key , value_key in child .selections .items ():
961+ composable_from_spec = next (
962+ (
963+ spec_composable
964+ for spec_composable in spec_composables
965+ if spec_composable .id == option_key
966+ ),
967+ None ,
968+ )
969+ if not composable_from_spec :
970+ self .diagnostics .append (
971+ UnknownOptionId (
972+ "composable-tutorial" ,
973+ option_key ,
974+ [
975+ spec_composable .id
976+ for spec_composable in spec_composables
977+ ],
978+ node .start [0 ],
979+ )
980+ )
981+ continue
982+ if not value_key or value_key == "None" :
983+ continue
984+ option_from_spec = next (
985+ (
986+ spec_option
987+ for spec_option in composable_from_spec .options
988+ if spec_option .id == value_key
989+ ),
990+ None ,
991+ )
992+ composable_option = next (
993+ (
994+ composable_option
995+ for composable_option in composable_options
996+ if composable_option ["value" ] == option_key
997+ ),
998+ {},
999+ )
1000+ if not option_from_spec or not composable_option :
1001+ continue
1002+ composable_option ["selections" ] = (
1003+ composable_option ["selections" ] or []
1004+ )
1005+ if isinstance (composable_option ["selections" ], list ):
1006+ composable_option ["selections" ].append (
1007+ {
1008+ "value" : option_from_spec .id ,
1009+ "text" : option_from_spec .title ,
1010+ }
1011+ )
1012+
1013+ default_values_found = default_values_found or all (
1014+ child .selections [composable_id ] == option_id
1015+ for composable_id , option_id in (default_ids_dict .items ())
1016+ )
1017+
1018+ except ChildValidationError :
1019+ continue
1020+
1021+ if not default_values_found :
1022+ self .diagnostics .append (
1023+ MissingChild (
1024+ "composable-tutorial" ,
1025+ f"selected-content with selections { default_ids_as_string } " ,
1026+ node .start [0 ],
1027+ )
1028+ )
1029+ node .composable_options = composable_options
1030+ node .options = {}
1031+
1032+ def handle_composable_content (
1033+ self ,
1034+ node : n .ComposableContent ,
1035+ spec_composables : List [Composable ],
1036+ ) -> None :
1037+ selection_ids = split_option_str (node .options .get ("selections" , "" ))
1038+ selections : Dict [str , str ] = {}
1039+ # validate all selection ids
1040+ for idx in range (len (selection_ids )):
1041+ selection_id = selection_ids [idx ]
1042+ spec_composable = spec_composables [idx ]
1043+ allowed_selection_ids = list (map (lambda x : x .id , spec_composable .options ))
1044+ # check if dependencies are met - then None is not allowed
1045+ met_dependencies : bool = all (
1046+ key in selections and selections [key ] == value
1047+ for dependency in (spec_composable .dependencies or [])
1048+ for key , value in dependency .items ()
1049+ )
1050+ if selection_id not in allowed_selection_ids and met_dependencies :
1051+ self .diagnostics .append (
1052+ UnknownOptionId (
1053+ "composable-tutorial" ,
1054+ selection_id ,
1055+ allowed_selection_ids ,
1056+ node .start [0 ],
1057+ )
1058+ )
1059+ break
1060+ composable_option_value = spec_composable .id
1061+ selections [composable_option_value ] = selection_id
1062+
1063+ node .selections = selections
1064+ node .options = {}
1065+
8571066 def handle_directive (
8581067 self , node : rstparser .directive , line : int
8591068 ) -> Optional [n .Node ]:
@@ -872,6 +1081,14 @@ def handle_directive(
8721081 )
8731082 return doc
8741083
1084+ elif name == "composable-tutorial" :
1085+ doc = n .ComposableDirective ((line ,), [], domain , name , [], options , [])
1086+ return doc
1087+
1088+ elif name == "selected-content" :
1089+ doc = n .ComposableContent ((line ,), [], domain , name , [], options , {})
1090+ return doc
1091+
8751092 doc = n .Directive ((line ,), [], domain , name , [], options )
8761093
8771094 # Find and move the argument from the children to the "argument" field.
0 commit comments