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 / 'zcbor' / '__init__.py' 29p_zcbor_py = p_root / 'zcbor' / 'zcbor.py' 30p_setup_py = p_root / 'setup.py' 31p_add_helptext = p_root / 'add_helptext.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_setup_py, p_test_versions_py, p_test_repo_files_py]) 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') 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 files = (list(p_pet_include.iterdir()) + list(p_pet_src.iterdir()) + [p_pet_cmake]) 105 contents = "".join(p.read_text() for p in files) 106 tmpdir = Path(mkdtemp()) 107 list(os.makedirs(tmpdir / f.relative_to(p_pet_sample).parent, exist_ok=True) for f in files) 108 list(copy2(f, tmpdir / f.relative_to(p_pet_sample)) for f in files) 109 self.popen_test(['cmake', p_pet_sample, "-DREGENERATE_ZCBOR=Y"], cwd=tmpdir) 110 new_contents = "".join(p.read_text() for p in files) 111 list(copy2(tmpdir / f.relative_to(p_pet_sample), f) for f in files) 112 rmtree(tmpdir) 113 self.maxDiff = None 114 self.assertEqual(contents, new_contents) 115 116 def test_pet_file_header(self): 117 files = (list(p_pet_include.iterdir()) + list(p_pet_src.iterdir()) + [p_pet_cmake]) 118 for p in [f for f in files if "pet" in f.name]: 119 with p.open('r') as f: 120 f.readline() # discard 121 self.assertEqual( 122 f.readline().strip(" *#\n"), 123 "Copyright (c) 2022 Nordic Semiconductor ASA") 124 f.readline() # discard 125 self.assertEqual( 126 f.readline().strip(" *#\n"), 127 "SPDX-License-Identifier: Apache-2.0") 128 f.readline() # discard 129 self.assertIn("Generated using zcbor version", f.readline()) 130 self.assertIn("https://github.com/NordicSemiconductor/zcbor", f.readline()) 131 self.assertIn("Generated with a --default-max-qty of", f.readline()) 132 133 134class TestDocs(TestCase): 135 def __init__(self, *args, **kwargs): 136 """Overridden to get base URL for relative links from remote tracking branch.""" 137 super(TestDocs, self).__init__(*args, **kwargs) 138 remote_tr_args = ['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'] 139 remote_tracking = run(remote_tr_args, capture_output=True).stdout.decode('utf-8').strip() 140 141 if remote_tracking: 142 remote, remote_branch = remote_tracking.split('/', 1) # '1' to only split one time. 143 repo_url_args = ['git', 'remote', 'get-url', remote] 144 repo_url = check_output(repo_url_args).decode('utf-8').strip().strip('.git') 145 if 'github.com' in repo_url: 146 self.base_url = (repo_url + '/tree/' + remote_branch + '/') 147 else: 148 # The URL is not in github.com, so we are not sure it is constructed correctly. 149 self.base_url = None 150 elif "GITHUB_SHA" in os.environ and "GITHUB_REPOSITORY" in os.environ: 151 repo = os.environ["GITHUB_REPOSITORY"] 152 sha = os.environ["GITHUB_SHA"] 153 self.base_url = f"https://github.com/{repo}/blob/{sha}/" 154 else: 155 # There is no remote tracking branch. 156 self.base_url = None 157 158 def check_code(self, link, codes): 159 """Check the status code of a URL link. Assert if not 200 (OK).""" 160 try: 161 call = request.urlopen(link) 162 code = call.getcode() 163 except HTTPError as e: 164 code = e.code 165 codes.append((link, code)) 166 167 def do_test_links(self, path): 168 """Get all Markdown links in the file at <path> and check that they work.""" 169 if self.base_url is None: 170 raise SkipTest('This test requires the current branch to be pushed to Github.') 171 172 text = path.read_text() 173 # Use .parent to test relative links (links to repo files): 174 relative_path = str(path.relative_to(p_root).parent) 175 relative_path = "" if relative_path == "." else relative_path + "/" 176 177 matches = findall(r'\[.*?\]\((?P<link>.*?)\)', text) 178 codes = list() 179 threads = list() 180 for m in matches: 181 # Github sometimes need the filename for anchor (#) links to work, so add it: 182 m = m if not m.startswith("#") else path.name + m 183 link = self.base_url + relative_path + m if "http" not in m else m 184 threads.append(t := Thread(target=self.check_code, args=(link, codes), daemon=True)) 185 t.start() 186 for t in threads: 187 t.join() 188 for link, code in codes: 189 self.assertEqual(code, 200, f"'{link}' gives code {code}") 190 191 def test_readme_links(self): 192 self.do_test_links(p_readme) 193 194 def test_architecture(self): 195 self.do_test_links(p_architecture) 196 197 def test_release_notes(self): 198 self.do_test_links(p_release_notes) 199 200 def test_hello_world_readme(self): 201 self.do_test_links(p_hello_world_sample / "README.md") 202 203 def test_pet_readme(self): 204 self.do_test_links(p_pet_sample / "README.md") 205 206 @skipIf(list(map(version_int, python_version_tuple())) < [3, 10, 0], 207 "Skip on Python < 3.10 because of different wording in argparse output.") 208 @skipIf(platform.startswith("win"), "Skip on Windows because of path/newline issues.") 209 def test_cli_doc(self): 210 """Check the auto-generated CLI docs in the top level README.md file.""" 211 add_help = Popen(["python3", p_add_helptext, "--check"]) 212 add_help.communicate() 213 self.assertEqual(0, add_help.returncode) 214 215 216if __name__ == "__main__": 217 main() 218