Skip to content

Commit 83efd7f

Browse files
authored
✨ NEW: Add glob key (#6)
Store toctree list items as one of: `GlobItem`, `RefItem`, `UrlItem`, add glob logic to `append_toctrees`, add addtional file creation to `create_site_from_toc`.
1 parent 34aa822 commit 83efd7f

File tree

7 files changed

+149
-41
lines changed

7 files changed

+149
-41
lines changed

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ The value of the `doc` key will be a path to a file (relative to the `conf.py`)
3838
Each document can only occur once in the ToC!
3939
```
4040

41-
Documents can then have a `parts` key - denoting a list of individual toctrees for that document - and in-turn each part should have a `sections` key - denoting a list child documents/URLs.
41+
Document pages can then have a `parts` key - denoting a list of individual toctrees for that document - and in-turn each part should have a `sections` key - denoting a list of children links, that are one of: `doc`, `url` or `glob`:
42+
43+
- `doc`: relating to a single document page (as above)
44+
- `glob`: relating to one or more document pages *via* Unix shell-style wildcards (similar to [`fnmatch`](https://docs.python.org/3/library/fnmatch.html), but single stars don't match slashes.)
45+
- `url`: relating to an external URL (`http` or `https`)
46+
4247
This can proceed recursively to any depth.
4348

4449
```yaml
@@ -51,6 +56,7 @@ main:
5156
- sections:
5257
- doc: doc2
5358
- url: https://example.com
59+
- glob: other*
5460
```
5561

5662
As a shorthand, the `sections` key can be at the same level as the `doc`, which denotes a document with a single `part`.
@@ -64,6 +70,7 @@ main:
6470
sections:
6571
- doc: doc2
6672
- url: https://example.com
73+
- glob: other*
6774
```
6875

6976
### Titles and Captions
@@ -166,6 +173,20 @@ To build a template site from only a ToC file:
166173
$ sphinx-etoc create-site -p path/to/site -e rst path/to/_toc.yml
167174
```
168175

176+
Note, when using `glob` you can also add additional files in `meta`/`create_additional`, e.g.
177+
178+
```yaml
179+
main:
180+
doc: intro
181+
sections:
182+
- glob: doc*
183+
meta:
184+
create_additional:
185+
- doc1
186+
- doc2
187+
- doc3
188+
```
189+
169190
## Development Notes
170191

171192
Want to have a built-in CLI including commands:
@@ -187,11 +208,10 @@ Questions / TODOs:
187208
- What if toctree directive found in file? (raise incompatibility error/warnings)
188209
- Should `titlesonly` default to `True` (as in jupyter-book)?
189210
- nested numbered toctree not allowed (logs warning), so should be handled if `numbered: true` is in defaults
190-
- Handle globbing in sections (separate `glob` key?), also deal with in `create_site_from_toc`
191211
- Add additional top-level keys, e.g. `appendices` and `bibliography`
192212
- testing against Windows
193213
- option to add files not in toc to `ignore_paths` (including glob)
194-
214+
- Add tests for "bad" toc files
195215

196216
[github-ci]: https://github.com/executablebooks/sphinx-external-toc/workflows/continuous-integration/badge.svg?branch=main
197217
[github-link]: https://github.com/executablebooks/sphinx-external-toc

sphinx_external_toc/api.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88
import yaml
99

1010

11+
class RefItem(str):
12+
"""A document name in a toctree list."""
13+
14+
15+
class GlobItem(str):
16+
"""A document glob in a toctree list."""
17+
18+
1119
@attr.s(slots=True)
1220
class UrlItem:
1321
"""A URL in a toctree."""
@@ -21,8 +29,10 @@ class TocItem:
2129
"""An individual toctree within a document."""
2230

2331
# TODO validate uniqueness of docnames (at least one item)
24-
sections: List[Union[str, UrlItem]] = attr.ib(
25-
validator=deep_iterable(instance_of((str, UrlItem)), instance_of(list))
32+
sections: List[Union[GlobItem, RefItem, UrlItem]] = attr.ib(
33+
validator=deep_iterable(
34+
instance_of((GlobItem, RefItem, UrlItem)), instance_of(list)
35+
)
2636
)
2737
caption: Optional[str] = attr.ib(None, validator=optional(instance_of(str)))
2838
numbered: Union[bool, int] = attr.ib(False, validator=instance_of((bool, int)))
@@ -31,7 +41,7 @@ class TocItem:
3141
reversed: bool = attr.ib(False, validator=instance_of(bool))
3242

3343
def docnames(self) -> List[str]:
34-
return [section for section in self.sections if isinstance(section, str)]
44+
return [section for section in self.sections if isinstance(section, RefItem)]
3545

3646

3747
@attr.s(slots=True)
@@ -53,15 +63,22 @@ def children(self) -> List[str]:
5363
class SiteMap(MutableMapping):
5464
"""A mapping of documents to their toctrees (or None if terminal)."""
5565

56-
def __init__(self, root: DocItem) -> None:
66+
def __init__(self, root: DocItem, meta: Optional[Dict[str, Any]] = None) -> None:
5767
self._docs: Dict[str, DocItem] = {}
5868
self[root.docname] = root
5969
self._root: DocItem = root
70+
self._meta: Dict[str, Any] = meta or {}
6071

6172
@property
6273
def root(self) -> DocItem:
74+
"""Return the root document."""
6375
return self._root
6476

77+
@property
78+
def meta(self) -> Dict[str, Any]:
79+
"""Return the site-map metadata."""
80+
return self._meta
81+
6582
def __getitem__(self, docname: str) -> DocItem:
6683
return self._docs[docname]
6784

@@ -80,10 +97,29 @@ def __iter__(self) -> Iterator[str]:
8097
def __len__(self) -> int:
8198
return len(self._docs)
8299

83-
def as_json(self, root_key: str = "_root") -> Dict[str, Any]:
84-
dct = {k: attr.asdict(v) if v else v for k, v in self._docs.items()}
100+
@staticmethod
101+
def _serializer(inst: Any, field: attr.Attribute, value: Any) -> Any:
102+
"""Serialize to JSON compatible value.
103+
104+
(parsed to ``attr.asdict``)
105+
"""
106+
if isinstance(value, (GlobItem, RefItem)):
107+
return str(value)
108+
return value
109+
110+
def as_json(
111+
self, root_key: str = "_root", meta_key: str = "_meta"
112+
) -> Dict[str, Any]:
113+
"""Return JSON serialized site-map representation."""
114+
dct = {
115+
k: attr.asdict(v, value_serializer=self._serializer) if v else v
116+
for k, v in self._docs.items()
117+
}
85118
assert root_key not in dct
86119
dct[root_key] = self.root.docname
120+
if self.meta:
121+
assert meta_key not in dct
122+
dct[meta_key] = self.meta
87123
return dct
88124

89125

@@ -107,7 +143,7 @@ def parse_toc_data(data: Dict[str, Any]) -> SiteMap:
107143

108144
doc_item, docs_list = _parse_doc_item(data["main"], defaults, "main/")
109145

110-
site_map = SiteMap(root=doc_item)
146+
site_map = SiteMap(root=doc_item, meta=data.get("meta"))
111147

112148
_parse_docs_list(docs_list, site_map, defaults, "main/")
113149

@@ -131,13 +167,13 @@ def _parse_doc_item(
131167
if not isinstance(parts_data, Sequence):
132168
raise MalformedError(f"'parts' not a sequence: '{path}'")
133169

134-
_known_link_keys = {"url", "doc"}
170+
_known_link_keys = {"url", "doc", "glob"}
135171

136172
parts = []
137173
for part_idx, part in enumerate(parts_data):
138174

139175
# generate sections list
140-
sections: List[Union[str, UrlItem]] = []
176+
sections: List[Union[GlobItem, RefItem, UrlItem]] = []
141177
for sect_idx, section in enumerate(part["sections"]):
142178
link_keys = _known_link_keys.intersection(section)
143179
if not link_keys:
@@ -150,10 +186,12 @@ def _parse_doc_item(
150186
"toctree section contains incompatible keys "
151187
f"{link_keys!r}: {path}{part_idx}/{sect_idx}"
152188
)
153-
if link_keys == {"url"}:
189+
if link_keys == {"doc"}:
190+
sections.append(RefItem(section["doc"]))
191+
elif link_keys == {"glob"}:
192+
sections.append(GlobItem(section["glob"]))
193+
elif link_keys == {"url"}:
154194
sections.append(UrlItem(section["url"], section.get("title")))
155-
else:
156-
sections.append(section["doc"])
157195

158196
# generate toc key-word arguments
159197
keywords = {}

sphinx_external_toc/events.py

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
from sphinx.environment import BuildEnvironment
1010
from sphinx.errors import ExtensionError
1111
from sphinx.transforms import SphinxTransform
12-
from sphinx.util import logging
13-
from sphinx.util.matching import Matcher
12+
from sphinx.util import docname_join, logging
13+
from sphinx.util.matching import Matcher, patfilter
1414

15-
from .api import DocItem, SiteMap, UrlItem, parse_toc_file
15+
from .api import DocItem, GlobItem, RefItem, SiteMap, UrlItem, parse_toc_file
1616

1717
logger = logging.getLogger(__name__)
1818

@@ -64,7 +64,10 @@ def append_toctrees(app: Sphinx, doctree: document) -> None:
6464
if doc_item is None or not doc_item.parts:
6565
return
6666

67+
# initial variables
6768
suffixes = app.config.source_suffix
69+
all_docnames = app.env.found_docs.copy()
70+
all_docnames.remove(app.env.docname) # remove current document
6871
excluded = Matcher(app.config.exclude_patterns)
6972

7073
for toctree in doc_item.parts:
@@ -79,7 +82,7 @@ def append_toctrees(app: Sphinx, doctree: document) -> None:
7982
# TODO this wasn't in the original code,
8083
# but alabaster theme intermittently raised `KeyError('rawcaption')`
8184
subnode["rawcaption"] = toctree.caption or ""
82-
subnode["glob"] = False
85+
subnode["glob"] = any(isinstance(entry, GlobItem) for entry in toctree.sections)
8386
subnode["hidden"] = True
8487
subnode["includehidden"] = False
8588
subnode["numbered"] = toctree.numbered
@@ -92,33 +95,46 @@ def append_toctrees(app: Sphinx, doctree: document) -> None:
9295
for entry in toctree.sections:
9396

9497
if isinstance(entry, UrlItem):
98+
9599
subnode["entries"].append((entry.title, entry.url))
96-
continue
97100

98-
child_doc_item = site_map[entry]
101+
elif isinstance(entry, RefItem):
99102

100-
docname = child_doc_item.docname
101-
title = child_doc_item.title
103+
child_doc_item = site_map[entry]
104+
docname = docname_join(app.env.docname, str(entry))
105+
title = child_doc_item.title
102106

103-
# remove any suffixes
104-
for suffix in suffixes:
105-
if docname.endswith(suffix):
106-
docname = docname[: -len(suffix)]
107-
break
107+
# remove any suffixes
108+
for suffix in suffixes:
109+
if docname.endswith(suffix):
110+
docname = docname[: -len(suffix)]
111+
break
108112

109-
if docname not in app.env.found_docs:
110-
if excluded(app.env.doc2path(docname, None)):
111-
message = (
112-
f"toctree contains reference to excluded document {docname!r}"
113-
)
114-
else:
115-
message = f"toctree contains reference to nonexisting document {docname!r}"
113+
if docname not in app.env.found_docs:
114+
if excluded(app.env.doc2path(docname, None)):
115+
message = f"toctree contains reference to excluded document {docname!r}"
116+
else:
117+
message = f"toctree contains reference to nonexisting document {docname!r}"
116118

117-
node_list.append(doctree.reporter.warning(message))
118-
app.env.note_reread()
119-
else:
120-
subnode["entries"].append((title, docname))
121-
subnode["includefiles"].append(docname)
119+
node_list.append(doctree.reporter.warning(message))
120+
app.env.note_reread()
121+
else:
122+
subnode["entries"].append((title, docname))
123+
subnode["includefiles"].append(docname)
124+
125+
elif isinstance(entry, GlobItem):
126+
patname = docname_join(app.env.docname, str(entry))
127+
docnames = sorted(patfilter(all_docnames, patname))
128+
for docname in docnames:
129+
all_docnames.remove(docname) # don't include it again
130+
subnode["entries"].append((None, docname))
131+
subnode["includefiles"].append(docname)
132+
if not docnames:
133+
node_list.append(
134+
doctree.reporter.warning(
135+
f"toctree glob pattern '{entry}' didn't match any documents"
136+
)
137+
)
122138

123139
# reversing entries can be useful when globbing
124140
if toctree.reversed:

sphinx_external_toc/tools.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from itertools import chain
12
from pathlib import Path, PurePosixPath
23
from os import linesep
34
from typing import Union
@@ -15,6 +16,9 @@ def create_site_from_toc(
1516
) -> Path:
1617
"""Create the files defined in the external toc file.
1718
19+
Additional files can also be created by defining them in
20+
`meta`/`create_additional` of the toc
21+
1822
:param toc_path: Path to ToC.
1923
:param root_path: The root directory , or use ToC file directory.
2024
:param default_ext: The default file extension to use.
@@ -28,7 +32,7 @@ def create_site_from_toc(
2832

2933
root_path = Path(toc_path).parent if root_path is None else Path(root_path)
3034

31-
for docname in site_map:
35+
for docname in chain(site_map, site_map.meta.get("create_additional", [])):
3236
if not any(docname.endswith(ext) for ext in {".rst", ".md"}):
3337
docname += default_ext
3438
docpath = root_path.joinpath(PurePosixPath(docname))

tests/_toc_files/glob.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
main:
2+
doc: intro
3+
title: Introduction
4+
sections:
5+
- glob: doc*
6+
meta:
7+
create_additional:
8+
- doc1
9+
- doc2
10+
- doc3
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
_meta:
2+
create_additional:
3+
- doc1
4+
- doc2
5+
- doc3
6+
_root: intro
7+
intro:
8+
docname: intro
9+
parts:
10+
- caption: null
11+
numbered: false
12+
reversed: false
13+
sections:
14+
- doc*
15+
titlesonly: true
16+
title: Introduction
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- doc1.rst
2+
- doc2.rst
3+
- doc3.rst
4+
- intro.rst

0 commit comments

Comments
 (0)