summaryrefslogtreecommitdiff
path: root/src/tox/tox_env/python/pip/req_file.py
blob: f45a851ae5436085c186b7e3a23fd4be52d34c76 (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
from __future__ import annotations

import re
from argparse import ArgumentParser, Namespace
from pathlib import Path

from .req.file import ParsedRequirement, ReqFileLines, RequirementsFile


class PythonDeps(RequirementsFile):
    # these options are valid in requirements.txt, but not via pip cli and
    # thus cannot be used in the testenv `deps` list
    _illegal_options = ["hash"]

    def __init__(self, raw: str, root: Path):
        super().__init__(root / "tox.ini", constraint=False)
        self._raw = self._normalize_raw(raw)
        self._unroll: tuple[list[str], list[str]] | None = None
        self._req_parser_: RequirementsFile | None = None

    def _extend_parser(self, parser: ArgumentParser) -> None:
        parser.add_argument("--no-deps", action="store_true", dest="no_deps", default=False)

    def _merge_option_line(self, base_opt: Namespace, opt: Namespace, filename: str) -> None:
        super()._merge_option_line(base_opt, opt, filename)
        if getattr(opt, "no_deps", False):  # if the option comes from a requirements file this flag is missing there
            base_opt.no_deps = True

    def _option_to_args(self, opt: Namespace) -> list[str]:
        result = super()._option_to_args(opt)
        if getattr(opt, "no_deps", False):
            result.append("--no-deps")
        return result

    @property
    def _req_parser(self) -> RequirementsFile:
        if self._req_parser_ is None:
            self._req_parser_ = RequirementsFile(path=self._path, constraint=False)
        return self._req_parser_

    def _get_file_content(self, url: str) -> str:
        if self._is_url_self(url):
            return self._raw
        return super()._get_file_content(url)

    def _is_url_self(self, url: str) -> bool:
        return url == str(self._path)

    def _pre_process(self, content: str) -> ReqFileLines:
        for at, line in super()._pre_process(content):
            if line.startswith("-r") or line.startswith("-c") and line[2].isalpha():
                line = f"{line[0:2]} {line[2:]}"
            yield at, line

    def lines(self) -> list[str]:
        return self._raw.splitlines()

    @staticmethod
    def _normalize_raw(raw: str) -> str:
        # a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively
        # ignored
        raw = "".join(raw.replace("\r", "").split("\\\n"))
        lines: list[str] = []
        for line in raw.splitlines():
            # for tox<4 supporting requirement/constraint files via -rreq.txt/-creq.txt
            arg_match = next(
                (
                    arg
                    for arg in ONE_ARG
                    if line.startswith(arg)
                    and len(line) > len(arg)
                    and not (line[len(arg)].isspace() or line[len(arg)] == "=")
                ),
                None,
            )
            if arg_match is not None:
                line = f"{arg_match} {line[len(arg_match):]}"
            # escape spaces
            escape_match = next((e for e in ONE_ARG_ESCAPE if line.startswith(e) and line[len(e)].isspace()), None)
            if escape_match is not None:
                # escape not already escaped spaces
                escaped = re.sub(r"(?<!\\)(\s)", r"\\\1", line[len(escape_match) + 1 :])
                line = f"{line[:len(escape_match)]} {escaped}"
            lines.append(line)
        adjusted = "\n".join(lines)
        raw = f"{adjusted}\n" if raw.endswith("\\\n") else adjusted  # preserve trailing newline if input has it
        return raw

    def _parse_requirements(self, opt: Namespace, recurse: bool) -> list[ParsedRequirement]:
        # check for any invalid options in the deps list
        # (requirements recursively included from other files are not checked)
        requirements = super()._parse_requirements(opt, recurse)
        for req in requirements:
            if req.from_file != str(self.path):
                continue
            for illegal_option in self._illegal_options:
                if req.options.get(illegal_option):
                    msg = f"Cannot use --{illegal_option} in deps list, it must be in requirements file. ({req})"
                    raise ValueError(msg)
        return requirements

    def unroll(self) -> tuple[list[str], list[str]]:
        if self._unroll is None:
            opts_dict = vars(self.options)
            if not self.requirements and opts_dict:
                raise ValueError("no dependencies")
            result_opts: list[str] = [f"{key}={value}" for key, value in opts_dict.items()]
            result_req = [str(req) for req in self.requirements]
            self._unroll = result_opts, result_req
        return self._unroll

    @classmethod
    def factory(cls, root: Path, raw: object) -> PythonDeps:
        if not isinstance(raw, str):
            raise TypeError(raw)
        return cls(raw, root)


ONE_ARG = {
    "-i",
    "--index-url",
    "--extra-index-url",
    "-e",
    "--editable",
    "-c",
    "--constraint",
    "-r",
    "--requirement",
    "-f",
    "--find-links",
    "--trusted-host",
    "--use-feature",
    "--no-binary",
    "--only-binary",
}
ONE_ARG_ESCAPE = {
    "-c",
    "--constraint",
    "-r",
    "--requirement",
    "-f",
    "--find-links",
    "-e",
    "--editable",
}

__all__ = (
    "PythonDeps",
    "ONE_ARG",
)