Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ To see all options:
$ sphinx-etoc --help
Usage: sphinx-etoc [OPTIONS] COMMAND [ARGS]...

Command-line for ``sphinx-external-toc``.
Command-line for sphinx-external-toc.

Options:
--version Show the version and exit.
Expand All @@ -285,7 +285,7 @@ Options:
Commands:
from-site Create a ToC file from a site directory.
migrate Migrate a ToC from a previous revision.
parse-toc Parse a ToC file to a site-map YAML.
parse Parse a ToC file to a site-map YAML.
to-site Create a site directory from a ToC file.
```

Expand Down
4 changes: 2 additions & 2 deletions sphinx_external_toc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(version=__version__)
def main():
"""Command-line for ``sphinx-external-toc``."""
"""Command-line for sphinx-external-toc."""


@main.command("parse-toc")
@main.command("parse")
@click.argument("toc_file", type=click.Path(exists=True, file_okay=True))
def parse_toc(toc_file):
"""Parse a ToC file to a site-map YAML."""
Expand Down
75 changes: 43 additions & 32 deletions sphinx_external_toc/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
DEFAULT_SUBTREES_KEY = "subtrees"
DEFAULT_ITEMS_KEY = "items"
FILE_FORMAT_KEY = "format"
ROOT_KEY = "root"
FILE_KEY = "file"
GLOB_KEY = "glob"
URL_KEY = "url"
Expand Down Expand Up @@ -97,7 +98,7 @@ def parse_toc_data(data: Dict[str, Any]) -> SiteMap:
defaults: Dict[str, Any] = {**file_format.toc_defaults, **data.get("defaults", {})}

doc_item, docs_list = _parse_doc_item(
data, defaults, "/", depth=0, file_key="root", file_format=file_format
data, defaults, "/", depth=0, is_root=True, file_format=file_format
)

site_map = SiteMap(
Expand All @@ -118,11 +119,12 @@ def _parse_doc_item(
*,
depth: int,
file_format: FileFormat,
file_key: str = FILE_KEY,
) -> Tuple[Document, Sequence[Dict[str, Any]]]:
is_root: bool = False,
) -> Tuple[Document, Sequence[Tuple[str, Dict[str, Any]]]]:
"""Parse a single doc item."""
file_key = ROOT_KEY if is_root else FILE_KEY
if file_key not in data:
raise MalformedError(f"'{file_key}' key not found: '{path}'")
raise MalformedError(f"'{file_key}' key not found @ '{path}'")

subtrees_key = file_format.get_subtrees_key(depth)
items_key = file_format.get_items_key(depth)
Expand All @@ -142,20 +144,23 @@ def _parse_doc_item(
if not allowed_keys.issuperset(data.keys()):
unknown_keys = set(data.keys()).difference(allowed_keys)
raise MalformedError(
f"Unknown keys found: {unknown_keys!r}, allowed: {allowed_keys!r}: '{path}'"
f"Unknown keys found: {unknown_keys!r}, allowed: {allowed_keys!r} @ '{path}'"
)

shorthand_used = False
if items_key in data:
# this is a shorthand for defining a single subtree
if subtrees_key in data:
raise MalformedError(
f"Both '{subtrees_key}' and '{items_key}' found: '{path}'"
f"Both '{subtrees_key}' and '{items_key}' found @ '{path}'"
)
subtrees_data = [{items_key: data[items_key], **data.get("options", {})}]
shorthand_used = True
elif subtrees_key in data:
subtrees_data = data[subtrees_key]
if not (isinstance(subtrees_data, Sequence) and subtrees_data):
raise MalformedError(f"'{subtrees_key}' not a non-empty list: '{path}'")
raise MalformedError(f"'{subtrees_key}' not a non-empty list @ '{path}'")
path = f"{path}{subtrees_key}/"
else:
subtrees_data = []

Expand All @@ -164,46 +169,46 @@ def _parse_doc_item(
toctrees = []
for toc_idx, toc_data in enumerate(subtrees_data):

toc_path = path if shorthand_used else f"{path}{toc_idx}/"

if not (isinstance(toc_data, Mapping) and items_key in toc_data):
raise MalformedError(
f"subtree not a mapping containing '{items_key}' key: '{path}{toc_idx}'"
f"item not a mapping containing '{items_key}' key @ '{toc_path}'"
)

items_data = toc_data[items_key]

if not (isinstance(items_data, Sequence) and items_data):
raise MalformedError(
f"'{items_key}' not a non-empty list: '{path}{toc_idx}'"
)
raise MalformedError(f"'{items_key}' not a non-empty list @ '{toc_path}'")

# generate items list
items: List[Union[GlobItem, FileItem, UrlItem]] = []
for item_idx, item_data in enumerate(items_data):

if not isinstance(item_data, Mapping):
raise MalformedError(
f"'{items_key}' item not a mapping type: '{path}{toc_idx}/{item_idx}'"
f"item not a mapping type @ '{toc_path}{items_key}/{item_idx}'"
)

link_keys = _known_link_keys.intersection(item_data)

# validation checks
if not link_keys:
raise MalformedError(
f"'{items_key}' item does not contain one of "
f"{_known_link_keys!r}: '{path}{toc_idx}/{item_idx}'"
f"item does not contain one of "
f"{_known_link_keys!r} @ '{toc_path}{items_key}/{item_idx}'"
)
if not len(link_keys) == 1:
raise MalformedError(
f"'{items_key}' item contains incompatible keys "
f"{link_keys!r}: {path}{toc_idx}/{item_idx}"
f"item contains incompatible keys "
f"{link_keys!r} @ '{toc_path}{items_key}/{item_idx}'"
)
for item_key in (GLOB_KEY, URL_KEY):
for other_key in (subtrees_key, items_key):
if link_keys == {item_key} and other_key in item_data:
raise MalformedError(
f"'{items_key}' item contains incompatible keys "
f"'{item_key}' and '{other_key}': {path}{toc_idx}/{item_idx}"
f"item contains incompatible keys "
f"'{item_key}' and '{other_key}' @ '{toc_path}{items_key}/{item_idx}'"
)

if link_keys == {FILE_KEY}:
Expand All @@ -222,7 +227,7 @@ def _parse_doc_item(
try:
toc_item = TocTree(items=items, **keywords)
except TypeError as exc:
raise MalformedError(f"toctree validation: {path}{toc_idx}") from exc
raise MalformedError(f"toctree validation @ '{toc_path}'") from exc
toctrees.append(toc_item)

try:
Expand All @@ -232,21 +237,27 @@ def _parse_doc_item(
except TypeError as exc:
raise MalformedError(f"doc validation: {path}") from exc

docs_data = [
item_data
for toc_data in subtrees_data
for item_data in toc_data[items_key]
# list of docs that need to be parsed recursively (and path)
docs_to_be_parsed_list = [
(
f"{path}/{items_key}/{ii}/"
if shorthand_used
else f"{path}{ti}/{items_key}/{ii}/",
item_data,
)
for ti, toc_data in enumerate(subtrees_data)
for ii, item_data in enumerate(toc_data[items_key])
if FILE_KEY in item_data
]

return (
doc_item,
docs_data,
docs_to_be_parsed_list,
)


def _parse_docs_list(
docs_list: Sequence[Dict[str, Any]],
docs_list: Sequence[Tuple[str, Dict[str, Any]]],
site_map: SiteMap,
defaults: Dict[str, Any],
path: str,
Expand All @@ -255,11 +266,10 @@ def _parse_docs_list(
file_format: FileFormat,
):
"""Parse a list of docs."""
for doc_data in docs_list:
docname = doc_data["file"]
for child_path, doc_data in docs_list:
docname = doc_data[FILE_KEY]
if docname in site_map:
raise MalformedError(f"document file used multiple times: {docname}")
child_path = f"{path}{docname}/"
raise MalformedError(f"document file used multiple times: '{docname}'")
child_item, child_docs_list = _parse_doc_item(
doc_data, defaults, child_path, depth=depth, file_format=file_format
)
Expand All @@ -280,13 +290,13 @@ def create_toc_dict(site_map: SiteMap, *, skip_defaults: bool = True) -> Dict[st
try:
file_format = FILE_FORMATS[site_map.file_format or "default"]
except KeyError:
raise KeyError(f"File format not recognised: '{site_map.file_format}'")
raise KeyError(f"File format not recognised @ '{site_map.file_format}'")
data = _docitem_to_dict(
site_map.root,
site_map,
depth=0,
skip_defaults=skip_defaults,
file_key="root",
is_root=True,
file_format=file_format,
)
if site_map.meta:
Expand All @@ -304,14 +314,15 @@ def _docitem_to_dict(
depth: int,
file_format: FileFormat,
skip_defaults: bool = True,
file_key: str = FILE_KEY,
is_root: bool = False,
parsed_docnames: Optional[Set[str]] = None,
) -> Dict[str, Any]:
"""

:param skip_defaults: do not add key/values for values that are already the default

"""
file_key = ROOT_KEY if is_root else FILE_KEY
subtrees_key = file_format.get_subtrees_key(depth)
items_key = file_format.get_items_key(depth)

Expand Down
7 changes: 7 additions & 0 deletions tests/_bad_toc_files/unknown_keys_nested.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
root: main
subtrees:
- file: doc1
items:
- file: doc2
- file: doc3
unknown: 1
20 changes: 18 additions & 2 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from sphinx_external_toc.parsing import MalformedError, create_toc_dict, parse_toc_yaml

TOC_FILES = list(Path(__file__).parent.joinpath("_toc_files").glob("*.yml"))
TOC_FILES_BAD = list(Path(__file__).parent.joinpath("_bad_toc_files").glob("*.yml"))


@pytest.mark.parametrize(
Expand All @@ -25,9 +24,26 @@ def test_create_toc_dict(path: Path, data_regression):
data_regression.check(data)


TOC_FILES_BAD = list(Path(__file__).parent.joinpath("_bad_toc_files").glob("*.yml"))
ERROR_MESSAGES = {
"empty.yml": "toc is not a mapping:",
"file_and_glob_present.yml": "item contains incompatible keys .* @ '/items/0'",
"list.yml": "toc is not a mapping:",
"unknown_keys.yml": "Unknown keys found: .* @ '/'",
"empty_items.yml": "'items' not a non-empty list @ '/'",
"items_in_glob.yml": "item contains incompatible keys 'glob' and 'items' @ '/items/0'",
"no_root.yml": "'root' key not found @ '/'",
"unknown_keys_nested.yml": "Unknown keys found: {'unknown'}, allow.* @ '/subtrees/0/items/1/'",
"empty_subtrees.yml": "'subtrees' not a non-empty list @ '/'",
"items_in_url.yml": "item contains incompatible keys 'url' and 'items' @ '/items/0'",
"subtree_with_no_items.yml": "item not a mapping containing 'items' key @ '/subtrees/0/'",
}


@pytest.mark.parametrize(
"path", TOC_FILES_BAD, ids=[path.name.rsplit(".", 1)[0] for path in TOC_FILES_BAD]
)
def test_malformed_file_parse(path: Path):
with pytest.raises(MalformedError):
message = ERROR_MESSAGES[path.name]
with pytest.raises(MalformedError, match=message):
parse_toc_yaml(path)