2 # org.onap.vnfrqts/requirements
3 # ============LICENSE_START====================================================
4 # Copyright © 2018 AT&T Intellectual Property. All rights reserved.
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
11 # http://www.apache.org/licenses/LICENSE-2.0
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.
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
24 # https://creativecommons.org/licenses/by/4.0/
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.
32 # ============LICENSE_END============================================
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.
39 from itertools import groupby, chain
45 from operator import itemgetter
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"
54 def check(predicate, msg):
56 Raises a ``RuntimeError`` if the given predicate is false.
58 :param predicate: Predicate to evaluate
59 :param msg: Error message to use if predicate is false
62 raise RuntimeError(msg)
65 class DifferenceFinder:
67 Class takes a needs.json data structure and finds the differences
68 between two different versions of the requirements
71 def __init__(self, needs, current_version, prior_version):
73 Determine the differences between the ``current_version`` and the
74 ``prior_version`` of the given requirements.
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
82 self.current_version = current_version
83 self.prior_version = prior_version
88 Validates the inputs to the ``DifferenceFinder`` constructor.
90 :raises RuntimeError: if the file is not structured properly or the
91 given versions can't be found.
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):
98 version in self.needs["versions"],
99 "Version " + version + " was not found in the needs file",
103 def current_requirements(self):
104 """Returns a dict of requirement ID to requirement metadata"""
105 return self.get_version(self.current_version)
108 def prior_requirements(self):
109 """Returns a dict of requirement ID to requirement metadata"""
110 return self.get_version(self.prior_version)
112 def get_version(self, version):
113 """Returns a dict of requirement ID to requirement metadata"""
114 return self.needs["versions"][version]["needs"]
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)
123 def current_ids(self):
124 """Returns a set of the requirement IDs for the current version"""
125 return set(self.current_requirements.keys())
129 """Returns a set of the requirement IDs for the prior version"""
130 return set(self.prior_requirements.keys())
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)
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)
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"]
150 "description": current_text,
151 "sections": sections,
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)
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"""
166 s = s.replace("\n", " ")
167 s = re.sub(r'[`*\'"]', "", s)
168 s = re.sub(r"\s+", " ", s)
172 def filter_needs(needs, ids):
174 Return the requirements with the given ids
176 :ids: sequence of requirement IDs
177 :returns: dict of requirement ID to requirement data for only the
178 requirements in ``ids``
180 return {r_id: data for r_id, data in needs.items() if r_id in ids}
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.")
188 with open(path, "r") as req_file:
189 return json.load(req_file)
193 """Parse the command-line arguments and return the arguments:
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"
204 "current_version", help="Current release in lowercase (ex: casablanca)"
206 parser.add_argument("prior_version",
207 help="Prior release to compare against")
208 return parser.parse_args()
211 def tag(dicts, key, value):
212 """Adds the key value to each dict in the sequence"""
218 def gather_section_changes(diffs):
220 Return a list of dicts that represent the changes for a given section path.
223 "section_path": path,
225 "updated: [req, ...],
226 "removed: [req, ...],
230 :param diffs: instance of DifferenceFinder
231 :return: list of section changes
233 # Add "change" and "section_path" keys to all requirements
236 tag(diffs.new_requirements.values(), "change", "added"),
237 tag(diffs.changed_requirements.values(), "change", "updated"),
238 tag(diffs.removed_requirements.values(), "change", "removed"),
242 req["section_path"] = " > ".join(reversed(req["sections"]))
244 # Build list of changes by section
245 reqs = sorted(reqs, key=itemgetter("section_path"))
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)
258 def render_to_file(template_path, output_path, **context):
259 """Read the template and render it ot the output_path using the given
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)
267 print("Writing requirement changes to " + output_path)
269 "Please be sure to update the docs/release-notes.rst document "
270 "with a link to this document"
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...")
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:
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:
293 if __name__ == "__main__":
295 requirements = load_requirements(NEEDS_PATH)
296 differ = DifferenceFinder(requirements,
297 args.current_version,
300 print_invalid_metadata_report(differ, args.current_version)
302 changes = gather_section_changes(differ)
304 "release-requirement-changes.rst.jinja2",
305 "docs/changes-by-section-" + args.current_version + ".rst",
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),