Skip to content

Commit 6fdc3af

Browse files
committed
✨ NEW: Add create_toc_dict
1 parent f83948b commit 6fdc3af

11 files changed

+161
-23
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ meta:
198198
create_append:
199199
intro: |
200200
This is some
201-
extra text
201+
appended text
202202
create_files:
203203
- doc1
204204
- doc2
@@ -211,9 +211,9 @@ The ToC file is parsed to a `SiteMap`, which is a `MutableMapping` subclass, wit
211211

212212
```python
213213
import yaml
214-
from sphinx_external_toc.api import parse_toc_file
214+
from sphinx_external_toc.api import parse_toc_yaml
215215
path = "path/to/_toc.yml"
216-
site_map = parse_toc_file(path)
216+
site_map = parse_toc_yaml(path)
217217
yaml.dump(site_map.as_json())
218218
```
219219

@@ -257,7 +257,7 @@ Questions / TODOs:
257257

258258
- Should `titlesonly` default to `True` (as in jupyter-book)?
259259
- nested numbered toctree not allowed (logs warning), so should be handled if `numbered: true` is in defaults
260-
- Add additional top-level keys, e.g. `appendices` and `bibliography`
260+
- Add additional top-level keys, e.g. `appendices` (see https://github.com/sphinx-doc/sphinx/issues/2502) and `bibliography`
261261
- Add tests for "bad" toc files
262262
- Using `external_toc_exclude_missing` to exclude a certain file suffix:
263263
currently if you had files `doc.md` and `doc.rst`, and put `doc.md` in your ToC,

sphinx_external_toc/api.py

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
from attr.validators import instance_of, deep_iterable, optional
88
import yaml
99

10+
FILE_KEY = "file"
11+
GLOB_KEY = "glob"
12+
URL_KEY = "url"
13+
1014

1115
class FileItem(str):
1216
"""A document path in a toctree list.
@@ -147,7 +151,7 @@ class MalformedError(Exception):
147151
"""Raised if toc file is malformed."""
148152

149153

150-
def parse_toc_file(path: Union[str, Path], encoding: str = "utf8") -> SiteMap:
154+
def parse_toc_yaml(path: Union[str, Path], encoding: str = "utf8") -> SiteMap:
151155
"""Parse the ToC file."""
152156
with Path(path).open(encoding=encoding) as handle:
153157
data = yaml.safe_load(handle)
@@ -168,7 +172,7 @@ def parse_toc_data(data: Dict[str, Any]) -> SiteMap:
168172

169173

170174
def _parse_doc_item(
171-
data: Dict[str, Any], defaults: Dict[str, Any], path: str, file_key: str = "file"
175+
data: Dict[str, Any], defaults: Dict[str, Any], path: str, file_key: str = FILE_KEY
172176
) -> Tuple[DocItem, Sequence[Dict[str, Any]]]:
173177
"""Parse a single doc item."""
174178
if file_key not in data:
@@ -184,7 +188,7 @@ def _parse_doc_item(
184188
if not isinstance(parts_data, Sequence):
185189
raise MalformedError(f"'parts' not a sequence: '{path}'")
186190

187-
_known_link_keys = {"file", "glob", "url"}
191+
_known_link_keys = {FILE_KEY, GLOB_KEY, URL_KEY}
188192

189193
parts = []
190194
for part_idx, part in enumerate(parts_data):
@@ -203,12 +207,12 @@ def _parse_doc_item(
203207
"toctree section contains incompatible keys "
204208
f"{link_keys!r}: {path}{part_idx}/{sect_idx}"
205209
)
206-
if link_keys == {"file"}:
207-
sections.append(FileItem(section["file"]))
208-
elif link_keys == {"glob"}:
209-
sections.append(GlobItem(section["glob"]))
210-
elif link_keys == {"url"}:
211-
sections.append(UrlItem(section["url"], section.get("title")))
210+
if link_keys == {FILE_KEY}:
211+
sections.append(FileItem(section[FILE_KEY]))
212+
elif link_keys == {GLOB_KEY}:
213+
sections.append(GlobItem(section[GLOB_KEY]))
214+
elif link_keys == {URL_KEY}:
215+
sections.append(UrlItem(section[URL_KEY], section.get("title")))
212216

213217
# generate toc key-word arguments
214218
keywords = {}
@@ -239,7 +243,7 @@ def _parse_doc_item(
239243
section
240244
for part in parts_data
241245
for section in part["sections"]
242-
if "file" in section
246+
if FILE_KEY in section
243247
]
244248

245249
return (
@@ -264,3 +268,72 @@ def _parse_docs_list(
264268
site_map[docname] = child_item
265269

266270
_parse_docs_list(child_docs_list, site_map, defaults, child_path)
271+
272+
273+
def create_toc_dict(site_map: SiteMap, *, skip_defaults: bool = True) -> Dict[str, Any]:
274+
"""Create the Toc dictionary from a site-map."""
275+
data = _docitem_to_dict(
276+
site_map.root, site_map, skip_defaults=skip_defaults, file_key="root"
277+
)
278+
if site_map.meta:
279+
data["meta"] = site_map.meta.copy()
280+
return data
281+
282+
283+
def _docitem_to_dict(
284+
doc_item: DocItem,
285+
site_map: SiteMap,
286+
*,
287+
skip_defaults: bool = True,
288+
file_key: str = FILE_KEY,
289+
parsed_docnames: Optional[Set[str]] = None,
290+
) -> Dict[str, Any]:
291+
292+
# protect against infinite recursion
293+
parsed_docnames = parsed_docnames or set()
294+
if doc_item.docname in parsed_docnames:
295+
raise RecursionError(f"{doc_item.docname!r} in site-map multiple times")
296+
parsed_docnames.add(doc_item.docname)
297+
298+
data: Dict[str, Any] = {}
299+
300+
data[file_key] = doc_item.docname
301+
if doc_item.title is not None:
302+
data["title"] = doc_item.title
303+
304+
if not doc_item.parts:
305+
return data
306+
307+
def _parse_section(item):
308+
if isinstance(item, FileItem):
309+
return _docitem_to_dict(
310+
site_map[item],
311+
site_map,
312+
skip_defaults=skip_defaults,
313+
parsed_docnames=parsed_docnames,
314+
)
315+
if isinstance(item, GlobItem):
316+
return {GLOB_KEY: str(item)}
317+
if isinstance(item, UrlItem):
318+
if item.title is not None:
319+
return {URL_KEY: item.url, "title": item.title}
320+
return {URL_KEY: item.url}
321+
raise TypeError(item)
322+
323+
data["parts"] = []
324+
fields = attr.fields_dict(TocItem)
325+
for part in doc_item.parts:
326+
# only add these keys if their value is not the default
327+
part_data = {
328+
key: getattr(part, key)
329+
for key in ("caption", "numbered", "reversed", "titlesonly")
330+
if (not skip_defaults) or getattr(part, key) != fields[key].default
331+
}
332+
part_data["sections"] = [_parse_section(s) for s in part.sections]
333+
data["parts"].append(part_data)
334+
335+
# apply shorthand if possible
336+
if len(data["parts"]) == 1 and list(data["parts"][0]) == ["sections"]:
337+
data["sections"] = data.pop("parts")[0]["sections"]
338+
339+
return data

sphinx_external_toc/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import yaml
33

44
from sphinx_external_toc import __version__
5-
from sphinx_external_toc.api import parse_toc_file
5+
from sphinx_external_toc.api import parse_toc_yaml
66
from sphinx_external_toc.tools import create_site_from_toc
77

88

@@ -16,7 +16,7 @@ def main():
1616
@click.argument("toc_file", type=click.Path(exists=True, file_okay=True))
1717
def parse_toc(toc_file):
1818
"""Parse a ToC file to a site-map YAML."""
19-
site_map = parse_toc_file(toc_file)
19+
site_map = parse_toc_yaml(toc_file)
2020
click.echo(yaml.dump(site_map.as_json()))
2121

2222

sphinx_external_toc/events.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from sphinx.util.docutils import SphinxDirective
1515
from sphinx.util.matching import Matcher, patfilter, patmatch
1616

17-
from .api import DocItem, GlobItem, FileItem, SiteMap, UrlItem, parse_toc_file
17+
from .api import DocItem, GlobItem, FileItem, SiteMap, UrlItem, parse_toc_yaml
1818

1919
logger = logging.getLogger(__name__)
2020

@@ -51,7 +51,7 @@ def parse_toc_to_env(app: Sphinx, config: Config) -> None:
5151
Also, change the ``master_doc`` and add to ``exclude_patterns`` if necessary.
5252
"""
5353
try:
54-
site_map = parse_toc_file(Path(app.srcdir) / app.config["external_toc_path"])
54+
site_map = parse_toc_yaml(Path(app.srcdir) / app.config["external_toc_path"])
5555
except Exception as exc:
5656
raise ExtensionError(f"[etoc] {exc}") from exc
5757
config.external_site_map = site_map
@@ -120,13 +120,15 @@ def add_changed_toctrees(
120120

121121

122122
class TableOfContentsNode(nodes.Element):
123-
"""A placeholder for the insertion of a toctree (in ``append_toctrees``)."""
123+
"""A placeholder for the insertion of a toctree (in ``insert_toctrees``)."""
124124

125125
def __init__(self, **attributes: Any) -> None:
126126
super().__init__(rawsource="", **attributes)
127127

128128

129129
class TableofContents(SphinxDirective):
130+
"""Insert a placeholder for toctree insertion."""
131+
130132
# TODO allow for name option of tableofcontents (to reference it)
131133
def run(self) -> List[TableOfContentsNode]:
132134
"""Insert a ``TableOfContentsNode`` node."""

sphinx_external_toc/tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import shutil
44
from typing import Mapping, Optional, Sequence, Union
55

6-
from .api import parse_toc_file, SiteMap
6+
from .api import parse_toc_yaml, SiteMap
77

88

99
def create_site_from_toc(
@@ -31,7 +31,7 @@ def create_site_from_toc(
3131
3232
"""
3333
assert default_ext in {".rst", ".md"}
34-
site_map = parse_toc_file(toc_path)
34+
site_map = parse_toc_yaml(toc_path)
3535

3636
root_path = Path(toc_path).parent if root_path is None else Path(root_path)
3737
root_path.mkdir(parents=True, exist_ok=True)

tests/test_api.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from sphinx_external_toc.api import parse_toc_file
5+
from sphinx_external_toc.api import create_toc_dict, parse_toc_yaml
66

77
TOC_FILES = list(Path(__file__).parent.joinpath("_toc_files").glob("*.yml"))
88

@@ -11,5 +11,14 @@
1111
"path", TOC_FILES, ids=[path.name.rsplit(".", 1)[0] for path in TOC_FILES]
1212
)
1313
def test_file_to_sitemap(path: Path, data_regression):
14-
site_map = parse_toc_file(path)
14+
site_map = parse_toc_yaml(path)
1515
data_regression.check(site_map.as_json())
16+
17+
18+
@pytest.mark.parametrize(
19+
"path", TOC_FILES, ids=[path.name.rsplit(".", 1)[0] for path in TOC_FILES]
20+
)
21+
def test_create_toc_dict(path: Path, data_regression):
22+
site_map = parse_toc_yaml(path)
23+
data = create_toc_dict(site_map)
24+
data_regression.check(data)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
meta:
2+
regress: intro
3+
parts:
4+
- caption: Part Caption
5+
numbered: true
6+
sections:
7+
- file: doc1
8+
- file: doc2
9+
- file: doc3
10+
sections:
11+
- file: subfolder/doc4
12+
- url: https://example.com
13+
root: intro
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
meta:
2+
regress: intro
3+
parts:
4+
- numbered: true
5+
sections:
6+
- file: doc1
7+
- file: doc2
8+
- file: doc3
9+
sections:
10+
- file: doc4
11+
- url: https://example.com
12+
root: intro
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
meta:
2+
create_files:
3+
- doc2
4+
- subfolder/other1
5+
exclude_missing: true
6+
root: intro
7+
sections:
8+
- file: doc1
9+
- glob: subfolder/other*
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
meta:
2+
create_files:
3+
- doc1
4+
- doc2
5+
- doc3
6+
root: intro
7+
sections:
8+
- glob: doc*

0 commit comments

Comments
 (0)