summaryrefslogtreecommitdiffstats
path: root/gen_requirement_changes.py
diff options
context:
space:
mode:
Diffstat (limited to 'gen_requirement_changes.py')
-rw-r--r--gen_requirement_changes.py312
1 files changed, 312 insertions, 0 deletions
diff --git a/gen_requirement_changes.py b/gen_requirement_changes.py
new file mode 100644
index 0000000..2661c59
--- /dev/null
+++ b/gen_requirement_changes.py
@@ -0,0 +1,312 @@
+# -*- coding: utf8 -*-
+# org.onap.vnfrqts/requirements
+# ============LICENSE_START====================================================
+# Copyright © 2018 AT&T Intellectual Property. All rights reserved.
+#
+# Unless otherwise specified, all software contained herein is licensed
+# under the Apache License, Version 2.0 (the "License");
+# you may not use this software 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.
+#
+# Unless otherwise specified, all documentation contained herein is licensed
+# under the Creative Commons License, Attribution 4.0 Intl. (the "License");
+# you may not use this documentation except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://creativecommons.org/licenses/by/4.0/
+#
+# Unless required by applicable law or agreed to in writing, documentation
+# 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.
+#
+# ============LICENSE_END============================================
+
+"""
+This script will generate an summary of the requirements changes between
+two version's of requirements by analyzing the needs.json file. The template
+can be customized by updating release-requirement-changes.rst.jinja2.
+"""
+from itertools import groupby, chain
+import json
+import os
+import re
+import sys
+import argparse
+from operator import itemgetter
+
+import jinja2
+
+REQ_JSON_URL = "https://onap.readthedocs.io/en/latest/_downloads/needs.json"
+NEEDS_PATH = "docs/data/needs.json"
+JINJA_TEMPLATE = "release-requirement-changes.rst.jinja2"
+
+
+def check(predicate, msg):
+ """
+ Raises a ``RuntimeError`` if the given predicate is false.
+
+ :param predicate: Predicate to evaluate
+ :param msg: Error message to use if predicate is false
+ """
+ if not predicate:
+ raise RuntimeError(msg)
+
+
+class DifferenceFinder:
+ """
+ Class takes a needs.json data structure and finds the differences
+ between two different versions of the requirements
+ """
+
+ def __init__(self, needs, current_version, prior_version):
+ """
+ Determine the differences between the ``current_version`` and the
+ ``prior_version`` of the given requirements.
+
+ :param needs: previously loaded needs.json file
+ :param current_version: most recent version to compare against
+ :param prior_version: a prior version
+ :return:
+ """
+ self.needs = needs
+ self.current_version = current_version
+ self.prior_version = prior_version
+ self._validate()
+
+ def _validate(self):
+ """
+ Validates the inputs to the ``DifferenceFinder`` constructor.
+
+ :raises RuntimeError: if the file is not structured properly or the
+ given versions can't be found.
+ """
+ check(self.needs is not None, "needs cannot be None")
+ check(isinstance(self.needs, dict), "needs must be be a dict")
+ check("versions" in self.needs, "needs file not properly formatted")
+ for version in (self.current_version, self.prior_version):
+ check(
+ version in self.needs["versions"],
+ "Version " + version + " was not found in the needs file",
+ )
+
+ @property
+ def current_requirements(self):
+ """Returns a dict of requirement ID to requirement metadata"""
+ return self.get_version(self.current_version)
+
+ @property
+ def prior_requirements(self):
+ """Returns a dict of requirement ID to requirement metadata"""
+ return self.get_version(self.prior_version)
+
+ def get_version(self, version):
+ """Returns a dict of requirement ID to requirement metadata"""
+ return self.needs["versions"][version]["needs"]
+
+ @property
+ def new_requirements(self):
+ """Requirements added since the prior version"""
+ new_ids = self.current_ids.difference(self.prior_ids)
+ return self.filter_needs(self.current_requirements, new_ids)
+
+ @property
+ def current_ids(self):
+ """Returns a set of the requirement IDs for the current version"""
+ return set(self.current_requirements.keys())
+
+ @property
+ def prior_ids(self):
+ """Returns a set of the requirement IDs for the prior version"""
+ return set(self.prior_requirements.keys())
+
+ @property
+ def removed_requirements(self):
+ """Requirements that were removed since the prior version"""
+ removed_ids = self.prior_ids.difference(self.current_ids)
+ return self.filter_needs(self.prior_requirements, removed_ids)
+
+ @property
+ def changed_requirements(self):
+ """"Requirements where the description changed since the last version"""
+ common_ids = self.prior_ids.intersection(self.current_ids)
+ result = {}
+ for r_id in common_ids:
+ current_text = self.current_requirements[r_id]["description"]
+ prior_text = self.prior_requirements[r_id]["description"]
+ if not self.is_equivalent(current_text, prior_text):
+ sections = self.current_requirements[r_id]["sections"]
+ result[r_id] = {
+ "id": r_id,
+ "description": current_text,
+ "sections": sections,
+ }
+ return result
+
+ def is_equivalent(self, current_text, prior_text):
+ """Returns true if there are meaningful differences between the
+ text. See normalize for more information"""
+ return self.normalize(current_text) == self.normalize(prior_text)
+
+ @staticmethod
+ def normalize(text):
+ """Strips out formatting, line breaks, and repeated spaces to normalize
+ the string for comparison. This ensures minor formatting changes
+ are not tagged as meaningful changes"""
+ s = text.lower()
+ s = s.replace("\n", " ")
+ s = re.sub(r'[`*\'"]', "", s)
+ s = re.sub(r"\s+", " ", s)
+ return s
+
+ @staticmethod
+ def filter_needs(needs, ids):
+ """
+ Return the requirements with the given ids
+
+ :ids: sequence of requirement IDs
+ :returns: dict of requirement ID to requirement data for only the
+ requirements in ``ids``
+ """
+ return {r_id: data for r_id, data in needs.items() if r_id in ids}
+
+
+def load_requirements(path):
+ """Load the requirements from the needs.json file"""
+ if not (os.path.exists(path)):
+ print("needs.json not found. Run tox -e docs to generate it.")
+ sys.exit(1)
+ with open(path, "r") as req_file:
+ return json.load(req_file)
+
+
+def parse_args():
+ """Parse the command-line arguments and return the arguments:
+
+ args.current_version
+ args.prior_version
+ """
+ parser = argparse.ArgumentParser(
+ description="Generate RST summarizing requirement changes between "
+ "two given releases. The resulting RST file will be "
+ "written to the docs/ directory"
+ )
+ parser.add_argument(
+ "current_version", help="Current release in lowercase (ex: casablanca)"
+ )
+ parser.add_argument("prior_version",
+ help="Prior release to compare against")
+ return parser.parse_args()
+
+
+def tag(dicts, key, value):
+ """Adds the key value to each dict in the sequence"""
+ for d in dicts:
+ d[key] = value
+ return dicts
+
+
+def gather_section_changes(diffs):
+ """
+ Return a list of dicts that represent the changes for a given section path.
+ [
+ {
+ "section_path": path,
+ "added: [req, ...],
+ "updated: [req, ...],
+ "removed: [req, ...],
+ },
+ ...
+ ]
+ :param diffs: instance of DifferenceFinder
+ :return: list of section changes
+ """
+ # Add "change" and "section_path" keys to all requirements
+ reqs = list(
+ chain(
+ tag(diffs.new_requirements.values(), "change", "added"),
+ tag(diffs.changed_requirements.values(), "change", "updated"),
+ tag(diffs.removed_requirements.values(), "change", "removed"),
+ )
+ )
+ for req in reqs:
+ req["section_path"] = " > ".join(reversed(req["sections"]))
+
+ # Build list of changes by section
+ reqs = sorted(reqs, key=itemgetter("section_path"))
+ all_changes = []
+ for section, section_reqs in groupby(reqs, key=itemgetter("section_path")):
+ change = itemgetter("change")
+ section_reqs = sorted(section_reqs, key=change)
+ section_changes = {"section_path": section}
+ for change, change_reqs in groupby(section_reqs, key=change):
+ section_changes[change] = list(change_reqs)
+ if any(k in section_changes for k in ("added", "updated", "removed")):
+ all_changes.append(section_changes)
+ return all_changes
+
+
+def render_to_file(template_path, output_path, **context):
+ """Read the template and render it ot the output_path using the given
+ context variables"""
+ with open(template_path, "r") as infile:
+ t = jinja2.Template(infile.read())
+ result = t.render(**context)
+ with open(output_path, "w") as outfile:
+ outfile.write(result)
+ print()
+ print("Writing requirement changes to " + output_path)
+ print(
+ "Please be sure to update the docs/release-notes.rst document "
+ "with a link to this document"
+ )
+
+
+def print_invalid_metadata_report(difference_finder, current_version):
+ """Write a report to the console for any instances where differences
+ are detected, but teh appropriate :introduced: or :updated: metadata
+ is not applied to the requirement."""
+ print("Validating Metadata...")
+ print()
+ print("Requirements Added, but Missing :introduced: Attribute")
+ print("----------------------------------------------------")
+ for req in difference_finder.new_requirements.values():
+ if "introduced" not in req or req["introduced"] != current_version:
+ print(req["id"])
+ print()
+ print("Requirements Changed, but Missing :updated: Attribute")
+ print("-----------------------------------------------------")
+ for req in difference_finder.changed_requirements.values():
+ if "updated" not in req or req["updated"] != current_version:
+ print(req["id"])
+
+
+if __name__ == "__main__":
+ args = parse_args()
+ requirements = load_requirements(NEEDS_PATH)
+ differ = DifferenceFinder(requirements,
+ args.current_version,
+ args.prior_version)
+
+ print_invalid_metadata_report(differ, args.current_version)
+
+ changes = gather_section_changes(differ)
+ render_to_file(
+ "release-requirement-changes.rst.jinja2",
+ "docs/changes-by-section-" + args.current_version + ".rst",
+ changes=changes,
+ current_version=args.current_version,
+ prior_version=args.prior_version,
+ num_added=len(differ.new_requirements),
+ num_removed=len(differ.removed_requirements),
+ num_changed=len(differ.changed_requirements),
+ )