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 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.
41 - Requirement missing required attributes
42 - Invalid values for attributes
43 - Invalid section header usage in any file
44 - :keyword: and requirement mismatch
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
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
63 THIS_DIR = Path(__file__).parent
64 CONF_PATH = THIS_DIR / "docs/conf.py"
67 "https://nexus.onap.org/service/local/repositories/raw/content"
68 "/org.onap.vnfrqts.requirements/master/needs.json"
71 HEADING_LEVELS = ("-", "^", "~", "+", "*", '"')
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+'(.*?)'")
78 VALID_KEYWORDS = ("MUST", "MUST NOT", "SHOULD", "SHOULD NOT", "MAY", "MAY NOT")
89 REQUIRED_ATTRIBUTES = (":keyword:", ":target:", ":id:")
94 "VNF DOCUMENTATION PACKAGE",
95 "PNF DOCUMENTATION PACKAGE",
96 "VNF or PNF DOCUMENTATION PACKAGE",
99 "VNF or PNF PROVIDER",
102 "VNF or PNF CSAR PACKAGE",
105 VALID_VALIDATION_MODES = ("static", "none", "in_service")
108 def check(predicate: bool, msg: str):
110 Raises RuntimeError with given msg if predicate is False
113 raise RuntimeError(msg)
116 def get_version() -> str:
118 Returns the version value from conf.py
120 with open(CONF_PATH) as f:
122 m = VERSION_PATTERN.match(line)
124 version = m.groups()[0]
125 if version not in VALID_VERSIONS:
127 f"ERROR: {version} in conf.py is not defined in "
128 f"VALID_VERSIONS. Update the script to continue"
134 VERSION = get_version()
137 def normalize(text: str):
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
144 s = s.replace("\n", " ")
145 s = re.sub(r'[`*\'"]', "", s)
146 s = re.sub(r"\s+", " ", s)
150 def warn(path: str, msg: str, req: "RequirementDirective" = None):
154 req_id = req.requirement_id or "UNKNOWN" if req else "UNKNOWN"
155 print(f"WARNING: {path} | {req_id} | {msg}")
158 class RequirementRepository:
160 Pulls needs.json and provides various options to interact with the data.
163 def __init__(self, data=None):
164 self.data = data or requests.get(NEEDS_JSON_URL).json()
167 for version in self.data["versions"].values()
168 for r in version["needs"].values()
172 def current_requirements(self) -> Mapping:
174 Returns the requirements specified by current_version in needs.json.
176 version = self.data["current_version"]
177 return self.data["versions"][version]["needs"]
180 def unique_targets(self) -> Set[str]:
181 return {r["target"] for r in self.current_requirements.values()}
184 def unique_validation_modes(self) -> Set[str]:
185 return {r["validation_mode"] for r in self.current_requirements.values()}
187 def create_id(self) -> str:
189 Generates a requirements ID that has not been used in any version
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)
199 def is_new_requirement(self, req: "RequirementDirective") -> bool:
200 return req.requirement_id not in self.current_requirements
202 def has_changed(self, req: "RequirementDirective") -> bool:
204 Returns True if the requirement already exists and the contents has
205 meaningfully changed. Small changes in spaces or formatting are not considered.
207 current_req = self.current_requirements.get(req.requirement_id)
210 return normalize(current_req["description"]) == normalize("".join(req.content))
213 class RequirementDirective:
215 Data structure to hold a .. req:: directive
228 def __init__(self, path: str):
230 self.attributes = OrderedDict()
235 def requirement_id(self) -> str:
236 return self.attributes.get(":id:", "")
238 @requirement_id.setter
239 def requirement_id(self, r_id: str):
240 self._update(":id:", r_id)
243 def keyword(self) -> str:
244 return self.attributes.get(":keyword:", "")
247 def keyword(self, k: str):
248 self._update(":keyword:", k)
251 def target(self) -> str:
252 return self.attributes.get(":target:", "")
255 def target(self, value: str):
256 self._update(":target", value)
259 def introduced(self) -> str:
260 return self.attributes.get(":introduced:", "")
263 def introduced(self, version: str):
264 self._update(":introduced:", version)
267 def updated(self) -> str:
268 return self.attributes.get(":updated:", "")
271 def updated(self, version: str):
272 self._update(":updated:", version)
275 def validation_mode(self) -> str:
276 return self.attributes.get(":validation_mode:", "")
278 @validation_mode.setter
279 def validation_mode(self, value: str):
280 self._update(":validation_mode:", value)
282 def parse(self, lines: Deque[str]):
284 Parses a ..req:: directive and populates the data structre
288 line = lines.popleft()
289 match = ATTRIBUTE_PATTERN.match(line) if parsing_attrs else None
291 self.indent = self.indent or self.calc_indent(line)
292 attr, value = match.groups()
293 self.attributes[attr] = value
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)
301 self.content.append(line)
303 def format_attributes(self) -> List[str]:
305 Converts a dict back to properly indented lines using ATTRIBUTE_ORDER
307 spaces = " " * self.indent
309 for key in self.ATTRIBUTE_ORDER:
310 value = self.attributes.get(key)
312 attr_lines.append(f"{spaces}{key} {value}\n")
316 def calc_indent(line: str) -> int:
318 Number of leading spaces of the line
320 return len(line) - len(line.lstrip())
323 return "".join(self.format_attributes() + self.content)
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}")
329 def _update(self, attr, value):
330 self.attributes[attr] = value
331 self._notify(attr, value)
334 class RequirementVisitor:
336 Walks a directory for reStructuredText files and and passes contents to
337 visitors when the content is encountered.
339 Types of visitors supported:
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
349 req_visitors: List[Callable[[str, RequirementDirective], None]],
350 post_processors: List[Callable[[str, List[str]], List[str]]],
352 self.req_visitors = req_visitors or []
353 self.post_processors = post_processors or []
355 def process(self, root_dir: Path):
357 Walks the `root_dir` looking for rst to files to parse
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))
366 """Read file at `path` and return lines as list"""
367 with open(path, "r") as f:
371 def write(path, content):
372 """Write a content to the given path"""
373 with open(path, "w") as f:
377 def handle_rst_file(self, path):
379 Parse the RST file notifying the registered visitors
381 lines = deque(self.read(path))
384 line = lines.popleft()
385 if self.is_req_directive(line):
386 req = RequirementDirective(path)
388 for func in self.req_visitors:
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)
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))
403 class AbstractRequirementVisitor(ABC):
405 def __call__(self, path: str, req: RequirementDirective):
406 raise NotImplementedError()
409 class MetadataFixer(AbstractRequirementVisitor):
411 Updates metadata based on the status of the requirement and contents of
415 def __init__(self, repos: RequirementRepository):
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
427 class MetadataValidator(AbstractRequirementVisitor):
428 def __init__(self, repos: RequirementRepository):
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)
449 def check_section_headers(path: str, lines: List[str]) -> List[str]:
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
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:
463 f"Unexpected heading char ({line[0]}) on line {i+1}. "
464 f"Expected one of {' '.join(expected)}",
466 if len(line.strip()) != len(lines[i - 1].strip()):
467 lines[i] = (line[0] * len(lines[i - 1].strip())) + "\n"
469 f"UPDATE: {path} | Matching section mark to title length "
472 current_heading_level = HEADING_LEVELS.index(line[0])
476 def check_keyword_text_alignment(path: str, req: RequirementDirective):
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)
484 if __name__ == "__main__":
485 print("Valid Versions")
486 print("-----------------------")
487 print("\n".join(VALID_VERSIONS))
489 print("Valid Keywords")
490 print("-----------------------")
491 print("\n".join(VALID_KEYWORDS))
493 print("Valid Targets")
494 print("-----------------------")
495 print("\n".join(VALID_TARGETS))
497 print("Valid Validation Modes")
498 print("-----------------------")
499 print("\n".join(VALID_VALIDATION_MODES))
501 print("Check-up Report")
503 repository = RequirementRepository()
504 visitor = RequirementVisitor(
506 MetadataFixer(repository),
507 MetadataValidator(repository),
508 check_keyword_text_alignment,
510 post_processors=[check_section_headers],
512 visitor.process(THIS_DIR / "docs")