From 1263efe416fea76365e30676e06ca4222c516787 Mon Sep 17 00:00:00 2001 From: Mike Perez Date: Thu, 8 Jun 2017 17:20:02 -0700 Subject: [PATCH] Build Common Framework for Feature Classification Matrix The feature classification matrix will provide information about plugins and the features they support. Acts as a launching point for users to ready to deploy their cloud. Users can use the matrix to find features and plugins that meet their needs. Pulling out the framework from Nova's implementation here: http://docs.openstack.org/developer/nova/support-matrix.html Neutron and Nova are currently working on implementation. Putting the extension in its own module will give all projects a common framework to use. Co-Authored-By: Change-Id: Icf4975b1dafefc9ba9f063bd8f9c6c54a36c1e13 --- doc/source/conf.py | 2 + doc/source/index.rst | 1 + doc/source/usage.rst | 81 ++++ requirements.txt | 1 + .../support-matrix.css | 33 ++ .../support_matrix.py | 452 ++++++++++++++++++ .../tests/fakes/__init__.py | 0 .../tests/fakes/support-matrix.ini | 14 + .../test_sphinx_feature_classification.py | 38 +- test-requirements.txt | 2 +- 10 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 doc/source/usage.rst create mode 100644 sphinx_feature_classification/support-matrix.css create mode 100644 sphinx_feature_classification/support_matrix.py create mode 100644 sphinx_feature_classification/tests/fakes/__init__.py create mode 100644 sphinx_feature_classification/tests/fakes/support-matrix.ini diff --git a/doc/source/conf.py b/doc/source/conf.py index 60c012e..04ead7b 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -52,6 +52,8 @@ pygments_style = 'sphinx' # -- Options for HTML output -------------------------------------------------- +html_theme = 'openstackdocs' + # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. # html_theme_path = ["."] diff --git a/doc/source/index.rst b/doc/source/index.rst index 247843a..de02f50 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -13,6 +13,7 @@ Contents: readme installation + usage contributing Indices and tables diff --git a/doc/source/usage.rst b/doc/source/usage.rst new file mode 100644 index 0000000..635f7d0 --- /dev/null +++ b/doc/source/usage.rst @@ -0,0 +1,81 @@ +======== +Usage +======== + +Sphinx Configuration +==================== + +To use the extension, add ``'sphinx-feature-classification'`` to the +``extensions`` list in the ``conf.py`` file in your Sphinx project. + +Documenting Your Drivers +======================== + +1. This extension uses an ini file to render your driver matrix in Sphinx. You + can begin by creating the file support-matrix.ini file in your sphinx's + source directory. + +2. In the INI file, create driver sections that are prefixed with driver-. The + section has various options that can be specified. + ++------------+-----------+---------------------------------------+ +| Field Name | Mandatory | Description | ++============+===========+=======================================+ +| title | **Yes** | Friendly name of the driver. | ++------------+-----------+---------------------------------------+ +| link | No | A link to documentation of the driver.| ++------------+-----------+---------------------------------------+ + +.. code-block:: INI + + [driver.slow-driver] + title=Slow Driver + link=https://docs.openstack.org/foo/latest/some-slow-driver-doc + + [driver.fast-driver] + title=Fast Driver + link=https://docs.openstack.org/foo/latest/some-fast-driver-doc + + +3. Next we'll create a feature section to show which drivers support it. + +.. code-block:: INI + + [operation.attach-volume] + title=Attach block volume to instance + status=optional + notes=The attach volume operation provides a means to hotplug + additional block storage to a running instance. + cli=nova volume-attach + driver-slow-driver=complete + driver-fast-driver=complete + +The 'status' field takes possible values + ++---------------+------------------------------------------------------+ +| Status | Description | ++===============+======================================================+ +| mandatory | Unconditionally required to be implemented. | ++---------------+------------------------------------------------------+ +| optional | Optional to support, nice to have. | ++---------------+------------------------------------------------------+ +| choice(group) | At least one of the options within the named group | +| | must be implemented. | ++---------------+------------------------------------------------------+ +| condition | Required, if the referenced condition is met. | ++---------------+------------------------------------------------------+ + +The value against each 'driver-XXXX' entry refers to the level +of the implementation of the feature in that driver + ++---------------+------------------------------------------------------+ +| Status | Description | ++===============+======================================================+ +| complete | Fully implemented, expected to work at all times. | ++---------------+------------------------------------------------------+ +| partial | Implemented, but with caveats about when it will | +| | work eg some configurations or hardware or guest OS | +| | may not support it. | ++---------------+------------------------------------------------------+ +| missing | Not implemented at all. | ++---------------+------------------------------------------------------+ diff --git a/requirements.txt b/requirements.txt index 1d18dd3..1532602 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. +docutils>=0.11 # OSI-Approved Open Source, Public Domain pbr>=2.0 # Apache-2.0 diff --git a/sphinx_feature_classification/support-matrix.css b/sphinx_feature_classification/support-matrix.css new file mode 100644 index 0000000..267c342 --- /dev/null +++ b/sphinx_feature_classification/support-matrix.css @@ -0,0 +1,33 @@ + +.sp_feature_required { + font-weight: bold; +} + +.sp_impl_complete { + color: rgb(0, 120, 0); + font-weight: normal; +} + +.sp_impl_missing { + color: rgb(120, 0, 0); + font-weight: normal; +} + +.sp_impl_partial { + color: rgb(170, 170, 0); + font-weight: normal; +} + +.sp_impl_unknown { + color: rgb(170, 170, 170); + font-weight: normal; +} + +.sp_impl_summary { + font-size: 2em; +} + +.sp_cli { + font-family: monospace; + background-color: #F5F5F5; +} diff --git a/sphinx_feature_classification/support_matrix.py b/sphinx_feature_classification/support_matrix.py new file mode 100644 index 0000000..e02afc5 --- /dev/null +++ b/sphinx_feature_classification/support_matrix.py @@ -0,0 +1,452 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +""" +This provides a sphinx extension able to render the support-matrix.ini +file into the developer documentation. + +It is used via a single directive in the .rst file + + .. support_matrix:: + +""" + +import re + +from docutils import nodes +from docutils.parsers import rst +from six.moves import configparser + +KEY_PATTERN = re.compile("[^a-zA-Z0-9_]") +DRIVER_PREFIX = "driver." +FEATURE_PREFIX = 'operation.' +DRIVER_NOTES_PREFIX = "driver-notes." + + +class Matrix(object): + """Represents the entire support matrix for project drivers""" + + def __init__(self, cfg): + self.drivers = self._set_drivers(cfg) + self.features = self._set_features(cfg) + + @staticmethod + def _set_drivers(cfg): + drivers = {} + + for section in cfg.sections(): + if not section.startswith(DRIVER_PREFIX): + continue + + title = cfg.get(section, "title") + + link = None + if cfg.has_option(section, 'link'): + link = cfg.get(section, "link") + + driver = Driver(title, link) + drivers[section] = driver + + return drivers + + def _set_features(self, cfg): + features = [] + + def _process_feature(section): + if not cfg.has_option(section, "title"): + raise Exception( + "'title' option missing in '[%s]' section" % section) + + title = cfg.get(section, "title") + status = Feature.STATUS_OPTIONAL + group = None + + if cfg.has_option(section, "status"): + # The value is a string "status(group)" where + # the 'group' part is optional + status, group = re.match('^([^(]+)(?:\(([^)]+)\))?$', + cfg.get(section, "status")).groups() + + if status not in Feature.STATUS_ALL: + raise ValueError( + "'status' option value '%s' in ['%s']" + "section must be one of (%s)" % + (status, section, + ", ".join(Feature.STATUS_ALL))) + + cli = [] + if cfg.has_option(section, "cli"): + cli = cfg.get(section, "cli") + + notes = None + if cfg.has_option(section, "notes"): + notes = cfg.get(section, "notes") + return Feature(section, title, status, group, notes, cli) + + def _process_implementation(section, option, feature): + if option not in self.drivers: + raise Exception( + "'%s' section is not declared in the " + "INI file." % (option)) + + status = cfg.get(section, option) + if status not in Implementation.STATUS_ALL: + raise ValueError( + "%s is set to %s in '[%s]' section but must be " + "one of (%s)" % (option, status, section, ", ".join( + Implementation.STATUS_ALL))) + + impl = Implementation(status) + feature.implementations[option] = impl + + return feature + + for section in cfg.sections(): + if not section.startswith(FEATURE_PREFIX): + continue + + feature = _process_feature(section) + + # Now we've got the basic feature details, we must process + # the backend driver implementation for each feature + for option in cfg.options(section): + if not option.startswith(DRIVER_PREFIX): + continue + + implementation = _process_implementation(section, option, + feature) + features.append(implementation) + + return features + + +class Feature(object): + STATUS_CHOICE = "choice" + STATUS_CONDITION = "condition" + STATUS_REQUIRED = "required" + STATUS_OPTIONAL = "optional" + STATUS_MATURE = "mature" + STATUS_IMMATURE = "immature" + + STATUS_ALL = [STATUS_REQUIRED, STATUS_OPTIONAL, STATUS_CHOICE, + STATUS_CONDITION, STATUS_MATURE, STATUS_IMMATURE] + + def __init__(self, key, title, status=STATUS_OPTIONAL, + group=None, notes=None, cli=(), api=None): + self.key = key + self.title = title + self.status = status + self.group = group + self.notes = notes + self.cli = cli + self.api = api + + self.implementations = {} + + +class Implementation(object): + STATUS_COMPLETE = "complete" + STATUS_PARTIAL = "partial" + STATUS_INCOMPLETE = "incomplete" + STATUS_UNKNOWN = "unknown" + + STATUS_ALL = [STATUS_COMPLETE, STATUS_INCOMPLETE, + STATUS_PARTIAL, STATUS_UNKNOWN] + + def __init__(self, status=STATUS_INCOMPLETE): + self.status = status + + +STATUS_SYMBOLS = { + Implementation.STATUS_COMPLETE: u"\u2714", + Implementation.STATUS_INCOMPLETE: u"\u2716", + Implementation.STATUS_PARTIAL: u"\u2714", + Implementation.STATUS_UNKNOWN: u"?" +} + + +class Driver(object): + def __init__(self, title, link=None): + """Driver object. + + :param title: Human readable name for plugin + :param link: A URL to documentation about the driver. + """ + self.title = title + self.link = link + + +class Directive(rst.Directive): + + # support-matrix.ini is the arg + required_arguments = 1 + + def run(self): + matrix = self._load_support_matrix() + return self._build_markup(matrix) + + def _load_support_matrix(self): + """Parse support-matrix.ini file. + + Reads the support-matrix.ini file and populates an instance of the + Matrix class with all the data. + + :returns: Matrix instance + """ + + cfg = configparser.ConfigParser() + env = self.state.document.settings.env + fname = self.arguments[0] + rel_fpath, fpath = env.relfn2path(fname) + with open(fpath) as fp: + cfg.readfp(fp) + + # This ensures that the docs are rebuilt whenever the + # .ini file changes + env.note_dependency(rel_fpath) + + matrix = Matrix(cfg) + return matrix + + def _build_markup(self, matrix): + """Constructs the docutils content for the support matrix.""" + content = [] + self._build_summary(matrix, content) + self._build_details(matrix, content) + self._build_notes(content) + return content + + @staticmethod + def _build_summary(matrix, content): + """Constructs the content for the summary of the support matrix. + + The summary consists of a giant table, with one row + for each feature, and a column for each backend + driver. It provides an 'at a glance' summary of the + status of each driver. + """ + + summary_title = nodes.subtitle(text="Summary") + summary = nodes.table() + cols = len(matrix.drivers.keys()) + + # Add two columns for the Feature and Status columns. + cols += 2 + + summary_group = nodes.tgroup(cols=cols) + summary_body = nodes.tbody() + summary_head = nodes.thead() + + for i in range(cols): + summary_group.append(nodes.colspec(colwidth=1)) + summary_group.append(summary_head) + summary_group.append(summary_body) + summary.append(summary_group) + content.append(summary_title) + content.append(summary) + + # This sets up all the column headers - two fixed + # columns for feature name & status + header = nodes.row() + blank = nodes.entry() + blank.append(nodes.emphasis(text="Feature")) + header.append(blank) + blank = nodes.entry() + blank.append(nodes.emphasis(text="Status")) + header.append(blank) + summary_head.append(header) + + # then one column for each backend driver + impls = matrix.drivers.keys() + impls.sort() + for key in impls: + driver = matrix.drivers[key] + implcol = nodes.entry() + header.append(implcol) + implcol.append(nodes.strong(text=driver.title)) + + # We now produce the body of the table, one row for + # each feature to report on + for feature in matrix.features: + item = nodes.row() + + # the hyperlink driver name linking to details + feature_id = re.sub(KEY_PATTERN, "_", feature.key) + + # first the fixed columns for title/status + key_col = nodes.entry() + item.append(key_col) + key_ref = nodes.reference(refid=feature_id) + key_txt = nodes.inline() + key_col.append(key_txt) + key_txt.append(key_ref) + key_ref.append(nodes.strong(text=feature.title)) + + status_col = nodes.entry() + item.append(status_col) + status_col.append(nodes.inline( + text=feature.status, + classes=["sp_feature_" + feature.status])) + + # and then one column for each backend driver + impls = matrix.drivers.keys() + impls.sort() + for key in impls: + impl = feature.implementations[key] + impl_col = nodes.entry() + item.append(impl_col) + + key_id = re.sub(KEY_PATTERN, "_", + "{}_{}".format(feature.key, key)) + + impl_ref = nodes.reference(refid=key_id) + impl_txt = nodes.inline() + impl_col.append(impl_txt) + impl_txt.append(impl_ref) + + status = STATUS_SYMBOLS.get(impl.status, "") + + impl_ref.append(nodes.literal( + text=status, + classes=["sp_impl_summary", "sp_impl_" + impl.status])) + + summary_body.append(item) + + def _build_details(self, matrix, content): + """Constructs the content for the details of the support matrix.""" + + details_title = nodes.subtitle(text="Details") + details = nodes.bullet_list() + + content.append(details_title) + content.append(details) + + # One list entry for each feature we're reporting on + for feature in matrix.features: + item = nodes.list_item() + + status = feature.status + if feature.group is not None: + status += "({})".format(feature.group) + + feature_id = re.sub(KEY_PATTERN, "_", feature.key) + + # Highlight the feature title name + item.append(nodes.strong(text=feature.title, ids=[feature_id])) + + # Add maturity status + para = nodes.paragraph() + para.append(nodes.strong(text="Status: {}. ".format(status))) + item.append(para) + + if feature.api is not None: + para = nodes.paragraph() + para.append( + nodes.strong(text="API Alias: {} ".format(feature.api))) + item.append(para) + + if feature.cli: + item.append(self._create_cli_paragraph(feature)) + + if feature.notes is not None: + item.append(self._create_notes_paragraph(feature.notes)) + + para_divers = nodes.paragraph() + para_divers.append(nodes.strong(text="Driver Support:")) + # A sub-list giving details of each backend driver + impls = nodes.bullet_list() + for key in feature.implementations: + driver = matrix.drivers[key] + impl = feature.implementations[key] + subitem = nodes.list_item() + + key_id = re.sub(KEY_PATTERN, "_", + "{}_{}".format(feature.key, key)) + + subitem += [ + nodes.strong(text="{}: ".format(driver.title)), + nodes.literal(text=impl.status, + classes=["sp_impl_{}".format(impl.status)], + ids=[key_id]), + ] + + impls.append(subitem) + + para_divers.append(impls) + item.append(para_divers) + details.append(item) + + @staticmethod + def _build_notes(content): + """Constructs a list of notes content for the support matrix. + + This is generated as a bullet list. + """ + notes_title = nodes.subtitle(text="Notes:") + notes = nodes.bullet_list() + + content.append(notes_title) + content.append(notes) + + for note in ["This document is a continuous work in progress"]: + item = nodes.list_item() + item.append(nodes.strong(text=note)) + notes.append(item) + + @staticmethod + def _create_cli_paragraph(feature): + """Create a paragraph which represents the CLI commands of the feature + + The paragraph will have a bullet list of CLI commands. + """ + para = nodes.paragraph() + para.append(nodes.strong(text="CLI commands:")) + commands = nodes.bullet_list() + for c in feature.cli.split(";"): + cli_command = nodes.list_item() + cli_command += nodes.literal(text=c, classes=["sp_cli"]) + commands.append(cli_command) + para.append(commands) + return para + + @staticmethod + def _create_notes_paragraph(notes): + """Constructs a paragraph which represents the implementation notes + + The paragraph consists of text and clickable URL nodes if links were + given in the notes. + """ + para = nodes.paragraph() + para.append(nodes.strong(text="Notes: ")) + # links could start with http:// or https:// + link_idxs = [m.start() for m in re.finditer('https?://', notes)] + start_idx = 0 + for link_idx in link_idxs: + # assume the notes start with text (could be empty) + para.append(nodes.inline(text=notes[start_idx:link_idx])) + # create a URL node until the next text or the end of the notes + link_end_idx = notes.find(" ", link_idx) + if link_end_idx == -1: + # In case the notes end with a link without a blank + link_end_idx = len(notes) + uri = notes[link_idx:link_end_idx + 1] + para.append(nodes.reference("", uri, refuri=uri)) + start_idx = link_end_idx + 1 + + # get all text after the last link (could be empty) or all of the + # text if no link was given + para.append(nodes.inline(text=notes[start_idx:])) + return para + + +def setup(app): + app.add_directive('support_matrix', Directive) + app.add_stylesheet('support-matrix.css') diff --git a/sphinx_feature_classification/tests/fakes/__init__.py b/sphinx_feature_classification/tests/fakes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sphinx_feature_classification/tests/fakes/support-matrix.ini b/sphinx_feature_classification/tests/fakes/support-matrix.ini new file mode 100644 index 0000000..66b32ea --- /dev/null +++ b/sphinx_feature_classification/tests/fakes/support-matrix.ini @@ -0,0 +1,14 @@ +[driver.foo] +title=Foo Driver +link=https://docs.openstack.org + +[driver.bar] +title=Bar Driver +link=https://docs.openstack.org + +[operation.Cool_Feature] +title=Cool Feature +status=optional +notes=A pretty darn cool feature. +driver.foo=complete +driver.bar=partial diff --git a/sphinx_feature_classification/tests/test_sphinx_feature_classification.py b/sphinx_feature_classification/tests/test_sphinx_feature_classification.py index ef2a780..fc1d633 100644 --- a/sphinx_feature_classification/tests/test_sphinx_feature_classification.py +++ b/sphinx_feature_classification/tests/test_sphinx_feature_classification.py @@ -18,11 +18,43 @@ test_sphinx_feature_classification Tests for `sphinx_feature_classification` module. """ +import os +from sphinx_feature_classification import support_matrix from sphinx_feature_classification.tests import base +import ddt +from six.moves import configparser -class TestSphinx_feature_classification(base.TestCase): - def test_something(self): - pass +@ddt.ddt +class MatrixTestCase(base.TestCase): + + def setUp(self): + super(MatrixTestCase, self).setUp() + + cfg = configparser.ConfigParser() + directory = os.path.dirname(os.path.abspath(__file__)) + config_file = os.path.join(directory, 'fakes', 'support-matrix.ini') + + with open(config_file) as fp: + cfg.readfp(fp) + + self.matrix = support_matrix.Matrix(cfg) + + def test_features_set(self): + fake_feature = self.matrix.features[0] + self.assertEqual('Cool Feature', fake_feature.title) + self.assertEqual('optional', fake_feature.status) + self.assertEqual('A pretty darn cool feature.', + fake_feature.notes) + + @ddt.unpack + @ddt.data({'key': 'driver.foo', 'title': 'Foo Driver', + 'link': 'https://docs.openstack.org'}, + {'key': 'driver.bar', 'title': 'Bar Driver', + 'link': 'https://docs.openstack.org'}) + def test_drivers_set(self, key, title, link): + fake_driver = self.matrix.drivers[key] + self.assertEqual(title, fake_driver.title) + self.assertEqual(link, fake_driver.link) diff --git a/test-requirements.txt b/test-requirements.txt index 7fbd4d7..bcf5329 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -3,10 +3,10 @@ # process, which may cause wedges in the gate later. hacking>=0.12.0,<0.13 # Apache-2.0 - coverage>=4.0,!=4.4 # Apache-2.0 openstackdocstheme>=1.17.0 # Apache-2.0 oslotest>=1.10.0 # Apache-2.0 +ddt>=1.0.1 # MIT python-subunit>=0.0.18 # Apache-2.0/BSD sphinx!=1.6.1,>=1.5.1 # BSD testrepository>=0.0.18 # Apache-2.0/BSD