Skip to content

Commit 357bae0

Browse files
authored
DOP-5477: ingest composable tutorials from parser (#652)
* define rstspec for composable tutorials * handle child content * extend Directives to composables, handle all options. * validate default ids. TODO: populate parent after processing child * populate parent from child content. TODO: postprocess error for pages with invalid directives * update errors. default values for next and append diagnostic * add postprocess errors for page level directive usage. TODO: handle dependent selection options * add comment * have expected ast options * test with ubuntu 22 * update ubuntu package * address comments. merge types for composable tutorial options, write util function for splitting options{@str} into str[] * safety checks for None values
1 parent 18d83d0 commit 357bae0

File tree

11 files changed

+617
-10
lines changed

11 files changed

+617
-10
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
test:
1010
strategy:
1111
matrix:
12-
platform: [ubuntu-20.04, macos-13, macos-latest]
12+
platform: [ubuntu-22.04, macos-13, macos-latest]
1313
runs-on: ${{ matrix.platform }}
1414
steps:
1515
- uses: actions/checkout@v4

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
test:
1212
strategy:
1313
matrix:
14-
platform: [ubuntu-20.04, macos-latest]
14+
platform: [ubuntu-22.04, macos-latest]
1515
python-version: ['3.8', '3.11']
1616
# Python 3.8 is only used for Pyston on Linux x86_64
1717
exclude:
@@ -35,7 +35,7 @@ jobs:
3535
testPackage:
3636
strategy:
3737
matrix:
38-
platform: [ubuntu-20.04, macos-latest]
38+
platform: [ubuntu-22.04, macos-latest]
3939
python-version: ['3.11']
4040
runs-on: ${{ matrix.platform }}
4141
steps:

snooty/n.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,18 @@ class Transition(Node):
512512
class Table(Parent[Node]):
513513
__slots__ = ()
514514
type = "table"
515+
516+
517+
ComposableOption = Dict[str, Union[str, List[Dict[str, str]]]]
518+
519+
520+
@dataclass
521+
class ComposableDirective(Directive):
522+
__slots__ = "composable_options"
523+
composable_options: List[ComposableOption]
524+
525+
526+
@dataclass
527+
class ComposableContent(Directive):
528+
__slots__ = "selections"
529+
selections: Dict[str, str]

snooty/parser.py

Lines changed: 222 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,11 @@
8484
UnmarshallingError,
8585
)
8686
from .icon_names import ICON_SET, LG_ICON_SET
87-
from .n import FileId, SerializableType, TocTreeDirectiveEntry
87+
from .n import ComposableOption, FileId, SerializableType, TocTreeDirectiveEntry
8888
from .page import Page, PendingTask
8989
from .page_database import PageDatabase
9090
from .postprocess import Postprocessor, PostprocessorResult
91+
from .specparser import Composable
9192
from .target_database import ProjectInterface, TargetDatabase
9293
from .types import (
9394
AssociatedProduct,
@@ -96,7 +97,7 @@
9697
ProjectConfig,
9798
StaticAsset,
9899
)
99-
from .util import RST_EXTENSIONS
100+
from .util import RST_EXTENSIONS, split_option_str
100101

101102
NO_CHILDREN = (n.SubstitutionReference,)
102103
MULTIPLE_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

Comments
 (0)