Skip to content

Commit f022bfe

Browse files
authored
* handle piped url for toctree directive * add validation for toc node entries. add test. TODO. add error with duplicate external nodes * throw error from post process if external node is duplicated * add unit test for duplicate external toc * remove testing code * format + cleanup * revert log level * update diagnostic messages. add diagnostic case for missing title with ref project * address comments. use set of tuples instead of untyped list
1 parent 7f4f465 commit f022bfe

File tree

7 files changed

+156
-12
lines changed

7 files changed

+156
-12
lines changed

snooty/diagnostics.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,40 @@ def did_you_mean(self) -> List[str]:
411411
return [".. ia::"]
412412

413413

414+
class MissingAssociatedToc(Diagnostic):
415+
severity = Diagnostic.Level.warning
416+
417+
def __init__(
418+
self,
419+
expected_project: str,
420+
start: Union[int, Tuple[int, int]],
421+
end: Union[None, int, Tuple[int, int]] = None,
422+
) -> None:
423+
super().__init__(
424+
f"""Detected an associated toctree entry at {expected_project}
425+
which does not exist in an associated_products entry within the snooty.toml.
426+
Removing this toctree entry.""",
427+
start,
428+
end,
429+
)
430+
431+
432+
class DuplicatedExternalToc(Diagnostic):
433+
severity = Diagnostic.Level.error
434+
435+
def __init__(
436+
self,
437+
duplicated_toc: str,
438+
start: Union[int, Tuple[int, int]],
439+
end: Union[None, int, Tuple[int, int]] = None,
440+
) -> None:
441+
super().__init__(
442+
f"Detected a duplicated associated toctree entry at {duplicated_toc}. Removing this toctree entry.",
443+
start,
444+
end,
445+
)
446+
447+
414448
class InvalidIAEntry(Diagnostic):
415449
severity = Diagnostic.Level.error
416450

snooty/n.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ class TocTreeDirectiveEntry(NamedTuple):
359359
title: Optional[str]
360360
url: Optional[str]
361361
slug: Optional[str]
362+
ref_project: Optional[str]
362363

363364
def serialize(self) -> SerializedNode:
364365
result: SerializedNode = {}
@@ -368,6 +369,8 @@ def serialize(self) -> SerializedNode:
368369
result["url"] = self.url
369370
if self.slug:
370371
result["slug"] = self.slug
372+
if self.ref_project:
373+
result["ref_project"] = self.ref_project
371374
return result
372375

373376

snooty/parser.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
InvalidURL,
5858
MalformedGlossary,
5959
MalformedRelativePath,
60+
MissingAssociatedToc,
6061
MissingChild,
6162
RemovedLiteralBlockSyntax,
6263
TabMustBeDirective,
@@ -67,7 +68,7 @@
6768
)
6869
from .gizaparser.nodes import GizaCategory
6970
from .icon_names import ICON_SET
70-
from .n import FileId, SerializableType
71+
from .n import FileId, SerializableType, TocTreeDirectiveEntry
7172
from .openapi import OpenAPI
7273
from .page import Page, PendingTask
7374
from .postprocess import DevhubPostprocessor, Postprocessor, PostprocessorResult
@@ -576,6 +577,11 @@ def handle_directive(
576577
options = node["options"] or {}
577578

578579
if name == "toctree":
580+
self.diagnostics.extend(
581+
validate_toc_entries(
582+
node["entries"], self.project_config.associated_products, line
583+
)
584+
)
579585
doc: n.Directive = n.TocTreeDirective(
580586
(line,), [], domain, name, [], options, node["entries"]
581587
)
@@ -1070,6 +1076,28 @@ def __make_child_visitor(self) -> "JSONVisitor":
10701076
return visitor
10711077

10721078

1079+
def validate_toc_entries(
1080+
node_entries: List[TocTreeDirectiveEntry],
1081+
associated_products: List[Dict[str, object]],
1082+
line: int,
1083+
) -> List[Diagnostic]:
1084+
"""
1085+
validates that external toc node exists as one of the associated products
1086+
if not found, removes this node and emits a warning
1087+
associated_products come in form of {name: str, versions: List[str]}
1088+
"""
1089+
diagnostics: List[Diagnostic] = []
1090+
associated_product_names = [product["name"] for product in associated_products]
1091+
for toc_entry in node_entries:
1092+
if (
1093+
toc_entry.ref_project
1094+
and toc_entry.ref_project not in associated_product_names
1095+
):
1096+
diagnostics.append(MissingAssociatedToc(toc_entry.ref_project, line))
1097+
node_entries.remove(toc_entry)
1098+
return diagnostics
1099+
1100+
10731101
def _validate_io_code_block_children(node: n.Directive) -> List[Diagnostic]:
10741102
"""Validates that a given io-code-block directive has 1 input and 1 output
10751103
child nodes, and copies the io-code-block's options into the options of the

snooty/postprocess.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
CannotOpenFile,
3636
ChapterAlreadyExists,
3737
Diagnostic,
38+
DuplicatedExternalToc,
3839
DuplicateDirective,
3940
ExpectedPathArg,
4041
ExpectedTabs,
@@ -1703,8 +1704,15 @@ def build_toctree(cls, context: Context) -> Dict[str, SerializableType]:
17031704
toc_landing_pages = [
17041705
clean_slug(slug) for slug in context[ProjectConfig].toc_landing_pages
17051706
]
1707+
ref_project_set: Set[Tuple[Optional[str], Optional[str]]] = set()
17061708
cls.find_toctree_nodes(
1707-
context, starting_fileid, ast, root, toc_landing_pages, {starting_fileid}
1709+
context,
1710+
starting_fileid,
1711+
ast,
1712+
root,
1713+
toc_landing_pages,
1714+
ref_project_set,
1715+
{starting_fileid},
17081716
)
17091717

17101718
return root
@@ -1717,6 +1725,7 @@ def find_toctree_nodes(
17171725
ast: n.Node,
17181726
node: Dict[str, Any],
17191727
toc_landing_pages: List[str],
1728+
external_nodes: Set[Tuple[Optional[str], Optional[str]]],
17201729
visited_file_ids: Set[FileId] = set(),
17211730
) -> None:
17221731
"""Iterate over AST to find toctree directives and construct their nodes for the unified toctree"""
@@ -1729,6 +1738,19 @@ def find_toctree_nodes(
17291738
# Recursively build the tree for each toctree node in this entries list
17301739
for entry in ast.entries:
17311740
toctree_node: Dict[str, object] = {}
1741+
if entry.ref_project:
1742+
toctree_node = {
1743+
"title": [n.Text((0,), entry.title).serialize()]
1744+
if entry.title
1745+
else None,
1746+
"options": {"project": entry.ref_project},
1747+
}
1748+
ref_project_pair = (entry.title, entry.ref_project)
1749+
if ref_project_pair in external_nodes:
1750+
context.diagnostics[fileid].append(
1751+
DuplicatedExternalToc(entry.ref_project, ast.span[0])
1752+
)
1753+
external_nodes.add(ref_project_pair)
17321754
if entry.url:
17331755
toctree_node = {
17341756
"title": [n.Text((0,), entry.title).serialize()]
@@ -1795,6 +1817,7 @@ def find_toctree_nodes(
17951817
new_ast,
17961818
toctree_node,
17971819
toc_landing_pages,
1820+
external_nodes,
17981821
visited_file_ids.union({slug_fileid}),
17991822
)
18001823

@@ -1804,7 +1827,13 @@ def find_toctree_nodes(
18041827
# Locate the correct directive object containing the toctree within this AST
18051828
for child_ast in ast.children:
18061829
cls.find_toctree_nodes(
1807-
context, fileid, child_ast, node, toc_landing_pages, visited_file_ids
1830+
context,
1831+
fileid,
1832+
child_ast,
1833+
node,
1834+
toc_landing_pages,
1835+
external_nodes,
1836+
visited_file_ids,
18081837
)
18091838

18101839
@staticmethod

snooty/rstparser.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -944,14 +944,19 @@ def make_toc_entry(
944944
title: Optional[str] = None
945945
url: Optional[str] = None
946946
slug: Optional[str] = None
947+
ref_project: Optional[str] = None
947948
if match:
948949
title, target = match["label"], match["target"]
950+
# pipelines denote project reference
951+
if target.startswith("|") and target.endswith("|"):
952+
ref_project = target[1:-1]
953+
target = None
949954
else:
950955
target = child
951956

952-
if not title and util.PAT_URI.match(target):
957+
if not title and (util.PAT_URI.match(target) or ref_project):
953958
# If entry is surrounded by <> tags, assume it is a URL and log an error.
954-
err = "toctree nodes with URLs must include titles"
959+
err = "toctree nodes with URLs or project references must include titles"
955960
error_node = self.state.document.reporter.error(err, line=self.lineno)
956961
return None, [error_node]
957962

@@ -960,7 +965,7 @@ def make_toc_entry(
960965
url = target
961966
else:
962967
slug = target
963-
return n.TocTreeDirectiveEntry(title, url, slug), []
968+
return n.TocTreeDirectiveEntry(title, url, slug, ref_project), []
964969

965970

966971
class NoTransformRstParser(docutils.parsers.rst.Parser):

snooty/test_parser.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2624,8 +2624,9 @@ def test_footnote_reference() -> None:
26242624

26252625

26262626
def test_toctree() -> None:
2627-
path = ROOT_PATH.joinpath(Path("test.rst"))
2628-
project_config = ProjectConfig(ROOT_PATH, "", source="./")
2627+
project_root = ROOT_PATH.joinpath("test_project")
2628+
path = project_root.joinpath(Path("source/test.rst")).resolve()
2629+
[project_config, project_config_diagnostics] = ProjectConfig.open(project_root)
26292630
parser = rstparser.Parser(project_config, JSONVisitor)
26302631

26312632
page, diagnostics = parse_rst(
@@ -2638,17 +2639,21 @@ def test_toctree() -> None:
26382639
Title here </test1>
26392640
/test2/faq
26402641
URL with title <https://www.mongodb.com/docs/atlas>
2642+
Associated Product <|non-existent-associated|>
2643+
Associated Product <|test-name|>
26412644
<https://www.mongodb.com/docs/stitch>
26422645
""",
26432646
)
26442647
page.finish(diagnostics)
2645-
2646-
# MESSAGE need to create a toctree diagnostic
2647-
assert len(diagnostics) == 1 and "toctree" in diagnostics[0].message
2648+
assert (
2649+
len(diagnostics) == 2
2650+
and "toctree" in diagnostics[0].message
2651+
and "toctree" in diagnostics[1].message
2652+
)
26482653
check_ast_testing_string(
26492654
page.ast,
26502655
"""<root fileid="test.rst">
2651-
<directive name="toctree" titlesonly="True" entries="[{'title': 'Title here', 'slug': '/test1'}, {'slug': '/test2/faq'}, {'title': 'URL with title', 'url': 'https://www.mongodb.com/docs/atlas'}]" />
2656+
<directive name="toctree" titlesonly="True" entries="[{'title': 'Title here', 'slug': '/test1'}, {'slug': '/test2/faq'}, {'title': 'URL with title', 'url': 'https://www.mongodb.com/docs/atlas'}, {'title': 'Associated Product', 'ref_project': 'test-name'}]" />
26522657
</root>""",
26532658
)
26542659

snooty/test_postprocess.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .diagnostics import (
99
ChapterAlreadyExists,
1010
DocUtilsParseError,
11+
DuplicatedExternalToc,
1112
DuplicateDirective,
1213
ExpectedPathArg,
1314
ExpectedTabs,
@@ -559,6 +560,45 @@ def test_toctree_self_add() -> None:
559560
)
560561

561562

563+
def test_toctree_duplicate_node() -> None:
564+
with make_test(
565+
{
566+
Path(
567+
"snooty.toml"
568+
): """
569+
name = "test_name"
570+
title = "MongoDB title"
571+
572+
[[associated_products]]
573+
name = "test_associated_product"
574+
versions = ["v1", "v2"]
575+
""",
576+
Path(
577+
"source/index.txt"
578+
): """
579+
.. toctree::
580+
581+
/page1
582+
Duplicate Toc <|test_associated_product|>
583+
""",
584+
Path(
585+
"source/page1.txt"
586+
): """
587+
==================
588+
Page 1
589+
==================
590+
591+
.. toctree::
592+
593+
Duplicate Toc <|test_associated_product|>
594+
""",
595+
}
596+
) as result:
597+
diagnostics = result.diagnostics[FileId("index.txt")]
598+
assert len(diagnostics) == 1
599+
assert isinstance(diagnostics[0], DuplicatedExternalToc)
600+
601+
562602
def test_case_sensitive_labels() -> None:
563603
with make_test(
564604
{

0 commit comments

Comments
 (0)