Create API documentation from docstrings

Create a new Sphinx extension called 'web_api_docstring' to process
docstrings from the API classes, in order to generate API
documentation.

Story: 2009785
Task: 44291
Change-Id: Ia6b2b3741e2b1cbd29531c21795df4f0f0dc70ca
This commit is contained in:
Mahnoor Asghar 2022-02-01 05:12:37 +05:00
parent 365a4545fe
commit 3e631a5931
8 changed files with 418 additions and 39 deletions

View File

@ -153,11 +153,6 @@ Request
.. literalinclude:: samples/node-create-request-dynamic.json
:language: javascript
**Example Node creation request with a classic driver:**
.. literalinclude:: samples/node-create-request-classic.json
:language: javascript
Response
--------

View File

@ -324,6 +324,13 @@ r_node_uuid:
in: query
required: false
type: string
r_owner:
description: |
Filter the list of returned allocations, and only return those with
the specified owner.
in: query
required: false
type: string
r_port_address:
description: |
Filter the list of returned Ports, and only return the ones with the
@ -412,7 +419,7 @@ sort_dir:
type: string
sort_key:
description: |
Sorts the response by the this attribute value.
Sorts the response by this attribute value.
Default is ``id``. You can specify multiple pairs of sort key and
sort direction query parameters. If you omit the sort direction in
a pair, the API uses the natural sorting direction of the server
@ -466,6 +473,12 @@ allocation_node:
in: body
required: true
type: string
allocation_patch:
description: |
A JSON patch document to apply to the allocation.
in: body
required: true
type: JSON
allocation_resource_class:
description: |
The resource class requested for the allocation. Can be ``null`` if

View File

@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
#
# 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.
from http import HTTPStatus
import os
import re # Stdlib
from docutils import nodes
from docutils.parsers.rst import Directive # 3rd Party
from sphinx.util.docfields import GroupedField # 3rd Party
import yaml # 3rd party
from ironic.common import exception # Application
def read_from_file(fpath):
"""Read the data in file given by fpath."""
with open(fpath, 'r') as stream:
yaml_data = yaml.load(stream, Loader=yaml.SafeLoader)
return yaml_data
def split_str_to_field(input_str):
"""Split the input_str into 2 parts, the field name and field body.
The split is based on this regex format: :field_name: field_body.
"""
regex_pattern = "((^:{1}.*:{1})(.*))"
field_name = None
field_body = None
if input_str is None:
return field_name, field_body
regex_output = re.match(regex_pattern, input_str)
if regex_output is None and len(input_str) > 0:
field_body = input_str.lstrip(' ')
if regex_output is not None:
field = regex_output.groups()
field_name = field[1].strip(':')
field_body = field[2].strip()
return field_name, field_body
def parse_field_list(content):
"""Convert list of fields as strings, to a dictionary.
This function takes a list of strings as input, each item being
a :field_name: field_body combination, and converts it into a dictionary
with the field names as keys, and field bodies as values.
"""
field_list = {} # dictionary to hold parsed input field list
for c in content:
if c is None:
continue
field_name, field_body = split_str_to_field(c)
field_list[field_name] = field_body
return field_list
def create_bullet_list(input_dict, input_build_env):
"""Convert input_dict into a sphinx representaion of a bullet list."""
grp_field = GroupedField('grp_field', label='title')
bullet_list = nodes.paragraph()
for field_name in input_dict:
fbody_txt_node = nodes.Text(data=input_dict[field_name])
tmp_field_node = grp_field.make_field(domain='py',
types=nodes.field,
items=[(field_name,
fbody_txt_node)],
env=input_build_env)
for c in tmp_field_node.children:
if c.tagname == 'field_body':
for ch in c.children:
bullet_list += ch
return bullet_list
def create_table(table_title, table_contents):
"""Construct a docutils-based table (single row and column)."""
table = nodes.table()
tgroup = nodes.tgroup(cols=1)
colspec = nodes.colspec(colwidth=1)
tgroup.append(colspec)
table += tgroup
thead = nodes.thead()
tgroup += thead
row = nodes.row()
entry = nodes.entry()
entry += nodes.paragraph(text=table_title)
row += entry
thead.append(row)
rows = []
row = nodes.row()
rows.append(row)
entry = nodes.entry()
entry += table_contents
row += entry
tbody = nodes.tbody()
tbody.extend(rows)
tgroup += tbody
return table
def split_list(input_list):
"""Split input_list into three sub-lists.
This function splits the input_list into three, one list containing the
inital non-empty items, one list containing items appearing after the
string 'Success' in input_list; and the other list containing items
appearing after the string 'Failure' in input_list.
"""
initial_flag = 1
success_flag = 0
failure_flag = 0
initial_list = []
success_list = []
failure_list = []
for c in input_list:
if c == 'Success:':
success_flag = 1
failure_flag = 0
elif c == 'Failure:':
failure_flag = 1
success_flag = 0
elif c != '' and success_flag:
success_list.append(c)
elif c != '' and failure_flag:
failure_list.append(c)
elif c != '' and initial_flag:
initial_list.append(c)
return initial_list, success_list, failure_list
def process_list(input_list):
"""Combine fields split over multiple list items into one.
This function expects to receive a field list as input,
with each item in the list representing a line
read from the document, as-is.
It combines the field bodies split over multiple lines into
one list item, making each field (name and body) one list item.
It also removes extra whitespace which was used for indentation
in input.
"""
out_list = []
# Convert list to string
str1 = "".join(input_list)
# Replace multiple spaces with one space
str2 = re.sub(r'\s+', ' ', str1)
regex_pattern = r'(:\S*.:)'
# Split the string, based on field names
list3 = re.split(regex_pattern, str2)
# Remove empty items from the list
list4 = list(filter(None, list3))
# Append the field name and field body strings together
for i in range(0, len(list4), 2):
out_list.append(list4[i] + list4[i + 1])
return out_list
def add_exception_info(failure_list):
"""Add exception information to fields.
This function takes a list of fields (field name and field body)
as an argument. If the field name is the name of an exception, it adds
the exception code into the field name, and exception message into
the field body.
"""
failure_dict = {}
# Add the exception code and message string
for f in failure_list:
field_name, field_body = split_str_to_field(f)
exc_code = ""
exc_msg = ""
if (field_name is not None) and hasattr(exception, field_name):
# Get the exception code and message string
exc_class = getattr(exception, field_name)
try:
exc_code = exc_class.code
exc_msg = exc_class._msg_fmt
except AttributeError:
pass
# Add the exception's HTTP code and HTTP phrase
# to the field name
if isinstance(exc_code, HTTPStatus):
field_name = (field_name
+ " (HTTP "
+ str(exc_code.value)
+ " "
+ exc_code.phrase
+ ")")
else:
field_name = field_name + " (HTTP " + str(exc_code) + ")"
# Add the exception's HTTP description to the field body
field_body = exc_msg + " \n" + field_body
# Add to dictionary if field name and field body exist
if field_name is not None and field_body is not None:
failure_dict[field_name] = field_body
return failure_dict
class Parameters(Directive):
"""This class implements the Parameters Directive."""
required_arguments = 1
has_content = True
def run(self):
# Parse the input field list from the docstring, as a dictionary
input_dict = {}
input_dict = parse_field_list(self.content)
# Read from yaml file
param_file = self.arguments[0]
cur_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
param_file_path = cur_path + '/' + param_file
yaml_data = read_from_file(param_file_path)
# Substitute the parameter descriptions with the yaml file descriptions
for field_name in input_dict:
old_field_body = input_dict[field_name]
if old_field_body in yaml_data.keys():
input_dict[field_name] = yaml_data[old_field_body]["description"]
# Convert dictionary to bullet list format
params_build_env = self.state.document.settings.env
params_bullet_list = create_bullet_list(input_dict, params_build_env)
# Create a table to display the final Parameters directive output
params_table = create_table('Parameters', params_bullet_list)
return [params_table]
class Return(Directive):
"""This class implements the Return Directive."""
has_content = True
def run(self):
initial_list, success_list, failure_list = split_list(self.content)
# Concatenate the field bodies split over multiple lines
proc_fail_list = process_list(failure_list)
# Add the exception code(s) and corresponding message string(s)
failure_dict = {}
failure_dict = add_exception_info(proc_fail_list)
ret_table_contents = nodes.paragraph()
if len(initial_list) > 0:
for i in initial_list:
initial_cont = nodes.Text(data=i)
ret_table_contents += initial_cont
if len(success_list) > 0:
# Add heading 'Success:' to output
success_heading = nodes.strong()
success_heading += nodes.Text(data='Success:')
ret_table_contents += success_heading
# Add Success details to output
success_detail = nodes.paragraph()
for s in success_list:
success_detail += nodes.Text(data=s)
ret_table_contents += success_detail
if len(proc_fail_list) > 0:
# Add heading 'Failure:' to output
failure_heading = nodes.strong()
failure_heading += nodes.Text(data='Failure:')
ret_table_contents += failure_heading
# Add failure details to output
ret_build_env = self.state.document.settings.env
failure_detail = create_bullet_list(failure_dict, ret_build_env)
ret_table_contents += failure_detail
if len(initial_list) > 0 or len(success_list) > 0 or len(proc_fail_list) > 0:
# Create a table to display the final Returns directive output
ret_table = create_table('Returns', ret_table_contents)
return [ret_table]
else:
return None
def setup(app):
app.add_directive("parameters", Parameters)
app.add_directive("return", Return)
return {
'version': '0.1',
'parallel_read_safe': True,
'parallel_write_safe': True,
}

View File

@ -42,7 +42,8 @@ extensions = ['sphinx.ext.viewcode',
'oslo_policy.sphinxext',
'oslo_policy.sphinxpolicygen',
'automated_steps',
'openstackdocstheme'
'openstackdocstheme',
'web_api_docstring'
]
# sphinxcontrib.apidoc options

View File

@ -259,20 +259,17 @@ class AllocationsController(pecan.rest.RestController):
owner=None):
"""Retrieve a list of allocations.
:param node: UUID or name of a node, to get only allocations for that
node.
:param resource_class: Filter by requested resource class.
:param state: Filter by allocation state.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
This value cannot be larger than the value of max_limit
in the [api] section of the ironic configuration, or only
max_limit resources will be returned.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
:param fields: Optional, a list with a specified set of fields
of the resource to be returned.
:param owner: Filter by owner.
.. parameters:: ../../api-ref/source/parameters.yaml
:node: r_allocation_node
:resource_class: req_allocation_resource_class
:state: r_allocation_state
:marker: marker
:limit: limit
:sort_key: sort_key
:sort_dir: sort_dir
:fields: fields
:owner: r_owner
"""
owner = api_utils.check_list_policy('allocation', owner)
@ -291,9 +288,10 @@ class AllocationsController(pecan.rest.RestController):
def get_one(self, allocation_ident, fields=None):
"""Retrieve information about the given allocation.
:param allocation_ident: UUID or logical name of an allocation.
:param fields: Optional, a list with a specified set of fields
of the resource to be returned.
.. parameters:: ../../api-ref/source/parameters.yaml
:allocation_ident: allocation_ident
:fields: fields
"""
rpc_allocation = api_utils.check_allocation_policy_and_retrieve(
'baremetal:allocation:get', allocation_ident)
@ -341,7 +339,9 @@ class AllocationsController(pecan.rest.RestController):
def post(self, allocation):
"""Create a new allocation.
:param allocation: an allocation within the request body.
.. parameters:: ../../api-ref/source/parameters.yaml
:allocation: req_allocation_name
"""
context = api.request.context
cdict = context.to_policy_values()
@ -472,8 +472,10 @@ class AllocationsController(pecan.rest.RestController):
def patch(self, allocation_ident, patch):
"""Update an existing allocation.
:param allocation_ident: UUID or logical name of an allocation.
:param patch: a json PATCH document to apply to this allocation.
.. parameters:: ../../api-ref/source/parameters.yaml
:allocation_ident: allocation_ident
:patch: allocation_patch
"""
if not api_utils.allow_allocation_update():
raise webob_exc.HTTPMethodNotAllowed(_(
@ -513,7 +515,9 @@ class AllocationsController(pecan.rest.RestController):
def delete(self, allocation_ident):
"""Delete an allocation.
:param allocation_ident: UUID or logical name of an allocation.
.. parameters:: ../../api-ref/source/parameters.yaml
:allocation_ident: allocation_ident
"""
context = api.request.context
rpc_allocation = api_utils.check_allocation_policy_and_retrieve(
@ -556,6 +560,12 @@ class NodeAllocationController(pecan.rest.RestController):
@method.expose()
@args.validate(fields=args.string_list)
def get_all(self, fields=None):
"""Get all allocations.
.. parameters:: ../../api-ref/source/parameters.yaml
:fields: fields
"""
parent_node = self.parent_node_ident
result = self.inner._get_allocations_collection(
parent_node,
@ -572,6 +582,7 @@ class NodeAllocationController(pecan.rest.RestController):
@METRICS.timer('NodeAllocationController.delete')
@method.expose(status_code=http_client.NO_CONTENT)
def delete(self):
"""Delete an allocation."""
context = api.request.context
rpc_node = api_utils.get_rpc_node_with_suffix(self.parent_node_ident)

View File

@ -225,7 +225,7 @@ class DriverPassthruController(rest.RestController):
:param driver_name: name of the driver.
:returns: dictionary with <vendor method name>:<method metadata>
entries.
:raises: DriverNotFound if the driver name is invalid or the
:raises DriverNotFound: if the driver name is invalid or the
driver cannot be loaded.
"""
api_utils.check_policy('baremetal:driver:vendor_passthru')
@ -272,15 +272,20 @@ class DriverRaidController(rest.RestController):
def logical_disk_properties(self, driver_name):
"""Returns the logical disk properties for the driver.
:param driver_name: Name of the driver.
:returns: A dictionary containing the properties that can be mentioned
for logical disks and a textual description for them.
:raises: UnsupportedDriverExtension if the driver doesn't
support RAID configuration.
:raises: NotAcceptable, if requested version of the API is less than
1.12.
:raises: DriverNotFound, if driver is not loaded on any of the
conductors.
.. parameters:: ../../api-ref/source/parameters.yaml
:driver_name: Name of the driver.
.. return::
Success:
A dictionary containing the properties that can be mentioned
Failure:
:UnsupportedDriverExtension: If the driver doesn't support RAID
configuration.
:NotAcceptable: If requested version of the API is less than 1.12.
:DriverNotFound: If driver is not loaded on any of the conductors.
"""
api_utils.check_policy(
'baremetal:driver:get_raid_logical_disk_properties')
@ -377,7 +382,7 @@ class DriversController(rest.RestController):
:param driver_name: name of the driver.
:returns: dictionary with <property name>:<property description>
entries.
:raises: DriverNotFound (HTTP 404) if the driver name is invalid or
:raises DriverNotFound (HTTP 404): if the driver name is invalid or
the driver cannot be loaded.
"""
api_utils.check_policy('baremetal:driver:get_properties')

View File

@ -2450,6 +2450,12 @@ class NodesController(rest.RestController):
"""Create a new node.
:param node: a node within the request body.
**Example Node creation request:**
.. literalinclude::
../../../../api-ref/source/samples/node-create-request-dynamic.json
:language: javascript
"""
if self.from_chassis:
raise exception.OperationNotPermitted()

View File

@ -92,9 +92,11 @@ commands =
[testenv:api-ref]
# NOTE(Mahnoor): documentation building process requires importing ironic API modules
usedevelop = False
deps =
-c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master}
-r{toxinidir}/requirements.txt
-r{toxinidir}/doc/requirements.txt
allowlist_externals = bash
commands =