diff --git a/README.md b/README.md index b52a4f3..8d5923a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. ``` diff --git a/sphinx_external_toc/cli.py b/sphinx_external_toc/cli.py index 7af5f60..27a6721 100644 --- a/sphinx_external_toc/cli.py +++ b/sphinx_external_toc/cli.py @@ -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.""" diff --git a/sphinx_external_toc/parsing.py b/sphinx_external_toc/parsing.py index ac3caba..1e10d15 100644 --- a/sphinx_external_toc/parsing.py +++ b/sphinx_external_toc/parsing.py @@ -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" @@ -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( @@ -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) @@ -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 = [] @@ -164,17 +169,17 @@ 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]] = [] @@ -182,7 +187,7 @@ def _parse_doc_item( 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) @@ -190,20 +195,20 @@ def _parse_doc_item( # 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}: @@ -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: @@ -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, @@ -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 ) @@ -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: @@ -304,7 +314,7 @@ 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]: """ @@ -312,6 +322,7 @@ def _docitem_to_dict( :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) diff --git a/tests/_bad_toc_files/empty_sections.yml b/tests/_bad_toc_files/empty_items.yml similarity index 100% rename from tests/_bad_toc_files/empty_sections.yml rename to tests/_bad_toc_files/empty_items.yml diff --git a/tests/_bad_toc_files/empty_parts.yml b/tests/_bad_toc_files/empty_subtrees.yml similarity index 100% rename from tests/_bad_toc_files/empty_parts.yml rename to tests/_bad_toc_files/empty_subtrees.yml diff --git a/tests/_bad_toc_files/section_in_glob.yml b/tests/_bad_toc_files/items_in_glob.yml similarity index 100% rename from tests/_bad_toc_files/section_in_glob.yml rename to tests/_bad_toc_files/items_in_glob.yml diff --git a/tests/_bad_toc_files/section_in_url.yml b/tests/_bad_toc_files/items_in_url.yml similarity index 100% rename from tests/_bad_toc_files/section_in_url.yml rename to tests/_bad_toc_files/items_in_url.yml diff --git a/tests/_bad_toc_files/empty_part.yml b/tests/_bad_toc_files/subtree_with_no_items.yml similarity index 100% rename from tests/_bad_toc_files/empty_part.yml rename to tests/_bad_toc_files/subtree_with_no_items.yml diff --git a/tests/_bad_toc_files/unknown_keys_nested.yml b/tests/_bad_toc_files/unknown_keys_nested.yml new file mode 100644 index 0000000..04898b9 --- /dev/null +++ b/tests/_bad_toc_files/unknown_keys_nested.yml @@ -0,0 +1,7 @@ +root: main +subtrees: +- file: doc1 + items: + - file: doc2 + - file: doc3 + unknown: 1 diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 70bc2c6..1facb21 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -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( @@ -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)