1import inspect
2import re
3import sys
4
5from docutils.parsers.rst import Directive
6from docutils.parsers.rst import directives
7from sphinx import addnodes
8from sphinx.directives import ObjectDescription
9from sphinx.domains import Domain
10from sphinx.domains import ObjType
11from sphinx.domains.python import PyAttribute
12from sphinx.domains.python import PyClasslike
13from sphinx.domains.python import PyMethod
14from sphinx.ext import autodoc
15from sphinx.locale import _
16from sphinx.roles import XRefRole
17from sphinx.util.docfields import Field
18from sphinx.util.nodes import make_refnode
19
20import wsme
21import wsme.rest.json
22import wsme.rest.xml
23import wsme.types
24
25field_re = re.compile(r':(?P<field>\w+)(\s+(?P<name>\w+))?:')
26
27
28def datatypename(datatype):
29    if isinstance(datatype, wsme.types.UserType):
30        return datatype.name
31    if isinstance(datatype, wsme.types.DictType):
32        return 'dict(%s: %s)' % (datatypename(datatype.key_type),
33                                 datatypename(datatype.value_type))
34    if isinstance(datatype, wsme.types.ArrayType):
35        return 'list(%s)' % datatypename(datatype.item_type)
36    return datatype.__name__
37
38
39def make_sample_object(datatype):
40    if datatype is wsme.types.bytes:
41        return b'samplestring'
42    if datatype is wsme.types.text:
43        return 'sample unicode'
44    if datatype is int:
45        return 5
46    sample_obj = getattr(datatype, 'sample', datatype)()
47    return sample_obj
48
49
50def get_protocols(names):
51    names = list(names)
52    protocols = []
53    if 'rest' in names:
54        names.remove('rest')
55        protocols.extend('restjson', 'restxml')
56    if 'restjson' in names:
57        names.remove('restjson')
58        protocols.append(('Json', wsme.rest.json))
59    if 'restxml' in names:
60        names.remove('restxml')
61        protocols.append(('XML', wsme.rest.xml))
62    for name in names:
63        p = wsme.protocol.getprotocol(name)
64        protocols.append((p.displayname or p.name, p))
65    return protocols
66
67
68class SampleType(object):
69    """A Sample Type"""
70
71    #: A Int
72    aint = int
73
74    def __init__(self, aint=None):
75        if aint:
76            self.aint = aint
77
78    @classmethod
79    def sample(cls):
80        return cls(10)
81
82
83class SampleService(wsme.WSRoot):
84    @wsme.expose(SampleType)
85    @wsme.validate(SampleType, int, str)
86    def change_aint(data, aint, dummy='useless'):
87        """
88        :param aint: The new value
89
90        :return: The data object with its aint field value changed.
91        """
92        data.aint = aint
93        return data
94
95
96def getroot(env, force=False):
97    root = env.temp_data.get('wsme:root')
98    if not force and root:
99        return root
100    rootpath = env.temp_data.get('wsme:rootpath', env.app.config.wsme_root)
101
102    if rootpath is None:
103        return None
104
105    modname, classname = rootpath.rsplit('.', 1)
106    __import__(modname)
107    module = sys.modules[modname]
108    root = getattr(module, classname)
109    env.temp_data['wsme:root'] = root
110    return root
111
112
113def scan_services(service, path=[]):
114    has_functions = False
115    for name in dir(service):
116        if name.startswith('_'):
117            continue
118        a = getattr(service, name)
119        if inspect.ismethod(a):
120            if hasattr(a, '_wsme_definition'):
121                has_functions = True
122        if inspect.isclass(a):
123            continue
124        if len(path) > wsme.rest.APIPATH_MAXLEN:
125            raise ValueError("Path is too long: " + str(path))
126        for value in scan_services(a, path + [name]):
127            yield value
128    if has_functions:
129        yield service, path
130
131
132def find_service_path(env, service):
133    root = getroot(env)
134    if service == root:
135        return []
136    for s, path in scan_services(root):
137        if s == service:
138            return path
139    return None
140
141
142class TypeDirective(PyClasslike):
143    def get_index_text(self, modname, name_cls):
144        return _('%s (webservice type)') % name_cls[0]
145
146    def add_target_and_index(self, name_cls, sig, signode):
147        ret = super(TypeDirective, self).add_target_and_index(
148            name_cls, sig, signode
149        )
150        name = name_cls[0]
151        types = self.env.domaindata['wsme']['types']
152        if name in types:
153            self.state_machine.reporter.warning(
154                'duplicate type description of %s ' % name)
155        types[name] = self.env.docname
156        return ret
157
158
159class AttributeDirective(PyAttribute):
160    doc_field_types = [
161        Field('datatype', label=_('Type'), has_arg=False,
162              names=('type', 'datatype'))
163    ]
164
165
166def check_samples_slot(value):
167    """Validate the samples_slot option to the TypeDocumenter.
168
169    Valid positions are 'before-docstring' and
170    'after-docstring'. Using the explicit 'none' disables sample
171    output. The default is after-docstring.
172    """
173    if not value:
174        return 'after-docstring'
175    val = directives.choice(
176        value,
177        ('none',              # do not include
178         'before-docstring',  # show samples then docstring
179         'after-docstring',   # show docstring then samples
180         ))
181    return val
182
183
184class TypeDocumenter(autodoc.ClassDocumenter):
185    objtype = 'type'
186    directivetype = 'type'
187    domain = 'wsme'
188
189    required_arguments = 1
190    default_samples_slot = 'after-docstring'
191
192    option_spec = dict(
193        autodoc.ClassDocumenter.option_spec,
194        **{'protocols': lambda l: [v.strip() for v in l.split(',')],
195           'samples-slot': check_samples_slot,
196           })
197
198    @staticmethod
199    def can_document_member(member, membername, isattr, parent):
200        # we don't want to be automaticaly used
201        # TODO check if the member is registered an an exposed type
202        return False
203
204    def format_name(self):
205        return self.object.__name__
206
207    def format_signature(self):
208        return u''
209
210    def add_directive_header(self, sig):
211        super(TypeDocumenter, self).add_directive_header(sig)
212        # remove the :module: option that was added by ClassDocumenter
213        result_len = len(self.directive.result)
214        for index, item in zip(reversed(range(result_len)),
215                               reversed(self.directive.result)):
216            if ':module:' in item:
217                self.directive.result.pop(index)
218
219    def import_object(self):
220        if super(TypeDocumenter, self).import_object():
221            wsme.types.register_type(self.object)
222            return True
223        else:
224            return False
225
226    def add_content(self, more_content, no_docstring=False):
227        # Check where to include the samples
228        samples_slot = self.options.samples_slot or self.default_samples_slot
229
230        def add_docstring():
231            super(TypeDocumenter, self).add_content(
232                more_content, no_docstring)
233
234        def add_samples():
235            protocols = get_protocols(
236                self.options.protocols or self.env.app.config.wsme_protocols
237            )
238            content = []
239            if protocols:
240                sample_obj = make_sample_object(self.object)
241                content.extend([
242                    _(u'Data samples:'),
243                    u'',
244                    u'.. cssclass:: toggle',
245                    u''
246                ])
247                for name, protocol in protocols:
248                    language, sample = protocol.encode_sample_value(
249                        self.object, sample_obj, format=True)
250                    content.extend([
251                        name,
252                        u'    .. code-block:: ' + language,
253                        u'',
254                    ])
255                    content.extend(
256                        u' ' * 8 + line
257                        for line in str(sample).split('\n'))
258            for line in content:
259                self.add_line(line, u'<wsmeext.sphinxext')
260
261            self.add_line(u'', '<wsmeext.sphinxext>')
262
263        if samples_slot == 'after-docstring':
264            add_docstring()
265            add_samples()
266        elif samples_slot == 'before-docstring':
267            add_samples()
268            add_docstring()
269        else:
270            add_docstring()
271
272
273class AttributeDocumenter(autodoc.AttributeDocumenter):
274    datatype = None
275    domain = 'wsme'
276
277    @staticmethod
278    def can_document_member(member, membername, isattr, parent):
279        return isinstance(parent, TypeDocumenter)
280
281    def import_object(self):
282        success = super(AttributeDocumenter, self).import_object()
283        if success:
284            self.datatype = self.object.datatype
285        return success
286
287    def add_content(self, more_content, no_docstring=False):
288        self.add_line(
289            u':type: %s' % datatypename(self.datatype),
290            '<wsmeext.sphinxext>'
291        )
292        self.add_line(u'', '<wsmeext.sphinxext>')
293        super(AttributeDocumenter, self).add_content(
294            more_content, no_docstring)
295
296    def add_directive_header(self, sig):
297        super(AttributeDocumenter, self).add_directive_header(sig)
298
299
300class RootDirective(Directive):
301    """
302    This directive is to tell what class is the Webservice root
303    """
304    has_content = False
305    required_arguments = 1
306    optional_arguments = 0
307    final_argument_whitespace = False
308    option_spec = {
309        'webpath': directives.unchanged
310    }
311
312    def run(self):
313        env = self.state.document.settings.env
314        rootpath = self.arguments[0].strip()
315        env.temp_data['wsme:rootpath'] = rootpath
316        if 'wsme:root' in env.temp_data:
317            del env.temp_data['wsme:root']
318        if 'webpath' in self.options:
319            env.temp_data['wsme:webpath'] = self.options['webpath']
320        return []
321
322
323class ServiceDirective(ObjectDescription):
324    name = 'service'
325
326    optional_arguments = 1
327
328    def handle_signature(self, sig, signode):
329        path = sig.split('/')
330
331        namespace = '/'.join(path[:-1])
332        if namespace and not namespace.endswith('/'):
333            namespace += '/'
334
335        servicename = path[-1]
336
337        if not namespace and not servicename:
338            servicename = '/'
339
340        signode += addnodes.desc_annotation('service ', 'service ')
341
342        if namespace:
343            signode += addnodes.desc_addname(namespace, namespace)
344
345        signode += addnodes.desc_name(servicename, servicename)
346
347        return sig
348
349
350class ServiceDocumenter(autodoc.ClassDocumenter):
351    domain = 'wsme'
352    objtype = 'service'
353    directivetype = 'service'
354
355    def add_directive_header(self, sig):
356        super(ServiceDocumenter, self).add_directive_header(sig)
357        # remove the :module: option that was added by ClassDocumenter
358        result_len = len(self.directive.result)
359        for index, item in zip(reversed(range(result_len)),
360                               reversed(self.directive.result)):
361            if ':module:' in item:
362                self.directive.result.pop(index)
363
364    def format_signature(self):
365        return u''
366
367    def format_name(self):
368        path = find_service_path(self.env, self.object)
369        if path is None:
370            return
371        return '/' + '/'.join(path)
372
373
374class FunctionDirective(PyMethod):
375    name = 'function'
376    objtype = 'function'
377
378    def get_signature_prefix(self, sig):
379        return 'function '
380
381
382def document_function(funcdef, docstrings=None, protocols=['restjson']):
383    """A helper function to complete a function documentation with return and
384    parameter types"""
385    # If the function doesn't have a docstring, add an empty list
386    # so the default behaviors below work correctly.
387    if not docstrings:
388        docstrings = [[]]
389    found_params = set()
390
391    for si, docstring in enumerate(docstrings):
392        for i, line in enumerate(docstring):
393            m = field_re.match(line)
394            if m and m.group('field') == 'param':
395                found_params.add(m.group('name'))
396
397    next_param_pos = (0, 0)
398
399    for arg in funcdef.arguments:
400        content = [
401            u':type  %s: :wsme:type:`%s`' % (
402                arg.name, datatypename(arg.datatype))
403        ]
404        if arg.name not in found_params:
405            content.insert(0, u':param %s: ' % (arg.name))
406            pos = next_param_pos
407        else:
408            for si, docstring in enumerate(docstrings):
409                for i, line in enumerate(docstring):
410                    m = field_re.match(line)
411                    if m and m.group('field') == 'param' \
412                            and m.group('name') == arg.name:
413                        pos = (si, i + 1)
414                        break
415        docstring = docstrings[pos[0]]
416        docstring[pos[1]:pos[1]] = content
417        next_param_pos = (pos[0], pos[1] + len(content))
418
419    if funcdef.return_type:
420        content = [
421            u':rtype: %s' % datatypename(funcdef.return_type)
422        ]
423        pos = None
424        for si, docstring in enumerate(docstrings):
425            for i, line in enumerate(docstring):
426                m = field_re.match(line)
427                if m and m.group('field') == 'return':
428                    pos = (si, i + 1)
429                    break
430        else:
431            pos = next_param_pos
432        docstring = docstrings[pos[0]]
433        docstring[pos[1]:pos[1]] = content
434
435    codesamples = []
436
437    if protocols:
438        params = []
439        for arg in funcdef.arguments:
440            params.append((
441                arg.name,
442                arg.datatype,
443                make_sample_object(arg.datatype)
444            ))
445        codesamples.extend([
446            u':%s:' % _(u'Parameters samples'),
447            u'    .. cssclass:: toggle',
448            u''
449        ])
450        for name, protocol in protocols:
451            language, sample = protocol.encode_sample_params(
452                params, format=True)
453            codesamples.extend([
454                u' ' * 4 + name,
455                u'        .. code-block:: ' + language,
456                u'',
457            ])
458            codesamples.extend((
459                u' ' * 12 + line
460                for line in str(sample).split('\n')
461            ))
462
463        if funcdef.return_type:
464            codesamples.extend([
465                u':%s:' % _(u'Return samples'),
466                u'    .. cssclass:: toggle',
467                u''
468            ])
469            sample_obj = make_sample_object(funcdef.return_type)
470            for name, protocol in protocols:
471                language, sample = protocol.encode_sample_result(
472                    funcdef.return_type, sample_obj, format=True)
473                codesamples.extend([
474                    u' ' * 4 + name,
475                    u'        .. code-block:: ' + language,
476                    u'',
477                ])
478                codesamples.extend((
479                    u' ' * 12 + line
480                    for line in str(sample).split('\n')
481                ))
482
483    docstrings[0:0] = [codesamples]
484    return docstrings
485
486
487class FunctionDocumenter(autodoc.MethodDocumenter):
488    domain = 'wsme'
489    directivetype = 'function'
490    objtype = 'function'
491    priority = 1
492
493    option_spec = {
494        'path': directives.unchanged,
495        'method': directives.unchanged
496    }
497
498    @staticmethod
499    def can_document_member(member, membername, isattr, parent):
500        return (isinstance(parent, ServiceDocumenter) and
501                wsme.api.iswsmefunction(member))
502
503    def import_object(self):
504        ret = super(FunctionDocumenter, self).import_object()
505        self.directivetype = 'function'
506        self.wsme_fd = wsme.api.FunctionDefinition.get(self.object)
507        self.retann = datatypename(self.wsme_fd.return_type)
508        return ret
509
510    def format_args(self):
511        args = [arg.name for arg in self.wsme_fd.arguments]
512        defaults = [
513            arg.default
514            for arg in self.wsme_fd.arguments if not arg.mandatory
515        ]
516        return inspect.formatargspec(args, defaults=defaults)
517
518    def get_doc(self, encoding=None):
519        """Inject the type and param fields into the docstrings so that the
520        user can add its own param fields to document the parameters"""
521        docstrings = super(FunctionDocumenter, self).get_doc(encoding)
522
523        protocols = get_protocols(
524            self.options.protocols or self.env.app.config.wsme_protocols
525        )
526
527        return document_function(
528            self.wsme_fd, docstrings, protocols
529        )
530
531    def add_content(self, more_content, no_docstring=False):
532        super(FunctionDocumenter, self).add_content(more_content, no_docstring)
533
534    def format_name(self):
535        return self.wsme_fd.name
536
537    def add_directive_header(self, sig):
538        super(FunctionDocumenter, self).add_directive_header(sig)
539        # remove the :module: option that was added by ClassDocumenter
540        result_len = len(self.directive.result)
541        for index, item in zip(reversed(range(result_len)),
542                               reversed(self.directive.result)):
543            if ':module:' in item:
544                self.directive.result.pop(index)
545
546
547class WSMEDomain(Domain):
548    name = 'wsme'
549    label = 'WSME'
550
551    object_types = {
552        'type': ObjType(_('type'), 'type', 'obj'),
553        'service': ObjType(_('service'), 'service', 'obj')
554    }
555
556    directives = {
557        'type': TypeDirective,
558        'attribute':  AttributeDirective,
559        'service': ServiceDirective,
560        'root': RootDirective,
561        'function': FunctionDirective,
562    }
563
564    roles = {
565        'type': XRefRole()
566    }
567
568    initial_data = {
569        'types': {},  # fullname -> docname
570    }
571
572    def clear_doc(self, docname):
573        keys = list(self.data['types'].keys())
574        for key in keys:
575            value = self.data['types'][key]
576            if value == docname:
577                del self.data['types'][key]
578
579    def resolve_xref(self, env, fromdocname, builder,
580                     type, target, node, contnode):
581        if target not in self.data['types']:
582            return None
583        todocname = self.data['types'][target]
584        return make_refnode(
585            builder, fromdocname, todocname, target, contnode, target)
586
587
588def setup(app):
589    app.add_domain(WSMEDomain)
590    app.add_autodocumenter(TypeDocumenter)
591    app.add_autodocumenter(AttributeDocumenter)
592    app.add_autodocumenter(ServiceDocumenter)
593    app.add_autodocumenter(FunctionDocumenter)
594
595    app.add_config_value('wsme_root', None, 'env')
596    app.add_config_value('wsme_webpath', '/', 'env')
597    app.add_config_value('wsme_protocols', ['restjson', 'restxml'], 'env')
598    app.add_js_file('toggle.js')
599    app.add_css_file('toggle.css')
600