Skip to content

Commit

Permalink
Generate JSON schema from documentation
Browse files Browse the repository at this point in the history
Using the structured information extracted from the documentation (see
previous commit), build and write a JSON schema. This also introduces a
new utility library to convert build the schema description from the set
of objects and attributes.
  • Loading branch information
mwoehlke committed May 10, 2024
1 parent c9269ab commit b2e7671
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 1 deletion.
47 changes: 47 additions & 0 deletions _extensions/cps.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import re
from dataclasses import dataclass
from typing import cast
Expand All @@ -6,6 +7,8 @@
from docutils.parsers.rst import Directive, directives, roles
from docutils.transforms import Transform

from jsb import JsonSchema

from sphinx import addnodes, domains
from sphinx.util import logging
from sphinx.util.nodes import clean_astext
Expand Down Expand Up @@ -346,9 +349,53 @@ def add_role(self, name, styles=None, parent=roles.generic_custom_role):
def add_code_role(self, name, styles=None, parent=roles.code_role):
self.add_role(name, styles, parent)


# =============================================================================
def write_schema(app, exception):
if exception is not None:
return

config = app.env.config
title = f'{config.project} v{config.version}'

domain = cast(CpsDomain, app.env.get_domain('cps'))
schema = JsonSchema(title, config.schema_id)

object_attributes = {}
for attribute_set in domain.attributes.values():
for i, attribute in enumerate(attribute_set.instances):
schema.add_attribute(
attribute_set.name, i,
attribute.typedesc,
attribute.description,
)

for context, attribute_ref in attribute_set.context.items():
attribute = (
attribute_set.name,
attribute_ref[0],
attribute_set.instances[attribute_ref[0]].required
)
if context in object_attributes:
object_attributes[context].append(attribute)
else:
object_attributes[context] = [attribute]

for name, description in domain.objects.items():
schema.add_object_type(name, description, object_attributes[name])

output_path = os.path.join(app.outdir, config.schema_filename)
schema.write(config.schema_root_object, output_path)

# =============================================================================
def setup(app):
app.add_domain(CpsDomain)
app.add_config_value('schema_id', '', '', [str])
app.add_config_value('schema_filename', 'schema.json', '', [str])
app.add_config_value('schema_root_object', None, '', [str])

# Add custom transform to resolve cross-file references
app.add_transform(InternalizeLinks)

# Add hook to write machine-readable schema description on completion
app.connect('build-finished', write_schema)
102 changes: 102 additions & 0 deletions _packages/jsb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import json
import re

# =============================================================================
class JsonSchema:
# -------------------------------------------------------------------------
def __init__(self, title, uri):
self.meta = {
'$schema': 'http://json-schema.org/draft-07/schema',
'$id': uri,
'title': title,
}
self.types = {}
self.object_types = {}
self.attributes = {}

# -------------------------------------------------------------------------
def add_type(self, typedesc):
if typedesc in self.types:
# Type already defined; nothing to do
return

if '|' in typedesc:
types = typedesc.split('|')
for t in types:
self.add_type(t)

self.types[typedesc] = {
'oneOf': [
{'$ref': f'#/definitions/types/{t}'} for t in types
],
}

return

m = re.match(r'^(list|map)[(](.*)[)]$', typedesc)
if m:
outer, inner = m.groups()
self.add_type(inner)

if outer == 'list':
self.types[typedesc] = {
'type': 'array',
'items': {'$ref': f'#/definitions/types/{inner}'},
}

else:
self.types[typedesc] = {
'type': 'object',
'patternProperties': {
'': {'$ref': f'#/definitions/types/{inner}'},
},
}

elif typedesc in {'string'}:
# Handle simple (non-compound) types
self.types[typedesc] = {'type': typedesc}

else:
# Anything unknown is assumed to be an object type, which must be
# defined via add_object_type
pass

# -------------------------------------------------------------------------
def add_attribute(self, name, instance, typedesc, description):
self.attributes[f'{name}@{instance}'] = {
'description': description,
'$ref': f'#/definitions/types/{typedesc}',
}

self.add_type(typedesc)

# -------------------------------------------------------------------------
def add_object_type(self, name, description, attributes, strict=False):
self.object_types[name] = {
'type': 'object',
'description': description,
'properties': {
n: {'$ref': f'#/definitions/attributes/{n}@{i}'}
for n, i, r in attributes
},
'required': [n for n, i, r in attributes if r],
'additionalProperties': not strict,
}

# -------------------------------------------------------------------------
def _build_schema(self, root_object):
all_types = self.types
all_types.update(self.object_types)

schema = self.meta
schema['definitions'] = {
'types': all_types,
'attributes': self.attributes,
}
schema['$ref'] = f'#/definitions/types/{root_object}'

return schema

# -------------------------------------------------------------------------
def write(self, root_object, path):
json.dump(self._build_schema(root_object), open(path, 'wt'))
5 changes: 5 additions & 0 deletions conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
highlight_language = 'none'
pygments_style = 'sphinx'

# -- Options for JSON schema output ---------------------------------------
schema_filename = 'cps.schema.json'
schema_root_object = 'package'
schema_id = 'https://cps-org.github.io/cps/' + schema_filename

# -- Options for HTML output ----------------------------------------------
html_style = 'cps.css'
html_theme = 'default'
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ description = ""
authors = ["The CPS Project <@>"]
readme = "readme.md"
packages = [
{ include = "jsb.py", from = "_packages" },
{ include = "cps.py", from = "_extensions" },
{ include = "autosectionlabel.py", from = "_extensions" }
{ include = "autosectionlabel.py", from = "_extensions" },
]

[tool.poetry.dependencies]
Expand Down

0 comments on commit b2e7671

Please sign in to comment.