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