Skip to content

Commit a462c53

Browse files
authored
DOP-2444: Add directives and metadata for guides (#349)
* DOP-2444: Add directives and metadata for guides * Get chapters' guides in separate function * Fix guide data serialization * Apply feedback * Remove context arg
1 parent d57c937 commit a462c53

File tree

4 files changed

+202
-30
lines changed

4 files changed

+202
-30
lines changed

snooty/diagnostics.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -618,11 +618,11 @@ class ChapterAlreadyExists(Diagnostic):
618618

619619
def __init__(
620620
self,
621-
chapterName: str,
621+
chapter_name: str,
622622
start: Union[int, Tuple[int, int]],
623623
end: Union[None, int, Tuple[int, int]] = None,
624624
) -> None:
625-
super().__init__(f'Chapter "{chapterName}" already exists', start, end)
625+
super().__init__(f'Chapter "{chapter_name}" already exists', start, end)
626626

627627

628628
class InvalidChapter(Diagnostic):
@@ -643,12 +643,30 @@ class MissingChild(Diagnostic):
643643
def __init__(
644644
self,
645645
directive: str,
646-
expectedChild: str,
646+
expected_child: str,
647647
start: Union[int, Tuple[int, int]],
648648
end: Union[None, int, Tuple[int, int]] = None,
649649
) -> None:
650650
super().__init__(
651-
f'Directive "{directive}" expects at least one child of type "{expectedChild}"; found 0',
651+
f'Directive "{directive}" expects at least one child of type "{expected_child}"; found 0',
652+
start,
653+
end,
654+
)
655+
656+
657+
class GuideAlreadyHasChapter(Diagnostic):
658+
severity = Diagnostic.Level.error
659+
660+
def __init__(
661+
self,
662+
guide_slug: str,
663+
assigned_chapter: str,
664+
target_chapter: str,
665+
start: Union[int, Tuple[int, int]],
666+
end: Union[None, int, Tuple[int, int]] = None,
667+
) -> None:
668+
super().__init__(
669+
f"""Cannot add guide "{guide_slug}" to chapter "{target_chapter}" because the guide is already assigned to chapter "{assigned_chapter}\"""",
652670
start,
653671
end,
654672
)

snooty/postprocess.py

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import urllib.parse
77
from collections import defaultdict
88
from copy import deepcopy
9-
from dataclasses import asdict, dataclass
9+
from dataclasses import asdict, dataclass, field
1010
from typing import (
1111
Any,
1212
Callable,
@@ -33,6 +33,7 @@
3333
DuplicateDirective,
3434
ExpectedPathArg,
3535
ExpectedTabs,
36+
GuideAlreadyHasChapter,
3637
InvalidChapter,
3738
InvalidChild,
3839
InvalidContextError,
@@ -723,19 +724,52 @@ class ChapterData:
723724
description: Optional[str]
724725
guides: List[str]
725726

727+
@dataclass
728+
class GuideData:
729+
chapter_name: str = ""
730+
completion_time: int = 0
731+
description: MutableSequence[n.Node] = field(default_factory=list)
732+
title: Sequence[n.InlineNode] = field(default_factory=list)
733+
734+
def serialize(self) -> n.SerializedNode:
735+
result: n.SerializedNode = {
736+
"chapter_name": self.chapter_name,
737+
"completion_time": self.completion_time,
738+
"description": [node.serialize() for node in self.description],
739+
"title": [node.serialize() for node in self.title],
740+
}
741+
return result
742+
743+
def add_guides_metadata(self, document: Dict[str, SerializableType]) -> None:
744+
"""Adds the guides-related metadata to the project's metadata document"""
745+
if self.chapters:
746+
document["chapters"] = {k: asdict(v) for k, v in self.chapters.items()}
747+
748+
if self.guides:
749+
slug_title_mapping = self.context[HeadingHandler].slug_title_mapping
750+
for slug, title in slug_title_mapping.items():
751+
if slug in self.guides:
752+
self.guides[slug].title = title
753+
document["guides"] = {k: v.serialize() for k, v in self.guides.items()}
754+
726755
def __init__(self, context: Context) -> None:
727756
super().__init__(context)
728757
self.chapters: Dict[str, GuidesHandler.ChapterData] = {}
758+
self.guides: Dict[str, GuidesHandler.GuideData] = defaultdict(
759+
GuidesHandler.GuideData
760+
)
729761

730-
def __handle_chapter(self, chapter: n.Directive, current_file: FileId) -> None:
731-
"""Saves a chapter's data into the handler's dictionary of chapters"""
762+
def __get_guides(
763+
self, chapter: n.Directive, chapter_title: str, current_file: FileId
764+
) -> List[str]:
765+
"""Returns the eligible guides that belong to a given chapter"""
732766

733767
guides: List[str] = []
734768

735769
for child in chapter.get_child_of_type(n.Directive):
770+
line = child.span[0]
771+
736772
if child.name != "guide":
737-
# Chapter directives should contain only guide directives
738-
line = chapter.span[0]
739773
self.context.diagnostics[current_file].append(
740774
InvalidChild(child.name, "chapter", "guide", line, None)
741775
)
@@ -744,26 +778,40 @@ def __handle_chapter(self, chapter: n.Directive, current_file: FileId) -> None:
744778
guide_argument = child.argument
745779
if not guide_argument:
746780
self.context.diagnostics[current_file].append(
747-
ExpectedPathArg(child.name, child.span[0])
781+
ExpectedPathArg(child.name, line)
748782
)
749783
continue
750784

751785
guide_slug = clean_slug(guide_argument[0].get_text())
786+
787+
current_guide_data = self.guides[guide_slug]
788+
if current_guide_data.chapter_name:
789+
self.context.diagnostics[current_file].append(
790+
GuideAlreadyHasChapter(
791+
guide_slug,
792+
current_guide_data.chapter_name,
793+
chapter_title,
794+
line,
795+
)
796+
)
797+
continue
798+
else:
799+
current_guide_data.chapter_name = chapter_title
800+
752801
guides.append(guide_slug)
753802

754-
line = chapter.span[0]
803+
return guides
755804

756-
# A chapter should always have at least one guide
757-
if not guides:
758-
self.context.diagnostics[current_file].append(
759-
MissingChild("chapter", "guide", line)
760-
)
761-
return
805+
def __handle_chapter(self, chapter: n.Directive, current_file: FileId) -> None:
806+
"""Saves a chapter's data into the handler's dictionary of chapters"""
762807

808+
line = chapter.span[0]
763809
title_argument = chapter.argument
764-
if not title_argument:
810+
if len(title_argument) != 1:
765811
self.context.diagnostics[current_file].append(
766-
InvalidChapter("Title argument is empty.", line)
812+
InvalidChapter(
813+
"Invalid title argument. The title should be plain text.", line
814+
)
767815
)
768816
return
769817

@@ -781,6 +829,14 @@ def __handle_chapter(self, chapter: n.Directive, current_file: FileId) -> None:
781829
if not description:
782830
return
783831

832+
guides: List[str] = self.__get_guides(chapter, title, current_file)
833+
# A chapter should always have at least one guide
834+
if not guides:
835+
self.context.diagnostics[current_file].append(
836+
MissingChild("chapter", "guide", line)
837+
)
838+
return
839+
784840
if not self.chapters.get(title):
785841
self.chapters[title] = GuidesHandler.ChapterData(
786842
len(self.chapters) + 1, description, guides
@@ -824,17 +880,26 @@ def __handle_chapters(
824880
)
825881

826882
def enter_node(self, fileid_stack: FileIdStack, node: n.Node) -> None:
883+
if not isinstance(node, n.Directive):
884+
return
885+
827886
current_file: FileId = fileid_stack.current
887+
current_slug = clean_slug(current_file.without_known_suffix)
828888

829-
if (
830-
isinstance(node, n.Directive)
831-
and node.name == "chapters"
832-
and current_file.as_posix() == "index.txt"
833-
):
889+
if node.name == "chapters" and current_file == FileId("index.txt"):
834890
if self.chapters:
835891
return
836-
837892
self.__handle_chapters(node, current_file)
893+
elif node.name == "time":
894+
if not node.argument:
895+
return
896+
try:
897+
completion_time = int(node.argument[0].get_text())
898+
self.guides[current_slug].completion_time = completion_time
899+
except ValueError:
900+
pass
901+
elif node.name == "short-description":
902+
self.guides[current_slug].description = node.children
838903

839904

840905
class IAHandler(Handler):
@@ -1391,9 +1456,7 @@ def generate_metadata(cls, context: Context) -> n.SerializedNode:
13911456
if iatree:
13921457
document["iatree"] = iatree
13931458

1394-
chapters = context[GuidesHandler].chapters
1395-
if chapters:
1396-
document["chapters"] = {k: asdict(v) for k, v in chapters.items()}
1459+
context[GuidesHandler].add_guides_metadata(document)
13971460

13981461
return document
13991462

snooty/rstspec.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,14 @@ example = """.. guide:: ${0: /path/to/guide.txt}"""
719719
argument_type = ["path", "uri"]
720720
content_type = "string"
721721

722+
[directive."mongodb:guide-next"]
723+
help = "A section that directs users to what they should do next after reading a guide"
724+
example = """.. guide-next:: ${0: string}
725+
${1: content}
726+
"""
727+
argument_type = "string"
728+
content_type = "block"
729+
722730
[directive."mongodb:introduction"]
723731
content_type = "block"
724732

@@ -773,13 +781,25 @@ example = """ .. quizchoice:: ${1:string}
773781
argument_type = "string"
774782
options.is-true = "flag"
775783

784+
[directive."mongodb:short-description"]
785+
help = "A description or preview of what the content will be about."
786+
content_type = "block"
787+
example = """.. short-description::
788+
${0: content}
789+
"""
790+
776791
[directive."mongodb:step"]
777792
help = "Make a single, numbered step."
778793
example = """.. step:: ${0: Step's headline string}
779794
${1: Step content}"""
780795
argument_type = "string"
781796
content_type = "block"
782797

798+
[directive."mongodb:time"]
799+
help = "The amount of time that it would take to go through the guide content in minutes."
800+
argument_type = {type = "nonnegative_integer", required = true}
801+
example = """.. time:: 15"""
802+
783803
###### Misc.
784804
[directive.atf-image]
785805
help = "Path to the image to use for the above-the-fold header image"

snooty/test_postprocess.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
DocUtilsParseError,
1111
DuplicateDirective,
1212
ExpectedTabs,
13+
GuideAlreadyHasChapter,
1314
InvalidChapter,
1415
InvalidChild,
1516
InvalidContextError,
@@ -149,7 +150,7 @@ def test_ia() -> None:
149150
)
150151

151152

152-
def test_chapters() -> None:
153+
def test_guides() -> None:
153154
# Chapters are generated properly and page ast should look as expected
154155
with make_test(
155156
{
@@ -235,6 +236,51 @@ def test_chapters() -> None:
235236
assert chapters["CRUD"]["guides"] == ["path/to/guide3"]
236237
assert chapters["CRUD"]["chapter_number"] == 2
237238

239+
# Guides metadata is added to the project's metadata document
240+
with make_test(
241+
{
242+
Path(
243+
"source/index.txt"
244+
): """
245+
======
246+
Guides
247+
======
248+
249+
.. chapters::
250+
251+
.. chapter:: Atlas
252+
:description: This is the description for the Atlas chapter.
253+
:image: /images/atlas.png
254+
255+
.. guide:: /path/to/guide1.txt
256+
""",
257+
Path(
258+
"source/path/to/guide1.txt"
259+
): """
260+
=======
261+
Guide 1
262+
=======
263+
264+
.. time:: 20
265+
.. short-description::
266+
267+
This is guide 1.
268+
""",
269+
}
270+
) as result:
271+
assert not [
272+
diagnostics for diagnostics in result.diagnostics.values() if diagnostics
273+
]
274+
guides = cast(Dict[str, Any], result.metadata["guides"])
275+
assert len(guides) == 1
276+
277+
test_guide_data = guides["path/to/guide1"]
278+
assert test_guide_data["completion_time"] == 20
279+
assert test_guide_data["title"][0]["value"] == "Guide 1"
280+
test_guide_description = test_guide_data["description"][0]["children"][0]
281+
assert test_guide_description["value"] == "This is guide 1."
282+
assert test_guide_data["chapter_name"] == "Atlas"
283+
238284
# Diagnostic errors reported
239285
with make_test(
240286
{
@@ -321,14 +367,39 @@ def test_chapters() -> None:
321367
.. chapter:: Test
322368
:description: This is a chapter
323369
324-
.. guide:: /path/to/guide1.txt
370+
.. guide:: /path/to/guide2.txt
325371
""",
326372
}
327373
) as result:
328374
diagnostics = result.diagnostics[FileId("index.txt")]
329375
assert len(diagnostics) == 1
330376
assert isinstance(diagnostics[0], ChapterAlreadyExists)
331377

378+
# Test adding 1 guide to multiple children
379+
with make_test(
380+
{
381+
Path(
382+
"source/index.txt"
383+
): """
384+
.. chapters::
385+
386+
.. chapter:: Test
387+
:description: This is a chapter
388+
389+
.. guide:: /path/to/guide1.txt
390+
391+
.. chapter:: Test: The Sequel
392+
:description: This is another chapter
393+
394+
.. guide:: /path/to/guide1.txt
395+
.. guide:: /path/to/guide2.txt
396+
""",
397+
}
398+
) as result:
399+
diagnostics = result.diagnostics[FileId("index.txt")]
400+
assert len(diagnostics) == 1
401+
assert isinstance(diagnostics[0], GuideAlreadyHasChapter)
402+
332403

333404
# ensure that broken links still generate titles
334405
def test_broken_link() -> None:

0 commit comments

Comments
 (0)