Skip to content

Commit

Permalink
Merge pull request #64 from cps-org/improve-schema
Browse files Browse the repository at this point in the history
Improve JSON schema
  • Loading branch information
mwoehlke authored Aug 7, 2024
2 parents 2bfdc4b + 05e3c3e commit f493d15
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 34 deletions.
80 changes: 65 additions & 15 deletions _extensions/cps.py → _extensions/cps/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import os
import re
from dataclasses import dataclass
from typing import cast
from types import NoneType
from typing import Any, cast

from docutils import nodes
from docutils.parsers.rst import Directive, directives, roles
from docutils.transforms import Transform
from docutils.utils.code_analyzer import Lexer, LexerError

from jsb import JsonSchema
import jsb

from sphinx import addnodes, domains
from sphinx.util import logging
from sphinx.util.docutils import SphinxRole
from sphinx.util.nodes import clean_astext

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -49,8 +52,11 @@ def apply(self, **kwargs):
if self.document.nameids.get(ref['refname']):
continue

# Convert remaining non-external links to intra-document references
refuri = ref['refuri'] if 'refuri' in ref else None
if refuri and refuri.startswith('./'):
continue

# Convert remaining non-external links to intra-document references
if self.is_internal_link(refuri):
# Get the raw text (strip ``s and _)
rawtext = re.sub('^`(.*)`_?$', '\\1', ref.rawsource)
Expand Down Expand Up @@ -126,6 +132,8 @@ class AttributeDirective(Directive):
'overload': directives.flag,
'required': directives.flag,
'conditionally-required': directives.flag,
'format': directives.unchanged,
'default': directives.unchanged,
}

# -------------------------------------------------------------------------
Expand Down Expand Up @@ -165,9 +173,8 @@ def parse_type(self, typedesc):

return content

m = re.match(r'^(list|map)[(](.*)[)]$', typedesc)
if m:
outer, inner = m.groups()
outer, inner = jsb.decompose_typedesc(typedesc)
if outer:
content = [
make_code(outer, 'type'),
nodes.Text(' of '),
Expand All @@ -180,12 +187,33 @@ def parse_type(self, typedesc):

return content + self.parse_type(inner)

elif typedesc in {'string'}:
elif typedesc in jsb.BUILTIN_TYPES:
return [make_code(typedesc, 'type')]

else:
return [make_code(typedesc, 'object')]

# -------------------------------------------------------------------------
def parse_data(self, data):
language = 'json'
classes = ['code', 'highlight', f'highlight-{language}']

try:
tokens = Lexer(nodes.unescape(data, True), language, 'short')
except LexerError as error:
msg = self.state.inliner.reporter.warning(error)
prb = self.state.inliner.problematic(data, data, msg)
return [prb]

node = nodes.literal(data, '', classes=classes)
for classes, value in tokens:
if classes:
node += nodes.inline(value, value, classes=classes)
else:
node += nodes.Text(value)

return [node]

# -------------------------------------------------------------------------
def run(self):
name = self.arguments[0]
Expand All @@ -194,6 +222,8 @@ def run(self):
overload = 'overload' in self.options
required = 'required' in self.options
conditionally_required = 'conditionally-required' in self.options
typeformat = self.options.get('format')
default = self.options.get('default')

if overload:
target = f'{name} ({context[0]})'
Expand Down Expand Up @@ -233,6 +263,9 @@ def run(self):
fields += self.make_field(
'Required', required_text, [nodes.Text(required_text)])
section += fields
if default:
fields += self.make_field(
'Default', default, self.parse_data(default))

# Parse attribute description
content = nodes.Element()
Expand All @@ -245,18 +278,29 @@ def run(self):
# Record object on domain
env = self.state.document.settings.env
domain = cast(CpsDomain, env.get_domain('cps'))
domain.note_attribute(name, context, typedesc, required=required,
description=simplify_text(content), node=section)
domain.note_attribute(name, context, typedesc, typeformat,
required=required, default=default,
description=simplify_text(content),
node=section)

# Return generated nodes
return [section]

# =============================================================================
class SchemaRole(SphinxRole):
def run(self):
uri = f'./{self.config.schema_filename}'
node = nodes.reference(self.rawtext, self.text, refuri=uri)
return [node], []

# =============================================================================
@dataclass
class Attribute:
typedesc: str
typeformat: str
description: str
required: bool
default: Any

# =============================================================================
class AttributeSet:
Expand Down Expand Up @@ -293,7 +337,10 @@ def __init__(self, *args, **kwargs):
self.directives['object'] = ObjectDirective
self.directives['attribute'] = AttributeDirective

# Site-specific custom roles (these just apply styling)
# Site-specific roles
self.roles['schema'] = SchemaRole()

# Additional site-specific roles (these just apply styling)
self.add_role('hidden')
self.add_role('applies-to')
self.add_role('separator')
Expand Down Expand Up @@ -326,9 +373,9 @@ def note_object(self, name, description):
self.objects[name] = description

# -------------------------------------------------------------------------
def note_attribute(self, name, context, typedesc,
required, description, node):
a = Attribute(typedesc, description, required)
def note_attribute(self, name, context, typedesc, typeformat,
required, default, description, node):
a = Attribute(typedesc, typeformat, description, required, default)
if name not in self.attributes:
self.attributes[name] = AttributeSet(name, context, a, node)
else:
Expand Down Expand Up @@ -359,15 +406,17 @@ def write_schema(app, exception):
title = f'{config.project} v{config.version}'

domain = cast(CpsDomain, app.env.get_domain('cps'))
schema = JsonSchema(title, config.schema_id)
schema = jsb.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.typeformat,
attribute.description,
attribute.default,
)

for context, attribute_ref in attribute_set.context.items():
Expand All @@ -385,14 +434,15 @@ def write_schema(app, exception):
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)
schema.write(config.schema_root_object, output_path, config.schema_indent)

# =============================================================================
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])
app.add_config_value('schema_indent', None, '', [NoneType, int])

# Add custom transform to resolve cross-file references
app.add_transform(InternalizeLinks)
Expand Down
13 changes: 13 additions & 0 deletions _extensions/cps/pygments_styles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import pygments.token

from sphinx.pygments_styles import SphinxStyle

# =============================================================================
class CpsDataStyle(SphinxStyle):
background_color = ''

styles = SphinxStyle.styles
styles.update({
pygments.token.Name.Tag: '#15a',
pygments.token.Literal.String.Double: '#d32',
})
37 changes: 29 additions & 8 deletions _packages/jsb.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import json
import re

BUILTIN_TYPES = {
'string',
}

# =============================================================================
def decompose_typedesc(typedesc):
m = re.match(r'^(list|map)[(](.*)[)]$', typedesc)
return m.groups() if m else (None, typedesc)

# =============================================================================
class JsonSchema:
# -------------------------------------------------------------------------
Expand Down Expand Up @@ -33,9 +42,8 @@ def add_type(self, typedesc):

return

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

if outer == 'list':
Expand All @@ -52,7 +60,7 @@ def add_type(self, typedesc):
},
}

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

Expand All @@ -62,12 +70,21 @@ def add_type(self, typedesc):
pass

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

if typeformat:
attr['format'] = typeformat

if default:
attr['default'] = json.loads(default)

self.attributes[f'{name}@{instance}'] = attr

self.add_type(typedesc)

# -------------------------------------------------------------------------
Expand Down Expand Up @@ -98,5 +115,9 @@ def _build_schema(self, root_object):
return schema

# -------------------------------------------------------------------------
def write(self, root_object, path):
json.dump(self._build_schema(root_object), open(path, 'wt'))
def write(self, root_object, path, indent=None):
json.dump(
self._build_schema(root_object),
fp=open(path, 'wt'),
indent=indent,
)
11 changes: 4 additions & 7 deletions conf.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# -*- coding: utf-8 -*-

import os
import re
import sys

# -- General configuration ------------------------------------------------
needs_sphinx = '6.2'
extensions = ['cps', 'autosectionlabel']
Expand All @@ -23,15 +19,16 @@

language = 'en'
primary_domain = 'cps'
#default_role = None
# default_role = None

highlight_language = 'none'
pygments_style = 'sphinx'
highlight_language = 'json'
pygments_style = 'cps.pygments_styles.CpsDataStyle'

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

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

Expand Down
2 changes: 1 addition & 1 deletion sample.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ Many of the most commonly used attributes are demonstrated,
as is the technique discussed in `Configurations as Flags`_.

.. literalinclude:: sample.cps
:language: javascript
:language: json
1 change: 1 addition & 0 deletions schema-supplement.rst
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ By definition, none of the following attributes are required.
.. cps:attribute:: website
:type: string
:context: package
:format: uri

Specifies the URI at which the package's website may be found.

Expand Down
Loading

0 comments on commit f493d15

Please sign in to comment.