VNFRQTS Fix broken links in Scale Out usecase
[vnfrqts/requirements.git] / gen_requirement_changes.py
1 # -*- coding: utf8 -*-
2 # org.onap.vnfrqts/requirements
3 # ============LICENSE_START====================================================
4 # Copyright © 2018 AT&T Intellectual Property. All rights reserved.
5 #
6 # Unless otherwise specified, all software contained herein is licensed
7 # under the Apache License, Version 2.0 (the "License");
8 # you may not use this software except in compliance with the License.
9 # You may obtain a copy of the License at
10 #
11 #             http://www.apache.org/licenses/LICENSE-2.0
12 #
13 # Unless required by applicable law or agreed to in writing, software
14 # distributed under the License is distributed on an "AS IS" BASIS,
15 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 # See the License for the specific language governing permissions and
17 # limitations under the License.
18 #
19 # Unless otherwise specified, all documentation contained herein is licensed
20 # under the Creative Commons License, Attribution 4.0 Intl. (the "License");
21 # you may not use this documentation except in compliance with the License.
22 # You may obtain a copy of the License at
23 #
24 #             https://creativecommons.org/licenses/by/4.0/
25 #
26 # Unless required by applicable law or agreed to in writing, documentation
27 # distributed under the License is distributed on an "AS IS" BASIS,
28 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
29 # See the License for the specific language governing permissions and
30 # limitations under the License.
31 #
32 # ============LICENSE_END============================================
33
34 """
35 This script will generate an summary of the requirements changes between
36 two version's of requirements by analyzing the needs.json file.  The template
37 can be customized by updating release-requirement-changes.rst.jinja2.
38 """
39 from itertools import groupby, chain
40 import json
41 import os
42 import re
43 import sys
44 import argparse
45 from operator import itemgetter
46
47 import jinja2
48
49 REQ_JSON_URL = "https://onap.readthedocs.io/en/latest/_downloads/needs.json"
50 NEEDS_PATH = "docs/data/needs.json"
51 JINJA_TEMPLATE = "release-requirement-changes.rst.jinja2"
52
53
54 def check(predicate, msg):
55     """
56     Raises a ``RuntimeError`` if the given predicate is false.
57
58     :param predicate: Predicate to evaluate
59     :param msg: Error message to use if predicate is false
60     """
61     if not predicate:
62         raise RuntimeError(msg)
63
64
65 class DifferenceFinder:
66     """
67     Class takes a needs.json data structure and finds the differences
68     between two different versions of the requirements
69     """
70
71     def __init__(self, needs, current_version, prior_version):
72         """
73         Determine the differences between the ``current_version`` and the
74         ``prior_version`` of the given requirements.
75
76         :param needs:           previously loaded needs.json file
77         :param current_version: most recent version to compare against
78         :param prior_version:   a prior version
79         :return:
80         """
81         self.needs = needs
82         self.current_version = current_version
83         self.prior_version = prior_version
84         self._validate()
85
86     def _validate(self):
87         """
88         Validates the inputs to the ``DifferenceFinder`` constructor.
89
90         :raises RuntimeError: if the file is not structured properly or the
91                               given versions can't be found.
92         """
93         check(self.needs is not None, "needs cannot be None")
94         check(isinstance(self.needs, dict), "needs must be be a dict")
95         check("versions" in self.needs, "needs file not properly formatted")
96         for version in (self.current_version, self.prior_version):
97             check(
98                 version in self.needs["versions"],
99                 "Version " + version + " was not found in the needs file",
100             )
101
102     @property
103     def current_requirements(self):
104         """Returns a dict of requirement ID to requirement metadata"""
105         return self.get_version(self.current_version)
106
107     @property
108     def prior_requirements(self):
109         """Returns a dict of requirement ID to requirement metadata"""
110         return self.get_version(self.prior_version)
111
112     def get_version(self, version):
113         """Returns a dict of requirement ID to requirement metadata"""
114         return self.needs["versions"][version]["needs"]
115
116     @property
117     def new_requirements(self):
118         """Requirements added since the prior version"""
119         new_ids = self.current_ids.difference(self.prior_ids)
120         return self.filter_needs(self.current_requirements, new_ids)
121
122     @property
123     def current_ids(self):
124         """Returns a set of the requirement IDs for the current version"""
125         return set(self.current_requirements.keys())
126
127     @property
128     def prior_ids(self):
129         """Returns a set of the requirement IDs for the prior version"""
130         return set(self.prior_requirements.keys())
131
132     @property
133     def removed_requirements(self):
134         """Requirements that were removed since the prior version"""
135         removed_ids = self.prior_ids.difference(self.current_ids)
136         return self.filter_needs(self.prior_requirements, removed_ids)
137
138     @property
139     def changed_requirements(self):
140         """"Requirements where the description changed since the last version"""
141         common_ids = self.prior_ids.intersection(self.current_ids)
142         result = {}
143         for r_id in common_ids:
144             current_text = self.current_requirements[r_id]["description"]
145             prior_text = self.prior_requirements[r_id]["description"]
146             if not self.is_equivalent(current_text, prior_text):
147                 sections = self.current_requirements[r_id]["sections"]
148                 result[r_id] = {
149                     "id": r_id,
150                     "description": current_text,
151                     "sections": sections,
152                 }
153         return result
154
155     def is_equivalent(self, current_text, prior_text):
156         """Returns true if there are meaningful differences between the
157         text.  See normalize for more information"""
158         return self.normalize(current_text) == self.normalize(prior_text)
159
160     @staticmethod
161     def normalize(text):
162         """Strips out formatting, line breaks, and repeated spaces to normalize
163          the string for comparison.  This ensures minor formatting changes
164          are not tagged as meaningful changes"""
165         s = text.lower()
166         s = s.replace("\n", " ")
167         s = re.sub(r'[`*\'"]', "", s)
168         s = re.sub(r"\s+", " ", s)
169         return s
170
171     @staticmethod
172     def filter_needs(needs, ids):
173         """
174         Return the requirements with the given ids
175
176         :ids: sequence of requirement IDs
177         :returns: dict of requirement ID to requirement data for only the
178                  requirements in ``ids``
179         """
180         return {r_id: data for r_id, data in needs.items() if r_id in ids}
181
182
183 def load_requirements(path):
184     """Load the requirements from the needs.json file"""
185     if not (os.path.exists(path)):
186         print("needs.json not found.  Run tox -e docs to generate it.")
187         sys.exit(1)
188     with open(path, "r") as req_file:
189         return json.load(req_file)
190
191
192 def parse_args():
193     """Parse the command-line arguments and return the arguments:
194
195     args.current_version
196     args.prior_version
197     """
198     parser = argparse.ArgumentParser(
199         description="Generate RST summarizing requirement changes between "
200         "two given releases. The resulting RST file will be "
201         "written to the docs/ directory"
202     )
203     parser.add_argument(
204         "current_version", help="Current release in lowercase (ex: casablanca)"
205     )
206     parser.add_argument("prior_version",
207                         help="Prior release to compare against")
208     return parser.parse_args()
209
210
211 def tag(dicts, key, value):
212     """Adds the key value to each dict in the sequence"""
213     for d in dicts:
214         d[key] = value
215     return dicts
216
217
218 def gather_section_changes(diffs):
219     """
220     Return a list of dicts that represent the changes for a given section path.
221     [
222         {
223          "section_path": path,
224          "added: [req, ...],
225          "updated: [req, ...],
226          "removed: [req, ...],
227         },
228         ...
229     ]
230     :param diffs: instance of DifferenceFinder
231     :return: list of section changes
232     """
233     # Add "change" and "section_path" keys to all requirements
234     reqs = list(
235         chain(
236             tag(diffs.new_requirements.values(), "change", "added"),
237             tag(diffs.changed_requirements.values(), "change", "updated"),
238             tag(diffs.removed_requirements.values(), "change", "removed"),
239         )
240     )
241     for req in reqs:
242         req["section_path"] = " > ".join(reversed(req["sections"]))
243
244     # Build list of changes by section
245     reqs = sorted(reqs, key=itemgetter("section_path"))
246     all_changes = []
247     for section, section_reqs in groupby(reqs, key=itemgetter("section_path")):
248         change = itemgetter("change")
249         section_reqs = sorted(section_reqs, key=change)
250         section_changes = {"section_path": section}
251         for change, change_reqs in groupby(section_reqs, key=change):
252             section_changes[change] = list(change_reqs)
253         if any(k in section_changes for k in ("added", "updated", "removed")):
254             all_changes.append(section_changes)
255     return all_changes
256
257
258 def render_to_file(template_path, output_path, **context):
259     """Read the template and render it ot the output_path using the given
260     context variables"""
261     with open(template_path, "r") as infile:
262         t = jinja2.Template(infile.read())
263     result = t.render(**context)
264     with open(output_path, "w") as outfile:
265         outfile.write(result)
266     print()
267     print("Writing requirement changes to " + output_path)
268     print(
269         "Please be sure to update the docs/release-notes.rst document "
270         "with a link to this document"
271     )
272
273
274 def print_invalid_metadata_report(difference_finder, current_version):
275     """Write a report to the console for any instances where differences
276     are detected, but teh appropriate :introduced: or :updated: metadata
277     is not applied to the requirement."""
278     print("Validating Metadata...")
279     print()
280     print("Requirements Added, but Missing :introduced: Attribute")
281     print("----------------------------------------------------")
282     for req in difference_finder.new_requirements.values():
283         if "introduced" not in req or req["introduced"] != current_version:
284             print(req["id"])
285     print()
286     print("Requirements Changed, but Missing :updated: Attribute")
287     print("-----------------------------------------------------")
288     for req in difference_finder.changed_requirements.values():
289         if "updated" not in req or req["updated"] != current_version:
290             print(req["id"])
291
292
293 if __name__ == "__main__":
294     args = parse_args()
295     requirements = load_requirements(NEEDS_PATH)
296     differ = DifferenceFinder(requirements,
297                               args.current_version,
298                               args.prior_version)
299
300     print_invalid_metadata_report(differ, args.current_version)
301
302     changes = gather_section_changes(differ)
303     render_to_file(
304         "release-requirement-changes.rst.jinja2",
305         "docs/changes-by-section-" + args.current_version + ".rst",
306         changes=changes,
307         current_version=args.current_version,
308         prior_version=args.prior_version,
309         num_added=len(differ.new_requirements),
310         num_removed=len(differ.removed_requirements),
311         num_changed=len(differ.changed_requirements),
312     )