summaryrefslogtreecommitdiffstats
path: root/test/sanity/code-smell/test-constraints.py
blob: df30fe123749f468bfbf97bc149beca5dbe37de2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
from __future__ import annotations

import os
import pathlib
import re
import sys


def main():
    constraints_path = 'test/lib/ansible_test/_data/requirements/constraints.txt'

    requirements = {}

    for path in sys.argv[1:] or sys.stdin.read().splitlines():
        if path == 'test/lib/ansible_test/_data/requirements/ansible.txt':
            # This file is an exact copy of the ansible requirements.txt and should not conflict with other constraints.
            continue

        with open(path, 'r') as path_fd:
            requirements[path] = parse_requirements(path_fd.read().splitlines())

        if path == 'test/lib/ansible_test/_data/requirements/ansible-test.txt':
            # Special handling is required for ansible-test's requirements file.
            check_ansible_test(path, requirements.pop(path))
            continue

    frozen_sanity = {}
    non_sanity_requirements = set()

    for path, requirements in requirements.items():
        filename = os.path.basename(path)

        is_sanity = filename.startswith('sanity.') or filename.endswith('.requirements.txt')
        is_constraints = path == constraints_path

        for lineno, line, requirement in requirements:
            if not requirement:
                print('%s:%d:%d: cannot parse requirement: %s' % (path, lineno, 1, line))
                continue

            name = requirement.group('name').lower()
            raw_constraints = requirement.group('constraints')
            constraints = raw_constraints.strip()
            comment = requirement.group('comment')

            is_pinned = re.search('^ *== *[0-9.]+(\\.post[0-9]+)?$', constraints)

            if is_sanity:
                sanity = frozen_sanity.setdefault(name, [])
                sanity.append((path, lineno, line, requirement))
            elif not is_constraints:
                non_sanity_requirements.add(name)

            if is_sanity:
                if not is_pinned:
                    # sanity test requirements must be pinned
                    print('%s:%d:%d: sanity test requirement (%s%s) must be frozen (use `==`)' % (path, lineno, 1, name, raw_constraints))

                continue

            if constraints and not is_constraints:
                allow_constraints = 'sanity_ok' in comment

                if not allow_constraints:
                    # keeping constraints for tests other than sanity tests in one file helps avoid conflicts
                    print('%s:%d:%d: put the constraint (%s%s) in `%s`' % (path, lineno, 1, name, raw_constraints, constraints_path))

    for name, requirements in frozen_sanity.items():
        if len(set(req[3].group('constraints').strip() for req in requirements)) != 1:
            for req in requirements:
                print('%s:%d:%d: sanity constraint (%s) does not match others for package `%s`' % (
                    req[0], req[1], req[3].start('constraints') + 1, req[3].group('constraints'), name))


def check_ansible_test(path: str, requirements: list[tuple[int, str, re.Match]]) -> None:
    sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent.joinpath('lib')))

    from ansible_test._internal.python_requirements import VIRTUALENV_VERSION
    from ansible_test._internal.coverage_util import COVERAGE_VERSIONS
    from ansible_test._internal.util import version_to_str

    expected_lines = set([
        f"virtualenv == {VIRTUALENV_VERSION} ; python_version < '3'",
    ] + [
        f"coverage == {item.coverage_version} ; python_version >= '{version_to_str(item.min_python)}' and python_version <= '{version_to_str(item.max_python)}'"
        for item in COVERAGE_VERSIONS
    ])

    for idx, requirement in enumerate(requirements):
        lineno, line, match = requirement

        if line in expected_lines:
            expected_lines.remove(line)
            continue

        print('%s:%d:%d: unexpected line: %s' % (path, lineno, 1, line))

    for expected_line in sorted(expected_lines):
        print('%s:%d:%d: missing line: %s' % (path, requirements[-1][0] + 1, 1, expected_line))


def parse_requirements(lines):
    # see https://www.python.org/dev/peps/pep-0508/#names
    pattern = re.compile(r'^(?P<name>[A-Z0-9][A-Z0-9._-]*[A-Z0-9]|[A-Z0-9])(?P<extras> *\[[^]]*])?(?P<constraints>[^;#]*)(?P<markers>[^#]*)(?P<comment>.*)$',
                         re.IGNORECASE)

    matches = [(lineno, line, pattern.search(line)) for lineno, line in enumerate(lines, start=1)]
    requirements = []

    for lineno, line, match in matches:
        if not line.strip():
            continue

        if line.strip().startswith('#'):
            continue

        if line.startswith('git+https://'):
            continue  # hack to ignore git requirements

        requirements.append((lineno, line, match))

    return requirements


if __name__ == '__main__':
    main()