diff options
author | Bernát Gábor <bgabor8@bloomberg.net> | 2021-04-09 09:03:32 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-09 09:03:32 +0100 |
commit | a9c8e723751d81655cf1baa051ae44d4f4b49010 (patch) | |
tree | b07d8bdc7c521018665195c7caa676d2a8b8759a | |
parent | 1a50180682fc861d9c6cad0f73adf77e42523387 (diff) | |
download | tox-git-a9c8e723751d81655cf1baa051ae44d4f4b49010.tar.gz |
Port pip requirements.txt parser (#2009)
-rw-r--r-- | .pre-commit-config.yaml | 12 | ||||
-rw-r--r-- | docs/changelog/1929.bugfix.rst | 2 | ||||
-rw-r--r-- | setup.cfg | 1 | ||||
-rw-r--r-- | src/tox/config/loader/stringify.py | 2 | ||||
-rw-r--r-- | src/tox/tox_env/package.py | 3 | ||||
-rw-r--r-- | src/tox/tox_env/python/pip/pip_install.py | 102 | ||||
-rw-r--r-- | src/tox/tox_env/python/pip/req/__init__.py | 5 | ||||
-rw-r--r-- | src/tox/tox_env/python/pip/req/args.py | 88 | ||||
-rw-r--r-- | src/tox/tox_env/python/pip/req/file.py | 407 | ||||
-rw-r--r-- | src/tox/tox_env/python/pip/req/util.py | 27 | ||||
-rw-r--r-- | src/tox/tox_env/python/pip/req_file.py | 415 | ||||
-rw-r--r-- | tests/tox_env/python/pip/req/test_file.py | 395 | ||||
-rw-r--r-- | tests/tox_env/python/pip/test_pip_install.py | 41 | ||||
-rw-r--r-- | tests/tox_env/python/pip/test_req_file.py | 389 | ||||
-rw-r--r-- | tests/tox_env/python/test_python_runner.py | 5 | ||||
-rw-r--r-- | tests/tox_env/python/virtual_env/test_setuptools.py | 1 | ||||
-rw-r--r-- | tox.ini | 14 | ||||
-rw-r--r-- | whitelist.txt | 31 |
18 files changed, 1101 insertions, 839 deletions
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 60d94ab7..5c2aafa1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: rev: "0.5.0" hooks: - id: tox-ini-fmt - args: [ "-p", "fix,flake8" ] + args: [ "-p", "fix" ] - repo: https://github.com/asottile/blacken-docs rev: v1.10.0 hooks: @@ -59,3 +59,13 @@ repos: entry: "changelog files must be named ####.(feature|bugfix|doc|removal|misc).rst" exclude: ^docs/changelog/(\d+\.(feature|bugfix|doc|removal|misc).rst|README.rst|template.jinja2) files: ^docs/changelog/ + - repo: https://github.com/PyCQA/flake8 + rev: 3.9.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear==21.4.3 + - flake8-comprehensions==3.4 + - flake8-pytest-style==1.4.1 + - flake8-spellcheck==0.24 + - flake8-unused-arguments==0.0.6 diff --git a/docs/changelog/1929.bugfix.rst b/docs/changelog/1929.bugfix.rst new file mode 100644 index 00000000..f4cfdf0f --- /dev/null +++ b/docs/changelog/1929.bugfix.rst @@ -0,0 +1,2 @@ +Port pip requirements file parser to ``tox`` to achieve full equivalency (such as support for the per requirement +``--install-option`` and ``--global-option`` flags) - by :user:`gaborbernat`. @@ -38,6 +38,7 @@ packages = find: install_requires = appdirs>=1.4.3 cachetools + chardet>=4 colorama>=0.4.3 packaging>=20.3 pluggy>=0.13.1 diff --git a/src/tox/config/loader/stringify.py b/src/tox/config/loader/stringify.py index b1816c41..c635ec38 100644 --- a/src/tox/config/loader/stringify.py +++ b/src/tox/config/loader/stringify.py @@ -29,7 +29,7 @@ def stringify(value: Any) -> Tuple[str, bool]: env_var_keys = sorted(value) return stringify({k: value.load(k) for k in env_var_keys}) if isinstance(value, PythonDeps): - return stringify([next(iter(v.keys())) if isinstance(v, dict) else v for v in value.validate_and_expand()]) + return stringify(value.lines()) return str(value), False diff --git a/src/tox/tox_env/package.py b/src/tox/tox_env/package.py index daf7dbb8..e2fbbeb2 100644 --- a/src/tox/tox_env/package.py +++ b/src/tox/tox_env/package.py @@ -25,6 +25,9 @@ class PathPackage(Package): super().__init__() self.path = path + def __str__(self) -> str: + return str(self.path) + class PackageToxEnv(ToxEnv, ABC): def __init__( diff --git a/src/tox/tox_env/python/pip/pip_install.py b/src/tox/tox_env/python/pip/pip_install.py index b7f30274..207f54f5 100644 --- a/src/tox/tox_env/python/pip/pip_install.py +++ b/src/tox/tox_env/python/pip/pip_install.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from typing import Any, Dict, List, Optional, Sequence, Set, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Union from packaging.requirements import Requirement @@ -8,20 +8,13 @@ from tox.config.cli.parser import DEFAULT_VERBOSITY from tox.config.main import Config from tox.config.types import Command from tox.execute.request import StdinSource +from tox.report import HandledError from tox.tox_env.errors import Recreate from tox.tox_env.installer import Installer from tox.tox_env.package import PathPackage from tox.tox_env.python.api import Python from tox.tox_env.python.package import DevLegacyPackage, SdistPackage, WheelPackage -from tox.tox_env.python.pip.req_file import ( - ConstraintFile, - EditablePathReq, - Flags, - PathReq, - PythonDeps, - RequirementsFile, - UrlReq, -) +from tox.tox_env.python.pip.req_file import PythonDeps class Pip(Installer[Python]): @@ -89,64 +82,37 @@ class Pip(Installer[Python]): raise SystemExit(1) def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type: str) -> None: - result = arguments.validate_and_expand() - new_set = arguments.unroll() - # content we can have here in a nested fashion - # the entire universe does not resolve anymore, therefore we only cache the first level - # root level -> Union[Flags, Requirement, PathReq, EditablePathReq, UrlReq, ConstraintFile, RequirementsFile] - # if the constraint file changes recreate - with self._env.cache.compare(new_set, section, of_type) as (eq, old): - if not eq: # pick all options and constraint files, do not pick other equal requirements - new_deps: List[str] = [] - found: Set[int] = set() - has_dep = False - for entry, as_cache in zip(result, new_set): - entry_as_str = str(entry) - found_pos = None - for at_pos, value in enumerate(old or []): - if (next(iter(value)) if isinstance(value, dict) else value) == entry_as_str: - found_pos = at_pos - break - if found_pos is not None: - found.add(found_pos) - if isinstance(entry, Flags): - if found_pos is None and old is not None: - raise Recreate(f"new flag {entry}") - new_deps.extend(entry.as_args()) - elif isinstance(entry, Requirement): - if found_pos is None: - has_dep = True - new_deps.append(str(entry)) - elif isinstance(entry, (PathReq, EditablePathReq, UrlReq)): - if found_pos is None: - has_dep = True - new_deps.extend(entry.as_args()) - elif isinstance(entry, ConstraintFile): - if found_pos is None and old is not None: - raise Recreate(f"new constraint file {entry}") - if old is not None and old[found_pos] != as_cache: - raise Recreate(f"constraint file {entry.rel_path} changed") - new_deps.extend(entry.as_args()) - elif isinstance(entry, RequirementsFile): - if found_pos is None: - has_dep = True - new_deps.extend(entry.as_args()) - elif old is not None and old[found_pos] != as_cache: - raise Recreate(f"requirements file {entry.rel_path} changed") - else: - # can only happen when we introduce new content and we don't handle it in any of the branches - logging.warning(f"pip cannot install {entry!r}") # pragma: no cover - raise SystemExit(1) # pragma: no cover - if len(found) != len(old or []): - missing = " ".join( - (next(iter(o)) if isinstance(o, dict) else o) for i, o in enumerate(old or []) if i not in found - ) - raise Recreate(f"dependencies removed: {missing}") - if new_deps: - if not has_dep: - logging.warning(f"no dependencies for tox env {self._env.name} within {of_type}") - raise SystemExit(1) - self._execute_installer(new_deps, of_type) + try: + new_options, new_reqs = arguments.unroll() + except ValueError as exception: + raise HandledError(f"{exception} for tox env py within deps") + new_requirements: List[str] = [] + new_constraints: List[str] = [] + for req in new_reqs: + (new_constraints if req.startswith("-c ") else new_requirements).append(req) + new = {"options": new_options, "requirements": new_requirements, "constraints": new_constraints} + # if option or constraint change in any way recreate, if the requirements change only if some are removed + with self._env.cache.compare(new, section, of_type) as (eq, old): + if not eq: + if old is not None: + self._recreate_if_diff("install flag(s)", new_options, old["options"], lambda i: i) + self._recreate_if_diff("constraint(s)", new_constraints, old["constraints"], lambda i: i[3:]) + missing_requirement = set(old["requirements"]) - set(new_requirements) + if missing_requirement: + raise Recreate(f"requirements removed: {' '.join(missing_requirement)}") + args = arguments.as_args() + if args: + self._execute_installer(args, of_type) + + @staticmethod + def _recreate_if_diff(of_type: str, new_opts: List[str], old_opts: List[str], fmt: Callable[[str], str]) -> None: + if old_opts == new_opts: + return + removed_opts = set(old_opts) - set(new_opts) + removed = f" removed {', '.join(sorted(fmt(i) for i in removed_opts))}" if removed_opts else "" + added_opts = set(new_opts) - set(old_opts) + added = f" added {', '.join(sorted(fmt(i) for i in added_opts))}" if added_opts else "" + raise Recreate(f"changed {of_type}{removed}{added}") def _install_list_of_deps( self, diff --git a/src/tox/tox_env/python/pip/req/__init__.py b/src/tox/tox_env/python/pip/req/__init__.py new file mode 100644 index 00000000..88f9084d --- /dev/null +++ b/src/tox/tox_env/python/pip/req/__init__.py @@ -0,0 +1,5 @@ +""" +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 +""" diff --git a/src/tox/tox_env/python/pip/req/args.py b/src/tox/tox_env/python/pip/req/args.py new file mode 100644 index 00000000..808197e9 --- /dev/null +++ b/src/tox/tox_env/python/pip/req/args.py @@ -0,0 +1,88 @@ +import bisect +import re +from argparse import Action, ArgumentParser, ArgumentTypeError, Namespace +from typing import IO, Any, NoReturn, Optional, Sequence, Union + + +class _OurArgumentParser(ArgumentParser): + def print_usage(self, file: Optional[IO[str]] = None) -> None: # noqa: U100 + """""" + + def exit(self, status: int = 0, message: Optional[str] = None) -> NoReturn: # noqa: U100 + message = "" if message is None else message + msg = message.lstrip(": ").rstrip() + if msg.startswith("error: "): + msg = msg[len("error: ") :] + raise ValueError(msg) + + +def build_parser(cli_only: bool) -> ArgumentParser: + parser = _OurArgumentParser(add_help=False, prog="") + _global_options(parser) + _req_options(parser, cli_only) + return parser + + +def _global_options(parser: ArgumentParser) -> None: + parser.add_argument("-i", "--index-url", "--pypi-url", dest="index_url", default=None) + parser.add_argument("--extra-index-url", action=AddUniqueAction) + parser.add_argument("--no-index", action="store_true", default=False) + parser.add_argument("-c", "--constraint", action=AddUniqueAction, dest="constraints") + parser.add_argument("-r", "--requirement", action=AddUniqueAction, dest="requirements") + parser.add_argument("-e", "--editable", action=AddUniqueAction, dest="editables") + parser.add_argument("-f", "--find-links", action=AddUniqueAction) + parser.add_argument("--no-binary", choices=[":all:", ":none:"]) # TODO: colon separated package names + parser.add_argument("--only-binary", choices=[":all:", ":none:"]) # TODO: colon separated package names + parser.add_argument("--prefer-binary", action="store_true", default=False) + parser.add_argument("--require-hashes", action="store_true", default=False) + parser.add_argument("--pre", action="store_true", default=False) + parser.add_argument("--trusted-host", action=AddSortedUniqueAction) + parser.add_argument( + "--use-feature", choices=["2020-resolver", "fast-deps"], action=AddSortedUniqueAction, dest="features_enabled" + ) + + +def _req_options(parser: ArgumentParser, cli_only: bool) -> None: + parser.add_argument("--install-option", action=AddSortedUniqueAction) + parser.add_argument("--global-option", action=AddSortedUniqueAction) + if not cli_only: + parser.add_argument("--hash", action=AddSortedUniqueAction, type=_validate_hash) + + +_HASH = re.compile(r"sha(256:[a-z0-9]{64}|384:[a-z0-9]{96}|521:[a-z0-9]{128})") + + +def _validate_hash(value: str) -> str: + if not _HASH.fullmatch(value): + raise ArgumentTypeError(value) + return value + + +class AddSortedUniqueAction(Action): + def __call__( + self, + parser: ArgumentParser, # noqa + namespace: Namespace, + values: Union[str, Sequence[Any], None], + option_string: Optional[str] = None, # noqa: U100 + ) -> None: + if getattr(namespace, self.dest, None) is None: + setattr(namespace, self.dest, []) + current = getattr(namespace, self.dest) + if values not in current: + bisect.insort(current, values) + + +class AddUniqueAction(Action): + def __call__( + self, + parser: ArgumentParser, # noqa + namespace: Namespace, + values: Union[str, Sequence[Any], None], + option_string: Optional[str] = None, # noqa: U100 + ) -> None: + if getattr(namespace, self.dest, None) is None: + setattr(namespace, self.dest, []) + current = getattr(namespace, self.dest) + if values not in current: + current.append(values) diff --git a/src/tox/tox_env/python/pip/req/file.py b/src/tox/tox_env/python/pip/req/file.py new file mode 100644 index 00000000..6c8e9c79 --- /dev/null +++ b/src/tox/tox_env/python/pip/req/file.py @@ -0,0 +1,407 @@ +"""Adapted from the pip code base""" + +import os +import re +import shlex +import sys +import urllib.parse +from argparse import ArgumentParser, Namespace +from pathlib import Path +from typing import IO, Any, Dict, Iterator, List, Optional, Tuple, Union +from urllib.request import urlopen + +import chardet +from packaging.requirements import InvalidRequirement, Requirement + +from .args import build_parser +from .util import VCS, get_url_scheme, is_url, url_to_path + +# Matches environment variable-style values in '${MY_VARIABLE_1}' with the variable name consisting of only uppercase +# letters, digits or the '_' (underscore). This follows the POSIX standard defined in IEEE Std 1003.1, 2013 Edition. +_ENV_VAR_RE = re.compile(r"(?P<var>\${(?P<name>[A-Z0-9_]+)})") +_SCHEME_RE = re.compile(r"^(http|https|file):", re.I) +_COMMENT_RE = re.compile(r"(^|\s+)#.*$") +# https://www.python.org/dev/peps/pep-0508/#extras +_EXTRA_PATH = re.compile(r"(.*)\[([-._,\sa-zA-Z0-9]*)]") +_EXTRA_ELEMENT = re.compile(r"[a-zA-Z0-9]*[-._a-zA-Z0-9]") +ReqFileLines = Iterator[Tuple[int, str]] + + +class ParsedRequirement: + def __init__(self, req: str, options: Dict[str, Any], from_file: str, lineno: int) -> None: + req = req.encode("utf-8").decode("utf-8") + try: + self._requirement: Union[Requirement, Path, str] = Requirement(req) + except InvalidRequirement: + if is_url(req) or any(req.startswith(f"{v}+") and is_url(req[len(v) + 1 :]) for v in VCS): + self._requirement = req + else: + root = Path(from_file).parent + extras: List[str] = [] + match = _EXTRA_PATH.fullmatch(Path(req).name) + if match: + for extra in match.group(2).split(","): + extra = extra.strip() + if not extra: + continue + if not _EXTRA_ELEMENT.fullmatch(extra): + extras = [] + path = root / req + break + extras.append(extra) + else: + path = root / Path(req).parent / match.group(1) + else: + path = root / req + extra_part = f"[{','.join(sorted(extras))}]" if extras else "" + rel_path = path.resolve().relative_to(root) + self._requirement = f"{rel_path}{extra_part}" + self._options = options + self._from_file = from_file + self._lineno = lineno + + @property + def requirement(self) -> Union[Requirement, Path, str]: + return self._requirement + + @property + def from_file(self) -> str: + return self._from_file + + @property + def lineno(self) -> int: + return self._lineno + + @property + def options(self) -> Dict[str, Any]: + return self._options + + def __repr__(self) -> str: + base = f"{self.__class__.__name__}(requirement={self._requirement}, " + if self._options: + base += f"options={self._options!r}, " + return f"{base.rstrip(', ')})" + + def __str__(self) -> str: + result = [] + if self.options.get("is_constraint"): + result.append("-c") + if self.options.get("is_editable"): + result.append("-e") + result.append(str(self.requirement)) + for hash_value in self.options.get("hash", []): + result.extend(("--hash", hash_value)) + return " ".join(result) + + +class _ParsedLine: + def __init__(self, filename: str, lineno: int, args: str, opts: Namespace, constraint: bool) -> None: + self.filename = filename + self.lineno = lineno + self.opts = opts + self.constraint = constraint + if args: + self.is_requirement = True + self.is_editable = False + self.requirement = args + elif opts.editables: + self.is_requirement = True + self.is_editable = True + # We don't support multiple -e on one line + self.requirement = opts.editables[0] + else: + self.is_requirement = False + + +class RequirementsFile: + def __init__(self, path: Path, constraint: bool) -> None: + self._path = path + self._is_constraint: bool = constraint + self._opt = Namespace() + self._result: List[ParsedRequirement] = [] + self._loaded = False + self._parser_private: Optional[ArgumentParser] = None + + def __str__(self) -> str: + return f"{'-c' if self.is_constraint else '-r'} {self.path}" + + @property + def path(self) -> Path: + return self._path + + @property + def is_constraint(self) -> bool: + return self._is_constraint + + @property + def options(self) -> Namespace: + self._parse_requirements() + return self._opt + + @property + def requirements(self) -> List[ParsedRequirement]: + self._parse_requirements() + return self._result + + @property + def _parser(self) -> ArgumentParser: + if self._parser_private is None: + self._parser_private = build_parser(False) + return self._parser_private + + def _parse_requirements(self) -> None: + if self._loaded: + return + self._result, found = [], set() + for parsed_line in self._parse_and_recurse(str(self._path), self.is_constraint): + parsed_req = self._handle_line(parsed_line) + if parsed_req is not None: + key = str(parsed_req) + if key not in found: + found.add(key) + self._result.append(parsed_req) + + def key_func(line: ParsedRequirement) -> Tuple[int, Tuple[int, str, str]]: + of_type = {Requirement: 0, Path: 1, str: 2}[type(line.requirement)] + between = of_type, str(line.requirement).lower(), str(line.options) + if "is_constraint" in line.options: + return 2, between + if "is_editable" in line.options: + return 1, between + return 0, between + + self._result.sort(key=key_func) + self._loaded = True + + def _parse_and_recurse(self, filename: str, constraint: bool) -> Iterator[_ParsedLine]: + for line in self._parse_file(filename, constraint): + if not line.is_requirement and (line.opts.requirements or line.opts.constraints): + if line.opts.requirements: # parse a nested requirements file + nested_constraint, req_path = False, line.opts.requirements[0] + else: + nested_constraint, req_path = True, line.opts.constraints[0] + if _SCHEME_RE.search(filename): # original file is over http + req_path = urllib.parse.urljoin(filename, req_path) # do a url join so relative paths work + elif not _SCHEME_RE.search(req_path): # original file and nested file are paths + # do a join so relative paths work + req_path = os.path.join(os.path.dirname(filename), req_path) + yield from self._parse_and_recurse(req_path, nested_constraint) + else: + yield line + + def _parse_file(self, url: str, constraint: bool) -> Iterator[_ParsedLine]: + content = self._get_file_content(url) + for line_number, line in self._pre_process(content): + args_str, opts = self._parse_line(line) + yield _ParsedLine(url, line_number, args_str, opts, constraint) + + def _get_file_content(self, url: str) -> str: + """ + Gets the content of a file; it may be a filename, file: URL, or http: URL. Returns (location, content). + Content is unicode. Respects # -*- coding: declarations on the retrieved files. + + :param url: File path or url. + """ + scheme = get_url_scheme(url) + if scheme in ["http", "https"]: + with urlopen(url) as response: + text = self._read_decode(response) + return text + elif scheme == "file": + url = url_to_path(url) + try: + with open(url, "rb") as file_handler: + text = self._read_decode(file_handler) + except OSError as exc: + raise ValueError(f"Could not open requirements file: {exc}") + return text + + @staticmethod + def _read_decode(file_handler: IO[bytes]) -> str: + raw = file_handler.read() + if not raw: + return "" + codec = chardet.detect(raw)["encoding"] + text = raw.decode(codec) + return text + + def _pre_process(self, content: str) -> ReqFileLines: + """Split, filter, and join lines, and return a line iterator + + :param content: the content of the requirements file + """ + lines_enum: ReqFileLines = enumerate(content.splitlines(), start=1) # noqa + lines_enum = self._join_lines(lines_enum) + lines_enum = self._ignore_comments(lines_enum) + lines_enum = self._expand_env_variables(lines_enum) + return lines_enum + + def _parse_line(self, line: str) -> Tuple[str, Namespace]: + args_str, options_str = self._break_args_options(line) + args = shlex.split(options_str, posix=sys.platform != "win32") + opts = self._parser.parse_args(args) + return args_str, opts + + def _handle_line(self, line: _ParsedLine) -> Optional[ParsedRequirement]: + """ + Handle a single parsed requirements line; This can result in creating/yielding requirements or updating options. + + :param line: The parsed line to be processed. + + Returns a ParsedRequirement object if the line is a requirement line, otherwise returns None. + + For lines that contain requirements, the only options that have an effect are from SUPPORTED_OPTIONS_REQ, and + they are scoped to the requirement. Other options from SUPPORTED_OPTIONS may be present, but are ignored. + + For lines that do not contain requirements, the only options that have an effect are from SUPPORTED_OPTIONS. + Options from SUPPORTED_OPTIONS_REQ may be present, but are ignored. These lines may contain multiple options + (although our docs imply only one is supported), and all our parsed and affect the finder. + """ + if line.is_requirement: + parsed_req = self._handle_requirement_line(line) + return parsed_req + else: + self._handle_option_line(line.opts, line.filename) + return None + + @staticmethod + def _handle_requirement_line(line: _ParsedLine) -> ParsedRequirement: + # For editable requirements, we don't support per-requirement options, so just return the parsed requirement. + # get the options that apply to requirements + req_options = {} + if line.is_editable: + req_options["is_editable"] = line.is_editable + if line.constraint: + req_options["is_constraint"] = line.constraint + hash_values = getattr(line.opts, "hash", []) + if hash_values: + req_options["hash"] = hash_values + return ParsedRequirement(line.requirement, req_options, line.filename, line.lineno) + + def _handle_option_line(self, opts: Namespace, filename: str) -> None: # noqa: C901 + # percolate options upward + if opts.require_hashes: + self._opt.require_hashes = True + if opts.features_enabled: + if not hasattr(self._opt, "features_enabled"): + self._opt.features_enabled = [] + for feature in opts.features_enabled: + if feature not in self._opt.features_enabled: + self._opt.features_enabled.append(feature) + self._opt.features_enabled.sort() + if opts.index_url: + if not hasattr(self._opt, "index_url"): + self._opt.index_url = [] + self._opt.index_url = [opts.index_url] + if opts.no_index is True: + self._opt.index_url = [] + if opts.extra_index_url: + if not hasattr(self._opt, "index_url"): + self._opt.index_url = [] + for url in opts.extra_index_url: + if url not in self._opt.index_url: + self._opt.index_url.extend(opts.extra_index_url) + if opts.find_links: + # FIXME: it would be nice to keep track of the source of the find_links: support a find-links local path + # relative to a requirements file. + if not hasattr(self._opt, "index_url"): # pragma: no branch + self._opt.find_links = [] + value = opts.find_links[0] + req_dir = os.path.dirname(os.path.abspath(filename)) + relative_to_reqs_file = os.path.join(req_dir, value) + if os.path.exists(relative_to_reqs_file): + value = relative_to_reqs_file # pragma: no cover + if value not in self._opt.find_links: # pragma: no branch + self._opt.find_links.append(value) + if opts.pre: + self._opt.pre = True + if opts.prefer_binary: + self._opt.prefer_binary = True + for host in opts.trusted_host or []: + if not hasattr(self._opt, "trusted_hosts"): + self._opt.trusted_hosts = [] + if host not in self._opt.trusted_hosts: + self._opt.trusted_hosts.append(host) + + @staticmethod + def _break_args_options(line: str) -> Tuple[str, str]: + """ + Break up the line into an args and options string. We only want to shlex (and then optparse) the options, not + the args. args can contain markers which are corrupted by shlex. + """ + tokens = line.split(" ") + args = [] + options = tokens[:] + for token in tokens: + if token.startswith("-") or token.startswith("--"): + break + else: + args.append(token) + options.pop(0) + return " ".join(args), " ".join(options) + + @staticmethod + def _join_lines(lines_enum: ReqFileLines) -> ReqFileLines: + """ + Joins a line ending in '\' with the previous line (except when following comments). The joined line takes on the + index of the first line. + """ + primary_line_number = None + new_line: List[str] = [] + for line_number, line in lines_enum: + if not line.endswith("\\") or _COMMENT_RE.match(line): + if _COMMENT_RE.match(line): + line = f" {line}" # this ensures comments are always matched later + if new_line: + new_line.append(line) + assert primary_line_number is not None + yield primary_line_number, "".join(new_line) + new_line = [] + else: + yield line_number, line + else: + if not new_line: # pragma: no branch + primary_line_number = line_number + new_line.append(line.strip("\\")) + # last line contains \ + if new_line: + assert primary_line_number is not None + yield primary_line_number, "".join(new_line) + + @staticmethod + def _ignore_comments(lines_enum: ReqFileLines) -> ReqFileLines: + """Strips comments and filter empty lines.""" + for line_number, line in lines_enum: + line = _COMMENT_RE.sub("", line) + line = line.strip() + if line: + yield line_number, line + + @staticmethod + def _expand_env_variables(lines_enum: ReqFileLines) -> ReqFileLines: + """Replace all environment variables that can be retrieved via `os.getenv`. + + The only allowed format for environment variables defined in the requirement file is `${MY_VARIABLE_1}` to + ensure two things: + + 1. Strings that contain a `$` aren't accidentally (partially) expanded. + 2. Ensure consistency across platforms for requirement files. + + These points are the result of a discussion on the `github pull request #3514 + <https://github.com/pypa/pip/pull/3514>`_. Valid characters in variable names follow the `POSIX standard + <http://pubs.opengroup.org/onlinepubs/9699919799/>`_ and are limited to uppercase letter, digits and the `_`. + """ + for line_number, line in lines_enum: + for env_var, var_name in _ENV_VAR_RE.findall(line): + value = os.getenv(var_name) + if not value: + continue + line = line.replace(env_var, value) + yield line_number, line + + +__all__ = ( + "RequirementsFile", + "ReqFileLines", + "ParsedRequirement", +) diff --git a/src/tox/tox_env/python/pip/req/util.py b/src/tox/tox_env/python/pip/req/util.py new file mode 100644 index 00000000..ec98bf76 --- /dev/null +++ b/src/tox/tox_env/python/pip/req/util.py @@ -0,0 +1,27 @@ +"""Borrowed from the pip code base""" +from typing import Optional +from urllib.parse import urlsplit +from urllib.request import url2pathname + +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]: + if ":" not in url: + return None + return url.split(":", 1)[0].lower() + + +def url_to_path(url: str) -> str: + _, netloc, path, _, _ = urlsplit(url) + if not netloc or netloc == "localhost": # According to RFC 8089, same as empty authority. + netloc = "" + else: + raise ValueError(f"non-local file URIs are not supported on this platform: {url!r}") + path = url2pathname(netloc + path) + return path diff --git a/src/tox/tox_env/python/pip/req_file.py b/src/tox/tox_env/python/pip/req_file.py index cd916e75..3c3e94d9 100644 --- a/src/tox/tox_env/python/pip/req_file.py +++ b/src/tox/tox_env/python/pip/req_file.py @@ -1,183 +1,51 @@ -import logging -import os import re -from abc import ABC, abstractmethod +import shlex +import sys +from argparse import ArgumentParser from pathlib import Path -from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union +from typing import List, Optional, Tuple -from packaging.requirements import InvalidRequirement, Requirement +from .req.args import build_parser +from .req.file import ReqFileLines, RequirementsFile -LOGGER = logging.getLogger(__name__) +class PythonDeps(RequirementsFile): + def __init__(self, raw: str, root: Path): + super().__init__(root / "tox.ini", constraint=False) + self._raw = self._normalize_raw(raw) + self._unroll: Optional[Tuple[List[str], List[str]]] = None -VCS = ["ftp", "ssh", "git", "hg", "bzr", "sftp", "svn"] -VALID_SCHEMAS = ["http", "https", "file"] + VCS + def _get_file_content(self, url: str) -> str: + if url == str(self._path): + return self._raw + return super()._get_file_content(url) + 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 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.partition(":")[0].lower() - - -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", -} -ONE_ARG_ESCAPE = { - "-c", - "--constraint", - "-r", - "--requirement", - "-f", - "--find-links", - "-e", - "--editable", -} - - -class PipRequirementEntry(ABC): - @abstractmethod - def as_args(self) -> Iterable[str]: - raise NotImplementedError - - @abstractmethod - def __eq__(self, other: Any) -> bool: # noqa: U100 - raise NotImplementedError - - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - - @abstractmethod - def __str__(self) -> str: - raise NotImplementedError - - -class Flags(PipRequirementEntry): - def __init__(self, *args: str) -> None: - self.args: Iterable[str] = args - - def as_args(self) -> Iterable[str]: - return self.args - - def __eq__(self, other: Any) -> bool: - return isinstance(other, Flags) and self.args == other.args - - def __str__(self) -> str: - return " ".join(self.args) - - -class RequirementWithFlags(Requirement, Flags): - def __init__(self, requirement_string: str, args: Sequence[str]) -> None: - Requirement.__init__(self, requirement_string) - Flags.__init__(self, *args) - - def as_args(self) -> Iterable[str]: - return (Requirement.__str__(self), *self.args) - - def __eq__(self, other: Any) -> bool: - return ( - isinstance(other, RequirementWithFlags) - and self.args == other.args - and Requirement.__str__(self) == Requirement.__str__(other) - ) - - def __str__(self) -> str: - return " ".join((Requirement.__str__(self), *self.args)) - - -class PathReq(PipRequirementEntry): - def __init__(self, path: Path, extras: List[str]) -> None: - self.path = path - self.extras = extras - - def as_args(self) -> Iterable[str]: - return (str(self),) - - def __eq__(self, other: Any) -> bool: - return isinstance(other, self.__class__) and self.path == other.path and self.extras == other.extras - - def __str__(self) -> str: - extra_group = f"[{','.join(self.extras)}]" if self.extras else "" - return f"{self.path}{extra_group}" - - -class EditablePathReq(PathReq): - def as_args(self) -> Iterable[str]: - return ("-e", super().__str__()) - - def __str__(self) -> str: - return f"-e {super().__str__()}" - - -class UrlReq(PipRequirementEntry): - def __init__(self, url: str) -> None: - self.url = url - - def as_args(self) -> Iterable[str]: - return (self.url,) - - def __eq__(self, other: Any) -> bool: - return isinstance(other, UrlReq) and self.url == other.url - - def __str__(self) -> str: - return self.url - - -# https://www.python.org/dev/peps/pep-0508/#extras -_EXTRA_PATH = re.compile(r"(.*)\[([-._,\sa-zA-Z0-9]*)]") -_EXTRA_ELEMENT = re.compile(r"[a-zA-Z0-9]*[-._a-zA-Z0-9]") - - -class PythonDeps: - """A sub-set form of the requirements files (support tox 3 syntax, and --hash is not valid on CLI)""" + @property + def _parser(self) -> ArgumentParser: + if self._parser_private is None: + self._parser_private = build_parser(cli_only=True) # e.g. no --hash for cli only + return self._parser_private - def __init__(self, raw: str, root: Optional[Path] = None): - self._root = Path().cwd() if root is None else root.resolve() - self._raw = raw - self._result: Optional[List[Any]] = None + def as_args(self) -> List[str]: + result = [] + for line in self.lines(): + result.extend(shlex.split(line, posix=sys.platform != "win32")) + return result - def validate_and_expand(self) -> List[Any]: - if self._result is None: - raw = self._normalize_raw() - result: List[Any] = [] - ini_dir = self.root - for at, line in enumerate(raw.splitlines(), start=1): - line = re.sub(r"(?<!\\)\s#.*", "", line).strip() - if not line or line.startswith("#"): - continue - if line.startswith("-"): - self._expand_flag(ini_dir, line, result) - else: - self._expand_non_flag(at, ini_dir, line, result) - self._result = result - return self._result + def lines(self) -> List[str]: + return self._raw.splitlines() - def _normalize_raw(self) -> str: + @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(self._raw.replace("\r", "").split("\\\n")) + 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 @@ -204,187 +72,46 @@ class PythonDeps: raw = f"{adjusted}\n" if raw.endswith("\\\n") else adjusted # preserve trailing newline if input has it return raw - def __str__(self) -> str: - return self._raw - - @property - def root(self) -> Path: - return self._root - - def _expand_non_flag(self, at: int, ini_dir: Path, line: str, result: List[Any]) -> None: # noqa - requirement, extra = self._load_requirement_with_extra(line) - try: - if not extra: - req = Requirement(requirement) - else: - req = RequirementWithFlags(requirement, extra) - 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(UrlReq(line)) - else: - for path, extra in self._path_candidate(ini_dir / line): - try: - if path.exists() and (path.is_file() or path.is_dir()): - result.append(PathReq(path, extra)) - break - except OSError: # https://bugs.python.org/issue42855 # pragma: no cover - continue - else: - raise ValueError(f"{at}: {line}") from exc - else: - result.append(req) - - @staticmethod - def _path_candidate(path: Path) -> Iterator[Tuple[Path, List[str]]]: - yield path, [] - # if there's a trailing [a,b] section that could mean either a folder or extras, try both - match = _EXTRA_PATH.fullmatch(path.name) - if match: - extras = [] - for extra in match.group(2).split(","): - extra = extra.strip() - if not extra: - continue - if not _EXTRA_ELEMENT.fullmatch(extra): - break - extras.append(extra) - else: - yield path.parent / match.group(1), extras - - def _load_requirement_with_extra(self, line: str) -> Tuple[str, List[str]]: - return line, [] - - def _expand_flag(self, ini_dir: Path, line: str, result: List[Any]) -> None: - words = list(re.split(r"(?<!\\)(\s|=)", line, maxsplit=1)) - first = words[0] - if first in NO_ARG: - if len(words) != 1: # argument provided - raise ValueError(line) - result.append(Flags(first)) - elif first in ONE_ARG: - if len(words) != 3: # no argument provided - raise ValueError(line) - if len(re.split(r"(?<!\\)\s", words[2])) > 1: # too many arguments provided - raise ValueError(line) - 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") - of_type = RequirementsFile if first in ("-r", "--requirement") else ConstraintFile - req_file = of_type(path, root=self.root) - req_file.validate_and_expand() - result.append(req_file) - elif first in ("-e", "--editable"): - result.append(EditablePathReq(Path(words[2]), [])) - elif first in [ - "-i", - "--index-url", - "--extra-index-url", - "-f", - "--find-links", - "--trusted-host", - "--use-feature", - "--no-binary", - "--only-binary", - ]: - result.append(Flags(first, words[2])) - else: - raise ValueError(first) - else: - raise ValueError(line) - - def unroll(self) -> List[Union[Dict[str, Any], str]]: - into: List[Union[Dict[str, Any], str]] = [] - for element in self.validate_and_expand(): - if isinstance(element, (RequirementsFile, ConstraintFile)): - res: Union[Dict[str, Any], str] = {str(element): element.unroll()} - elif isinstance(element, (Requirement, Flags, PathReq, EditablePathReq, UrlReq)): - res = str(element) - else: # pragma: no cover - raise ValueError(element) # pragma: no cover - into.append(res) - return into - - -class _BaseRequirementsFile(PythonDeps, PipRequirementEntry): - """ - 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 - """ - - arg_flag: str = "" - - def __init__(self, path: Path, root: Path): - self.path = path - super().__init__(path.read_text(), root=root) - - def __str__(self) -> str: - return f"{self.arg_flag} {self.rel_path}" - - def as_args(self) -> Iterable[str]: - return self.arg_flag, str(self.rel_path) - - @property - def rel_path(self) -> Path: - try: - return self.path.relative_to(self.root) - except ValueError: - return self.path - - def __eq__(self, other: Any) -> bool: - return isinstance(other, self.__class__) and self.path == other.path - - 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 - - -_HASH = re.compile(r"\B--hash(=|\s+)sha(256:[a-z0-9]{64}|384:[a-z0-9]{96}|521:[a-z0-9]{128})\b") - + 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 -class RequirementsFile(_BaseRequirementsFile): - arg_flag = "-r" - - def _load_requirement_with_extra(self, line: str) -> Tuple[str, List[str]]: - args = [f"--hash=sha{i[1]}" for i in _HASH.findall(line)] - value = _HASH.sub("", line).strip() - return value, args - - -class ConstraintFile(_BaseRequirementsFile): - arg_flag = "-c" +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__ = ( - "Flags", - "RequirementWithFlags", - "PathReq", - "EditablePathReq", - "UrlReq", "PythonDeps", - "RequirementsFile", - "ConstraintFile", "ONE_ARG", - "ONE_ARG_ESCAPE", - "NO_ARG", ) diff --git a/tests/tox_env/python/pip/req/test_file.py b/tests/tox_env/python/pip/req/test_file.py new file mode 100644 index 00000000..b6d65f6c --- /dev/null +++ b/tests/tox_env/python/pip/req/test_file.py @@ -0,0 +1,395 @@ +import sys +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +from typing import IO, Any, Dict, Iterator, List + +import pytest +from pytest_mock import MockerFixture + +from tox.pytest import CaptureFixture, MonkeyPatch +from tox.tox_env.python.pip.req.file import ParsedRequirement, RequirementsFile + + +@pytest.mark.parametrize( + ("req", "opts", "requirements"), + [ + pytest.param("--pre", {"pre": True}, [], id="pre"), + pytest.param("--no-index", {"index_url": []}, [], id="no-index"), + pytest.param("--no-index\n-i a\n--no-index", {"index_url": []}, [], id="no-index overwrites index"), + pytest.param("--prefer-binary", {"prefer_binary": True}, [], id="prefer-binary"), + pytest.param("--require-hashes", {"require_hashes": True}, [], id="requires-hashes"), + pytest.param("--pre ", {"pre": True}, [], id="space after"), + pytest.param(" --pre", {"pre": True}, [], id="space before"), + pytest.param("--pre\\\n", {"pre": True}, [], id="newline after"), + pytest.param("--pre # magic", {"pre": True}, [], id="comment after space"), + pytest.param("--pre\t# magic", {"pre": True}, [], id="comment after tab"), + pytest.param( + "--find-links /my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + id="find-links path", + ), + pytest.param( + "--find-links /my/local/archives --find-links /my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + id="find-links duplicate same line", + ), + pytest.param( + "--find-links /my/local/archives\n--find-links /my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + id="find-links duplicate different line", + ), + pytest.param( + "--find-links \\\n/my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + id="find-links newline path", + ), + pytest.param( + "--find-links http://some.archives.com/archives", + {"find_links": ["http://some.archives.com/archives"]}, + [], + id="find-links url", + ), + pytest.param("-i a", {"index_url": ["a"]}, [], id="index url short"), + pytest.param("--index-url a", {"index_url": ["a"]}, [], id="index url long"), + pytest.param("-i a -i b\n-i c", {"index_url": ["c"]}, [], id="index url multiple"), + pytest.param("--extra-index-url a", {"index_url": ["a"]}, [], id="extra-index-url"), + pytest.param( + "--extra-index-url a --extra-index-url a", {"index_url": ["a"]}, [], id="extra-index-url dup same line" + ), + pytest.param( + "--extra-index-url a\n--extra-index-url a", + {"index_url": ["a"]}, + [], + id="extra-index-url dup different line", + ), + pytest.param("-e a", {}, ["-e a"], id="e"), + pytest.param("--editable a", {}, ["-e a"], id="editable"), + pytest.param("--editable .[2,1]", {}, ["-e .[1,2]"], id="editable extra"), + pytest.param(".[\t, a1. , B2-\t, C3_, ]", {}, [".[B2-,C3_,a1.]"], id="path with extra"), + pytest.param(".[a.1]", {}, [".[a.1]"], id="path with invalid extra is path"), + pytest.param("-f a", {"find_links": ["a"]}, [], id="f"), + pytest.param("--find-links a", {"find_links": ["a"]}, [], id="find-links"), + pytest.param("--trusted-host a", {"trusted_hosts": ["a"]}, [], id="trusted-host"), + pytest.param( + "--trusted-host a --trusted-host a", {"trusted_hosts": ["a"]}, [], id="trusted-host dup same line" + ), + pytest.param( + "--trusted-host a\n--trusted-host a", {"trusted_hosts": ["a"]}, [], id="trusted-host dup different line" + ), + pytest.param( + "--use-feature 2020-resolver", {"features_enabled": ["2020-resolver"]}, [], id="use-feature space" + ), + pytest.param("--use-feature=fast-deps", {"features_enabled": ["fast-deps"]}, [], id="use-feature equal"), + pytest.param( + "--use-feature=fast-deps --use-feature 2020-resolver", + {"features_enabled": ["2020-resolver", "fast-deps"]}, + [], + id="use-feature multiple same line", + ), + pytest.param( + "--use-feature=fast-deps\n--use-feature 2020-resolver", + {"features_enabled": ["2020-resolver", "fast-deps"]}, + [], + id="use-feature multiple different line", + ), + pytest.param( + "--use-feature=fast-deps\n--use-feature 2020-resolver\n" * 2, + {"features_enabled": ["2020-resolver", "fast-deps"]}, + [], + id="use-feature multiple duplicate different line", + ), + pytest.param("--no-binary :all:", {}, [], id="no-binary"), + pytest.param("--only-binary :all:", {}, [], id="only-binary space"), + pytest.param("--only-binary=:all:", {}, [], id="only-binary equal"), + pytest.param("####### example-requirements.txt #######", {}, [], id="comment"), + pytest.param("\t##### Requirements without Version Specifiers ######", {}, [], id="tab and comment"), + pytest.param(" # start", {}, [], id="space and comment"), + pytest.param("nose", {}, ["nose"], id="req"), + pytest.param("nose\nnose", {}, ["nose"], id="req dup"), + pytest.param( + "numpy[2,1] @ file://./downloads/numpy-1.9.2-cp34-none-win32.whl", + {}, + ["numpy[1,2]@ file://./downloads/numpy-1.9.2-cp34-none-win32.whl"], + id="path with name-extra-protocol", + ), + pytest.param( + "docopt == 0.6.1 # Version Matching. Must be version 0.6.1", + {}, + ["docopt==0.6.1"], + id="req equal comment", + ), + pytest.param( + "keyring >= 4.1.1 # Minimum version 4.1.1", + {}, + ["keyring>=4.1.1"], + id="req ge comment", + ), + pytest.param( + "coverage != 3.5 # Version Exclusion. Anything except version 3.5", + {}, + ["coverage!=3.5"], + id="req ne comment", + ), + pytest.param( + "Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*", + {}, + ["Mopidy-Dirble~=1.1"], + id="req approx comment", + ), + pytest.param("b==1.3", {}, ["b==1.3"], id="req eq"), + pytest.param("c >=1.2,<2.0", {}, ["c<2.0,>=1.2"], id="req ge lt"), + pytest.param("d[bar,foo]", {}, ["d[bar,foo]"], id="req extras"), + pytest.param("d[foo, bar]", {}, ["d[bar,foo]"], id="req extras space"), + pytest.param("d[foo,\tbar]", {}, ["d[bar,foo]"], id="req extras tab"), + pytest.param("e~=1.4.2", {}, ["e~=1.4.2"], id="req approx"), + pytest.param( + "f ==5.4 ; python_version < '2.7'", {}, ['f==5.4; python_version < "2.7"'], id="python version filter" + ), + pytest.param("g; sys_platform == 'win32'", {}, ['g; sys_platform == "win32"'], id="platform filter"), + pytest.param( + "http://w.org/w_P-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl", + {}, + ["http://w.org/w_P-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl"], + id="http URI", + ), + pytest.param( + "git+https://git.example.com/MyProject#egg=MyProject", + {}, + ["git+https://git.example.com/MyProject#egg=MyProject"], + id="vcs with https", + ), + pytest.param( + "git+ssh://git.example.com/MyProject#egg=MyProject", + {}, + ["git+ssh://git.example.com/MyProject#egg=MyProject"], + id="vcs with ssh", + ), + pytest.param( + "git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject", + {}, + ["git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject"], + id="vcs with commit hash pin", + ), + pytest.param( + "attrs --hash sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5e2ff2c528ecae04" + "912224782ab\t--hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 # ok", + {}, + [ + "attrs --hash sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 --hash sha384:" + "142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5e2ff2c528ecae04912224782ab" + ], + id="hash", + ), + pytest.param( + "attrs --hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152\\\n " + "--hash sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5" + "e2ff2c528ecae04912224782ab\n", + {}, + [ + "attrs --hash sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 --hash sha384:" + "142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5e2ff2c528ecae04912224782ab" + ], + id="hash with escaped newline", + ), + ], +) +def test_req_file(tmp_path: Path, req: str, opts: Dict[str, Any], requirements: List[str]) -> None: + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text(req) + req_file = RequirementsFile(requirements_txt, constraint=False) + assert str(req_file) == f"-r {requirements_txt}" + assert vars(req_file.options) == opts + found = [str(i) for i in req_file.requirements] + assert found == requirements + + +def test_requirements_env_var_present(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("ENV_VAR", "beta") + requirements_file = tmp_path / "req.txt" + requirements_file.write_text("${ENV_VAR} >= 1") + req_file = RequirementsFile(requirements_file, constraint=False) + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == ["beta>=1"] + + +def test_requirements_env_var_missing(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("ENV_VAR", raising=False) + requirements_file = tmp_path / "req.txt" + requirements_file.write_text("${ENV_VAR}") + req_file = RequirementsFile(requirements_file, constraint=False) + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == ["${ENV_VAR}"] + + +@pytest.mark.parametrize("flag", ["-r", "--requirement"]) +def test_requirements_txt_transitive(tmp_path: Path, flag: str) -> None: + other_req = tmp_path / "other-requirements.txt" + other_req.write_text("magic\nmagical") + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(f"{flag} other-requirements.txt") + req_file = RequirementsFile(requirements_file, constraint=False) + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == ["magic", "magical"] + + +@pytest.mark.parametrize( + ("raw", "error"), + [ + ("--pre something", "unrecognized arguments: something"), + ("--missing", "unrecognized arguments: --missing"), + ("--index-url a b", "unrecognized arguments: b"), + ("--index-url", "argument -i/--index-url/--pypi-url: expected one argument"), + ("-k", "unrecognized arguments: -k"), + ], +) +def test_bad_line(tmp_path: Path, raw: str, capfd: CaptureFixture, error: str) -> None: + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(raw) + req_file = RequirementsFile(requirements_file, constraint=False) + with pytest.raises(ValueError, match=f"^{error}$"): + assert req_file.options + out, err = capfd.readouterr() + assert not out + assert not err + + +def test_requirements_file_missing(tmp_path: Path) -> None: + requirements_file = tmp_path / "req.txt" + requirements_file.write_text("-r one.txt") + req_file = RequirementsFile(requirements_file, constraint=False) + with pytest.raises(ValueError, match="No such file or directory: .*one.txt"): + assert req_file.options + + +@pytest.mark.parametrize("flag", ["-c", "--constraint"]) +def test_constraint_txt_expanded(tmp_path: Path, flag: str) -> None: + other_req = tmp_path / "other.txt" + other_req.write_text("magic\nmagical\n-i a") + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(f"{flag} other.txt") + req_file = RequirementsFile(requirements_file, constraint=True) + assert vars(req_file.options) == {"index_url": ["a"]} + found = [str(i) for i in req_file.requirements] + assert found == ["-c magic", "-c magical"] + + +@pytest.mark.skipif(sys.platform == "win32", reason=r"on windows the escaped \ is overloaded by path separator") +def test_req_path_with_space_escape(tmp_path: Path) -> None: + dep_requirements_file = tmp_path / "a b" + dep_requirements_file.write_text("c") + path = f"-r {str(dep_requirements_file)}" + path = f'{path[:-len("a b")]}a\\ b' + + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(path) + req_file = RequirementsFile(requirements_file, constraint=False) + + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == ["c"] + + +def test_bad_hash(tmp_path: Path) -> None: + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text("attrs --hash sha256:a") + req_file = RequirementsFile(requirements_txt, constraint=False) + with pytest.raises(ValueError, match="^argument --hash: sha256:a$"): + assert req_file.requirements + + +@pytest.mark.parametrize("codec", ["utf-8", "utf-16", "utf-32"]) +def test_custom_file_encoding(codec: str, tmp_path: Path) -> None: + requirements_file = tmp_path / "r.txt" + raw = "art".encode(codec) + requirements_file.write_bytes(raw) + req_file = RequirementsFile(requirements_file, constraint=False) + assert [str(i) for i in req_file.requirements] == ["art"] + + +def test_parsed_requirement_properties(tmp_path: Path) -> None: + req = ParsedRequirement("a", {"b": 1}, str(tmp_path), 1) + assert req.options == {"b": 1} + assert str(req.requirement) == "a" + assert req.from_file == str(tmp_path) + assert req.lineno == 1 + + +def test_parsed_requirement_repr_with_opt(tmp_path: Path) -> None: + req = ParsedRequirement("a", {"b": 1}, str(tmp_path), 1) + assert repr(req) == "ParsedRequirement(requirement=a, options={'b': 1})" + + +def test_parsed_requirement_repr_no_opt(tmp_path: Path) -> None: + assert repr(ParsedRequirement("a", {}, str(tmp_path), 2)) == "ParsedRequirement(requirement=a)" + + +@pytest.mark.parametrize("flag", ["-r", "--requirement", "-c", "--constraint"]) +def test_req_over_http(tmp_path: Path, flag: str, mocker: MockerFixture) -> None: + is_constraint = flag in ("-c", "--constraint") + url_open = mocker.patch("tox.tox_env.python.pip.req.file.urlopen", autospec=True) + url_open.return_value.__enter__.return_value = BytesIO(b"-i i\na") + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text(f"{flag} https://zopefoundation.github.io/Zope/releases/4.5.5/requirements-full.txt") + req_file = RequirementsFile(requirements_txt, constraint=is_constraint) + assert str(req_file) == f"-{'c' if is_constraint else 'r'} {requirements_txt}" + assert vars(req_file.options) == {"index_url": ["i"]} + found = [str(i) for i in req_file.requirements] + assert found == [f"{'-c ' if is_constraint else ''}a"] + + +def test_req_over_http_has_req(tmp_path: Path, mocker: MockerFixture) -> None: + @contextmanager + def enter(url: str) -> Iterator[IO[bytes]]: + if url == "https://root.org/a.txt": + yield BytesIO(b"-r b.txt") + elif url == "https://root.org/b.txt": + yield BytesIO(b"-i i\na") + else: # pragma: no cover + raise RuntimeError # pragma: no cover + + mocker.patch("tox.tox_env.python.pip.req.file.urlopen", autospec=True, side_effect=enter) + + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text("-r https://root.org/a.txt") + req_file = RequirementsFile(requirements_txt, constraint=False) + + assert vars(req_file.options) == {"index_url": ["i"]} + found = [str(i) for i in req_file.requirements] + assert found == ["a"] + + +@pytest.mark.parametrize( + "loc", + ["file://", "file://localhost"], +) +def test_requirement_via_file_protocol(tmp_path: Path, loc: str) -> None: + other_req = tmp_path / "other-requirements.txt" + other_req.write_text("-i i\na") + requirements_text = tmp_path / "req.txt" + requirements_text.write_text(f"-r {loc}{'/' if sys.platform == 'win32' else ''}{other_req}") + + req_file = RequirementsFile(requirements_text, constraint=False) + + assert vars(req_file.options) == {"index_url": ["i"]} + found = [str(i) for i in req_file.requirements] + assert found == ["a"] + + +def test_requirement_via_file_protocol_na(tmp_path: Path) -> None: + other_req = tmp_path / "other-requirements.txt" + other_req.write_text("-i i\na") + requirements_text = tmp_path / "req.txt" + requirements_text.write_text(f"-r file://magic.com{'/' if sys.platform == 'win32' else ''}{other_req}") + + req_file = RequirementsFile(requirements_text, constraint=False) + pattern = r"non-local file URIs are not supported on this platform: 'file://magic\.com\.*" + with pytest.raises(ValueError, match=pattern): + assert req_file.options diff --git a/tests/tox_env/python/pip/test_pip_install.py b/tests/tox_env/python/pip/test_pip_install.py index 5aa2a174..b1a3c068 100644 --- a/tests/tox_env/python/pip/test_pip_install.py +++ b/tests/tox_env/python/pip/test_pip_install.py @@ -46,18 +46,18 @@ def test_pip_install_new_flag_recreates(tox_project: ToxProjectCreator) -> None: result = proj.run("r") result.assert_success() - (proj.path / "tox.ini").write_text("[testenv:py]\ndeps=a\n -i a\nskip_install=true") + (proj.path / "tox.ini").write_text("[testenv:py]\ndeps=a\n -i i\nskip_install=true") result_second = proj.run("r") result_second.assert_success() - assert "recreate env because new flag -i a" in result_second.out - assert "install_deps> python -I -m pip install a -i a" in result_second.out + assert "recreate env because changed install flag(s) added index_url=['i']" in result_second.out + assert "install_deps> python -I -m pip install a -i i" in result_second.out @pytest.mark.parametrize( ("content", "args"), [ pytest.param("-e .", ["-e", "."], id="short editable"), - pytest.param("--editable .", ["-e", "."], id="long editable"), + pytest.param("--editable .", ["--editable", "."], id="long editable"), pytest.param( "git+ssh://git.example.com/MyProject\\#egg=MyProject", ["git+ssh://git.example.com/MyProject#egg=MyProject"], @@ -87,7 +87,7 @@ def test_pip_install_req_file_req_like(tox_project: ToxProjectCreator, content: result_second = proj.run("r") result_second.assert_success() assert execute_calls.call_count == 1 - assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a"] + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install"] + args + ["a"] def test_pip_req_path(tox_project: ToxProjectCreator) -> None: @@ -98,7 +98,7 @@ def test_pip_req_path(tox_project: ToxProjectCreator) -> None: result.assert_success() assert execute_calls.call_count == 1 - assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", str(proj.path)] + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "."] def test_deps_remove_recreate(tox_project: ToxProjectCreator) -> None: @@ -111,7 +111,7 @@ def test_deps_remove_recreate(tox_project: ToxProjectCreator) -> None: (proj.path / "tox.ini").write_text("[testenv]\npackage=skip\ndeps=setuptools\n") result_second = proj.run("r") result_second.assert_success() - assert "py: recreate env because dependencies removed: wheel" in result_second.out, result_second.out + assert "py: recreate env because requirements removed: wheel" in result_second.out, result_second.out assert execute_calls.call_count == 2 @@ -180,46 +180,47 @@ def test_pip_install_requirements_file_deps(tox_project: ToxProjectCreator) -> N assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-r", "r.txt"] # check that adding a new dependency correctly finds the previous one - (proj.path / "tox.ini").write_text("[testenv]\ndeps=-r r.txt\n a\nskip_install=true") + (proj.path / "tox.ini").write_text("[testenv]\ndeps=-r r.txt\n b\nskip_install=true") execute_calls.reset_mock() result_second = proj.run("r") result_second.assert_success() assert execute_calls.call_count == 1 - assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a"] + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-r", "r.txt", "b"] # if the requirement file changes recreate - (proj.path / "r.txt").write_text("a\nb") + (proj.path / "r.txt").write_text("c\nd") execute_calls.reset_mock() result_third = proj.run("r") result_third.assert_success() - assert "py: recreate env because requirements file r.txt changed" in result_third.out, result_third.out + assert "py: recreate env because requirements removed: a" in result_third.out, result_third.out assert execute_calls.call_count == 1 - assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-r", "r.txt", "a"] + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-r", "r.txt", "b"] def test_pip_install_constraint_file_create_change(tox_project: ToxProjectCreator) -> None: - proj = tox_project({"tox.ini": "[testenv]\ndeps=-c c.txt\n a\nskip_install=true", "c.txt": "a"}) + proj = tox_project({"tox.ini": "[testenv]\ndeps=-c c.txt\n a\nskip_install=true", "c.txt": "b"}) execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) result = proj.run("r") result.assert_success() assert execute_calls.call_count == 1 assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-c", "c.txt", "a"] - # a new dependency removes the previous dependency but keeps constraint - (proj.path / "tox.ini").write_text("[testenv]\ndeps=-c c.txt\n a\n b\nskip_install=true") + # a new dependency triggers an install + (proj.path / "tox.ini").write_text("[testenv]\ndeps=-c c.txt\n a\n d\nskip_install=true") execute_calls.reset_mock() result_second = proj.run("r") result_second.assert_success() assert execute_calls.call_count == 1 - assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-c", "c.txt", "b"] + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-c", "c.txt", "a", "d"] - (proj.path / "c.txt").write_text("a\nb") + # a new constraints triggers a recreate + (proj.path / "c.txt").write_text("") execute_calls.reset_mock() result_third = proj.run("r") result_third.assert_success() - assert "py: recreate env because constraint file c.txt changed" in result_third.out, result_third.out + assert "py: recreate env because changed constraint(s) removed b" in result_third.out, result_third.out assert execute_calls.call_count == 1 - assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-c", "c.txt", "a", "b"] + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-c", "c.txt", "a", "d"] def test_pip_install_constraint_file_new(tox_project: ToxProjectCreator) -> None: @@ -235,6 +236,6 @@ def test_pip_install_constraint_file_new(tox_project: ToxProjectCreator) -> None execute_calls.reset_mock() result_second = proj.run("r") result_second.assert_success() - assert "py: recreate env because new constraint file -c c.txt" in result_second.out, result_second.out + assert "py: recreate env because changed constraint(s) added a" in result_second.out, result_second.out assert execute_calls.call_count == 1 assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "-c", "c.txt"] diff --git a/tests/tox_env/python/pip/test_req_file.py b/tests/tox_env/python/pip/test_req_file.py index 5f61f44f..8423f97f 100644 --- a/tests/tox_env/python/pip/test_req_file.py +++ b/tests/tox_env/python/pip/test_req_file.py @@ -1,387 +1,14 @@ -import re from pathlib import Path import pytest -from packaging.requirements import Requirement -from pytest_mock import MockerFixture -from tox.pytest import MonkeyPatch -from tox.tox_env.python.pip.req_file import ( - ONE_ARG, - ConstraintFile, - EditablePathReq, - Flags, - PathReq, - PythonDeps, - RequirementsFile, - RequirementWithFlags, - UrlReq, -) +from tox.tox_env.python.pip.req_file import PythonDeps -@pytest.mark.parametrize( - ("req", "key"), - [ - pytest.param("--pre", "--pre", id="pre"), - pytest.param("--no-index", "--no-index", id="no-index"), - pytest.param("--prefer-binary", "--prefer-binary", id="prefer-binary"), - pytest.param("--require-hashes", "--require-hashes", id="requires-hashes"), - pytest.param("--pre ", "--pre", id="space after"), - pytest.param(" --pre ", "--pre", id="space before"), - pytest.param("--pre\\\n", "--pre", id="newline after"), - pytest.param("--pre # magic", "--pre", id="comment after space"), - pytest.param("--pre\t# magic", "--pre", id="comment after tab"), - pytest.param("--find-links /my/local/archives", "--find-links /my/local/archives", id="find-links path"), - pytest.param( - "--find-links \\\n/my/local/archives", "--find-links /my/local/archives", id="find-links newline path" - ), - pytest.param( - "--find-links http://some.archives.com/archives", - "--find-links http://some.archives.com/archives", - id="find-links url", - ), - pytest.param("-i a", "-i a", id="i"), - pytest.param("--index-url a", "--index-url a", id="index-url"), - pytest.param("--extra-index-url a", "--extra-index-url a", id="extra-index-url"), - pytest.param("-e a", "-e a", id="e"), - pytest.param("--editable a", "-e a", id="editable"), - pytest.param("--editable .[extra1,extra2]", "-e .[extra1,extra2]", id="editable extra"), - pytest.param("-f a", "-f a", id="f"), - pytest.param("--find-links a", "--find-links a", id="find-links"), - pytest.param("--trusted-host a", "--trusted-host a", id="trusted-host"), - pytest.param("--use-feature a", "--use-feature a", id="use-feature space"), - pytest.param("--use-feature=a", "--use-feature a", id="use-feature equal"), - pytest.param("--no-binary a", "--no-binary a", id="no-binary"), - pytest.param("--only-binary a", "--only-binary a", id="only-binary space"), - pytest.param("--only-binary=a", "--only-binary a", id="only-binary equal"), - pytest.param("####### example-requirements.txt #######", "", id="comment"), - pytest.param("\t##### Requirements without Version Specifiers ######", "", id="tab and comment"), - pytest.param(" # start", "", id="space and comment"), - pytest.param("nose", "nose", id="req"), - pytest.param( - "docopt == 0.6.1 # Version Matching. Must be version 0.6.1", - "docopt==0.6.1", - id="req equal comment", - ), - pytest.param( - "keyring >= 4.1.1 # Minimum version 4.1.1", - "keyring>=4.1.1", - id="req ge comment", - ), - pytest.param( - "coverage != 3.5 # Version Exclusion. Anything except version 3.5", - "coverage!=3.5", - id="req ne comment", - ), - pytest.param( - "Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*", - "Mopidy-Dirble~=1.1", - id="req approx comment", - ), - pytest.param("b==1.3", "b==1.3", id="req eq"), - pytest.param("c >=1.2,<2.0", "c<2.0,>=1.2", id="req ge lt"), - pytest.param("d[bar,foo]", "d[bar,foo]", id="req extras"), - pytest.param("d[foo, bar]", "d[bar,foo]", id="req extras space"), - pytest.param("d[foo,\tbar]", "d[bar,foo]", id="req extras tab"), - pytest.param("e~=1.4.2", "e~=1.4.2", id="req approx"), - pytest.param("f ==5.4 ; python_version < '2.7'", 'f==5.4; python_version < "2.7"', id="python version filter"), - pytest.param("g; sys_platform == 'win32'", 'g; sys_platform == "win32"', id="platform filter"), - pytest.param( - "http://w.org/w_P-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl", - "http://w.org/w_P-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl", - id="http URI", - ), - pytest.param( - "git+https://git.example.com/MyProject#egg=MyProject", - "git+https://git.example.com/MyProject#egg=MyProject", - id="vcs with https", - ), - pytest.param( - "git+ssh://git.example.com/MyProject#egg=MyProject", - "git+ssh://git.example.com/MyProject#egg=MyProject", - id="vcs with ssh", - ), - pytest.param( - "git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject", - "git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject", - id="vcs with commit hash pin", - ), - pytest.param( - "attrs --hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152\t" - "--hash sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5" - "e2ff2c528ecae04912224782ab", - "attrs --hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 " - "--hash=sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5" - "e2ff2c528ecae04912224782ab", - id="hash", - ), - pytest.param( - "attrs --hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152\\\n " - "--hash sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5" - "e2ff2c528ecae04912224782ab", - "attrs --hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 " - "--hash=sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5" - "e2ff2c528ecae04912224782ab", - id="hash with escaped newline", - ), - ], -) -def test_requirements_txt(tmp_path: Path, req: str, key: str) -> None: - requirements_file = tmp_path / "req.txt" - requirements_file.write_text(req) - req_file = RequirementsFile(requirements_file, root=tmp_path) - assert "-r req.txt" == str(req_file) - expanded = req_file.validate_and_expand() - if key: - assert len(expanded) == 1 - assert str(expanded[0]) == key - else: - assert expanded == [] - - -def test_deps_path_with_extra_ok(tmp_path: Path) -> None: - result = PythonDeps(".[\t, a1. , B2-\t, C3_, ]", root=tmp_path).unroll() - assert result == [f"{tmp_path}[a1.,B2-,C3_]"] - - -def test_deps_path_with_extra_nok(tmp_path: Path) -> None: - with pytest.raises(ValueError, match=re.escape(".[\t, a.1]")): - PythonDeps(".[\t, a.1]", root=tmp_path).unroll() - - -def test_requirements_txt_local_path_file_protocol(tmp_path: Path) -> None: - (tmp_path / "downloads").mkdir() - (tmp_path / "downloads" / "numpy-1.9.2-cp34-none-win32.whl").write_text("1") - - raw = "numpy @ file://./downloads/numpy-1.9.2-cp34-none-win32.whl" - requirements_file = tmp_path / "req.txt" - requirements_file.write_text(raw) - req = RequirementsFile(requirements_file, root=tmp_path) - expanded = [str(i) for i in req.validate_and_expand()] - expected = [str(Requirement("numpy@ file://./downloads/numpy-1.9.2-cp34-none-win32.whl"))] - assert expanded == expected - - -def test_requirements_txt_local_path_implicit(tmp_path: Path) -> None: - (tmp_path / "downloads").mkdir() - (tmp_path / "downloads" / "numpy-1.9.2-cp34-none-win32.whl").write_text("1") - raw = "./downloads/numpy-1.9.2-cp34-none-win32.whl" - requirements_file = tmp_path / "req.txt" - requirements_file.write_text(raw) - req = RequirementsFile(requirements_file, root=tmp_path) - assert [str(i.path) for i in req.validate_and_expand()] == [str(tmp_path / raw)] - - -def test_requirements_env_var_present(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setenv("ENV_VAR", "beta") - requirements_file = tmp_path / "req.txt" - requirements_file.write_text("${ENV_VAR} >= 1") - req = RequirementsFile(requirements_file, root=tmp_path) - assert [str(i) for i in req.validate_and_expand()] == ["beta>=1"] - - -def test_requirements_env_var_missing(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.delenv("ENV_VAR", raising=False) - requirements_file = tmp_path / "req.txt" - requirements_file.write_text("${ENV_VAR}") - req = RequirementsFile(requirements_file, root=tmp_path) - assert req.validate_and_expand() == [] - - -@pytest.mark.parametrize("flag", ["-r", "--requirement"]) -def test_requirements_txt_transitive(tmp_path: Path, flag: str) -> None: - other_req = tmp_path / "other-requirements.txt" - other_req.write_text("magic\nmagical") - requirements_file = tmp_path / "req.txt" - requirements_file.write_text(f"{flag} other-requirements.txt") - req = RequirementsFile(requirements_file, root=tmp_path) - assert req.unroll() == [{"-r other-requirements.txt": ["magic", "magical"]}] - - -@pytest.mark.parametrize( - "raw", - [ - "--pre something", - "--missing", - "--index-url a b", - "--index-url", - "-k", - "magic+https://git.example.com/MyProject#egg=MyProject", - ], -) -def test_bad_line(tmp_path: Path, raw: str) -> None: - requirements_file = tmp_path / "req.txt" - requirements_file.write_text(raw) - req = RequirementsFile(requirements_file, root=tmp_path) - with pytest.raises(ValueError, match=re.escape(raw)): - req.validate_and_expand() - - -def test_requirements_file_missing(tmp_path: Path) -> None: - requirements_file = tmp_path / "req.txt" - requirements_file.write_text("-r one.txt") - req = RequirementsFile(requirements_file, root=tmp_path) - with pytest.raises(ValueError, match="requirement file path '.*one.txt' does not exist"): - req.validate_and_expand() - - -def test_legacy_requirement_file(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "requirement.txt").write_text("a") - raw = PythonDeps("-rrequirement.txt") - assert raw.unroll() == [{"-r requirement.txt": ["a"]}] - - -def test_legacy_constraint_file(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "constraint.txt").write_text("b") - raw = PythonDeps("-cconstraint.txt") - assert raw.unroll() == [{"-c constraint.txt": ["b"]}] - - -@pytest.mark.parametrize("flag", ["-c", "--constraint"]) -def test_constraint_txt_expanded(tmp_path: Path, flag: str) -> None: - other_req = tmp_path / "other.txt" - other_req.write_text("magic\nmagical") - requirements_file = tmp_path / "req.txt" - requirements_file.write_text(f"{flag} other.txt") - req = RequirementsFile(requirements_file, root=tmp_path) - assert req.unroll() == [{"-c other.txt": ["magic", "magical"]}] - - -@pytest.mark.parametrize("flag", sorted(ONE_ARG - {"-c", "--constraint", "-r", "--requirement"})) -def test_one_arg_expanded(tmp_path: Path, flag: str) -> None: - req = PythonDeps(f"{flag}argument", root=tmp_path) - if flag == "--editable": - flag = "-e" - assert req.unroll() == [f"{flag} argument"] - - -def test_req_path_with_space(tmp_path: Path) -> None: - req_file = tmp_path / "a b" - req_file.write_text("c") - path = f"-r {str(req_file)}" - path = f'{path[:-len("a b")]}a\\ b' - - requirements_file = tmp_path / "req.txt" - requirements_file.write_text(path) - req = RequirementsFile(requirements_file, root=tmp_path) - - # must be escaped within the requirements file - assert "a\\ b" in req._raw - - # but still unroll during transitive dependencies - assert req.unroll() == [{"-r a b": ["c"]}] - assert str(req) == "-r req.txt" - - -def test_flags() -> None: - flag = Flags("-i", "a") - - assert flag.as_args() == ("-i", "a") - assert str(flag) == "-i a" - - assert flag != Flags("-i", "b") - assert flag == Flags("-i", "a") - assert flag != object - - -def test_requirement_with_flags_no_args() -> None: - req = RequirementWithFlags("a", ()) - - assert req.as_args() == ("a",) - assert str(req) == "a" - - assert req == RequirementWithFlags("a", ()) - assert req != object - assert req != RequirementWithFlags("b", ()) - assert req != RequirementWithFlags("a", ("b")) - - -def test_requirement_with_flags_has_args() -> None: - req = RequirementWithFlags("a", ("1")) - - assert req.as_args() == ("a", "1") - assert str(req) == "a 1" - - assert req == RequirementWithFlags("a", ("1")) - assert req != object - assert req != RequirementWithFlags("a", ()) - assert req != RequirementWithFlags("a", ("2")) - - -def test_path_req(tmp_path: Path) -> None: - path_req = PathReq(tmp_path, []) - - assert path_req.as_args() == (str(tmp_path),) - assert str(path_req) == str(tmp_path) - - assert path_req != PathReq(tmp_path / "a", []) - assert path_req == PathReq(tmp_path, []) - assert path_req != object - - -def test_editable_path_req(tmp_path: Path) -> None: - editable_path_req = EditablePathReq(tmp_path, []) - - assert editable_path_req.as_args() == ("-e", str(tmp_path)) - assert str(editable_path_req) == f"-e {tmp_path}" - - assert editable_path_req != EditablePathReq(tmp_path / "a", []) - assert editable_path_req == EditablePathReq(tmp_path, []) - assert editable_path_req != object - - -def test_url_req() -> None: - path_req = UrlReq("a") - - assert path_req.as_args() == ("a",) - assert str(path_req) == "a" - - assert path_req != UrlReq("b") - assert path_req == UrlReq("a") - assert path_req != object - - -def test_invalid_flag_python_dep(mocker: MockerFixture) -> None: - mocker.patch("tox.tox_env.python.pip.req_file.ONE_ARG", ONE_ARG | {"--magic"}) - with pytest.raises(ValueError, match="--magic"): - PythonDeps("--magic a").unroll() - - -def test_requirement_file_str(tmp_path: Path) -> None: - (tmp_path / "a").mkdir() - req_file = tmp_path / "a" / "r.txt" - req_file.write_text("a") - - assert str(RequirementsFile(req_file, tmp_path)) == f"-r {Path('a') / 'r.txt'}" - - here = Path(__file__).parent - try: - tmp_path.relative_to(here) - except ValueError: - assert str(RequirementsFile(req_file, here)) == f"-r {req_file}" - - -def test_requirement_file_eq(tmp_path: Path) -> None: - req_file = tmp_path / "r.txt" - req_file.write_text("a") - assert RequirementsFile(req_file, tmp_path) == RequirementsFile(req_file, tmp_path) - - req_file_2 = tmp_path / "r2.txt" - req_file_2.write_text("a") - - assert RequirementsFile(req_file, tmp_path) != RequirementsFile(req_file_2, tmp_path) - assert RequirementsFile(req_file, tmp_path) != ConstraintFile(req_file, tmp_path) - - -def test_constraint_file_eq(tmp_path: Path) -> None: - constraint_file = tmp_path / "r.txt" - constraint_file.write_text("a") - assert ConstraintFile(constraint_file, tmp_path) == ConstraintFile(constraint_file, tmp_path) - - constraint_file_2 = tmp_path / "r2.txt" - constraint_file_2.write_text("a") - - assert ConstraintFile(constraint_file, tmp_path) != ConstraintFile(constraint_file_2, tmp_path) - assert ConstraintFile(constraint_file, tmp_path) != RequirementsFile(constraint_file, tmp_path) +@pytest.mark.parametrize("legacy_flag", ["-r", "-c"]) +def test_legacy_requirement_file(tmp_path: Path, legacy_flag: str) -> None: + python_deps = PythonDeps(f"{legacy_flag}a.txt", tmp_path) + (tmp_path / "a.txt").write_text("b") + assert python_deps.as_args() == [legacy_flag, "a.txt"] + assert vars(python_deps.options) == {} + assert [str(i) for i in python_deps.requirements] == ["b" if legacy_flag == "-r" else "-c b"] diff --git a/tests/tox_env/python/test_python_runner.py b/tests/tox_env/python/test_python_runner.py index d0f55664..6d252f55 100644 --- a/tests/tox_env/python/test_python_runner.py +++ b/tests/tox_env/python/test_python_runner.py @@ -17,8 +17,9 @@ def test_deps_config_path_req(tox_project: ToxProjectCreator) -> None: result = project.run("c", "-e", "py") result.assert_success() deps = result.state.conf.get_env("py")["deps"] - assert deps.unroll() == [{"-r path.txt": ["alpha"]}, {"-r path2.txt": ["beta"]}, "pytest"] - assert str(deps) == f"-rpath.txt\n-r {project.path / 'path2.txt'}\npytest" + assert deps.unroll() == ([], ["alpha", "beta", "pytest"]) + assert deps.as_args() == ["-r", "path.txt", "-r", str(project.path / "path2.txt"), "pytest"] + assert str(deps) == f"-r {project.path / 'tox.ini'}" def test_journal_package_empty() -> None: diff --git a/tests/tox_env/python/virtual_env/test_setuptools.py b/tests/tox_env/python/virtual_env/test_setuptools.py index 614a2da0..7982fe7d 100644 --- a/tests/tox_env/python/virtual_env/test_setuptools.py +++ b/tests/tox_env/python/virtual_env/test_setuptools.py @@ -37,6 +37,7 @@ def test_setuptools_package( assert len(packages) == 1 package = packages[0] assert isinstance(package, WheelPackage) + assert str(package) == str(package.path) assert package.path.name == f"demo_pkg_setuptools-1.2.3-py{sys.version_info.major}-none-any.whl" result = outcome.out.split("\n") @@ -1,6 +1,5 @@ [tox] envlist = - flake8 fix py39 py38 @@ -35,19 +34,6 @@ commands = package = wheel wheel_build_env = .pkg -[testenv:flake8] -description = run style checker on the code -skip_install = true -deps = - flake8==3.9 - flake8-bugbear==21.3.2 - flake8-comprehensions==3.4 - flake8-pytest-style==1.4 - flake8-spellcheck==0.24 - flake8-unused-arguments==0.0.6 -commands = - flake8 src tests docs - [testenv:fix] description = format the code base to adhere to our styles, and complain about what we cannot do automatically passenv = diff --git a/whitelist.txt b/whitelist.txt index ba973073..511d203e 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -4,7 +4,6 @@ 2s 5s abi -accel addinivalue addnodes addoption @@ -14,6 +13,9 @@ autoclass autodoc autosectionlabel autouse +bom +BOM +BOMS BUFSIZE byref cachetools @@ -23,9 +25,13 @@ caplog capsys cfg Cfg +changelog +chardet chdir cmd Cmd +codec +codecs colorama conf Conf @@ -61,6 +67,7 @@ dpkg E3 E4 EBADF +editables EIO entrypoints envs @@ -76,7 +83,6 @@ extlinks extractall favicon filelock -firstresult fixup fmt formatter @@ -88,17 +94,18 @@ fs fullmatch func getbasetemp +getdefaultencoding getfqdn getitem getoption getpid +getpreferredencoding getresult getsockname globals groupby groupdict Hookimpl -hookspec Hookspec hookspecs htmlhelp @@ -106,7 +113,9 @@ ident IGN impl INET +insort intersphinx +isalpha isatty isspace iterdir @@ -118,11 +127,11 @@ levelname levelno libs LIGHTRED +lineno linesep list2cmdline loc lvl -maxsplit metavar mktemp modifyitems @@ -143,6 +152,7 @@ parsers pathlib pathname pathsep +PEP263 pep517 Pep517 pluggy @@ -168,18 +178,21 @@ pyproject quickstart readline readouterr +recurse +refspec releaselevel replacer Replacer +repo +Repo req Req +reqs retann rfind rpartition rreq rtd -runenvreport -runtest runtime Runtime sdist @@ -211,24 +224,26 @@ towncrier tox Tox TOX -tox's transcoding trylast tty typehints typeshed unbuffered +UNC unescaped unimported unittest unlink untyped +url2pathname usedevelop +UTF16 +UTF32 util utils v3 VCS -venv ver virtualenv whl |