Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add documentation generation functionality #367

Merged
merged 13 commits into from
Mar 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/rpdk/core/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def generate(_args):
project = Project()
project.load()
project.generate()
project.generate_docs()

LOG.warning("Generated files for %s", project.type_name)

Expand Down
1 change: 1 addition & 0 deletions src/rpdk/core/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def init(args):

project.init(type_name, language)
project.generate()
project.generate_docs()

LOG.warning("Initialized a new project in %s", project.root.resolve())

Expand Down
139 changes: 135 additions & 4 deletions src/rpdk/core/project.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import json
import logging
import shutil
import zipfile
from pathlib import Path
from tempfile import TemporaryFile
from uuid import uuid4

from botocore.exceptions import ClientError, WaiterError
from jinja2 import Environment, PackageLoader, select_autoescape
from jsonschema import Draft6Validator
from jsonschema import Draft6Validator, RefResolver
from jsonschema.exceptions import ValidationError

from .boto_helpers import create_sdk_session
Expand All @@ -18,6 +19,8 @@
InvalidProjectError,
SpecValidationError,
)
from .jsonutils.pointer import fragment_decode, fragment_encode
from .jsonutils.utils import traverse
from .plugin_registry import load_plugin
from .upload import Uploader

Expand All @@ -41,12 +44,10 @@
"java8",
"java11",
"go1.x",
# python2.7 is EOL soon (2020-01-01)
"python3.6",
"python3.7",
"python3.8",
"dotnetcore2.1",
# nodejs8.10 is EOL soon (2019-12-31)
"nodejs10.x",
"nodejs12.x",
}
Expand All @@ -67,6 +68,14 @@
)


BASIC_TYPE_MAPPINGS = {
"string": "String",
"number": "Double",
"integer": "Double",
Copy link
Contributor

@PatMyron PatMyron Aug 5, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be Integer, right?

Suggested change
"integer": "Double",
"integer": "Integer",

"boolean": "Boolean",
}


class Project: # pylint: disable=too-many-instance-attributes
def __init__(self, overwrite_enabled=False, root=None):
self.overwrite_enabled = overwrite_enabled
Expand All @@ -86,7 +95,7 @@ def __init__(self, overwrite_enabled=False, root=None):
lstrip_blocks=True,
keep_trailing_newline=True,
loader=PackageLoader(__name__, "templates/"),
autoescape=select_autoescape(["html", "htm", "xml"]),
autoescape=select_autoescape(["html", "htm", "xml", "md"]),
)

LOG.debug("Root directory: %s", self.root)
Expand Down Expand Up @@ -320,6 +329,128 @@ def submit(
f, endpoint_url, region_name, role_arn, use_role, set_default
)

def generate_docs(self):
# generate the docs folder that contains documentation based on the schema
docs_path = self.root / "docs"

if not self.type_info or not self.schema or "properties" not in self.schema:
LOG.warning(
"Could not generate schema docs due to missing type info or schema"
)
return
iann0036 marked this conversation as resolved.
Show resolved Hide resolved

LOG.debug("Removing generated docs: %s", docs_path)
shutil.rmtree(docs_path, ignore_errors=True)
docs_path.mkdir(exist_ok=True)

LOG.debug("Writing generated docs")

# take care not to modify the master schema
docs_schema = json.loads(json.dumps(self.schema))

docs_schema["properties"] = {
name: self._set_docs_properties(name, value, (name,))
for name, value in docs_schema["properties"].items()
}

LOG.debug("Finished documenting nested properties")

ref = self._get_docs_primary_identifier(docs_schema)
getatt = self._get_docs_gettable_atts(docs_schema)

readme_path = docs_path / "README.md"
LOG.debug("Writing docs README: %s", readme_path)
template = self.env.get_template("docs-readme.md")
contents = template.render(
type_name=self.type_name, schema=docs_schema, ref=ref, getatt=getatt
)
self.safewrite(readme_path, contents)

@staticmethod
def _get_docs_primary_identifier(docs_schema):
try:
primary_id = docs_schema["primaryIdentifier"]
if len(primary_id) == 1:
tobywf marked this conversation as resolved.
Show resolved Hide resolved
# drop /properties
primary_id_path = fragment_decode(primary_id[0], prefix="")[1:]
# at some point, someone might use a nested primary ID
if len(primary_id_path) == 1:
return primary_id_path[0]
LOG.warning("Nested primaryIdentifier found")
except (KeyError, ValueError):
pass
return None

@staticmethod
def _get_docs_gettable_atts(docs_schema):
def _get_property_description(prop):
path = fragment_decode(prop, prefix="")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name = path[-1]
try:
desc, _resolved_path, _parent = traverse(
docs_schema, path + ("description",)
)
except (KeyError, IndexError, ValueError):
desc = f"Returns the <code>{name}</code> value."
return {"name": name, "description": desc}

return [
_get_property_description(prop)
for prop in docs_schema.get("readOnlyProperties", [])
]

def _set_docs_properties(self, propname, prop, proppath):
if "$ref" in prop:
prop = RefResolver.from_schema(self.schema).resolve(prop["$ref"])[1]

proppath_ptr = fragment_encode(("properties",) + proppath, prefix="")
if (
"createOnlyProperties" in self.schema
and proppath_ptr in self.schema["createOnlyProperties"]
):
prop["createonly"] = True

if prop["type"] in BASIC_TYPE_MAPPINGS:
mapped = BASIC_TYPE_MAPPINGS[prop["type"]]
prop["jsontype"] = prop["yamltype"] = prop["longformtype"] = mapped
elif prop["type"] == "array":
prop["arrayitems"] = arrayitems = self._set_docs_properties(
propname, prop["items"], proppath
)
prop["jsontype"] = f'[ {arrayitems["jsontype"]}, ... ]'
prop["yamltype"] = f'\n - {arrayitems["longformtype"]}'
prop["longformtype"] = f'List of {arrayitems["longformtype"]}'
elif prop["type"] == "object":
template = self.env.get_template("docs-subproperty.md")
docs_path = self.root / "docs"

prop["properties"] = {
name: self._set_docs_properties(name, value, proppath + (name,))
for name, value in prop["properties"].items()
}

subproperty_name = " ".join(proppath)
subproperty_filename = "-".join(proppath).lower() + ".md"
subproperty_path = docs_path / subproperty_filename

LOG.debug("Writing docs %s: %s", subproperty_filename, subproperty_path)
contents = template.render(
type_name=self.type_name, subproperty_name=subproperty_name, schema=prop
)
self.safewrite(subproperty_path, contents)

href = f'<a href="{subproperty_filename}">{propname}</a>'
prop["jsontype"] = prop["yamltype"] = prop["longformtype"] = href
else:
prop["jsontype"] = "Unknown"
prop["yamltype"] = "Unknown"
prop["longformtype"] = "Unknown"

if "enum" in prop:
prop["allowedvalues"] = prop["enum"]

return prop

def _upload(
self, fileobj, endpoint_url, region_name, role_arn, use_role, set_default
): # pylint: disable=too-many-arguments, too-many-locals
Expand Down
105 changes: 105 additions & 0 deletions src/rpdk/core/templates/docs-readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# {{ type_name }}
{% if schema.description %}

{{ schema.description }}
{% endif %}

## Syntax

To declare this entity in your AWS CloudFormation template, use the following syntax:

### JSON

<pre>
{
"Type" : "{{ type_name }}",
"Properties" : {
{% if schema.properties %}
{% for propname, prop in schema.properties.items() %}
"<a href="#{{ propname.lower() }}" title="{{ propname }}">{{ propname }}</a>" : <i>{{ prop.jsontype }}</i>{% if not loop.last %},{% endif %}

{% endfor %}
{% endif %}
}
}
</pre>

### YAML

<pre>
Type: {{ type_name }}
Properties:
{% if schema.properties %}
{% for propname, prop in schema.properties.items() %}
<a href="#{{ propname.lower() }}" title="{{ propname }}">{{ propname }}</a>: <i>{{ prop.yamltype }}</i>
{% endfor %}
{% endif %}
</pre>
{% if schema.properties %}

## Properties

{% for propname, prop in schema.properties.items() %}
#### {{ propname }}
{% if prop.description %}

{{ prop.description }}
{% endif %}

_Required_: {% if propname in schema.required %}Yes{% else %}No{% endif %}


_Type_: {{ prop.longformtype }}
{% if prop.allowedvalues %}

_Allowed Values_: {% for allowedvalue in prop.allowedvalues %}<code>{{ allowedvalue }}</code>{% if not loop.last %} | {% endif %}{% endfor %}

{% endif %}
{% if prop.minLength %}

_Minimum_: <code>{{ prop.minLength }}</code>
{% endif %}
{% if prop.maxLength %}

_Maximum_: <code>{{ prop.maxLength }}</code>
{% endif %}
{% if prop.pattern %}

_Pattern_: <code>{{ prop.pattern }}</code>
{% endif %}
{% if prop.createonly %}

_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement)
{% else %}

_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
{% endif %}
Comment on lines +70 to +76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're generating documentation, it's probably worth noting nuance for something as important as potential resource replacement. For example, a version property might be upgradable in-place but not downgradable in-place, so neither of these definitions would be entirely accurate. My assumption would be while schema definitions like required and createOnlyProperties guarantee a property is required or requires resource replacement to update respectively, other properties not specified as required or createOnlyProperties may in fact be required or cause resource replacement under some circumstances. Immutability is important enough that its nuance should probably be captured in the resource schema itself in the future

Copy link
Contributor

@PatMyron PatMyron Mar 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


{% endfor %}
{% endif %}
{% if getatt or ref %}
## Return Values
{% if ref %}

### Ref

When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the {{ ref }}.
{% endif %}
{% if getatt %}

### Fn::GetAtt

The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values.

For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html).

{% for att in getatt %}
#### {{ att.name }}
{% if att.description %}

{{ att.description }}
{% endif %}

{% endfor %}
{% endif %}
{% endif %}
73 changes: 73 additions & 0 deletions src/rpdk/core/templates/docs-subproperty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# {{ type_name }} {{ subproperty_name }}
{% if schema.description %}

{{ schema.description }}
{% endif %}

## Syntax

To declare this entity in your AWS CloudFormation template, use the following syntax:

### JSON

<pre>
{
{% if schema.properties %}
{% for propname, prop in schema.properties.items() %}
"<a href="#{{ propname.lower() }}" title="{{ propname }}">{{ propname }}</a>" : <i>{{ prop.jsontype }}</i>{% if not loop.last %},{% endif %}

{% endfor %}
{% endif %}
}
</pre>

### YAML

<pre>
{% if schema.properties %}
{% for propname, prop in schema.properties.items() %}
<a href="#{{ propname.lower() }}" title="{{ propname }}">{{ propname }}</a>: <i>{{ prop.yamltype }}</i>
{% endfor %}
{% endif %}
</pre>
{% if schema.properties %}

## Properties

{% for propname, prop in schema.properties.items() %}
#### {{ propname }}
{% if prop.description %}

{{ prop.description }}
{% endif %}

_Required_: {% if propname in schema.required %}Yes{% else %}No{% endif %}

_Type_: {{ prop.longformtype }}
{% if prop.allowedvalues %}

_Allowed Values_: {% for allowedvalue in prop.allowedvalues %}<code>{{ allowedvalue }}</code>{% if not loop.last %} | {% endif %}{% endfor %}

{% endif %}
{% if prop.minLength %}

_Minimum_: <code>{{ prop.minLength }}</code>
{% endif %}
{% if prop.maxLength %}

_Maximum_: <code>{{ prop.maxLength }}</code>
{% endif %}
{% if prop.pattern %}

_Pattern_: <code>{{ prop.pattern }}</code>
{% endif %}
{% if prop.createonly %}

_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement)
{% else %}

_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
{% endif %}

{% endfor %}
{% endif %}
Loading