Update Istanbul Release Notes
[vnfrqts/requirements.git] / check.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 should be run before every commit to ensure proper
36 standards are being followed within the project.  The script
37 will also automatically fix certain issues when they are encountered, and
38 warn about other issues it cannot automatically resolve.
39
40 Warnings:
41 - Requirement missing required attributes
42 - Invalid values for attributes
43 - Invalid section header usage in any file
44 - :keyword: and requirement mismatch
45
46 Auto Updates:
47 - Assigning :id: on new requirements where an ID missing
48 - Adding :introduced: attribute on new requirements
49 - Adding/correcting :updated: attribute on changed requirements
50 """
51
52 import os
53 import random
54 import re
55 import sys
56 from abc import ABC, abstractmethod
57 from collections import OrderedDict, deque
58 from pathlib import Path
59 from typing import Deque, List, Mapping, Callable, Set
60
61 import requests
62
63 THIS_DIR = Path(__file__).parent
64 CONF_PATH = THIS_DIR / "docs/conf.py"
65
66 NEEDS_JSON_URL = (
67     "https://nexus.onap.org/service/local/repositories/raw/content"
68     "/org.onap.vnfrqts.requirements/master/needs.json"
69 )
70
71 HEADING_LEVELS = ("-", "^", "~", "+", "*", '"')
72
73 SPACES = re.compile(r"\s+")
74 REQ_DIRECTIVE_PATTERN = re.compile(r"\.\.\s+req::.*")
75 ATTRIBUTE_PATTERN = re.compile(r"^\s+(:\w+:)\s+(.*)$")
76 VERSION_PATTERN = re.compile(r"version\s+=\s+'(.*?)'")
77
78 VALID_KEYWORDS = ("MUST", "MUST NOT", "SHOULD", "SHOULD NOT", "MAY", "MAY NOT")
79 VALID_VERSIONS = (
80     "amsterdam",
81     "beijing",
82     "casablanca",
83     "dublin",
84     "el alto",
85     "frankfurt",
86     "guilin",
87     "honolulu"
88 )
89 REQUIRED_ATTRIBUTES = (":keyword:", ":target:", ":id:")
90 VALID_TARGETS = (
91     "VNF",
92     "PNF",
93     "VNF or PNF",
94     "VNF DOCUMENTATION PACKAGE",
95     "PNF DOCUMENTATION PACKAGE",
96     "VNF or PNF DOCUMENTATION PACKAGE",
97     "VNF PROVIDER",
98     "PNF PROVIDER",
99     "VNF or PNF PROVIDER",
100     "VNF CSAR PACKAGE",
101     "PNF CSAR PACKAGE",
102     "VNF or PNF CSAR PACKAGE",
103     "VNF HEAT PACKAGE",
104 )
105 VALID_VALIDATION_MODES = ("static", "none", "in_service")
106
107
108 def check(predicate: bool, msg: str):
109     """
110     Raises RuntimeError with given msg if predicate is False
111     """
112     if not predicate:
113         raise RuntimeError(msg)
114
115
116 def get_version() -> str:
117     """
118     Returns the version value from conf.py
119     """
120     with open(CONF_PATH) as f:
121         for line in f:
122             m = VERSION_PATTERN.match(line)
123             if m:
124                 version = m.groups()[0]
125                 if version not in VALID_VERSIONS:
126                     print(
127                         f"ERROR: {version} in conf.py is not defined in "
128                         f"VALID_VERSIONS. Update the script to continue"
129                     )
130                     sys.exit(1)
131                 return version
132
133
134 VERSION = get_version()
135
136
137 def normalize(text: str):
138     """
139     Strips out formatting, line breaks, and repeated spaces to normalize
140     the string for comparison.  This ensures minor formatting changes
141     are not tagged as meaningful changes
142     """
143     s = text.lower()
144     s = s.replace("\n", " ")
145     s = re.sub(r'[`*\'"]', "", s)
146     s = re.sub(r"\s+", " ", s)
147     return s
148
149
150 def warn(path: str, msg: str, req: "RequirementDirective" = None):
151     """
152     Log a warning
153     """
154     req_id = req.requirement_id or "UNKNOWN" if req else "UNKNOWN"
155     print(f"WARNING: {path} | {req_id} | {msg}")
156
157
158 class RequirementRepository:
159     """
160     Pulls needs.json and provides various options to interact with the data.
161     """
162
163     def __init__(self, data=None):
164         self.data = data or requests.get(NEEDS_JSON_URL).json()
165         self.all_ids = {
166             r["id"]
167             for version in self.data["versions"].values()
168             for r in version["needs"].values()
169         }
170
171     @property
172     def current_requirements(self) -> Mapping:
173         """
174         Returns the requirements specified by current_version in needs.json.
175         """
176         version = self.data["current_version"]
177         return self.data["versions"][version]["needs"]
178
179     @property
180     def unique_targets(self) -> Set[str]:
181         return {r["target"] for r in self.current_requirements.values()}
182
183     @property
184     def unique_validation_modes(self) -> Set[str]:
185         return {r["validation_mode"] for r in self.current_requirements.values()}
186
187     def create_id(self) -> str:
188         """
189         Generates a requirements ID that has not been used in any version
190         of the requirements.
191         """
192         while True:
193             new_id = "R-{:0>5d}".format(random.randint(0, 99999))
194             if new_id in self.all_ids:
195                 continue  # skip this one and generate another one
196             self.all_ids.add(new_id)
197             return new_id
198
199     def is_new_requirement(self, req: "RequirementDirective") -> bool:
200         return req.requirement_id not in self.current_requirements
201
202     def has_changed(self, req: "RequirementDirective") -> bool:
203         """
204         Returns True if the requirement already exists and the contents has
205         meaningfully changed. Small changes in spaces or formatting are not considered.
206         """
207         current_req = self.current_requirements.get(req.requirement_id)
208         if not current_req:
209             return False
210         return normalize(current_req["description"]) == normalize("".join(req.content))
211
212
213 class RequirementDirective:
214     """
215     Data structure to hold a .. req:: directive
216     """
217
218     ATTRIBUTE_ORDER = (
219         ":id:",
220         ":target:",
221         ":keyword:",
222         ":introduced:",
223         ":updated:",
224         ":validation_mode:",
225         ":impacts:",
226     )
227
228     def __init__(self, path: str):
229         self.path = path
230         self.attributes = OrderedDict()
231         self.content = []
232         self.indent = None
233
234     @property
235     def requirement_id(self) -> str:
236         return self.attributes.get(":id:", "")
237
238     @requirement_id.setter
239     def requirement_id(self, r_id: str):
240         self._update(":id:", r_id)
241
242     @property
243     def keyword(self) -> str:
244         return self.attributes.get(":keyword:", "")
245
246     @keyword.setter
247     def keyword(self, k: str):
248         self._update(":keyword:", k)
249
250     @property
251     def target(self) -> str:
252         return self.attributes.get(":target:", "")
253
254     @target.setter
255     def target(self, value: str):
256         self._update(":target", value)
257
258     @property
259     def introduced(self) -> str:
260         return self.attributes.get(":introduced:", "")
261
262     @introduced.setter
263     def introduced(self, version: str):
264         self._update(":introduced:", version)
265
266     @property
267     def updated(self) -> str:
268         return self.attributes.get(":updated:", "")
269
270     @updated.setter
271     def updated(self, version: str):
272         self._update(":updated:", version)
273
274     @property
275     def validation_mode(self) -> str:
276         return self.attributes.get(":validation_mode:", "")
277
278     @validation_mode.setter
279     def validation_mode(self, value: str):
280         self._update(":validation_mode:", value)
281
282     def parse(self, lines: Deque[str]):
283         """
284         Parses a ..req:: directive and populates the data structre
285         """
286         parsing_attrs = True
287         while lines:
288             line = lines.popleft()
289             match = ATTRIBUTE_PATTERN.match(line) if parsing_attrs else None
290             if match:
291                 self.indent = self.indent or self.calc_indent(line)
292                 attr, value = match.groups()
293                 self.attributes[attr] = value
294             else:
295                 parsing_attrs = False  # passed attributes, capture content
296                 if line.strip() and self.calc_indent(line) < self.indent:
297                     # past end of the directive so we'll put this line back
298                     lines.appendleft(line)
299                     break
300                 else:
301                     self.content.append(line)
302
303     def format_attributes(self) -> List[str]:
304         """
305         Converts a dict back to properly indented lines using ATTRIBUTE_ORDER
306         """
307         spaces = " " * self.indent
308         attr_lines = []
309         for key in self.ATTRIBUTE_ORDER:
310             value = self.attributes.get(key)
311             if value:
312                 attr_lines.append(f"{spaces}{key} {value}\n")
313         return attr_lines
314
315     @staticmethod
316     def calc_indent(line: str) -> int:
317         """
318         Number of leading spaces of the line
319         """
320         return len(line) - len(line.lstrip())
321
322     def __str__(self):
323         return "".join(self.format_attributes() + self.content)
324
325     def _notify(self, field, value):
326         req_id = self.requirement_id or "UNKNOWN"
327         print(f"UPDATE: {self.path} | {req_id} | Setting {field} to {value}")
328
329     def _update(self, attr, value):
330         self.attributes[attr] = value
331         self._notify(attr, value)
332
333
334 class RequirementVisitor:
335     """
336     Walks a directory for reStructuredText files and and passes contents to
337     visitors when the content is encountered.
338
339     Types of visitors supported:
340
341     - Requirement:    Take the path and a RequirementDirective which may be modified
342                       If modified, the file will be updated using the modified directive
343     - Post Processor: Take the path and all lines for processing; returning a
344                       potentially changed set of lines
345     """
346
347     def __init__(
348         self,
349         req_visitors: List[Callable[[str, RequirementDirective], None]],
350         post_processors: List[Callable[[str, List[str]], List[str]]],
351     ):
352         self.req_visitors = req_visitors or []
353         self.post_processors = post_processors or []
354
355     def process(self, root_dir: Path):
356         """
357         Walks the `root_dir` looking for rst to files to parse
358         """
359         for dir_path, sub_dirs, filenames in os.walk(root_dir.as_posix()):
360             for filename in filenames:
361                 if filename.lower().endswith(".rst"):
362                     self.handle_rst_file(os.path.join(dir_path, filename))
363
364     @staticmethod
365     def read(path):
366         """Read file at `path` and return lines as list"""
367         with open(path, "r") as f:
368             return list(f)
369
370     @staticmethod
371     def write(path, content):
372         """Write a content to the given path"""
373         with open(path, "w") as f:
374             for line in content:
375                 f.write(line)
376
377     def handle_rst_file(self, path):
378         """
379         Parse the RST file notifying the registered visitors
380         """
381         lines = deque(self.read(path))
382         new_contents = []
383         while lines:
384             line = lines.popleft()
385             if self.is_req_directive(line):
386                 req = RequirementDirective(path)
387                 req.parse(lines)
388                 for func in self.req_visitors:
389                     func(path, req)
390                 # Put the lines back for processing by the line visitor
391                 lines.extendleft(reversed(req.format_attributes() + req.content))
392             new_contents.append(line)
393         for processor in self.post_processors:
394             new_contents = processor(path, new_contents) or new_contents
395         self.write(path, new_contents)
396
397     @staticmethod
398     def is_req_directive(line):
399         """Returns True if the line denotes the start of a needs directive"""
400         return bool(REQ_DIRECTIVE_PATTERN.match(line))
401
402
403 class AbstractRequirementVisitor(ABC):
404     @abstractmethod
405     def __call__(self, path: str, req: RequirementDirective):
406         raise NotImplementedError()
407
408
409 class MetadataFixer(AbstractRequirementVisitor):
410     """
411     Updates metadata based on the status of the requirement and contents of
412     the metadata
413     """
414
415     def __init__(self, repos: RequirementRepository):
416         self.repos = repos
417
418     def __call__(self, path: str, req: RequirementDirective):
419         if not req.requirement_id:
420             req.requirement_id = self.repos.create_id()
421         if self.repos.is_new_requirement(req) and req.introduced != VERSION:
422             req.introduced = VERSION
423         if self.repos.has_changed(req) and req.updated != VERSION:
424             req.updated = VERSION
425
426
427 class MetadataValidator(AbstractRequirementVisitor):
428     def __init__(self, repos: RequirementRepository):
429         self.repos = repos
430
431     def __call__(self, path: str, req: RequirementDirective):
432         for attr in REQUIRED_ATTRIBUTES:
433             if attr not in req.attributes:
434                 warn(path, f"Missing required attribute {attr}", req)
435         if req.keyword and req.keyword not in VALID_KEYWORDS:
436             warn(path, f"Invalid :keyword: value ({req.keyword})", req)
437         if repository.is_new_requirement(req) and req.introduced != VERSION:
438             warn(path, f":introduced: is not {VERSION} on new requirement", req)
439         if req.introduced and req.introduced not in VALID_VERSIONS:
440             warn(path, f"Invalid :introduced: value ({req.introduced})", req)
441         if req.updated and req.updated not in VALID_VERSIONS:
442             warn(path, f"Invalid :updated: value ({req.updated})", req)
443         if req.target and req.target not in VALID_TARGETS:
444             warn(path, f"Invalid :target: value ({req.target})", req)
445         if req.validation_mode and req.validation_mode not in VALID_VALIDATION_MODES:
446             warn(path, f"Invalid :validation_mode: value ({req.validation_mode})", req)
447
448
449 def check_section_headers(path: str, lines: List[str]) -> List[str]:
450     """
451     Ensure hierarchy of section headers follows the expected progression as defined
452     by `HEADING_LEVELS`, and that section heading marks match the length of the
453     section title.
454     """
455     current_heading_level = 0
456     for i, line in enumerate(lines):
457         if any(line.startswith(char * 3) for char in HEADING_LEVELS):
458             # heading level should go down, stay the same, or be next level
459             expected = HEADING_LEVELS[0 : current_heading_level + 2]
460             if line[0] not in expected:
461                 warn(
462                     path,
463                     f"Unexpected heading char ({line[0]}) on line {i+1}. "
464                     f"Expected one of {' '.join(expected)}",
465                 )
466             if len(line.strip()) != len(lines[i - 1].strip()):
467                 lines[i] = (line[0] * len(lines[i - 1].strip())) + "\n"
468                 print(
469                     f"UPDATE: {path} | Matching section mark to title length "
470                     f"on line {i+1}"
471                 )
472             current_heading_level = HEADING_LEVELS.index(line[0])
473     return lines
474
475
476 def check_keyword_text_alignment(path: str, req: RequirementDirective):
477     if not req.keyword:
478         return req
479     keyword = f"**{req.keyword}**"
480     if not any(keyword in line for line in req.content):
481         warn(path, f"Keyword is {req.keyword}, but {keyword} not in requirement", req)
482
483
484 if __name__ == "__main__":
485     print("Valid Versions")
486     print("-----------------------")
487     print("\n".join(VALID_VERSIONS))
488     print()
489     print("Valid Keywords")
490     print("-----------------------")
491     print("\n".join(VALID_KEYWORDS))
492     print()
493     print("Valid Targets")
494     print("-----------------------")
495     print("\n".join(VALID_TARGETS))
496     print()
497     print("Valid Validation Modes")
498     print("-----------------------")
499     print("\n".join(VALID_VALIDATION_MODES))
500     print()
501     print("Check-up Report")
502     print("-" * 100)
503     repository = RequirementRepository()
504     visitor = RequirementVisitor(
505         req_visitors=[
506             MetadataFixer(repository),
507             MetadataValidator(repository),
508             check_keyword_text_alignment,
509         ],
510         post_processors=[check_section_headers],
511     )
512     visitor.process(THIS_DIR / "docs")