forked from dave-shawley/sphinx-jsondomain
-
Notifications
You must be signed in to change notification settings - Fork 3
/
sphinxjsondomain.py
718 lines (589 loc) · 26.9 KB
/
sphinxjsondomain.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
import json
import re
from docutils import nodes
from docutils.parsers.rst import directives as rst_directives
from sphinx import addnodes, directives, domains, roles
from sphinx.util import docfields
from sphinx.util import nodes as node_utils
import faker
try:
import yaml
except ImportError:
yaml = None
def is_json_array(typename):
""" Check if a property type is an array.
For example: [int], [uuid4] are array
"""
return typename and typename.startswith('[') and typename.endswith(']')
def strip_json_array(typename):
""" Strip json array enclosing brackets from a typename.
[int] -> int
"""
if typename and typename.startswith('[') and typename.endswith(']'):
return typename[1:-1]
return typename
class JsonField(docfields.TypedField):
def __init__(self, name, names=(), typenames=[], label=None, examplenames=(),
rolename=None, typerolename=None, can_collapse=False):
docfields.TypedField.__init__(self, name, names, typenames, label, rolename, typerolename, can_collapse)
self.examplenames = examplenames
class JsonFieldTransformer(docfields.DocFieldTransformer):
def __init__(self, directive):
docfields.DocFieldTransformer.__init__(self, directive)
self.examplemap = {}
for fieldtype in self.directive.__class__.doc_field_types:
for name in fieldtype.examplenames:
self.examplemap[name] = True
def transform(self, node):
# type: (nodes.Node) -> None
"""Transform a single field list *node*."""
typemap = self.typemap
entries = []
groupindices = {} # type: Dict[unicode, int]
types = {} # type: Dict[unicode, Dict]
# step 1: traverse all fields and collect field types and content
for field in node:
fieldname, fieldbody = field
try:
# split into field type and argument
fieldtype, fieldarg = fieldname.astext().split(None, 1)
except ValueError:
# maybe an argument-less field type?
fieldtype, fieldarg = fieldname.astext(), ''
typedesc, is_typefield = typemap.get(fieldtype, (None, None))
# collect the content, trying not to keep unnecessary paragraphs
if docfields._is_single_paragraph(fieldbody):
content = fieldbody.children[0].children
else:
content = fieldbody.children
# sort out unknown fields
if typedesc is None or typedesc.has_arg != bool(fieldarg):
continue
typename = typedesc.name
# if the field specifies a type, put it in the types collection
if is_typefield:
# filter out only inline nodes; others will result in invalid
# markup being written out
content = [n for n in content if isinstance(n, nodes.Inline) or
isinstance(n, nodes.Text)]
if content:
types.setdefault(typename, {})[fieldarg] = content
continue
# also support syntax like ``:param type name:``
if typedesc.is_typed:
try:
argtype, argname = fieldarg.split(None, 1)
except ValueError:
pass
else:
xrefs = typedesc.make_xrefs(
nodes.Text(argtype),
self.directive.domain,
nodes.Text(argtype),
)
xrefs[0].attributes['json:name'] = strip_json_array(nodes.Text(argtype))
types.setdefault(typename, {})[argname] = xrefs
fieldarg = argname
translatable_content = nodes.inline(fieldbody.rawsource,
translatable=True)
translatable_content.document = fieldbody.parent.document
translatable_content.source = fieldbody.parent.source
translatable_content.line = fieldbody.parent.line
translatable_content += content
# grouped entries need to be collected in one entry, while others
# get one entry per field
if typedesc.is_grouped:
if typename in groupindices:
group = entries[groupindices[typename]]
else:
groupindices[typename] = len(entries)
group = [typedesc, []]
entries.append(group)
entry = typedesc.make_entry(fieldarg, [translatable_content])
group[1].append(entry)
else:
entry = typedesc.make_entry(fieldarg, [translatable_content])
entries.append([typedesc, entry])
# step 2: all entries are collected, construct the new field list
new_list = nodes.field_list()
for entry in entries:
if isinstance(entry, nodes.field):
# pass-through old field
new_list += entry
else:
fieldtype, content = entry
fieldtypes = types.get(fieldtype.name, {})
env = self.directive.state.document.settings.env
new_list += fieldtype.make_field(fieldtypes, self.directive.domain,
content, env=env)
node.replace_self(new_list)
class JSONObject(directives.ObjectDescription):
"""
Implementation of ``json:object``.
The :meth:`run` method is called to process each ``.. json:object::``
directive. It builds the docutil nodes that the builder uses to
generate the output. We don't do anything that is too complex
with formatting. Cross-references and examples are a different
story. The :class:`JSONDomain` instance is responsible for
creating the examples since we have to wait until all of the JSON
objects have been parsed to do that.
Cross-references are generated using the :meth:`JSONDomain.add_object`
method of the Domain instance. This stores the JSON object definition
so that we can access it via the ``:json:object:`` role as well.
Take a look at :class:`JSONXRef` and :meth:`JSONDomain.resolve_xref`
for the implementation of the lookup.
"""
doc_field_types = [JsonField('property',
label='Object Properties',
names=('property', 'property-opt', 'member'),
rolename='prop',
typerolename='jsonprop',
typenames=('proptype', 'type'),
examplenames=['propexample'])]
"""A list of fields that are implemented."""
option_spec = {
'noindex': rst_directives.flag,
'showexample': rst_directives.unchanged,
}
"""Mapping from supported option to an option processor."""
has_content = True
"""JSONObject directives accept content."""
# Inherited Attribute Notes
# --------- --------- -----
# self.name - directive name ... probably `object'
# self.arguments - pos args as list of strings
# self.options - mapping of name -> value after passing thru option_spec
# self.content - content as a list of lines (strings)
# self.lineno - input line number
# self.src - input file/path
# self.srcline - input line number in source file
# self.content_offset - line offset of the first line
# self.block_text - raw text
# self.state - `state' which called the directive (?)
# self.state_machine - `state machine' that controls the state (?)
#
# NB self.domain is required to be the name of the sphinx domain
# inside of DocFieldTransformer
def run(self):
"""
Process a ``json:object`` directive.
This method parses the property definitions and generates a
single :class:`sphinx.addnodes.desc` element to hold the
generated docutils tree. The structure is::
<sphinx.addnodes.desc>
<sphinx.addnodes.desc_signature>name
<sphinx.addnodes.desc_name>name
<sphinx.addnodes.desc_content>
# sub-tree generated from parsing content
<sphinx.addnodes.compact_paragraph>
<docutils.nodes.strong>"JSON Example"
<docutils.nodes.literal_block>{...}
The example block is generated when :meth:`JSONDomain.process_doc`
is called. We create the ``compact_paragraph`` node, add it to the
``desc`` node and tell the Domain instance to populate the
``compact_paragraph`` later on.
"""
self.domain, sep, objtype = self.name.partition(':')
if not objtype: # swap domain and objtype
objtype = self.domain
self.domain = ''
env = self.state.document.settings.env
self.names = []
self.indexnode = addnodes.index(entries=[])
self.domain_obj = env.domains['json']
node = addnodes.desc()
node.document = self.state.document
node['domain'] = self.domain
node['objtype'] = node['desctype'] = objtype
noindex = node['noindex'] = 'noindex' in self.options
name = self.arguments[0]
signode = addnodes.desc_signature(name)
node.append(signode)
signode += addnodes.desc_name(text=name)
contentnode = addnodes.desc_content()
node.append(contentnode)
self.before_content()
self.state.nested_parse(self.content, self.content_offset, contentnode)
props = self.domain_obj.get_object(name)
if props:
env.warn(env.docname,
'JSON type {} already documented in {}'.format(
name,
env.doc2path(props.docname)),
self.lineno)
else:
self.domain_obj.add_object(name, env, contentnode)
if not noindex:
self.add_target_and_index(name, node, signode)
JsonFieldTransformer(self).transform_all(contentnode)
if 'showexample' in self.options:
paragraph = addnodes.compact_paragraph()
contentnode.append(paragraph)
self.domain_obj.data['examples'].append(
(name, self.options['showexample'] or 'json', paragraph))
self.after_content()
return [self.indexnode, node]
def add_target_and_index(self, name, sig, signode):
"""
Add an entry for ``name`` to the general index.
:param str name: name to insert into the index.
:param docutils.nodes.Element sig: unused
:param docutils.nodes.Element signode: node to index
The entry is stored as a child of the ``JSON Objects`` index
entry.
"""
key = normalize_object_name(name)
if key in self.state.document.ids:
return
signode['names'].append(name)
signode['ids'].append(key)
signode['first'] = not self.names
self.indexnode['entries'].append(
('single', 'JSON Objects; {}'.format(name), key, '', None))
class JSONXRef(roles.XRefRole):
"""Implementation of the ``:json:object:`` cross-reference."""
def process_link(self, env, refnode, has_explicit_title, title, target):
"""
Process a single link.
:param refnode: reference node
:param title: used as-is
:param str target: target value
:return: :class:`tuple` containing the `title` and the target
after normalizing it as a object name
The ``json:name`` attribute of `refnode` is set to the normalized
target name. This is used in :meth:`JSONDomain.resolve_xref` to
recognize explicit cross-references and link to the correct
information.
"""
refnode['json:name'] = normalize_object_name(target)
return title, normalize_object_name(target)
class JSONDomain(domains.Domain):
"""
Implementation of the JSON domain.
The majority of the work is done by :class:`JSONObject` and the
existing Sphinx/docutils classes. The domain instance is responsible
for tying everything together and providing somewhere to store data
as the documents are processed. The ``data`` dictionary contains
three entries:
:objects: mapping from normalized object name to
:class:`PropertyDefinition` instance. This mapping is cleared for
each document in :meth:`.clear_doc`.
:examples: list of "object key", "format", "content node" tuples
that is processed in :meth:`.process_doc` to generate example
nodes in the document. See the :meth:`.generate_examples` method
for details.
:all_objects: mapping from normalized object name to
:class:`PropertyDefinition` instance. This mapping persists
for as long as the Domain object. It is used to keep object
descriptions around for processing examples.
"""
name = 'json'
label = 'JSON'
data_version = 1
object_types = {
'object': domains.ObjType('object', 'object', 'obj'),
}
directives = {
'object': JSONObject,
}
roles = {
'object': JSONXRef(),
}
initial_data = {
'objects': {}, # name -> PropertyDefinition
'all_objects': {}, # name -> PropertyDefinition
'examples': [], # tuple(obj-key, format, content-parent)
}
indicies = []
REF_TYPES = { # type-name -> (URL, tool tip)
'uri': ('https://tools.ietf.org/html/rfc3986',
'URI as described in RFC3986'),
'boolean': (
'https://docs.python.org/library/stdtypes.html#boolean-values',
'Python Boolean'),
'string': ('https://docs.python.org/library/stdtypes.html#str',
'Python String'),
'integer': ('https://docs.python.org/library/stdtypes.html#int',
'Python Integer'),
'float': ('https://docs.python.org/library/stdtypes.html#float',
'Python Float'),
'null': ('https://docs.python.org/library/constants.html#None',
'Python None'),
'email': ('https://tools.ietf.org/html/rfc2822#section-3.4.1',
'Email Address'),
'iso8601': ('https://tools.ietf.org/html/rfc3339#section-5.6',
'ISO8601 Date/Time'),
'uuid4': ('https://tools.ietf.org/html/rfc4122#section-4.4',
'UUIDv4 in canonical syntax'),
'md5': ('https://tools.ietf.org/html/rfc1321', 'MD5 checksum'),
'sha1': ('https://tools.ietf.org/html/rfc3174', 'SHA1 checksum'),
'sha256': ('https://tools.ietf.org/html/rfc6234', 'SHA256 checksum'),
'sentence': ('https://faker.readthedocs.io/en/master/locales/en_US.html#faker-providers-lorem', 'A sentence'),
}
for alias, target in [('url', 'uri'), ('int', 'integer'),
('str', 'string'), ('user_name', 'string'),
('number', 'float'), ('bool', 'boolean')]:
REF_TYPES[alias] = REF_TYPES[target]
# create aliases for array
for target in REF_TYPES.copy().keys():
REF_TYPES['[%s]' % target] = REF_TYPES[target]
def clear_doc(self, docname):
names = [k for k, v in self.data['objects'].items()
if v.docname == docname]
for name in names:
del self.data['objects'][name]
del self.data['examples'][:]
def process_doc(self, env, docname, document):
super(JSONDomain, self).process_doc(env, docname, document)
self.generate_examples(docname)
def get_objects(self):
for objdef in self.data['objects'].values():
yield (objdef.name, objdef.name, 'object', objdef.docname,
objdef.key, 1)
def resolve_xref(self, env, fromdocname, builder, typ, target,
node, contnode):
"""
Generate a cross-reference node.
:param str fromdocname: document name that contains the reference
:param sphinx.builders.Builder builder: active builder
:param str typ: identifies the type of cross reference
:param str target: cross-reference target. In the case of a
property, this will be the property type.
:param docutils.nodes.Element node: unresolved cross-reference
node. If this has a ``json:name`` attribute then it was
processed by :class:`JSONXRef`
:param docutils.nodes.Element contnode: content node. This is
what the new reference node should wrap
:return: a cross-reference node or :data:`None`
:rtype: docutils.nodes.reference|NoneType
This method is also used to make property types ``clickable``.
In this case the `typ` will be ``jsonprop`` and `target` will
be the property type. :data:`.REF_TYPES` is consulted to
generate an appropriate link and tool tip -- for example, if
the type is ``uri``, then a link to the RFC is generated.
"""
if node.get('json:name'):
objdef = self.get_object(node['json:name'])
if objdef:
return node_utils.make_refnode(builder, fromdocname,
objdef.docname, objdef.key,
contnode)
if typ in self.REF_TYPES:
try:
ref = nodes.reference(internal=False)
ref['refuri'], ref['reftitle'] = self.REF_TYPES[target]
ref.append(contnode)
return ref
except KeyError:
pass
def get_object(self, name):
"""
Retrieve an existing object.
:param str name: object name
:return: object defintion or :data:`None`
:rtype: PropertyDefinition|NoneType
"""
try:
return self.data['objects'][normalize_object_name(name)]
except KeyError:
return None
def add_object(self, name, env, contentnode):
"""
Create a new object.
:param str name:
:param env:
:param docutils.nodes.Element contentnode:
:return: a new :class:`PropertyDefinition` instance
:rtype: PropertyDefinition
"""
props = PropertyDefinition(name, env.docname)
props.gather(contentnode)
self.data['objects'][props.key] = props
self.data['all_objects'][props.key] = props
return props
def generate_examples(self, docname):
"""
Generate example snippets after the document has been processed.
:param str docname: documentat that is being processed. This is
used to report warnings.
This is called from within :meth:`.process_doc` after our superclass
has done all of the work. When an object definition that wants an
example generated is encountered by :meth:`JSONObject.run`, a new
empty content node is inserted into the tree and a entry is added
to our list of examples.
Once we have processed *all* of the object descriptions, we can
safely populate examples for objects that refer to each other.
That is what this method is doing.
"""
fake_factory = faker.Factory.create()
for name, language, parent in self.data['examples']:
props = self.get_object(name)
sample_data = props.generate_sample_data(self.data['all_objects'],
fake_factory)
if language == 'yaml' and yaml is not None:
title = 'YAML Example'
code_text = yaml.safe_dump(sample_data, indent=4,
default_flow_style=False,
explicit_start=True,
version=(1, 2))
else:
if language == 'yaml':
self.env.warn(docname,
'YAML support is disabled, pip install yaml '
'to enable.')
title = 'JSON Example'
language = 'json'
code_text = json.dumps(sample_data, indent=4,
ensure_ascii=False)
example = nodes.literal_block(code_text, code_text)
example['language'] = language
parent.append(nodes.strong(title, title))
parent.append(example)
class PropertyQualifier:
def __init__(self):
self.is_array = False
self.example_data = None
class PropertyDefinition(object):
"""
Information about a specific JSON Object definition.
:param str name: display name (title) of the JSON object
:param str docname: document that the object is described in
:param bool should_index: should this be placed in the index?
"""
def __init__(self, name, docname, should_index=True):
self.name = name
self.key = normalize_object_name(name)
self.docname = docname
self.should_index = should_index
self.property_types = {}
self.property_options = {}
self.property_qualifiers = {} # a map of property name --> PropertyQualifier
def gather(self, contentnode):
"""
:param docutils.nodes.Element contentnode:
"""
field_nodes = {}
optional_props = {}
for node in contentnode:
if isinstance(node, nodes.field_list):
children = list(node)
for field in node:
description, content = field
tokens = description.astext().split()
if tokens[0] == 'property' or tokens[0] == 'property-opt':
if len(tokens) == 3:
typ = tokens[1]
name = tokens[2]
else:
typ = None
name = tokens[1]
# check if name is enclosed by []
if is_json_array(typ):
typ = strip_json_array(typ)
self.property_qualifiers[name] = self.property_qualifiers.get(name, PropertyQualifier())
self.property_qualifiers[name].is_array = True
self.set_property_type(name, typ)
field_nodes[name] = content
if tokens[0] == 'property-opt':
optional_props[name] = True
elif tokens[0] == 'proptype':
name = tokens[1]
try:
maybe_xref = content[0][0]
typ = maybe_xref['json:name']
except (IndexError, KeyError, TypeError):
typ = content.astext()
self.set_property_type(name, typ)
elif tokens[0] == 'propexample':
name = tokens[1]
self.property_qualifiers[name] = self.property_qualifiers.get(name, PropertyQualifier())
self.property_qualifiers[name].example_data = content[0][0].lstrip()
elif tokens[0] == 'options':
name = tokens[1]
self.property_options[name] = \
content.astext().replace(' ', '').split(',')
children.remove(field)
node.children = children
for opt_name in optional_props.keys():
try:
n = field_nodes[opt_name][0]
n += nodes.inline(' (', ' (')
n += nodes.strong('optional', 'optional')
n += nodes.inline(')', ')')
except KeyError:
pass
for name, options in self.property_options.items():
if not options:
continue
try:
n = field_nodes[name][0]
n += nodes.inline(' (', ' (')
n += nodes.emphasis(options[0], options[0])
for opt in options[1:]:
n += nodes.inline(', ', ', ')
n += nodes.emphasis(opt, opt)
n += nodes.inline(')', ')')
except KeyError:
pass
def set_property_type(self, name, typ):
if name in self.property_types and not typ:
return
self.property_types[name] = typ
def generate_sample_data(self, all_objects, fake_factory):
sample_data = {}
for name, typ in self.property_types.items():
# the data generator
def gen_data():
if name in self.property_qualifiers and self.property_qualifiers[name].example_data:
# try to cast data to user type
data = self.property_qualifiers[name].example_data
try:
data = __builtins__[typ](data)
except:
pass
return data
else:
return self.generate_sample_data_for_type(typ, all_objects, fake_factory)
if name in self.property_qualifiers:
if self.property_qualifiers[name].is_array:
sample_data[name] = [
gen_data(),
gen_data()]
else:
sample_data[name] = gen_data()
else:
sample_data[name] = gen_data()
return sample_data
@staticmethod
def generate_sample_data_for_type(typ, all_objects, fake_factory):
if typ:
# if name in self.property_qualifiers:
# if self.property_qualifiers[name].is_array:
try:
other = all_objects[typ]
value = other.generate_sample_data(all_objects,
fake_factory)
except KeyError:
value = None
if value is None:
if hasattr(fake_factory, typ):
value = getattr(fake_factory, typ)()
elif typ in ('integer', 'int'):
value = fake_factory.pyint()
elif typ in ('string', 'str'):
value = fake_factory.pystr()
elif typ in ('boolean', 'bool'):
value = fake_factory.pybool()
elif typ in ('float'):
value = fake_factory.pyfloat()
elif typ == 'null':
value = None
if value is None and typ != 'null':
value = '{%s object}' % typ
else:
value = '\uFFFD (Unspecified)'
return value
def normalize_object_name(obj_name):
return re.sub(r'\s+', '-', obj_name).strip('-').lower()
def setup(app):
app.add_domain(JSONDomain)