summaryrefslogtreecommitdiff
path: root/src/tox/tox_env/python/req_file.py
blob: cd6906468fe143937b83912b7902afc5f303bdd3 (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
import logging
import os
import re
from contextlib import contextmanager
from pathlib import Path
from tempfile import mkstemp
from typing import Iterator, List, Optional

from packaging.requirements import InvalidRequirement, Requirement

LOGGER = logging.getLogger(__name__)


VCS = ["ftp", "ssh", "git", "hg", "bzr", "sftp", "svn"]
VALID_SCHEMAS = ["http", "https", "file"] + VCS


def is_url(name: str) -> bool:
    return get_url_scheme(name) in VALID_SCHEMAS


def get_url_scheme(url: str) -> Optional[str]:
    return None if ":" not in url else url.split(":", 1)[0].lower()


class RequirementsFile:
    """
    Specification is defined within pip itself and documented under:
    - https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format
    - https://github.com/pypa/pip/blob/master/src/pip/_internal/req/constructors.py#L291
    """

    VALID_OPTIONS = {
        "no_arg": [
            "--no-index",
            "--prefer-binary",
            "--require-hashes",
            "--pre",
        ],
        "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",
        ],
    }

    def __init__(self, raw: str, within_tox_ini: bool = True, root: Optional[Path] = None) -> None:
        self._root = Path().cwd() if root is None else root
        if within_tox_ini:  # patch the content coming from tox.ini
            lines: List[str] = []
            for line in raw.splitlines():
                # for tox<4 supporting requirement/constraint files via -rreq.txt/-creq.txt
                if len(line) >= 3 and (line.startswith("-r") or line.startswith("-c")) and not line[2].isspace():
                    line = f"{line[:2]} {line[2:]}"
                # escape spaces
                escape_for = ("-c", "--constraint", "-r", "--requirement", "-f", "--find-links" "-e", "--editable")
                escape_match = next((e for e in escape_for 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
        self._raw = raw

    def __str__(self) -> str:
        return self._raw

    @property
    def root(self) -> Path:
        return self._root

    def validate_and_expand(self) -> List[str]:
        raw = self._normalize_raw()
        result: List[str] = []
        ini_dir = self.root
        for at, line in enumerate(raw.splitlines(), start=1):
            if line.startswith("#"):
                continue
            line = re.sub(r"\s#.*", "", line).strip()
            if not line:
                continue
            if line.startswith("-"):  # handle flags
                words = [i for i in re.split(r"(?<!\\)\s", line) if i]
                first = words[0]
                if first in self.VALID_OPTIONS["no_arg"]:
                    if len(words) != 1:
                        raise ValueError(line)
                    else:
                        result.append(" ".join(words))
                elif first in self.VALID_OPTIONS["one_arg"]:
                    if len(words) != 2:
                        raise ValueError(line)
                    else:
                        if first in ("-r", "--requirement", "-c", "--constraint"):
                            raw_path = line[len(first) + 1 :].strip()
                            unescaped_path = re.sub(r"\\(\s)", r"\1", raw_path)
                            path = Path(unescaped_path)
                            if not path.is_absolute():
                                path = ini_dir / path
                            if not path.exists():
                                raise ValueError(f"requirement file path {str(path)!r} does not exist")
                            req_file = RequirementsFile(path.read_text(), within_tox_ini=False, root=self.root)
                            result.extend(req_file.validate_and_expand())
                        else:
                            result.append(" ".join(words))
                else:
                    raise ValueError(line)
            else:
                try:
                    req = Requirement(line)
                    result.append(str(req))
                except InvalidRequirement as exc:
                    if is_url(line) or any(line.startswith(f"{v}+") and is_url(line[len(v) + 1 :]) for v in VCS):
                        result.append(line)
                    else:
                        path = ini_dir / line
                        try:
                            is_valid_file = path.exists() and path.is_file()
                        except OSError:  # https://bugs.python.org/issue42855 # pragma: no cover
                            is_valid_file = False  # pragma: no cover
                        if not is_valid_file:
                            raise ValueError(f"{at}: {line}") from exc
                        result.append(str(path))
        return result

    def _normalize_raw(self) -> str:
        # a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively
        # ignored
        raw = "".join(self._raw.replace("\r", "").split("\\\n"))
        # Since version 10, pip supports the use of environment variables inside the requirements file.
        # You can now store sensitive data (tokens, keys, etc.) in environment variables and only specify the variable
        # name for your requirements, letting pip lookup the value at runtime.
        # You have to use the POSIX format for variable names including brackets around the uppercase name as shown in
        # this example: ${API_TOKEN}. pip will attempt to find the corresponding environment variable defined on the
        # host system at runtime.
        while True:
            match = re.search(r"\$\{([A-Z_]+)\}", raw)
            if match is None:
                break
            value = os.environ.get(match.groups()[0], "")
            start, end = match.span()
            raw = f"{raw[:start]}{value}{raw[end:]}"
        return raw

    @contextmanager
    def with_file(self) -> Iterator[Path]:
        file_no, path = mkstemp(dir=self.root, prefix="requirements-", suffix=".txt")
        try:
            try:
                with open(path, "wt") as f:
                    f.write(self._raw)
            finally:
                os.close(file_no)
            yield Path(path)
        finally:
            os.unlink(path)