xref: /petsc/src/binding/petsc4py/docs/source/apidoc.py (revision 552edb6364df478b294b3111f33a8f37ca096b20)
1import os
2import sys
3import inspect
4import textwrap
5from sphinx.util import logging
6
7logger = logging.getLogger(__name__)
8
9
10def is_cyfunction(obj):
11    return type(obj).__name__ == 'cython_function_or_method'
12
13
14def is_function(obj):
15    return inspect.isbuiltin(obj) or is_cyfunction(obj) or type(obj) is type(ord)
16
17
18def is_method(obj):
19    return (
20        inspect.ismethoddescriptor(obj)
21        or inspect.ismethod(obj)
22        or is_cyfunction(obj)
23        or type(obj)
24        in (
25            type(str.index),
26            type(str.__add__),
27            type(str.__new__),
28        )
29    )
30
31
32def is_classmethod(obj):
33    return inspect.isbuiltin(obj) or type(obj).__name__ in (
34        'classmethod',
35        'classmethod_descriptor',
36    )
37
38
39def is_staticmethod(obj):
40    return type(obj).__name__ in ('staticmethod',)
41
42
43def is_constant(obj):
44    return isinstance(obj, (int, float, str, dict))
45
46
47def is_datadescr(obj):
48    return inspect.isdatadescriptor(obj) and not hasattr(obj, 'fget')
49
50
51def is_property(obj):
52    return inspect.isdatadescriptor(obj) and hasattr(obj, 'fget')
53
54
55def is_class(obj):
56    return inspect.isclass(obj) or type(obj) is type(int)
57
58
59def is_hidden(obj):
60    return obj.__qualname__.startswith('_')
61
62
63class Lines(list):
64    INDENT = ' ' * 4
65    level = 0
66
67    @property
68    def add(self):
69        return self
70
71    @add.setter
72    def add(self, lines):
73        if lines is None:
74            return
75        if isinstance(lines, str):
76            lines = textwrap.dedent(lines).strip().split('\n')
77        indent = self.INDENT * self.level
78        for line in lines:
79            self.append(indent + line)
80
81
82def signature(obj, fail=True):
83    doc = obj.__doc__
84    if not doc:
85        if fail and not is_hidden(obj):
86            logger.warning(f'Missing signature for {obj}')
87        doc = f'{obj.__name__}: Any'
88    sig = doc.partition('\n')[0].split('.', 1)[-1]
89    return sig or None
90
91
92def docstring(obj, fail=True):
93    doc = obj.__doc__
94    if not doc:
95        if fail and not is_hidden(obj):
96            logger.warning(f'Missing docstring for {obj}')
97        doc = ''
98    link = None
99    sig = None
100    cl = is_class(obj)
101    if cl:
102        doc = doc.strip()
103    else:
104        sig, _, doc = doc.partition('\n')
105        doc, _, link = doc.rpartition('\n')
106
107    summary, _, docbody = doc.partition('\n')
108    summary = summary.strip()
109    docbody = textwrap.dedent(docbody).strip()
110
111    # raise warning if docstring is not provided for a method
112    if not summary and not is_function(obj) and is_method(obj):
113        logger.warning(f'docstring: Missing summary for {obj}')
114
115    # warnings for docstrings that are not compliant
116    if len(summary) > 79:
117        logger.warning(f'Summary for {obj} too long.')
118    if docbody:
119        if not summary.endswith('.'):
120            logger.warning(f'Summary for {obj} does not end with period.')
121        # FIXME
122        lines = docbody.split('\n')
123        for i, line in enumerate(lines):
124            if len(line) > 79:
125                logger.warning(f'Line {i} for documentation of {obj} too long.')
126        if not cl:
127            init = (
128                'Collective.',
129                'Not collective.',
130                'Logically collective.',
131                'Neighborwise collective.',
132                'Collective the first time it is called.',
133            )
134            if lines[0] not in init:
135                logger.warning(f'Unexpected collectiveness for {sig}\nFound {lines[0]}')
136
137    if link:
138        linktxt, _, link = link.rpartition(' ')
139        linkloc = link.replace(':', '#L')
140        # FIXME do we want to use a special section?
141        # section = f'References\n----------`'
142        section = '\n'
143        linkbody = f':sources:`{linktxt} {link} <{linkloc}>`'
144        linkbody = f'{section}\n{linkbody}'
145        if docbody:
146            docbody = f'{docbody}\n\n{linkbody}'
147        else:
148            docbody = linkbody
149
150    if docbody:
151        doc = f'"""{summary}\n\n{docbody}\n\n"""'
152    else:
153        doc = f'"""{summary}"""'
154    return textwrap.indent(doc, Lines.INDENT)
155
156
157def visit_data(constant):
158    name, value = constant
159    typename = type(value).__name__
160    kind = 'Constant' if isinstance(value, int) else 'Object'
161    init = f"_def({typename}, '{name}')"
162    doc = f'#: {kind} ``{name}`` of type :class:`{typename}`'
163    return f'{name}: {typename} = {init}  {doc}\n'
164
165
166def visit_function(function):
167    sig = signature(function)
168    doc = docstring(function)
169    body = Lines.INDENT + '...'
170    return f'def {sig}:\n{doc}\n{body}\n'
171
172
173def visit_method(method):
174    sig = signature(method)
175    doc = docstring(method)
176    body = Lines.INDENT + '...'
177    return f'def {sig}:\n{doc}\n{body}\n'
178
179
180def visit_datadescr(datadescr, name=None):
181    sig = signature(datadescr)
182    doc = docstring(datadescr)
183    name = sig.partition(':')[0].strip() or datadescr.__name__
184    rtype = sig.partition(':')[2].strip() or 'Any'
185    sig = f'{name}(self) -> {rtype}'
186    body = Lines.INDENT + '...'
187    return f'@property\ndef {sig}:\n{doc}\n{body}\n'
188
189
190def visit_property(prop, name=None):
191    sig = signature(prop.fget)
192    name = name or prop.fget.__name__
193    rtype = sig.rsplit('->', 1)[-1].strip()
194    sig = f'{name}(self) -> {rtype}'
195    doc = f'"""{prop.__doc__}"""'
196    doc = textwrap.indent(doc, Lines.INDENT)
197    body = Lines.INDENT + '...'
198    return f'@property\ndef {sig}:\n{doc}\n{body}\n'
199
200
201def visit_constructor(cls, name='__init__', args=None):
202    init = name == '__init__'
203    argname = cls.__mro__[-2].__name__.lower()
204    argtype = cls.__name__
205    initarg = args or f'{argname}: Optional[{argtype}] = None'
206    selfarg = 'self' if init else 'cls'
207    rettype = 'None' if init else argtype
208    arglist = f'{selfarg}, {initarg}'
209    sig = f'{name}({arglist}) -> {rettype}'
210    ret = '...' if init else 'return super().__new__(cls)'
211    body = Lines.INDENT + ret
212    return f'def {sig}:\n{body}'
213
214
215def visit_class(cls, outer=None, done=None):
216    skip = {
217        '__doc__',
218        '__dict__',
219        '__module__',
220        '__weakref__',
221        '__pyx_vtable__',
222        '__lt__',
223        '__le__',
224        '__ge__',
225        '__gt__',
226        '__enum2str',  # FIXME refactor implementation
227        '_traceback_',  # FIXME maybe refactor?
228    }
229    special = {
230        '__len__': '__len__(self) -> int',
231        '__bool__': '__bool__(self) -> bool',
232        '__hash__': '__hash__(self) -> int',
233        '__int__': '__int__(self) -> int',
234        '__index__': '__int__(self) -> int',
235        '__str__': '__str__(self) -> str',
236        '__repr__': '__repr__(self) -> str',
237        '__eq__': '__eq__(self, other: object) -> bool',
238        '__ne__': '__ne__(self, other: object) -> bool',
239    }
240
241    qualname = cls.__name__
242    cls_name = cls.__name__
243    if outer is not None and cls_name.startswith(outer):
244        cls_name = cls_name[len(outer) :]
245        qualname = f'{outer}.{cls_name}'
246
247    override = OVERRIDE.get(qualname, {})
248    done = set() if done is None else done
249    lines = Lines()
250
251    base = cls.__base__
252    if base is object:
253        lines.add = f'class {cls_name}:'
254    else:
255        lines.add = f'class {cls_name}({base.__name__}):'
256    lines.level += 1
257
258    lines.add = docstring(cls)
259
260    for name in ('__new__', '__init__', '__hash__'):
261        if name in cls.__dict__:
262            done.add(name)
263
264    dct = cls.__dict__
265    keys = list(dct.keys())
266
267    def dunder(name):
268        return name.startswith('__') and name.endswith('__')
269
270    def members(seq):
271        for name in seq:
272            if name in skip:
273                continue
274            if name in done:
275                continue
276            if dunder(name):
277                if name not in special and name not in override:
278                    done.add(name)
279                    continue
280            yield name
281
282    for name in members(keys):
283        attr = getattr(cls, name)
284        if is_class(attr):
285            done.add(name)
286            lines.add = visit_class(attr, outer=cls_name)
287            continue
288
289    for name in members(keys):
290        if name in override:
291            done.add(name)
292            lines.add = override[name]
293            continue
294
295        if name in special:
296            done.add(name)
297            sig = special[name]
298            lines.add = f'def {sig}: ...'
299            continue
300
301        attr = getattr(cls, name)
302
303        if is_method(attr):
304            done.add(name)
305            if name == attr.__name__:
306                obj = dct[name]
307                if is_classmethod(obj):
308                    lines.add = '@classmethod'
309                elif is_staticmethod(obj):
310                    lines.add = '@staticmethod'
311                lines.add = visit_method(attr)
312            continue
313
314        if is_datadescr(attr):
315            done.add(name)
316            lines.add = visit_datadescr(attr)
317            continue
318
319        if is_property(attr):
320            done.add(name)
321            lines.add = visit_property(attr, name)
322            continue
323
324        if is_constant(attr):
325            done.add(name)
326            lines.add = visit_data((name, attr))
327            continue
328
329    leftovers = [name for name in keys if name not in done and name not in skip]
330    if leftovers:
331        raise RuntimeError(f'leftovers: {leftovers}')
332
333    lines.level -= 1
334    return lines
335
336
337def visit_module(module, done=None):
338    skip = {
339        '__doc__',
340        '__name__',
341        '__loader__',
342        '__spec__',
343        '__file__',
344        '__package__',
345        '__builtins__',
346        '__pyx_capi__',
347        '__pyx_unpickle_Enum',  # FIXME review
348    }
349
350    done = set() if done is None else done
351    lines = Lines()
352
353    keys = list(module.__dict__.keys())
354    keys.sort(key=lambda name: name.startswith('_'))
355
356    constants = [
357        (name, getattr(module, name))
358        for name in keys
359        if all(
360            (
361                name not in done and name not in skip,
362                is_constant(getattr(module, name)),
363            )
364        )
365    ]
366    for _, value in constants:
367        cls = type(value)
368        name = cls.__name__
369        if name in done or name in skip:
370            continue
371        if cls.__module__ == module.__name__:
372            done.add(name)
373            lines.add = visit_class(cls)
374            lines.add = ''
375    for attr in constants:
376        name, value = attr
377        done.add(name)
378        if name in OVERRIDE:
379            lines.add = OVERRIDE[name]
380        else:
381            lines.add = visit_data((name, value))
382    if constants:
383        lines.add = ''
384
385    for name in keys:
386        if name in done or name in skip:
387            continue
388        value = getattr(module, name)
389
390        if is_class(value):
391            done.add(name)
392            if value.__name__ != name:
393                continue
394            if value.__module__ != module.__name__:
395                continue
396            lines.add = visit_class(value)
397            lines.add = ''
398            instances = [
399                (k, getattr(module, k))
400                for k in keys
401                if all(
402                    (
403                        k not in done and k not in skip,
404                        type(getattr(module, k)) is value,
405                    )
406                )
407            ]
408            for attrname, attrvalue in instances:
409                done.add(attrname)
410                lines.add = visit_data((attrname, attrvalue))
411            if instances:
412                lines.add = ''
413            continue
414
415        if is_function(value):
416            done.add(name)
417            if name == value.__name__:
418                lines.add = visit_function(value)
419            else:
420                lines.add = f'{name} = {value.__name__}'
421            continue
422
423    lines.add = ''
424    for name in keys:
425        if name in done or name in skip:
426            continue
427        value = getattr(module, name)
428        done.add(name)
429        if name in OVERRIDE:
430            lines.add = OVERRIDE[name]
431        else:
432            lines.add = visit_data((name, value))
433
434    leftovers = [name for name in keys if name not in done and name not in skip]
435    if leftovers:
436        raise RuntimeError(f'leftovers: {leftovers}')
437    return lines
438
439
440IMPORTS = """
441from __future__ import annotations
442import sys
443from typing import (
444    Any,
445    Union,
446    Literal,
447    Optional,
448    NoReturn,
449    Final,
450)
451from typing import (
452    Callable,
453    Hashable,
454    Iterable,
455    Iterator,
456    Sequence,
457    Mapping,
458)
459if sys.version_info >= (3, 11):
460    from typing import Self
461else:
462    from typing_extensions import Self
463
464import numpy
465from numpy import dtype, ndarray
466from mpi4py.MPI import (
467    Intracomm,
468    Datatype,
469    Op,
470)
471
472class _dtype:
473    def __init__(self, name):
474        self.name = name
475    def __repr__(self):
476        return self.name
477
478IntType: dtype = _dtype('IntType')
479RealType: dtype =  _dtype('RealType')
480ComplexType: dtype = _dtype('ComplexType')
481ScalarType: dtype = _dtype('ScalarType')
482"""
483
484HELPERS = """
485class _Int(int): pass
486class _Str(str): pass
487class _Float(float): pass
488class _Dict(dict): pass
489
490def _repr(obj):
491    try:
492        return obj._name
493    except AttributeError:
494        return super(obj).__repr__()
495
496def _def(cls, name):
497    if cls is int:
498       cls = _Int
499    if cls is str:
500       cls = _Str
501    if cls is float:
502       cls = _Float
503    if cls is dict:
504       cls = _Dict
505
506    obj = cls()
507    obj._name = name
508    if '__repr__' not in cls.__dict__:
509        cls.__repr__ = _repr
510    return obj
511"""
512
513OVERRIDE = {}
514
515TYPING = """
516from .typing import *
517"""
518
519
520def visit_petsc4py_PETSc(done=None):
521    from petsc4py import PETSc
522
523    lines = Lines()
524    lines.add = f'"""{PETSc.__doc__}"""'
525    lines.add = IMPORTS
526    lines.add = ''
527    lines.add = HELPERS
528    lines.add = ''
529    lines.add = visit_module(PETSc)
530    lines.add = ''
531    lines.add = TYPING
532    return lines
533
534
535def generate(filename):
536    dirname = os.path.dirname(filename)
537    os.makedirs(dirname, exist_ok=True)
538    with open(filename, 'w') as f:
539        for line in visit_petsc4py_PETSc():
540            print(line, file=f)
541
542
543def load_module(filename, name=None):
544    if name is None:
545        name, _ = os.path.splitext(os.path.basename(filename))
546    module = type(sys)(name)
547    module.__file__ = filename
548    module.__package__ = name.rsplit('.', 1)[0]
549    old = replace_module(module)
550    with open(filename) as f:
551        exec(f.read(), module.__dict__)  # noqa: S102
552    restore_module(old)
553    return module
554
555
556_sys_modules = {}
557
558
559def replace_module(module):
560    name = module.__name__
561    if name in _sys_modules:
562        raise RuntimeError(f'{name} in modules')
563    _sys_modules[name] = sys.modules[name]
564    sys.modules[name] = module
565    return _sys_modules[name]
566
567
568def restore_module(module):
569    name = module.__name__
570    if name not in _sys_modules:
571        raise RuntimeError(f'{name} not in modules')
572    sys.modules[name] = _sys_modules[name]
573    del _sys_modules[name]
574
575
576def annotate(dest, source):
577    try:
578        dest.__annotations__ = source.__annotations__
579    except AttributeError:
580        pass
581    if isinstance(dest, type):
582        for name in dest.__dict__.keys():
583            if hasattr(source, name):
584                obj = getattr(dest, name)
585                annotate(obj, getattr(source, name))
586    if isinstance(dest, type(sys)):
587        for name in dir(dest):
588            if hasattr(source, name):
589                obj = getattr(dest, name)
590                mod = getattr(obj, '__module__', None)
591                if dest.__name__ == mod:
592                    annotate(obj, getattr(source, name))
593        for name in dir(source):
594            if not hasattr(dest, name):
595                setattr(dest, name, getattr(source, name))
596
597
598OUTDIR = 'reference'
599
600if __name__ == '__main__':
601    generate(os.path.join(OUTDIR, 'petsc4py.PETSc.py'))
602