1#!/usr/bin/env python3 2# 3# Copyright (c) 2022 Nordic Semiconductor ASA 4# 5# SPDX-License-Identifier: Apache-2.0 6 7from unittest import TestCase, main, skipIf, SkipTest 8from pathlib import Path 9from re import findall, search, S 10from urllib import request 11from urllib.error import HTTPError 12from argparse import ArgumentParser 13from subprocess import Popen, check_output, PIPE, run 14from pycodestyle import StyleGuide 15from shutil import rmtree, copy2 16from platform import python_version_tuple 17from sys import platform 18from threading import Thread 19from tempfile import mkdtemp 20import os 21 22 23p_root = Path(__file__).absolute().parents[2] 24p_tests = p_root / 'tests' 25p_readme = p_root / "README.md" 26p_architecture = p_root / "ARCHITECTURE.md" 27p_release_notes = p_root / "RELEASE_NOTES.md" 28p_init_py = p_root / '__init__.py' 29p_zcbor_py = p_root / 'zcbor' / 'zcbor.py' 30p_add_helptext = p_root / 'scripts' / 'add_helptext.py' 31p_regenerate_samples = p_root / 'scripts' / 'regenerate_samples.py' 32p_test_zcbor_py = p_tests / 'scripts' / 'test_zcbor.py' 33p_test_versions_py = p_tests / 'scripts' / 'test_versions.py' 34p_test_repo_files_py = p_tests / 'scripts' / 'test_repo_files.py' 35p_hello_world_sample = p_root / 'samples' / 'hello_world' 36p_hello_world_build = p_hello_world_sample / 'build' 37p_pet_sample = p_root / 'samples' / 'pet' 38p_pet_cmake = p_pet_sample / 'pet.cmake' 39p_pet_include = p_pet_sample / 'include' 40p_pet_src = p_pet_sample / 'src' 41p_pet_build = p_pet_sample / 'build' 42 43 44class TestCodestyle(TestCase): 45 def do_codestyle(self, files, **kwargs): 46 style = StyleGuide(max_line_length=100, **kwargs) 47 result = style.check_files([str(f) for f in files]) 48 result.print_statistics() 49 self.assertEqual(result.total_errors, 0, 50 f"Found {result.total_errors} style errors") 51 52 def test_codestyle(self): 53 """Run codestyle tests on all Python scripts in the repo.""" 54 self.do_codestyle([p_init_py, p_test_versions_py, p_test_repo_files_py, p_add_helptext]) 55 self.do_codestyle([p_zcbor_py], ignore=['W191', 'E101', 'W503']) 56 self.do_codestyle([p_test_zcbor_py], ignore=['E402', 'E501', 'W503']) 57 58 59def version_int(in_str): 60 return int(search(r'\A\d+', in_str)[0]) # e.g. '0rc' -> '0' 61 62 63class TestSamples(TestCase): 64 def popen_test(self, args, input='', exp_retcode=0, **kwargs): 65 call0 = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, **kwargs) 66 stdout0, stderr0 = call0.communicate(input) 67 self.assertEqual(exp_retcode, call0.returncode, stderr0.decode('utf-8')) 68 return stdout0, stderr0 69 70 def cmake_build_run(self, path, build_path): 71 if build_path.exists(): 72 rmtree(build_path) 73 with open(path / 'README.md', 'r', encoding="utf-8") as f: 74 contents = f.read() 75 76 to_build_patt = r'### To build:.*?```(?P<to_build>.*?)```' 77 to_run_patt = r'### To run:.*?```(?P<to_run>.*?)```' 78 exp_out_patt = r'### Expected output:.*?(?P<exp_out>(\n>[^\n]*)+)' 79 to_build = search(to_build_patt, contents, flags=S)['to_build'].strip() 80 to_run = search(to_run_patt, contents, flags=S)['to_run'].strip() 81 exp_out = search(exp_out_patt, contents, flags=S)['exp_out'].replace("\n> ", "\n").strip() 82 83 os.chdir(path) 84 commands_build = [(line.split(' ')) for line in to_build.split('\n')] 85 assert '\n' not in to_run, "The 'to run' section should only have one command." 86 commands_run = to_run.split(' ') 87 for c in commands_build: 88 self.popen_test(c) 89 output_run = "" 90 for c in commands_run: 91 output, _ = self.popen_test(c) 92 output_run += output.decode('utf-8') 93 self.assertEqual(exp_out, output_run.strip()) 94 95 @skipIf(platform.startswith("win"), "Skip on Windows because requires a Unix shell.") 96 def test_hello_world(self): 97 output = self.cmake_build_run(p_hello_world_sample, p_hello_world_build) 98 99 @skipIf(platform.startswith("win"), "Skip on Windows because requires a Unix shell.") 100 def test_pet(self): 101 output = self.cmake_build_run(p_pet_sample, p_pet_build) 102 103 def test_pet_regenerate(self): 104 """Check the zcbor-generated code for the "pet" sample""" 105 regenerate = Popen(["python3", p_regenerate_samples, "--check"]) 106 regenerate.communicate() 107 self.assertEqual(0, regenerate.returncode) 108 109 def test_pet_file_header(self): 110 files = (list(p_pet_include.iterdir()) + list(p_pet_src.iterdir()) + [p_pet_cmake]) 111 for p in [f for f in files if "pet" in f.name]: 112 with p.open('r', encoding="utf-8") as f: 113 f.readline() # discard 114 self.assertEqual( 115 f.readline().strip(" *#\n"), 116 "Copyright (c) 2022 Nordic Semiconductor ASA") 117 f.readline() # discard 118 self.assertEqual( 119 f.readline().strip(" *#\n"), 120 "SPDX-License-Identifier: Apache-2.0") 121 f.readline() # discard 122 self.assertIn("Generated using zcbor version", f.readline()) 123 self.assertIn("https://github.com/NordicSemiconductor/zcbor", f.readline()) 124 self.assertIn("Generated with a --default-max-qty of", f.readline()) 125 126 127class TestDocs(TestCase): 128 def __init__(self, *args, **kwargs): 129 """Overridden to get base URL for relative links from remote tracking branch.""" 130 super(TestDocs, self).__init__(*args, **kwargs) 131 remote_tr_args = ['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'] 132 remote_tracking = run(remote_tr_args, capture_output=True).stdout.decode('utf-8').strip() 133 134 if remote_tracking: 135 remote, remote_branch = remote_tracking.split('/', 1) # '1' to only split one time. 136 repo_url_args = ['git', 'remote', 'get-url', remote] 137 repo_url = check_output(repo_url_args).decode('utf-8').strip().strip('.git') 138 if 'github.com' in repo_url: 139 self.base_url = (repo_url + '/tree/' + remote_branch + '/') 140 else: 141 # The URL is not in github.com, so we are not sure it is constructed correctly. 142 self.base_url = None 143 elif "GITHUB_SHA" in os.environ and "GITHUB_REPOSITORY" in os.environ: 144 repo = os.environ["GITHUB_REPOSITORY"] 145 sha = os.environ["GITHUB_SHA"] 146 self.base_url = f"https://github.com/{repo}/blob/{sha}/" 147 else: 148 # There is no remote tracking branch. 149 self.base_url = None 150 151 def check_code(self, link, codes): 152 """Check the status code of a URL link. Assert if not 200 (OK).""" 153 try: 154 call = request.urlopen(link) 155 code = call.getcode() 156 except HTTPError as e: 157 code = e.code 158 codes.append((link, code)) 159 160 def do_test_links(self, path): 161 """Get all Markdown links in the file at <path> and check that they work.""" 162 if self.base_url is None: 163 raise SkipTest('This test requires the current branch to be pushed to Github.') 164 165 text = path.read_text(encoding="utf-8") 166 # Use .parent to test relative links (links to repo files): 167 relative_path = str(path.relative_to(p_root).parent) 168 relative_path = "" if relative_path == "." else relative_path + "/" 169 170 matches = findall(r'\[.*?\]\((?P<link>.*?)\)', text) 171 codes = list() 172 threads = list() 173 for m in matches: 174 # Github sometimes need the filename for anchor (#) links to work, so add it: 175 m = m if not m.startswith("#") else path.name + m 176 link = self.base_url + relative_path + m if "http" not in m else m 177 threads.append(t := Thread(target=self.check_code, args=(link, codes), daemon=True)) 178 t.start() 179 for t in threads: 180 t.join() 181 for link, code in codes: 182 self.assertEqual(code, 200, f"'{link}' gives code {code}") 183 184 def test_readme_links(self): 185 self.do_test_links(p_readme) 186 187 def test_architecture(self): 188 self.do_test_links(p_architecture) 189 190 def test_release_notes(self): 191 self.do_test_links(p_release_notes) 192 193 def test_hello_world_readme(self): 194 self.do_test_links(p_hello_world_sample / "README.md") 195 196 def test_pet_readme(self): 197 self.do_test_links(p_pet_sample / "README.md") 198 199 @skipIf(list(map(version_int, python_version_tuple())) < [3, 10, 0], 200 "Skip on Python < 3.10 because of different wording in argparse output.") 201 @skipIf(platform.startswith("win"), "Skip on Windows because of path/newline issues.") 202 def test_cli_doc(self): 203 """Check the auto-generated CLI docs in the top level README.md file.""" 204 add_help = Popen(["python3", p_add_helptext, "--check"]) 205 add_help.communicate() 206 self.assertEqual(0, add_help.returncode) 207 208 209if __name__ == "__main__": 210 main() 211