VNFRQTS - Fix incorrect metadata usage
[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 import csv
40 from itertools import groupby, chain
41 import json
42 import os
43 import re
44 import sys
45 import argparse
46 from operator import itemgetter
47
48 import jinja2
49
50 REQ_JSON_URL = "https://onap.readthedocs.io/en/latest/_downloads/needs.json"
51 NEEDS_PATH = "docs/data/needs.json"
52 JINJA_TEMPLATE = "release-requirement-changes.rst.jinja2"
53
54
55 def check(predicate, msg):
56     """
57     Raises a ``RuntimeError`` if the given predicate is false.
58
59     :param predicate: Predicate to evaluate
60     :param msg: Error message to use if predicate is false
61     """
62     if not predicate:
63         raise RuntimeError(msg)
64
65
66 class DifferenceFinder:
67     """
68     Class takes a needs.json data structure and finds the differences
69     between two different versions of the requirements
70     """
71
72     def __init__(self, needs, current_version, prior_version):
73         """
74         Determine the differences between the ``current_version`` and the
75         ``prior_version`` of the given requirements.
76
77         :param needs:           previously loaded needs.json file
78         :param current_version: most recent version to compare against
79         :param prior_version:   a prior version
80         :return:
81         """
82         self.needs = needs
83         self.current_version = current_version
84         self.prior_version = prior_version
85         self._validate()
86
87     def _validate(self):
88         """
89         Validates the inputs to the ``DifferenceFinder`` constructor.
90
91         :raises RuntimeError: if the file is not structured properly or the
92                               given versions can't be found.
93         """
94         check(self.needs is not None, "needs cannot be None")
95         check(isinstance(self.needs, dict), "needs must be be a dict")
96         check("versions" in self.needs, "needs file not properly formatted")
97         for version in (self.current_version, self.prior_version):
98             check(
99                 version in self.needs["versions"],
100                 "Version " + version + " was not found in the needs file",
101             )
102
103     @property
104     def current_requirements(self):
105         """Returns a dict of requirement ID to requirement metadata"""
106         return self.get_version(self.current_version)
107
108     @property
109     def prior_requirements(self):
110         """Returns a dict of requirement ID to requirement metadata"""
111         return self.get_version(self.prior_version)
112
113     def get_version(self, version):
114         """Returns a dict of requirement ID to requirement metadata"""
115         return self.needs["versions"][version]["needs"]
116
117     @property
118     def new_requirements(self):
119         """Requirements added since the prior version"""
120         new_ids = self.current_ids.difference(self.prior_ids)
121         return self.filter_needs(self.current_requirements, new_ids)
122
123     @property
124     def current_ids(self):
125         """Returns a set of the requirement IDs for the current version"""
126         return set(self.current_requirements.keys())
127
128     @property
129     def prior_ids(self):
130         """Returns a set of the requirement IDs for the prior version"""
131         return set(self.prior_requirements.keys())
132
133     @property
134     def removed_requirements(self):
135         """Requirements that were removed since the prior version"""
136         removed_ids = self.prior_ids.difference(self.current_ids)
137         return self.filter_needs(self.prior_requirements, removed_ids)
138
139     @property
140     def changed_requirements(self):
141         """"Requirements where the description changed since the last version"""
142         common_ids = self.prior_ids.intersection(self.current_ids)
143         result = {}
144         for r_id in common_ids:
145             current_text = self.current_requirements[r_id]["description"]
146             prior_text = self.prior_requirements[r_id]["description"]
147             if not self.is_equivalent(current_text, prior_text):
148                 sections = self.current_requirements[r_id]["sections"]
149                 result[r_id] = {
150                     "id": r_id,
151                     "description": current_text,
152                     "sections": sections,
153                 }
154         return result
155
156     def is_equivalent(self, current_text, prior_text):
157         """Returns true if there are meaningful differences between the
158         text.  See normalize for more information"""
159         return self.normalize(current_text) == self.normalize(prior_text)
160
161     @staticmethod
162     def normalize(text):
163         """Strips out formatting, line breaks, and repeated spaces to normalize
164          the string for comparison.  This ensures minor formatting changes
165          are not tagged as meaningful changes"""
166         s = text.lower()
167         s = s.replace("\n", " ")
168         s = re.sub(r'[`*\'"]', "", s)
169         s = re.sub(r"\s+", " ", s)
170         return s
171
172     @staticmethod
173     def filter_needs(needs, ids):
174         """
175         Return the requirements with the given ids
176
177         :ids: sequence of requirement IDs
178         :returns: dict of requirement ID to requirement data for only the
179                  requirements in ``ids``
180         """
181         return {r_id: data for r_id, data in needs.items() if r_id in ids}
182
183
184 def load_requirements(path):
185     """Load the requirements from the needs.json file"""
186     if not (os.path.exists(path)):
187         print("needs.json not found.  Run tox -e docs to generate it.")
188         sys.exit(1)
189     with open(path, "r") as req_file:
190         return json.load(req_file)
191
192
193 def parse_args():
194     """Parse the command-line arguments and return the arguments:
195
196     args.current_version
197     args.prior_version
198     """
199     parser = argparse.ArgumentParser(
200         description="Generate RST summarizing requirement changes between "
201         "two given releases. The resulting RST file will be "
202         "written to the docs/ directory"
203     )
204     parser.add_argument(
205         "current_version", help="Current release in lowercase (ex: casablanca)"
206     )
207     parser.add_argument("prior_version",
208                         help="Prior release to compare against")
209     return parser.parse_args()
210
211
212 def tag(dicts, key, value):
213     """Adds the key value to each dict in the sequence"""
214     for d in dicts:
215         d[key] = value
216     return dicts
217
218
219 def gather_section_changes(diffs):
220     """
221     Return a list of dicts that represent the changes for a given section path.
222     [
223         {
224          "section_path": path,
225          "added: [req, ...],
226          "updated: [req, ...],
227          "removed: [req, ...],
228         },
229         ...
230     ]
231     :param diffs: instance of DifferenceFinder
232     :return: list of section changes
233     """
234     # Add "change" and "section_path" keys to all requirements
235     reqs = list(
236         chain(
237             tag(diffs.new_requirements.values(), "change", "added"),
238             tag(diffs.changed_requirements.values(), "change", "updated"),
239             tag(diffs.removed_requirements.values(), "change", "removed"),
240         )
241     )
242     for req in reqs:
243         req["section_path"] = " > ".join(reversed(req["sections"]))
244
245     # Build list of changes by section
246     reqs = sorted(reqs, key=itemgetter("section_path"))
247     all_changes = []
248     for section, section_reqs in groupby(reqs, key=itemgetter("section_path")):
249         change = itemgetter("change")
250         section_reqs = sorted(section_reqs, key=change)
251         section_changes = {"section_path": section}
252         for change, change_reqs in groupby(section_reqs, key=change):
253             section_changes[change] = list(change_reqs)
254         if any(k in section_changes for k in ("added", "updated", "removed")):
255             all_changes.append(section_changes)
256     return all_changes
257
258
259 def render_to_file(template_path, output_path, **context):
260     """Read the template and render it ot the output_path using the given
261     context variables"""
262     with open(template_path, "r") as infile:
263         t = jinja2.Template(infile.read())
264     result = t.render(**context)
265     with open(output_path, "w") as outfile:
266         outfile.write(result)
267     print()
268     print("Writing requirement changes to " + output_path)
269     print(
270         "Please be sure to update the docs/release-notes.rst document "
271         "with a link to this document"
272     )
273
274
275 def print_invalid_metadata_report(difference_finder, current_version):
276     """Write a report to the console for any instances where differences
277     are detected, but teh appropriate :introduced: or :updated: metadata
278     is not applied to the requirement."""
279     print("Validating Metadata...")
280     print()
281     print("Requirements Added, but Missing :introduced: Attribute")
282     print("----------------------------------------------------")
283     errors = [["reqt_id", "attribute", "value"]]
284     for req in difference_finder.new_requirements.values():
285         if "introduced" not in req or req["introduced"] != current_version:
286             errors.append([req["id"], ":introduced:", current_version])
287             print(req["id"])
288     print()
289     print("Requirements Changed, but Missing :updated: Attribute")
290     print("-----------------------------------------------------")
291     for req in difference_finder.changed_requirements.values():
292         if "updated" not in req or req["updated"] != current_version:
293             errors.append([req["id"], ":updated:", current_version])
294             print(req["id"])
295     with open("invalid_metadata.csv", "w", newline="") as error_report:
296         error_report = csv.writer(error_report)
297         error_report.writerows(errors)
298
299
300 if __name__ == "__main__":
301     args = parse_args()
302     requirements = load_requirements(NEEDS_PATH)
303     differ = DifferenceFinder(requirements,
304                               args.current_version,
305                               args.prior_version)
306
307     print_invalid_metadata_report(differ, args.current_version)
308
309     changes = gather_section_changes(differ)
310     render_to_file(
311         "release-requirement-changes.rst.jinja2",
312         "docs/changes-by-section-" + args.current_version + ".rst",
313         changes=changes,
314         current_version=args.current_version,
315         prior_version=args.prior_version,
316         num_added=len(differ.new_requirements),
317         num_removed=len(differ.removed_requirements),
318         num_changed=len(differ.changed_requirements),
319     )
320
321