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.
40 from itertools import groupby, chain
46 from pathlib import Path
48 from operator import itemgetter
52 THIS_DIR = Path(__file__).parent
53 NEEDS_PATH = THIS_DIR / "docs/data/needs.json"
54 JINJA_TEMPLATE = "release-requirement-changes.rst.jinja2"
57 def check(predicate, msg):
59 Raises a ``RuntimeError`` if the given predicate is false.
61 :param predicate: Predicate to evaluate
62 :param msg: Error message to use if predicate is false
65 raise RuntimeError(msg)
68 class DifferenceFinder:
70 Class takes a needs.json data structure and finds the differences
71 between two different versions of the requirements
74 def __init__(self, current_version, prior_version):
76 Determine the differences between the ``current_version`` and the
77 ``prior_version`` of the given requirements.
79 :param current_version: most recent version to compare against
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 for category, needs in (
94 ("current needs", self.current_version),
95 ("prior needs", self.prior_version),
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")
102 def current_requirements(self):
103 """Returns a dict of requirement ID to requirement metadata"""
104 return self.get_current_version(self.current_version)
107 def prior_requirements(self):
108 """Returns a dict of requirement ID to requirement metadata"""
109 return self.get_current_version(self.prior_version)
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"]
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)
124 def current_ids(self):
125 """Returns a set of the requirement IDs for the current version"""
126 return set(self.current_requirements.keys())
130 """Returns a set of the requirement IDs for the prior version"""
131 return set(self.prior_requirements.keys())
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)
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)
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"]
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"),
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)
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"""
169 s = s.replace("\n", " ")
170 s = re.sub(r'[`*\'"]', "", s)
171 s = re.sub(r"\s+", " ", s)
175 def filter_needs(needs, ids):
177 Return the requirements with the given ids
179 :ids: sequence of requirement IDs
180 :returns: dict of requirement ID to requirement data for only the
181 requirements in ``ids``
183 return {r_id: data for r_id, data in needs.items() if r_id in ids}
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.")
191 with path.open("r") as req_file:
192 return json.load(req_file)
196 """Parse the command-line arguments and return the arguments:
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"
206 "prior_version", help="Path to file containing prior needs.json file"
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):
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.
280 print("Validating Metadata...")
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])
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])
297 with open("invalid_metadata.csv", "w", newline="") as error_report:
298 error_report = csv.writer(error_report)
299 error_report.writerows(errors)
302 if __name__ == "__main__":
304 current_reqs = load_requirements(NEEDS_PATH)
305 prior_reqs = load_requirements(Path(args.prior_version))
306 differ = DifferenceFinder(current_reqs, prior_reqs)
308 print_invalid_metadata_report(differ)
310 changes = gather_section_changes(differ)
312 "release-requirement-changes.rst.jinja2",
313 "docs/changes-by-section-" + current_reqs["current_version"] + ".rst",
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),