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