From 146bc68e03e75090e22adad9e827ad3619d2447e Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 29 Aug 2023 10:48:20 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20Add=20strict=20typing=20for=20`s?= =?UTF-8?q?phinx=5Fneeds.layout`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 - sphinx_needs/layout.py | 150 ++++++++++++++++++++++------------------- 2 files changed, 81 insertions(+), 70 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0db313919..b931398e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,6 @@ module = [ 'sphinx_needs.api.need', 'sphinx_needs.directives.needuml', 'sphinx_needs.functions.functions', - 'sphinx_needs.layout', ] ignore_errors = true diff --git a/sphinx_needs/layout.py b/sphinx_needs/layout.py index cf47c9953..b8547b28a 100644 --- a/sphinx_needs/layout.py +++ b/sphinx_needs/layout.py @@ -9,7 +9,7 @@ from contextlib import suppress from functools import lru_cache from optparse import Values -from typing import List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple, Union from urllib.parse import urlparse import requests @@ -18,18 +18,20 @@ from docutils.parsers.rst import Parser, languages from docutils.parsers.rst.states import Inliner, Struct from docutils.utils import new_document -from jinja2 import BaseLoader, Environment +from jinja2 import Environment from sphinx.application import Sphinx from sphinx.environment.collectors.asset import DownloadFileCollector, ImageCollector from sphinx_needs.config import NeedsSphinxConfig -from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.data import NeedsInfoType, SphinxNeedsData from sphinx_needs.debug import measure_time from sphinx_needs.utils import INTERNALS, match_string_link, unwrap @measure_time("need") -def create_need(need_id: str, app: Sphinx, layout=None, style=None, docname: Optional[str] = None) -> nodes.container: +def create_need( + need_id: str, app: Sphinx, layout: Optional[str] = None, style: Optional[str] = None, docname: Optional[str] = None +) -> nodes.container: """ Creates a new need-node for a given layout. @@ -62,7 +64,9 @@ def create_need(need_id: str, app: Sphinx, layout=None, style=None, docname: Opt # We must create a standalone copy of the content_node, as it may be reused several time # (multiple needextract for the same need) and the Sphinx ImageTransformator add location specific # uri to some nodes, which are not valid for all locations. - node_inner = needs[need_id]["content_node"].deepcopy() + content_node = needs[need_id]["content_node"] + assert content_node is not None, f"Need {need_id} has no content node." + node_inner = content_node.deepcopy() # Rerun some important Sphinx collectors for need-content coming from "needsexternal". # This is needed, as Sphinx needs to know images and download paths. @@ -72,8 +76,8 @@ def create_need(need_id: str, app: Sphinx, layout=None, style=None, docname: Opt # Overwrite the docname, which must be the original one from the reused need, as all used paths are relative # to the original location, not to the current document. env.temp_data["docname"] = need_data["docname"] # Dirty, as in this phase normally no docname is set anymore in env - ImageCollector().process_doc(app, node_inner) - DownloadFileCollector().process_doc(app, node_inner) + ImageCollector().process_doc(app, node_inner) # type: ignore[arg-type] + DownloadFileCollector().process_doc(app, node_inner) # type: ignore[arg-type] del env.temp_data["docname"] # Be sure our env is as it was before @@ -102,7 +106,7 @@ def create_need(need_id: str, app: Sphinx, layout=None, style=None, docname: Opt return node_container -def replace_pending_xref_refdoc(node, new_refdoc: str) -> None: +def replace_pending_xref_refdoc(node: nodes.Element, new_refdoc: str) -> None: """ Overwrites the refdoc attribute of all pending_xref nodes. This is needed, if a doctree with references gets copied used somewhereelse in the documentation. @@ -117,11 +121,13 @@ def replace_pending_xref_refdoc(node, new_refdoc: str) -> None: node.attributes["refdoc"] = new_refdoc else: for child in node.children: - replace_pending_xref_refdoc(child, new_refdoc) + replace_pending_xref_refdoc(child, new_refdoc) # type: ignore[arg-type] @measure_time("need") -def build_need(layout, node, app: Sphinx, style=None, fromdocname: Optional[str] = None) -> None: +def build_need( + layout: str, node: nodes.Element, app: Sphinx, style: Optional[str] = None, fromdocname: Optional[str] = None +) -> None: """ Builds a need based on a given layout for a given need-node. @@ -137,31 +143,24 @@ def build_need(layout, node, app: Sphinx, style=None, fromdocname: Optional[str] ------ custom layout nodes The level structure must be kept, otherwise docutils can not handle it! - - :param layout: - :param node: - :param app: - :param style: - :param fromdocname: - :return: """ env = app.builder.env needs = SphinxNeedsData(env).get_or_create_needs() node_container = nodes.container() - need_layout = layout need_id = node.attributes["ids"][0] need_data = needs[need_id] if need_data["hide"]: - node.parent.replace(node, []) + if node.parent: + node.parent.replace(node, []) # type: ignore return if fromdocname is None: fromdocname = need_data["docname"] - lh = LayoutHandler(app, need_data, need_layout, node, style, fromdocname) + lh = LayoutHandler(app, need_data, layout, node, style, fromdocname) new_need_node = lh.get_need_table() node_container.append(new_need_node) @@ -171,14 +170,14 @@ def build_need(layout, node, app: Sphinx, style=None, fromdocname: Optional[str] # We need to replace the current need-node (containing content only) with our new table need node. # node.parent.replace(node, node_container) - node.parent.replace(node, node_container) + node.parent.replace(node, node_container) # type: ignore @lru_cache(1) def _generate_inline_parser() -> Tuple[Values, Inliner]: doc_settings = OptionParser(components=(Parser,)).get_default_values() inline_parser = Inliner() - inline_parser.init_customizations(doc_settings) + inline_parser.init_customizations(doc_settings) # type: ignore return doc_settings, inline_parser @@ -187,13 +186,21 @@ class LayoutHandler: Cares about the correct layout handling """ - def __init__(self, app: Sphinx, need, layout, node, style=None, fromdocname: Optional[str] = None) -> None: + def __init__( + self, + app: Sphinx, + need: NeedsInfoType, + layout: str, + node: nodes.Element, + style: Optional[str] = None, + fromdocname: Optional[str] = None, + ) -> None: self.app = app self.need = need - self.config = NeedsSphinxConfig(app.config) + self.needs_config = NeedsSphinxConfig(app.config) self.layout_name = layout - available_layouts = self.config.layouts + available_layouts = self.needs_config.layouts if self.layout_name not in available_layouts.keys(): raise SphinxNeedLayoutException( 'Given layout "{}" is unknown for need {}. Registered layouts are: {}'.format( @@ -212,9 +219,9 @@ def __init__(self, app: Sphinx, need, layout, node, style=None, fromdocname: Opt # For ReadTheDocs Theme we need to add 'rtd-exclude-wy-table'. classes = ["need", "needs_grid_" + self.layout["grid"], "needs_layout_" + self.layout_name] - classes.extend(self.config.table_classes) + classes.extend(self.needs_config.table_classes) - self.style = style or self.need["style"] or getattr(self.app.config, "needs_default_style", None) + self.style = style or self.need["style"] or self.needs_config.default_style if self.style: for style in self.style.strip().split(","): @@ -294,7 +301,7 @@ def __init__(self, app: Sphinx, need, layout, node, style=None, fromdocname: Opt inliner=None, ) - self.functions = { + self.functions: Dict[str, Callable[..., Union[None, nodes.Node, List[nodes.Node]]]] = { "meta": self.meta, "meta_all": self.meta_all, "meta_links": self.meta_links, @@ -312,10 +319,10 @@ def __init__(self, app: Sphinx, need, layout, node, style=None, fromdocname: Opt # This would lead to deepcopy()-errors, as needs_string_links gets some "pickled" and jinja Environment is # too complex for this. self.string_links = {} - for link_name, link_conf in self.config.string_links.items(): + for link_name, link_conf in self.needs_config.string_links.items(): self.string_links[link_name] = { - "url_template": Environment(loader=BaseLoader).from_string(link_conf["link_url"]), - "name_template": Environment(loader=BaseLoader).from_string(link_conf["link_name"]), + "url_template": Environment().from_string(link_conf["link_url"]), + "name_template": Environment().from_string(link_conf["link_name"]), "regex_compiled": re.compile(link_conf["regex"]), "options": link_conf["options"], "name": link_name, @@ -331,7 +338,7 @@ def get_need_table(self) -> nodes.table: if callable(func): func() else: - func["func"](**func["configs"]) + func["func"](**func["configs"]) # type: ignore[index] return self.node_table @@ -366,12 +373,12 @@ def _parse(self, line: str) -> List[nodes.Node]: :param line: string to parse :return: nodes """ - result, message = self.inline_parser.parse(line, 0, self.doc_memo, self.dummy_doc) + result, message = self.inline_parser.parse(line, 0, self.doc_memo, self.dummy_doc) # type: ignore if message: raise SphinxNeedLayoutException(message) - return result + return result # type: ignore[no-any-return] - def _func_replace(self, section_nodes): + def _func_replace(self, section_nodes: List[nodes.Node]) -> List[nodes.Node]: """ Replaces a function definition like ``<>`` with the related docutils nodes. @@ -382,11 +389,12 @@ def _func_replace(self, section_nodes): :return: docutils nodes """ return_nodes = [] + result: Union[None, nodes.Node, List[nodes.Node]] for node in section_nodes: if not isinstance(node, nodes.Text): for child in node.children: new_child = self._func_replace([child]) - node.replace(child, new_child) + node.replace(child, new_child) # type: ignore[attr-defined] return_nodes.append(node) else: node_str = str(node) @@ -462,18 +470,18 @@ def _func_replace(self, section_nodes): return_nodes.append(node_line) return return_nodes - def _replace_place_holder(self, data): + def _replace_place_holder(self, data: str) -> str: replace_items = re.findall(r"{{(.*)}}", data) for item in replace_items: - if item not in self.need.keys(): + if item not in self.need: raise SphinxNeedLayoutException(item) # To escape { we need to use 2 of them. # So {{ becomes {{{{ replace_string = f"{{{{{item}}}}}" - data = data.replace(replace_string, self.need[item]) + data = data.replace(replace_string, self.need[item]) # type: ignore[literal-required] return data - def meta(self, name: str, prefix: Optional[str] = None, show_empty: bool = False): + def meta(self, name: str, prefix: Optional[str] = None, show_empty: bool = False) -> nodes.inline: """ Returns the specific metadata of a need inside docutils nodes. Usage:: @@ -493,7 +501,7 @@ def meta(self, name: str, prefix: Optional[str] = None, show_empty: bool = False label_node += prefix_node data_container.append(label_node) try: - data = self.need[name] + data = self.need[name] # type: ignore[literal-required] except KeyError: data = "" @@ -509,7 +517,7 @@ def meta(self, name: str, prefix: Optional[str] = None, show_empty: bool = False # data_node.append(nodes.Text(data) # data_container.append(data_node) needs_string_links_option: List[str] = [] - for v in self.config.string_links.values(): + for v in self.needs_config.string_links.values(): needs_string_links_option.extend(v["options"]) if name in needs_string_links_option: @@ -529,7 +537,7 @@ def meta(self, name: str, prefix: Optional[str] = None, show_empty: bool = False data=datum, need_key=name, matching_link_confs=matching_link_confs, - render_context=self.config.render_context, + render_context=self.needs_config.render_context, ) else: # Normal text handling @@ -560,7 +568,7 @@ def meta(self, name: str, prefix: Optional[str] = None, show_empty: bool = False return data_container - def meta_id(self): + def meta_id(self) -> nodes.inline: """ Returns the current need id as clickable and linked reference. @@ -591,11 +599,11 @@ def meta_all( self, prefix: str = "", postfix: str = "", - exclude=None, + exclude: Optional[List[str]] = None, no_links: bool = False, defaults: bool = True, show_empty: bool = False, - ): + ) -> nodes.inline: """ ``meta_all()`` excludes by default the output of: ``docname``, ``lineno``, ``refid``, ``content``, ``collapse``, ``parts``, ``id_parent``, @@ -635,8 +643,8 @@ def meta_all( exclude += default_excludes if no_links: - link_names = [x["option"] for x in self.config.extra_links] - link_names += [x["option"] + "_back" for x in self.config.extra_links] + link_names = [x["option"] for x in self.needs_config.extra_links] + link_names += [x["option"] + "_back" for x in self.needs_config.extra_links] exclude += link_names data_container = nodes.inline() for data in self.need.keys(): @@ -657,7 +665,7 @@ def meta_all( return data_container - def meta_links(self, name: str, incoming: bool = False): + def meta_links(self, name: str, incoming: bool = False) -> nodes.inline: """ Documents the set links of a given link type. The documented links are all full clickable links to the target needs. @@ -667,7 +675,7 @@ def meta_links(self, name: str, incoming: bool = False): :return: docutils nodes """ data_container = nodes.inline(classes=[name]) - if name not in [x["option"] for x in self.config.extra_links]: + if name not in [x["option"] for x in self.needs_config.extra_links]: raise SphinxNeedLayoutException(f"Invalid link name {name} for link-type") # if incoming: @@ -686,7 +694,9 @@ def meta_links(self, name: str, incoming: bool = False): data_container.append(node_links) return data_container - def meta_links_all(self, prefix: str = "", postfix: str = "", exclude=None): + def meta_links_all( + self, prefix: str = "", postfix: str = "", exclude: Optional[List[str]] = None + ) -> List[nodes.line]: """ Documents all used link types for the current need automatically. @@ -697,9 +707,9 @@ def meta_links_all(self, prefix: str = "", postfix: str = "", exclude=None): """ exclude = exclude or [] data_container = [] - for link_type in self.config.extra_links: + for link_type in self.needs_config.extra_links: type_key = link_type["option"] - if self.need[type_key] and type_key not in exclude: + if self.need[type_key] and type_key not in exclude: # type: ignore[literal-required] outgoing_line = nodes.line() outgoing_label = prefix + "{}:".format(link_type["outgoing"]) + postfix + " " outgoing_line += self._parse(outgoing_label) @@ -707,7 +717,7 @@ def meta_links_all(self, prefix: str = "", postfix: str = "", exclude=None): data_container.append(outgoing_line) type_key = link_type["option"] + "_back" - if self.need[type_key] and type_key not in exclude: + if self.need[type_key] and type_key not in exclude: # type: ignore[literal-required] incoming_line = nodes.line() incoming_label = prefix + "{}:".format(link_type["incoming"]) + postfix + " " incoming_line += self._parse(incoming_label) @@ -718,11 +728,11 @@ def meta_links_all(self, prefix: str = "", postfix: str = "", exclude=None): def image( self, - url, - height=None, - width=None, - align=None, - no_link=False, + url: str, + height: Optional[str] = None, + width: Optional[str] = None, + align: Optional[str] = None, + no_link: bool = False, prefix: str = "", is_external: bool = False, img_class: str = "", @@ -802,7 +812,7 @@ def image( elif url.startswith("field:"): field = url.split(":")[1] try: - value = self.need[field] + value = self.need[field] # type: ignore[literal-required] except KeyError: value = "" @@ -860,8 +870,8 @@ def link( url: str, text: Optional[str] = None, image_url: Optional[str] = None, - image_height=None, - image_width=None, + image_height: Optional[str] = None, + image_width: Optional[str] = None, prefix: str = "", is_dynamic: bool = False, ) -> nodes.inline: @@ -894,7 +904,7 @@ def link( if is_dynamic: try: - url = self.need[url] + url = self.need[url] # type: ignore[literal-required] except KeyError: url = "" @@ -973,11 +983,11 @@ def collapse_button( def permalink( self, image_url: Optional[str] = None, - image_height=None, - image_width=None, + image_height: Optional[str] = None, + image_width: Optional[str] = None, text: Optional[str] = None, prefix: str = "", - ): + ) -> nodes.inline: """ Shows a permanent link to the need. Link can be a text, an image or both @@ -1000,7 +1010,7 @@ def permalink( image_url = "icon:share-2" image_width = "17px" - permalink = self.config.permalink_file + permalink = self.needs_config.permalink_file id = self.need["id"] docname = self.need["docname"] permalink_url = "" @@ -1017,7 +1027,9 @@ def permalink( prefix=prefix, ) - def _grid_simple(self, colwidths, side_left, side_right, footer): + def _grid_simple( + self, colwidths: List[int], side_left: Union[bool, str], side_right: Union[bool, str], footer: bool + ) -> None: """ Creates most "simple" grid layouts. Side parts and footer can be activated via config. @@ -1190,7 +1202,7 @@ def _grid_complex(self) -> None: # Construct table node_tgroup += self.node_tbody - def _grid_content(self, colwidths, side_left, side_right, footer): + def _grid_content(self, colwidths: List[int], side_left: bool, side_right: bool, footer: bool) -> None: """ Creates most "content" based grid layouts. Side parts and footer can be activated via config.