summaryrefslogtreecommitdiffstats
path: root/python/mach/mach/requirements.py
blob: d5141e23f631e713c3d70bb8f3de9c06c6c35751 (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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import os
from pathlib import Path

from packaging.requirements import Requirement

THUNDERBIRD_PYPI_ERROR = """
Thunderbird requirements definitions cannot include PyPI packages.
""".strip()


class PthSpecifier:
    def __init__(self, path: str):
        self.path = path


class PypiSpecifier:
    def __init__(self, requirement):
        self.requirement = requirement


class PypiOptionalSpecifier(PypiSpecifier):
    def __init__(self, repercussion, requirement):
        super().__init__(requirement)
        self.repercussion = repercussion


class MachEnvRequirements:
    """Requirements associated with a "site dependency manifest", as
    defined in "python/sites/".

    Represents the dependencies of a site. The source files consist
    of colon-delimited fields. The first field
    specifies the action. The remaining fields are arguments to that
    action. The following actions are supported:

    pth -- Adds the path given as argument to "mach.pth" under
        the virtualenv's site packages directory.

    pypi -- Fetch the package, plus dependencies, from PyPI.

    pypi-optional -- Attempt to install the package and dependencies from PyPI.
        Continue using the site, even if the package could not be installed.

    packages.txt -- Denotes that the specified path is a child manifest. It
        will be read and processed as if its contents were concatenated
        into the manifest being read.

    thunderbird-packages.txt -- Denotes a Thunderbird child manifest.
        Thunderbird child manifests are only activated when working on Thunderbird,
        and they can cannot have "pypi" or "pypi-optional" entries.
    """

    def __init__(self):
        self.requirements_paths = []
        self.pth_requirements = []
        self.pypi_requirements = []
        self.pypi_optional_requirements = []
        self.vendored_requirements = []

    def pths_as_absolute(self, topsrcdir: str):
        return [
            os.path.normcase(Path(topsrcdir) / pth.path)
            for pth in (self.pth_requirements + self.vendored_requirements)
        ]

    @classmethod
    def from_requirements_definition(
        cls,
        topsrcdir: str,
        is_thunderbird,
        only_strict_requirements,
        requirements_definition,
    ):
        requirements = cls()
        _parse_mach_env_requirements(
            requirements,
            Path(requirements_definition),
            Path(topsrcdir),
            is_thunderbird,
            only_strict_requirements,
        )
        return requirements


def _parse_mach_env_requirements(
    requirements_output,
    root_requirements_path: Path,
    topsrcdir: Path,
    is_thunderbird,
    only_strict_requirements,
):
    def _parse_requirements_line(
        current_requirements_path: Path, line, line_number, is_thunderbird_packages_txt
    ):
        line = line.strip()
        if not line or line.startswith("#"):
            return

        action, params = line.rstrip().split(":", maxsplit=1)
        if action == "pth":
            path = topsrcdir / params
            if not path.exists():
                # In sparse checkouts, not all paths will be populated.
                return

            requirements_output.pth_requirements.append(PthSpecifier(params))
        elif action == "vendored":
            requirements_output.vendored_requirements.append(PthSpecifier(params))
        elif action == "packages.txt":
            _parse_requirements_definition_file(
                topsrcdir / params,
                is_thunderbird_packages_txt,
            )
        elif action == "pypi":
            if is_thunderbird_packages_txt:
                raise Exception(THUNDERBIRD_PYPI_ERROR)

            requirements_output.pypi_requirements.append(
                PypiSpecifier(
                    _parse_package_specifier(params, only_strict_requirements)
                )
            )
        elif action == "pypi-optional":
            if is_thunderbird_packages_txt:
                raise Exception(THUNDERBIRD_PYPI_ERROR)

            if len(params.split(":", maxsplit=1)) != 2:
                raise Exception(
                    "Expected pypi-optional package to have a repercussion "
                    'description in the format "package:fallback explanation", '
                    'found "{}"'.format(params)
                )
            raw_requirement, repercussion = params.split(":")
            requirements_output.pypi_optional_requirements.append(
                PypiOptionalSpecifier(
                    repercussion,
                    _parse_package_specifier(raw_requirement, only_strict_requirements),
                )
            )
        elif action == "thunderbird-packages.txt":
            if is_thunderbird:
                _parse_requirements_definition_file(
                    topsrcdir / params, is_thunderbird_packages_txt=True
                )
        else:
            raise Exception("Unknown requirements definition action: %s" % action)

    def _parse_requirements_definition_file(
        requirements_path: Path, is_thunderbird_packages_txt
    ):
        """Parse requirements file into list of requirements"""
        if not requirements_path.is_file():
            raise Exception(f'Missing requirements file at "{requirements_path}"')

        requirements_output.requirements_paths.append(str(requirements_path))

        with open(requirements_path, "r") as requirements_file:
            lines = [line for line in requirements_file]

        for number, line in enumerate(lines, start=1):
            _parse_requirements_line(
                requirements_path, line, number, is_thunderbird_packages_txt
            )

    _parse_requirements_definition_file(root_requirements_path, False)


class UnexpectedFlexibleRequirementException(Exception):
    def __init__(self, raw_requirement):
        self.raw_requirement = raw_requirement


def _parse_package_specifier(raw_requirement, only_strict_requirements):
    requirement = Requirement(raw_requirement)

    if only_strict_requirements and [
        s for s in requirement.specifier if s.operator != "=="
    ]:
        raise UnexpectedFlexibleRequirementException(raw_requirement)
    return requirement