|
1 | | -""" """ |
2 | | -from collections.abc import Mapping, MutableMapping |
3 | | -from pathlib import Path |
4 | | -from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Union |
| 1 | +"""Defines the `SiteMap` object, for storing the parsed ToC.""" |
| 2 | +from collections.abc import MutableMapping |
| 3 | +from typing import Any, Dict, Iterator, List, Optional, Set, Union |
5 | 4 |
|
6 | 5 | import attr |
7 | | -import yaml |
8 | 6 | from attr.validators import deep_iterable, instance_of, optional |
9 | 7 |
|
10 | | -FILE_KEY = "file" |
11 | | -GLOB_KEY = "glob" |
12 | | -URL_KEY = "url" |
13 | | - |
14 | 8 |
|
15 | 9 | class FileItem(str): |
16 | 10 | """A document path in a toctree list. |
@@ -145,211 +139,3 @@ def as_json( |
145 | 139 | assert meta_key not in dct |
146 | 140 | dct[meta_key] = self.meta |
147 | 141 | return dct |
148 | | - |
149 | | - |
150 | | -class MalformedError(Exception): |
151 | | - """Raised if toc file is malformed.""" |
152 | | - |
153 | | - |
154 | | -def parse_toc_yaml(path: Union[str, Path], encoding: str = "utf8") -> SiteMap: |
155 | | - """Parse the ToC file.""" |
156 | | - with Path(path).open(encoding=encoding) as handle: |
157 | | - data = yaml.safe_load(handle) |
158 | | - return parse_toc_data(data) |
159 | | - |
160 | | - |
161 | | -def parse_toc_data(data: Dict[str, Any]) -> SiteMap: |
162 | | - """Parse a dictionary of the ToC.""" |
163 | | - if not isinstance(data, Mapping): |
164 | | - raise MalformedError(f"toc is not a mapping: {type(data)}") |
165 | | - |
166 | | - defaults: Dict[str, Any] = data.get("defaults", {}) |
167 | | - |
168 | | - doc_item, docs_list = _parse_doc_item(data, defaults, "/", file_key="root") |
169 | | - |
170 | | - site_map = SiteMap(root=doc_item, meta=data.get("meta")) |
171 | | - |
172 | | - _parse_docs_list(docs_list, site_map, defaults, "/") |
173 | | - |
174 | | - return site_map |
175 | | - |
176 | | - |
177 | | -def _parse_doc_item( |
178 | | - data: Dict[str, Any], defaults: Dict[str, Any], path: str, file_key: str = FILE_KEY |
179 | | -) -> Tuple[DocItem, Sequence[Dict[str, Any]]]: |
180 | | - """Parse a single doc item.""" |
181 | | - if file_key not in data: |
182 | | - raise MalformedError(f"'{file_key}' key not found: '{path}'") |
183 | | - if "sections" in data: |
184 | | - # this is a shorthand for defining a single part |
185 | | - if "parts" in data: |
186 | | - raise MalformedError(f"Both 'sections' and 'parts' found: '{path}'") |
187 | | - parts_data = [{"sections": data["sections"]}] |
188 | | - else: |
189 | | - parts_data = data.get("parts", []) |
190 | | - |
191 | | - if not isinstance(parts_data, Sequence): |
192 | | - raise MalformedError(f"'parts' not a sequence: '{path}'") |
193 | | - |
194 | | - _known_link_keys = {FILE_KEY, GLOB_KEY, URL_KEY} |
195 | | - |
196 | | - parts = [] |
197 | | - for part_idx, part in enumerate(parts_data): |
198 | | - |
199 | | - # generate sections list |
200 | | - sections: List[Union[GlobItem, FileItem, UrlItem]] = [] |
201 | | - for sect_idx, section in enumerate(part["sections"]): |
202 | | - link_keys = _known_link_keys.intersection(section) |
203 | | - if not link_keys: |
204 | | - raise MalformedError( |
205 | | - "toctree section does not contain one of " |
206 | | - f"{_known_link_keys!r}: {path}{part_idx}/{sect_idx}" |
207 | | - ) |
208 | | - if not len(link_keys) == 1: |
209 | | - raise MalformedError( |
210 | | - "toctree section contains incompatible keys " |
211 | | - f"{link_keys!r}: {path}{part_idx}/{sect_idx}" |
212 | | - ) |
213 | | - |
214 | | - if link_keys == {FILE_KEY}: |
215 | | - sections.append(FileItem(section[FILE_KEY])) |
216 | | - elif link_keys == {GLOB_KEY}: |
217 | | - if "sections" in section or "parts" in section: |
218 | | - raise MalformedError( |
219 | | - "toctree section contains incompatible keys " |
220 | | - f"{GLOB_KEY} and parts/sections: {path}{part_idx}/{sect_idx}" |
221 | | - ) |
222 | | - sections.append(GlobItem(section[GLOB_KEY])) |
223 | | - elif link_keys == {URL_KEY}: |
224 | | - if "sections" in section or "parts" in section: |
225 | | - raise MalformedError( |
226 | | - "toctree section contains incompatible keys " |
227 | | - f"{URL_KEY} and parts/sections: {path}{part_idx}/{sect_idx}" |
228 | | - ) |
229 | | - sections.append(UrlItem(section[URL_KEY], section.get("title"))) |
230 | | - |
231 | | - # generate toc key-word arguments |
232 | | - keywords = {} |
233 | | - for key in ("caption", "numbered", "titlesonly", "reversed"): |
234 | | - if key in part: |
235 | | - keywords[key] = part[key] |
236 | | - elif key in defaults: |
237 | | - keywords[key] = defaults[key] |
238 | | - |
239 | | - # TODO this is a hacky fix for the fact that sphinx logs a warning |
240 | | - # for nested toctrees, see: |
241 | | - # sphinx/environment/collectors/toctree.py::TocTreeCollector::assign_section_numbers::_walk_toctree |
242 | | - if keywords.get("numbered") and path != "/": |
243 | | - keywords.pop("numbered") |
244 | | - |
245 | | - try: |
246 | | - toc_item = TocItem(sections=sections, **keywords) |
247 | | - except TypeError as exc: |
248 | | - raise MalformedError(f"toctree validation: {path}{part_idx}") from exc |
249 | | - parts.append(toc_item) |
250 | | - |
251 | | - try: |
252 | | - doc_item = DocItem(docname=data[file_key], title=data.get("title"), parts=parts) |
253 | | - except TypeError as exc: |
254 | | - raise MalformedError(f"doc validation: {path}") from exc |
255 | | - |
256 | | - docs_data = [ |
257 | | - section |
258 | | - for part in parts_data |
259 | | - for section in part["sections"] |
260 | | - if FILE_KEY in section |
261 | | - ] |
262 | | - |
263 | | - return ( |
264 | | - doc_item, |
265 | | - docs_data, |
266 | | - ) |
267 | | - |
268 | | - |
269 | | -def _parse_docs_list( |
270 | | - docs_list: Sequence[Dict[str, Any]], |
271 | | - site_map: SiteMap, |
272 | | - defaults: Dict[str, Any], |
273 | | - path: str, |
274 | | -): |
275 | | - """Parse a list of docs.""" |
276 | | - for doc_data in docs_list: |
277 | | - docname = doc_data["file"] |
278 | | - if docname in site_map: |
279 | | - raise MalformedError(f"document file used multiple times: {docname}") |
280 | | - child_path = f"{path}{docname}/" |
281 | | - child_item, child_docs_list = _parse_doc_item(doc_data, defaults, child_path) |
282 | | - site_map[docname] = child_item |
283 | | - |
284 | | - _parse_docs_list(child_docs_list, site_map, defaults, child_path) |
285 | | - |
286 | | - |
287 | | -def create_toc_dict(site_map: SiteMap, *, skip_defaults: bool = True) -> Dict[str, Any]: |
288 | | - """Create the Toc dictionary from a site-map.""" |
289 | | - data = _docitem_to_dict( |
290 | | - site_map.root, site_map, skip_defaults=skip_defaults, file_key="root" |
291 | | - ) |
292 | | - if site_map.meta: |
293 | | - data["meta"] = site_map.meta.copy() |
294 | | - return data |
295 | | - |
296 | | - |
297 | | -def _docitem_to_dict( |
298 | | - doc_item: DocItem, |
299 | | - site_map: SiteMap, |
300 | | - *, |
301 | | - skip_defaults: bool = True, |
302 | | - file_key: str = FILE_KEY, |
303 | | - parsed_docnames: Optional[Set[str]] = None, |
304 | | -) -> Dict[str, Any]: |
305 | | - |
306 | | - # protect against infinite recursion |
307 | | - parsed_docnames = parsed_docnames or set() |
308 | | - if doc_item.docname in parsed_docnames: |
309 | | - raise RecursionError(f"{doc_item.docname!r} in site-map multiple times") |
310 | | - parsed_docnames.add(doc_item.docname) |
311 | | - |
312 | | - data: Dict[str, Any] = {} |
313 | | - |
314 | | - data[file_key] = doc_item.docname |
315 | | - if doc_item.title is not None: |
316 | | - data["title"] = doc_item.title |
317 | | - |
318 | | - if not doc_item.parts: |
319 | | - return data |
320 | | - |
321 | | - def _parse_section(item): |
322 | | - if isinstance(item, FileItem): |
323 | | - if item in site_map: |
324 | | - return _docitem_to_dict( |
325 | | - site_map[item], |
326 | | - site_map, |
327 | | - skip_defaults=skip_defaults, |
328 | | - parsed_docnames=parsed_docnames, |
329 | | - ) |
330 | | - return {FILE_KEY: str(item)} |
331 | | - if isinstance(item, GlobItem): |
332 | | - return {GLOB_KEY: str(item)} |
333 | | - if isinstance(item, UrlItem): |
334 | | - if item.title is not None: |
335 | | - return {URL_KEY: item.url, "title": item.title} |
336 | | - return {URL_KEY: item.url} |
337 | | - raise TypeError(item) |
338 | | - |
339 | | - data["parts"] = [] |
340 | | - fields = attr.fields_dict(TocItem) |
341 | | - for part in doc_item.parts: |
342 | | - # only add these keys if their value is not the default |
343 | | - part_data = { |
344 | | - key: getattr(part, key) |
345 | | - for key in ("caption", "numbered", "reversed", "titlesonly") |
346 | | - if (not skip_defaults) or getattr(part, key) != fields[key].default |
347 | | - } |
348 | | - part_data["sections"] = [_parse_section(s) for s in part.sections] |
349 | | - data["parts"].append(part_data) |
350 | | - |
351 | | - # apply shorthand if possible |
352 | | - if len(data["parts"]) == 1 and list(data["parts"][0]) == ["sections"]: |
353 | | - data["sections"] = data.pop("parts")[0]["sections"] |
354 | | - |
355 | | - return data |
0 commit comments