Coverage for pyquickhelper/sphinxext/sphinx_docassert_extension.py: 75%
159 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
1# -*- coding: utf-8 -*-
2"""
3@file
4@brief Defines a :epkg:`sphinx` extension which if all parameters are documented.
5"""
6import inspect
7from docutils import nodes
8import sphinx
9from sphinx.util import logging
10from sphinx.util.docfields import DocFieldTransformer, _is_single_paragraph
11from .import_object_helper import import_any_object
14def check_typed_make_field(self, types, domain, items, env=None, parameters=None,
15 function_name=None, docname=None, kind=None):
16 """
17 Overwrites function
18 #L197>`_.
19 `make_field <https://github.com/sphinx-doc/sphinx/blob/master/sphinx/util/docfields.py
20 Processes one argument of a function.
22 @param self from original function
23 @param types from original function
24 @param domain from original function
25 @param items from original function
26 @param env from original function
27 @param parameters list of known arguments for the function or method
28 @param function_name function name these arguments belong to
29 @param docname document which contains the object
30 @param kind tells which kind of object *function_name* is (function, method or class)
32 Example of warnings it raises:
34 ::
36 [docassert] 'onefunction' has no parameter 'a' (in '...project_name\\subproject\\myexampleb.py').
37 [docassert] 'onefunction' has undocumented parameters 'a, b' (...project_name\\subproject\\myexampleb.py').
39 """
40 if parameters is None:
41 parameters = None
42 check_params = {}
43 else:
44 parameters = list(parameters)
45 if kind == "method":
46 parameters = parameters[1:]
48 def kg(p):
49 "local function"
50 return p if isinstance(p, str) else p.name
51 check_params = {kg(p): 0 for p in parameters}
52 logger = logging.getLogger("docassert")
54 def check_item(fieldarg, content, logger):
55 "local function"
56 if fieldarg not in check_params:
57 if function_name is not None:
58 logger.warning("[docassert] %r has no parameter %r (in %r).",
59 function_name, fieldarg, docname)
60 else:
61 check_params[fieldarg] += 1
62 if check_params[fieldarg] > 1:
63 logger.warning("[docassert] %r of %r is duplicated (in %r).",
64 fieldarg, function_name, docname)
66 if isinstance(items, list):
67 for fieldarg, content in items:
68 check_item(fieldarg, content, logger)
69 mini = None if len(check_params) == 0 else min(check_params.values())
70 if mini == 0:
71 check_params = list(check_params.items())
72 nodoc = list(sorted(k for k, v in check_params if v == 0))
73 if len(nodoc) > 0:
74 if len(nodoc) == 1 and nodoc[0] == 'self':
75 # Behavior should be improved.
76 pass
77 else:
78 logger.warning("[docassert] %r has undocumented parameters %r (in %r).",
79 function_name, ", ".join(nodoc), docname)
80 else:
81 # Documentation related to the return.
82 pass
85class OverrideDocFieldTransformer:
86 """
87 Overrides one function with assigning it to a method
88 """
90 def __init__(self, replaced):
91 """
92 Constructor
94 @param replaced should be *DocFieldTransformer.transform*
95 """
96 self.replaced = replaced
98 def override_transform(self, other_self, node):
99 """
100 Transform a single field list *node*.
101 Overwrite function `transform
102 <https://github.com/sphinx-doc/sphinx/blob/
103 master/sphinx/util/docfields.py#L271>`_.
104 It only adds extra verification and returns results from
105 the replaced function.
107 @param other_self the builder
108 @param node node the replaced function changes or replace
110 The function parses the original function and checks that the list
111 of arguments declared by the function is the same the list of
112 documented arguments.
113 """
114 typemap = other_self.typemap
115 entries = []
116 groupindices = {}
117 types = {}
119 # step 1: traverse all fields and collect field types and content
120 for field in node:
121 fieldname, fieldbody = field
122 try:
123 # split into field type and argument
124 fieldtype, fieldarg = fieldname.astext().split(None, 1)
125 except ValueError:
126 # maybe an argument-less field type?
127 fieldtype, fieldarg = fieldname.astext(), ''
128 if fieldtype == "Parameters":
129 # numpydoc style
130 keyfieldtype = 'parameter'
131 elif fieldtype == "param":
132 keyfieldtype = 'param'
133 else:
134 continue
135 typedesc, is_typefield = typemap.get(keyfieldtype, (None, None))
137 # sort out unknown fields
138 extracted = []
139 if keyfieldtype == 'parameter':
140 # numpydoc
142 for child in fieldbody.children:
143 if isinstance(child, nodes.definition_list):
144 for child2 in child.children:
145 extracted.append(child2)
146 elif typedesc is None or typedesc.has_arg != bool(fieldarg):
147 # either the field name is unknown, or the argument doesn't
148 # match the spec; capitalize field name and be done with it
149 new_fieldname = fieldtype[0:1].upper() + fieldtype[1:]
150 if fieldarg:
151 new_fieldname += ' ' + fieldarg
152 fieldname[0] = nodes.Text(new_fieldname)
153 entries.append(field)
154 continue
156 typename = typedesc.name
158 # collect the content, trying not to keep unnecessary paragraphs
159 if extracted:
160 content = extracted
161 elif _is_single_paragraph(fieldbody):
162 content = fieldbody.children[0].children
163 else:
164 content = fieldbody.children
166 # if the field specifies a type, put it in the types collection
167 if is_typefield:
168 # filter out only inline nodes; others will result in invalid
169 # markup being written out
170 content = [n for n in content if isinstance(
171 n, (nodes.Inline, nodes.Text))]
172 if content:
173 types.setdefault(typename, {})[fieldarg] = content
174 continue
176 # also support syntax like ``:param type name:``
177 if typedesc.is_typed:
178 try:
179 argtype, argname = fieldarg.split(None, 1)
180 except ValueError:
181 pass
182 else:
183 types.setdefault(typename, {})[argname] = [
184 nodes.Text(argtype)]
185 fieldarg = argname
187 translatable_content = nodes.inline(
188 fieldbody.rawsource, translatable=True)
189 translatable_content.document = fieldbody.parent.document
190 translatable_content.source = fieldbody.parent.source
191 translatable_content.line = fieldbody.parent.line
192 translatable_content += content
194 # Import object, get the list of parameters
195 docs = fieldbody.parent.source.split(":docstring of")[-1].strip()
197 myfunc = None
198 funckind = None
199 function_name = None
200 excs = []
201 try:
202 myfunc, function_name, funckind = import_any_object(docs)
203 except ImportError as e:
204 excs.append(e)
206 if myfunc is None:
207 if len(excs) > 0:
208 reasons = "\n".join(f" {e}" for e in excs)
209 else:
210 reasons = "unknown"
211 logger = logging.getLogger("docassert")
212 logger.warning("[docassert] unable to import object %r, reasons:\n%s",
213 docs, reasons)
214 myfunc = None
216 if myfunc is None:
217 signature = None
218 parameters = None
219 else:
220 try:
221 signature = inspect.signature(myfunc)
222 parameters = signature.parameters
223 except (TypeError, ValueError):
224 logger = logging.getLogger("docassert")
225 logger.warning(
226 "[docassert] unable to get signature of %r.", docs)
227 signature = None
228 parameters = None
230 # grouped entries need to be collected in one entry, while others
231 # get one entry per field
232 if extracted:
233 # numpydoc
234 group_entries = []
235 for ext in extracted:
236 name = ext.astext().split('\n')[0].split()[0]
237 group_entries.append((name, ext))
238 entries.append([typedesc, group_entries])
239 elif typedesc.is_grouped:
240 if typename in groupindices:
241 group = entries[groupindices[typename]]
242 else:
243 groupindices[typename] = len(entries)
244 group = [typedesc, []]
245 entries.append(group)
246 entry = typedesc.make_entry(fieldarg, [translatable_content])
247 group[1].append(entry)
248 else:
249 entry = typedesc.make_entry(fieldarg, [translatable_content])
250 entries.append([typedesc, entry])
252 # step 2: all entries are collected, check the parameters list.
253 try:
254 env = other_self.directive.state.document.settings.env
255 except AttributeError as e:
256 logger = logging.getLogger("docassert")
257 logger.warning("[docassert] %s", e)
258 env = None
260 docname = fieldbody.parent.source.split(':docstring')[0]
262 for entry in entries:
263 if isinstance(entry, nodes.field):
264 logger = logging.getLogger("docassert")
265 logger.warning(
266 "[docassert] unable to check [nodes.field] %s", entry)
267 else:
268 fieldtype, content = entry
269 fieldtypes = types.get(fieldtype.name, {})
270 check_typed_make_field(other_self, fieldtypes, other_self.directive.domain,
271 content, env=env, parameters=parameters,
272 function_name=function_name, docname=docname,
273 kind=funckind)
275 return self.replaced(other_self, node)
278def setup_docassert(app):
279 """
280 Setup for ``docassert`` extension (sphinx).
281 This changes ``DocFieldTransformer.transform`` and replaces
282 it by a function which calls the current function and does
283 extra checking on the list of parameters.
285 .. warning:: This class does not handle methods if the parameter name
286 for the class is different from *self*. Classes included in other
287 classes are not properly handled.
288 """
289 inst = OverrideDocFieldTransformer(DocFieldTransformer.transform)
291 def local_transform(me, node):
292 "local function"
293 return inst.override_transform(me, node)
295 DocFieldTransformer.transform = local_transform
296 return {'version': sphinx.__display_version__, 'parallel_read_safe': True}
299def setup(app):
300 "setup for docassert"
301 return setup_docassert(app)