Skip to content

Commit

Permalink
Create simple tool to create skeleton of new rules
Browse files Browse the repository at this point in the history
  • Loading branch information
macisamuele committed Feb 13, 2020
1 parent 580b6c2 commit 7d7c71b
Show file tree
Hide file tree
Showing 6 changed files with 314 additions and 1 deletion.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ omit =
venv/*
/usr/*
setup.py
create_new_rule.py
./integration_tests/*

[report]
Expand Down
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ Use the following steps to define a new rule:
with two files: ``old.yaml`` and ``new.yaml``. The two files represent two versions of the swagger specs that need to be checked for
backward compatibility.
Pro tip: You can the skeleton generated via ``python -m create_new_rule``.
Contributing
~~~~~~~~~~~~
Expand Down
304 changes: 304 additions & 0 deletions create_new_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals

import os
import re
import sys
import typing
from argparse import ArgumentParser

from swagger_spec_compatibility.rules.common import Level
from swagger_spec_compatibility.rules.common import RuleRegistry
from swagger_spec_compatibility.rules.common import RuleType


_RULE_TYPE_TEMPLATE = {
RuleType.REQUEST_CONTRACT: 'REQ-E{number:03d}',
RuleType.RESPONSE_CONTRACT: 'RES-E{number:03d}',
RuleType.MISCELLANEOUS: 'MIS-E{number:03d}',
}
_RULE_CLASS_FILE_TEMPLATE = """# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals
import typing
from bravado_core.spec import Spec
from swagger_spec_compatibility.rules.common import BaseRule
from swagger_spec_compatibility.rules.common import Level
from swagger_spec_compatibility.rules.common import RuleType
from swagger_spec_compatibility.rules.common import ValidationMessage
class {detection_class_name}(BaseRule):
description = {description}
error_code = {error_code}
error_level = {error_level}
rule_type = {rule_type}
short_name = {short_name}
documentation_link = {documentation_link}
@classmethod
def validate(cls, left_spec, right_spec):
# type: (Spec, Spec) -> typing.Iterable[ValidationMessage]
raise NotImplementedError()
"""
_RULE_CLASS_TEST_FILE_TEMPLATE = """# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals
def test_dummy():
assert 1 == 2
"""
_RULE_CLASS_DOC_TEMPLATE = """.. automodule:: swagger_spec_compatibility.rules.{module_name}
:members:
:undoc-members:
:show-inheritance:
"""
_MINIMAL_SPECS = """swagger: '2.0'
info:
title: Minimal Case of {error_code} Rule
version: '1.0'
definitions: {{}}
paths: {{}}
"""
_TESTER_TEMPLATE = """# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals
from os.path import abspath
from bravado.client import SwaggerClient
from six.moves.urllib.parse import urljoin
from six.moves.urllib.request import pathname2url
old_client = SwaggerClient.from_url(
spec_url=urljoin('file:', pathname2url(abspath('old.yaml'))),
)
new_client = SwaggerClient.from_url(
spec_url=urljoin('file:', pathname2url(abspath('new.yaml'))),
)
raise NotImplementedError()
"""
_RULE_LONG_DOC_TEMPLATE = """[{error_code}] - {short_name}
=====================================================
Rationale
---------
TODO
Mitigation
----------
TODO
Example
-------
Old Swagger Specs
~~~~~~~~~~~~~~~~~
.. literalinclude:: examples/{error_code}/old.yaml
:name: Old Swagger Spec
:language: yaml
:linenos:
New Swagger Specs
~~~~~~~~~~~~~~~~~
.. literalinclude:: examples/{error_code}/new.yaml
:name: New Swagger Spec
:language: yaml
:linenos:
.. Please highlight the different lines by using `:emphasize-lines: #`
Backward Incompatibility
~~~~~~~~~~~~~~~~~~~~~~~~
The following snippet triggers the incompatibility error.
.. literalinclude:: examples/{error_code}/tester.py
:language: py
:linenos:
**NOTE**: The code is taking advantage of `bravado <https://github.com/Yelp/bravado>`_
"""


def parser():
# type: () -> ArgumentParser
argument_parser = ArgumentParser(description='Helper to create new swagger-spec-compatiblity detection rules')
argument_parser.add_argument(
'--description',
help='Short description of the rationale of the rule. This will be visible on CLI only',
)
argument_parser.add_argument(
'--detection-class-name',
help='Name of the detection class',
required=True,
)
argument_parser.add_argument(
'--documentation-link',
help='Documentation link',
)
argument_parser.add_argument(
'--error-level',
help='Error level associated to the rule',
choices=[item.name for item in Level],
required=True,
)
argument_parser.add_argument(
'--rule-type',
help='Type of the rule associated',
choices=[item.name for item in RuleType],
required=True,
)
argument_parser.add_argument(
'--short-name',
help='Short name of the rule. This will be visible on CLI in case the rule is triggered',
)
return argument_parser


def _create_code_skeleton(
description, # type: typing.Optional[typing.Text]
detection_class_name, # type: typing.Text
documentation_link, # type: typing.Optional[typing.Text]
error_code, # type: typing.Text
error_level, # type: Level
python_file_name_no_ext, # type: typing.Text
rule_type, # type: RuleType
short_name, # type: typing.Optional[typing.Text]
):
python_file_name = '{}.py'.format(python_file_name_no_ext)
with open(os.path.join('swagger_spec_compatibility', 'rules', python_file_name), 'w') as f:
f.write(str(_RULE_CLASS_FILE_TEMPLATE.format(
description=description or repr('TODO'),
detection_class_name=detection_class_name,
documentation_link=documentation_link,
error_code=repr(error_code),
error_level='Level.{}'.format(error_level.name),
python_file_name=python_file_name,
rule_type='RuleType.{}'.format(rule_type.name),
short_name=short_name or repr('TODO'),
)))

with open(os.path.join('tests', 'rules', '{}_test.py'.format(python_file_name_no_ext)), 'w') as f:
f.write(str(_RULE_CLASS_TEST_FILE_TEMPLATE))


def _create_documentation_skeleton(
description, # type: typing.Optional[typing.Text]
detection_class_name, # type: typing.Text
documentation_link, # type: typing.Optional[typing.Text]
error_code, # type: typing.Text
error_level, # type: Level
python_file_name_no_ext, # type: typing.Text
rule_type, # type: RuleType
short_name, # type: typing.Optional[typing.Text]
):
module_doc_file_path = os.path.join('docs', 'source', 'swagger_spec_compatibility.rst')
with open(module_doc_file_path, 'r') as f:
swagger_spec_compatibility_lines = f.readlines()

add_new_rules_anchor = '.. ADD NEW RULES HERE\n'
add_new_rules_anchor_index = swagger_spec_compatibility_lines.index(str(add_new_rules_anchor))
swagger_spec_compatibility_lines[add_new_rules_anchor_index] = str('{doc}\n{anchor}'.format(
anchor=add_new_rules_anchor,
doc=_RULE_CLASS_DOC_TEMPLATE.format(module_name=python_file_name_no_ext),
))

with open(module_doc_file_path, 'w') as f:
f.writelines(swagger_spec_compatibility_lines)

rules_directory = os.path.join('docs', 'source', 'rules')
examples_directory = os.path.join('docs', 'source', 'rules', 'examples', error_code)
os.makedirs(examples_directory, exist_ok=True) # type: ignore # for some reason mypy does not find exist_ok parameter
with open(os.path.join(examples_directory, 'old.yaml'), 'w') as f:
f.write(str(_MINIMAL_SPECS.format(error_code=error_code)))
with open(os.path.join(examples_directory, 'new.yaml'), 'w') as f:
f.write(str(_MINIMAL_SPECS.format(error_code=error_code)))
with open(os.path.join(examples_directory, 'tester.py'), 'w') as f:
f.write(str(_TESTER_TEMPLATE))
with open(os.path.join(rules_directory, '{}.rst'.format(error_code)), 'w') as f:
f.write(str(_RULE_LONG_DOC_TEMPLATE.format(
error_code=error_code,
short_name=short_name or repr('TODO'),
)))

with open(os.path.join(rules_directory, 'index.rst'.format(error_code)), 'r') as f:
index_lines = f.readlines()

add_new_rules_index_anchor = '.. ADD HERE NEW {} rules\n'.format(_RULE_TYPE_TEMPLATE[rule_type])
add_new_rules_index_anchor_index = index_lines.index(str(add_new_rules_index_anchor))
index_lines[add_new_rules_index_anchor_index] = str(' {error_code}\n{anchor}'.format(
anchor=add_new_rules_index_anchor,
error_code=error_code,
))

with open(os.path.join(rules_directory, 'index.rst'.format(error_code)), 'w') as f:
f.writelines(index_lines)


def create_rule_skeleton(
description, # type: typing.Optional[typing.Text]
detection_class_name, # type: typing.Text
documentation_link, # type: typing.Optional[typing.Text]
error_level, # type: Level
rule_type, # type: RuleType
short_name, # type: typing.Optional[typing.Text]
):
def _camel_to_snake_case(text):
# type: (typing.Text) -> typing.Text
# Thanks to https://stackoverflow.com/a/1176023
return re.sub(r'(?<!^)(?=[A-Z])', '_', text).lower()

python_file_name_no_ext = _camel_to_snake_case(detection_class_name)

error_code = _RULE_TYPE_TEMPLATE[rule_type].format(
number=1 + sum(
1
for rule in RuleRegistry.rules()
if rule.rule_type == rule_type
),
)

for method in _create_code_skeleton, _create_documentation_skeleton:
method(
description=description,
detection_class_name=detection_class_name,
documentation_link=documentation_link,
error_code=error_code,
error_level=error_level,
python_file_name_no_ext=python_file_name_no_ext,
rule_type=rule_type,
short_name=short_name,
)
return 1


def main(args=None):
# type: (typing.Optional[typing.List[typing.Text]]) -> None
parsed_arguments = parser().parse_args(args=args)
print(
'The tool is far from perfect. Please make sure that all the created skeleton make sense ;)',
file=sys.stderr,
)
create_rule_skeleton(
description=parsed_arguments.description,
detection_class_name=parsed_arguments.detection_class_name,
documentation_link=parsed_arguments.documentation_link,
error_level=Level[parsed_arguments.error_level],
rule_type=RuleType[parsed_arguments.rule_type],
short_name=parsed_arguments.short_name,
)


if __name__ == '__main__':
main()
3 changes: 3 additions & 0 deletions docs/source/rules/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Request Contract Changes
REQ-E001
REQ-E002
REQ-E003
.. ADD HERE NEW REQ-E{number:03d} rules
Response Contract Changes
-------------------------
Expand All @@ -20,6 +21,7 @@ Response Contract Changes
RES-E001
RES-E002
RES-E003
.. ADD HERE NEW RES-E{number:03d} rules
Miscellaneous Changes
---------------------
Expand All @@ -29,3 +31,4 @@ Miscellaneous Changes

MIS-E001
MIS-E002
.. ADD HERE NEW MIS-E{number:03d} rules
3 changes: 3 additions & 0 deletions docs/source/swagger_spec_compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ swagger_spec_compatibility Package
:undoc-members:
:show-inheritance:

.. ADD NEW RULES HERE
:mod:`spec_utils` Module
------------------------

Expand Down
2 changes: 1 addition & 1 deletion swagger_spec_compatibility/rules/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ class BaseRule(with_metaclass(RuleRegistry)):
error_code = None # type: typing_extensions.ClassVar[typing.Text]
# Short name of the rule. This will be visible on CLI in case the rule is triggered
short_name = None # type: typing_extensions.ClassVar[typing.Text]
# Short description of the rationale of the rule. This will be visible on CLI only.
# Short description of the rationale of the rule. This will be visible on CLI only
description = None # type: typing_extensions.ClassVar[typing.Text]
# Error level associated to the rule
error_level = None # type: typing_extensions.ClassVar[Level]
Expand Down

0 comments on commit 7d7c71b

Please sign in to comment.