Update Istanbul Release Notes
[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 pathlib import Path
47
48 from operator import itemgetter
49
50 import jinja2
51
52 THIS_DIR = Path(__file__).parent
53 NEEDS_PATH = THIS_DIR / "docs/data/needs.json"
54 JINJA_TEMPLATE = "release-requirement-changes.rst.jinja2"
55
56
57 def check(predicate, msg):
58     """
59     Raises a ``RuntimeError`` if the given predicate is false.
60
61     :param predicate: Predicate to evaluate
62     :param msg: Error message to use if predicate is false
63     """
64     if not predicate:
65         raise RuntimeError(msg)
66
67
68 class DifferenceFinder:
69     """
70     Class takes a needs.json data structure and finds the differences
71     between two different versions of the requirements
72     """
73
74     def __init__(self, current_version, prior_version):
75         """
76         Determine the differences between the ``current_version`` and the
77         ``prior_version`` of the given requirements.
78
79         :param current_version: most recent version to compare against
80         :return:
81         """
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         for category, needs in (
94             ("current needs", self.current_version),
95             ("prior needs", self.prior_version),
96         ):
97             check(needs is not None, f"{category} cannot be None")
98             check(isinstance(needs, dict), f"{category} needs must be a dict")
99             check("versions" in needs, f"{category} needs file not properly formatted")
100
101     @property
102     def current_requirements(self):
103         """Returns a dict of requirement ID to requirement metadata"""
104         return self.get_current_version(self.current_version)
105
106     @property
107     def prior_requirements(self):
108         """Returns a dict of requirement ID to requirement metadata"""
109         return self.get_current_version(self.prior_version)
110
111     @staticmethod
112     def get_current_version(needs):
113         """Returns a dict of requirement ID to requirement metadata"""
114         version = needs["current_version"]
115         return 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                     "introduced": self.current_requirements[r_id].get("introduced"),
154                     "updated": self.current_requirements[r_id].get("updated"),
155                 }
156         return result
157
158     def is_equivalent(self, current_text, prior_text):
159         """Returns true if there are meaningful differences between the
160         text.  See normalize for more information"""
161         return self.normalize(current_text) == self.normalize(prior_text)
162
163     @staticmethod
164     def normalize(text):
165         """Strips out formatting, line breaks, and repeated spaces to normalize
166          the string for comparison.  This ensures minor formatting changes
167          are not tagged as meaningful changes"""
168         s = text.lower()
169         s = s.replace("\n", " ")
170         s = re.sub(r'[`*\'"]', "", s)
171         s = re.sub(r"\s+", " ", s)
172         return s
173
174     @staticmethod
175     def filter_needs(needs, ids):
176         """
177         Return the requirements with the given ids
178
179         :ids: sequence of requirement IDs
180         :returns: dict of requirement ID to requirement data for only the
181                  requirements in ``ids``
182         """
183         return {r_id: data for r_id, data in needs.items() if r_id in ids}
184
185
186 def load_requirements(path: Path):
187     """Load the requirements from the needs.json file"""
188     if not (path.exists()):
189         print("needs.json not found.  Run tox -e docs to generate it.")
190         sys.exit(1)
191     with path.open("r") as req_file:
192         return json.load(req_file)
193
194
195 def parse_args():
196     """Parse the command-line arguments and return the arguments:
197
198     args.prior_version
199     """
200     parser = argparse.ArgumentParser(
201         description="Generate RST summarizing requirement changes between "
202         "the current release and a prior releases needs.json file. The resulting RST "
203         "file will be written to the docs/ directory"
204     )
205     parser.add_argument(
206         "prior_version", help="Path to file containing prior needs.json file"
207     )
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):
275     """
276     Write a report to the console for any instances where differences
277     are detected, but the appropriate :introduced: or :updated: metadata
278     is not applied to the requirement.
279     """
280     print("Validating Metadata...")
281     print()
282     print("Requirements Added, but Missing :introduced: Attribute")
283     print("----------------------------------------------------")
284     errors = [["reqt_id", "attribute", "value"]]
285     current_version = difference_finder.current_version["current_version"]
286     for req in difference_finder.new_requirements.values():
287         if "introduced" not in req or req["introduced"] != current_version:
288             errors.append([req["id"], ":introduced:", current_version])
289             print(req["id"])
290     print()
291     print("Requirements Changed, but Missing :updated: Attribute")
292     print("-----------------------------------------------------")
293     for req in difference_finder.changed_requirements.values():
294         if "updated" not in req or req["updated"] != current_version:
295             errors.append([req["id"], ":updated:", current_version])
296             print(req["id"])
297     with open("invalid_metadata.csv", "w", newline="") as error_report:
298         error_report = csv.writer(error_report)
299         error_report.writerows(errors)
300
301
302 if __name__ == "__main__":
303     args = parse_args()
304     current_reqs = load_requirements(NEEDS_PATH)
305     prior_reqs = load_requirements(Path(args.prior_version))
306     differ = DifferenceFinder(current_reqs, prior_reqs)
307
308     print_invalid_metadata_report(differ)
309
310     changes = gather_section_changes(differ)
311     render_to_file(
312         "release-requirement-changes.rst.jinja2",
313         "docs/changes-by-section-" + current_reqs["current_version"] + ".rst",
314         changes=changes,
315         current_version=current_reqs["current_version"],
316         prior_version=prior_reqs["current_version"],
317         num_added=len(differ.new_requirements),
318         num_removed=len(differ.removed_requirements),
319         num_changed=len(differ.changed_requirements),
320     )