summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBernát Gábor <bgabor8@bloomberg.net>2021-04-09 09:03:32 +0100
committerGitHub <noreply@github.com>2021-04-09 09:03:32 +0100
commita9c8e723751d81655cf1baa051ae44d4f4b49010 (patch)
treeb07d8bdc7c521018665195c7caa676d2a8b8759a
parent1a50180682fc861d9c6cad0f73adf77e42523387 (diff)
downloadtox-git-a9c8e723751d81655cf1baa051ae44d4f4b49010.tar.gz
Port pip requirements.txt parser (#2009)
-rw-r--r--.pre-commit-config.yaml12
-rw-r--r--docs/changelog/1929.bugfix.rst2
-rw-r--r--setup.cfg1
-rw-r--r--src/tox/config/loader/stringify.py2
-rw-r--r--src/tox/tox_env/package.py3
-rw-r--r--src/tox/tox_env/python/pip/pip_install.py102
-rw-r--r--src/tox/tox_env/python/pip/req/__init__.py5
-rw-r--r--src/tox/tox_env/python/pip/req/args.py88
-rw-r--r--src/tox/tox_env/python/pip/req/file.py407
-rw-r--r--src/tox/tox_env/python/pip/req/util.py27
-rw-r--r--src/tox/tox_env/python/pip/req_file.py415
-rw-r--r--tests/tox_env/python/pip/req/test_file.py395
-rw-r--r--tests/tox_env/python/pip/test_pip_install.py41
-rw-r--r--tests/tox_env/python/pip/test_req_file.py389
-rw-r--r--tests/tox_env/python/test_python_runner.py5
-rw-r--r--tests/tox_env/python/virtual_env/test_setuptools.py1
-rw-r--r--tox.ini14
-rw-r--r--whitelist.txt31
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`.
diff --git a/setup.cfg b/setup.cfg
index 576006e7..dcf4bfc5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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")
diff --git a/tox.ini b/tox.ini
index b8a77ec7..9ff9f574 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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