diff --git a/packaging/_parser.py b/packaging/_parser.py index ec02e72c..4dcf03d1 100644 --- a/packaging/_parser.py +++ b/packaging/_parser.py @@ -1,18 +1,13 @@ -# The docstring for each parse function contains the grammar for the rule. -# The grammar uses a simple EBNF-inspired syntax: -# -# - Uppercase names are tokens -# - Lowercase names are rules (parsed with a parse_* function) -# - Parentheses are used for grouping -# - A | means either-or -# - A * means 0 or more -# - A + means 1 or more -# - A ? means 0 or 1 +"""Handwritten parser of dependency specifiers. -from ast import literal_eval -from typing import Any, List, NamedTuple, Tuple, Union +The docstring for each __parse_* function contains ENBF-inspired grammar representing +the implementation. +""" -from ._tokenizer import Tokenizer +import ast +from typing import Any, List, NamedTuple, Optional, Tuple, Union + +from ._tokenizer import DEFAULT_RULES, Tokenizer class Node: @@ -54,141 +49,255 @@ def serialize(self) -> str: MarkerList = List[Any] -class Requirement(NamedTuple): +class ParsedRequirement(NamedTuple): name: str url: str extras: List[str] specifier: str - marker: str + marker: Optional[MarkerList] + + +# -------------------------------------------------------------------------------------- +# Recursive descent parser for dependency specifier +# -------------------------------------------------------------------------------------- +def parse_requirement(source: str) -> ParsedRequirement: + return _parse_requirement(Tokenizer(source, rules=DEFAULT_RULES)) + + +def _parse_requirement(tokenizer: Tokenizer) -> ParsedRequirement: + """ + requirement = WS? IDENTIFIER WS? extras WS? requirement_details + """ + tokenizer.consume("WS") + name_token = tokenizer.expect( + "IDENTIFIER", expected="package name at the start of dependency specifier" + ) + name = name_token.text + tokenizer.consume("WS") -def parse_named_requirement(requirement: str) -> Requirement: + extras = _parse_extras(tokenizer) + tokenizer.consume("WS") + + url, specifier, marker = _parse_requirement_details(tokenizer) + tokenizer.expect("END", expected="end of dependency specifier") + + return ParsedRequirement(name, url, extras, specifier, marker) + + +def _parse_requirement_details( + tokenizer: Tokenizer, +) -> Tuple[str, str, Optional[MarkerList]]: """ - named_requirement: - IDENTIFIER extras (URL_SPEC | specifier) (SEMICOLON marker_expr)? END + requirement_details = AT URL (WS requirement_marker)? + | specifier WS? (requirement_marker)? """ - tokens = Tokenizer(requirement) - tokens.expect("IDENTIFIER", error_message="Expression must begin with package name") - name = tokens.read("IDENTIFIER").text - extras = parse_extras(tokens) + specifier = "" url = "" - if tokens.match("URL_SPEC"): - url = tokens.read().text[1:].strip() - elif not tokens.match("END"): - specifier = parse_specifier(tokens) - if tokens.try_read("SEMICOLON"): - marker = "" - while not tokens.match("END"): - # we don't validate markers here, it's done later as part of - # packaging/requirements.py - marker += tokens.read().text + marker = None + + if tokenizer.check("AT"): + tokenizer.read() + tokenizer.consume("WS") + + url_start = tokenizer.position + url = tokenizer.expect("URL", expected="URL after @").text + if tokenizer.check("END", peek=True): + return (url, specifier, marker) + + tokenizer.expect("WS", expected="whitespace after URL") + + marker = _parse_requirement_marker( + tokenizer, span_start=url_start, after="URL and whitespace" + ) else: - marker = "" - tokens.expect( - "END", - error_message="Expected semicolon (followed by markers) or end of string", + specifier_start = tokenizer.position + specifier = _parse_specifier(tokenizer) + tokenizer.consume("WS") + + if tokenizer.check("END", peek=True): + return (url, specifier, marker) + + marker = _parse_requirement_marker( + tokenizer, + span_start=specifier_start, + after=( + "version specifier" + if specifier + else "name and no valid version specifier" + ), ) - return Requirement(name, url, extras, specifier, marker) + + return (url, specifier, marker) + + +def _parse_requirement_marker( + tokenizer: Tokenizer, *, span_start: int, after: str +) -> MarkerList: + """ + requirement_marker = SEMICOLON marker WS? + """ + + if not tokenizer.check("SEMICOLON"): + tokenizer.raise_syntax_error( + f"Expected end or semicolon (after {after})", + span_start=span_start, + ) + else: + tokenizer.read() + + marker = _parse_marker(tokenizer) + tokenizer.consume("WS") + + return marker -def parse_extras(tokens: Tokenizer) -> List[str]: +def _parse_extras(tokenizer: Tokenizer) -> List[str]: """ - extras: LBRACKET (IDENTIFIER (COMMA IDENTIFIER)*)? RBRACKET + extras = (LEFT_BRACKET wsp* extras_list? wsp* RIGHT_BRACKET)? """ - extras = [] - if tokens.try_read("LBRACKET"): - while tokens.match("IDENTIFIER"): - extras.append(tokens.read("IDENTIFIER").text) - if not tokens.match("RBRACKET"): - tokens.read("COMMA", error_message="Missing comma after extra") - if not tokens.match("COMMA") and tokens.match("RBRACKET"): - break - tokens.read("RBRACKET", error_message="Closing square bracket is missing") + if not tokenizer.check("LEFT_BRACKET", peek=True): + return [] + + with tokenizer.enclosing_tokens("LEFT_BRACKET", "RIGHT_BRACKET"): + tokenizer.consume("WS") + extras = _parse_extras_list(tokenizer) + tokenizer.consume("WS") + + return extras + + +def _parse_extras_list(tokenizer: Tokenizer) -> List[str]: + """ + extras_list = identifier (wsp* ',' wsp* identifier)* + """ + extras: List[str] = [] + + if not tokenizer.check("IDENTIFIER"): + return extras + + extras.append(tokenizer.read().text) + + while True: + tokenizer.consume("WS") + if tokenizer.check("IDENTIFIER", peek=True): + tokenizer.raise_syntax_error("Expected comma between extra names") + elif not tokenizer.check("COMMA"): + break + + tokenizer.read() + tokenizer.consume("WS") + + extra_token = tokenizer.expect("IDENTIFIER", expected="extra name after comma") + extras.append(extra_token.text) + return extras -def parse_specifier(tokens: Tokenizer) -> str: +def _parse_specifier(tokenizer: Tokenizer) -> str: """ - specifier: - LPAREN version_many? RPAREN | version_many + specifier = LEFT_PARENTHESIS WS? version_many WS? RIGHT_PARENTHESIS + | WS? version_many WS? """ - lparen = False - if tokens.try_read("LPAREN"): - lparen = True - parsed_specifiers = parse_version_many(tokens) - if lparen and not tokens.try_read("RPAREN"): - tokens.raise_syntax_error(message="Closing right parenthesis is missing") + with tokenizer.enclosing_tokens("LEFT_PARENTHESIS", "RIGHT_PARENTHESIS"): + tokenizer.consume("WS") + parsed_specifiers = _parse_version_many(tokenizer) + tokenizer.consume("WS") + return parsed_specifiers -def parse_version_many(tokens: Tokenizer) -> str: +def _parse_version_many(tokenizer: Tokenizer) -> str: """ - version_many: OP VERSION (COMMA OP VERSION)* + version_many = (OP VERSION (COMMA OP VERSION)*)? """ parsed_specifiers = "" - while tokens.match("OP"): - parsed_specifiers += tokens.read("OP").text - if tokens.match("VERSION"): - parsed_specifiers += tokens.read("VERSION").text - else: - tokens.raise_syntax_error(message="Missing version") - if not tokens.match("COMMA"): + while tokenizer.check("OP"): + parsed_specifiers += tokenizer.read().text + + # We intentionally do not consume whitespace here, since the regular expression + # for `VERSION` uses a lookback for the operator, to determine what + # corresponding syntax is permitted. + + version_token = tokenizer.expect("VERSION", expected="version after operator") + parsed_specifiers += version_token.text + tokenizer.consume("WS") + + if not tokenizer.check("COMMA"): break - tokens.expect("COMMA", error_message="Missing comma after version") - parsed_specifiers += tokens.read("COMMA").text + parsed_specifiers += tokenizer.read().text + tokenizer.consume("WS") + return parsed_specifiers -def parse_marker_expr(tokens: Tokenizer) -> MarkerList: +# -------------------------------------------------------------------------------------- +# Recursive descent parser for marker expression +# -------------------------------------------------------------------------------------- +def parse_marker(source: str) -> MarkerList: + return _parse_marker(Tokenizer(source, rules=DEFAULT_RULES)) + + +def _parse_marker(tokenizer: Tokenizer) -> MarkerList: """ - marker_expr: MARKER_ATOM (BOOLOP + MARKER_ATOM)+ + marker = marker_atom (BOOLOP marker_atom)+ """ - expression = [parse_marker_atom(tokens)] - while tokens.match("BOOLOP"): - tok = tokens.read("BOOLOP") - expr_right = parse_marker_atom(tokens) - expression.extend((tok.text, expr_right)) + expression = [_parse_marker_atom(tokenizer)] + while tokenizer.check("BOOLOP"): + token = tokenizer.read() + expr_right = _parse_marker_atom(tokenizer) + expression.extend((token.text, expr_right)) return expression -def parse_marker_atom(tokens: Tokenizer) -> MarkerAtom: +def _parse_marker_atom(tokenizer: Tokenizer) -> MarkerAtom: """ - marker_atom: LPAREN marker_expr RPAREN | marker_item + marker_atom = WS? LEFT_PARENTHESIS WS? marker WS? RIGHT_PARENTHESIS WS? + | WS? marker_item WS? """ - if tokens.try_read("LPAREN"): - marker = parse_marker_expr(tokens) - tokens.read("RPAREN", error_message="Closing right parenthesis is missing") - return marker + + tokenizer.consume("WS") + if tokenizer.check("LEFT_PARENTHESIS", peek=True): + with tokenizer.enclosing_tokens("LEFT_PARENTHESIS", "RIGHT_PARENTHESIS"): + tokenizer.consume("WS") + marker: MarkerAtom = _parse_marker(tokenizer) + tokenizer.consume("WS") else: - return parse_marker_item(tokens) + marker = _parse_marker_item(tokenizer) + tokenizer.consume("WS") + return marker -def parse_marker_item(tokens: Tokenizer) -> MarkerItem: +def _parse_marker_item(tokenizer: Tokenizer) -> MarkerItem: """ - marker_item: marker_var marker_op marker_var + marker_item = WS? marker_var WS? marker_op WS? marker_var WS? """ - marker_var_left = parse_marker_var(tokens) - marker_op = parse_marker_op(tokens) - marker_var_right = parse_marker_var(tokens) + tokenizer.consume("WS") + marker_var_left = _parse_marker_var(tokenizer) + tokenizer.consume("WS") + marker_op = _parse_marker_op(tokenizer) + tokenizer.consume("WS") + marker_var_right = _parse_marker_var(tokenizer) + tokenizer.consume("WS") return (marker_var_left, marker_op, marker_var_right) -def parse_marker_var(tokens: Tokenizer) -> MarkerVar: +def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: """ - marker_var: env_var | python_str + marker_var = VARIABLE | QUOTED_STRING """ - if tokens.match("VARIABLE"): - return parse_env_var(tokens) + if tokenizer.check("VARIABLE"): + return process_env_var(tokenizer.read().text.replace(".", "_")) + elif tokenizer.check("QUOTED_STRING"): + return process_python_str(tokenizer.read().text) else: - return parse_python_str(tokens) + tokenizer.raise_syntax_error( + message="Expected a marker variable or quoted string" + ) -def parse_env_var(tokens: Tokenizer) -> Variable: - """ - env_var: VARIABLE - """ - env_var = tokens.read("VARIABLE").text.replace(".", "_") +def process_env_var(env_var: str) -> Variable: if ( env_var == "platform_python_implementation" or env_var == "python_implementation" @@ -198,31 +307,27 @@ def parse_env_var(tokens: Tokenizer) -> Variable: return Variable(env_var) -def parse_python_str(tokens: Tokenizer) -> Value: - """ - python_str: QUOTED_STRING - """ - token = tokens.read( - "QUOTED_STRING", - error_message="String with single or double quote at the beginning is expected", - ).text - python_str = literal_eval(token) - return Value(str(python_str)) +def process_python_str(python_str: str) -> Value: + value = ast.literal_eval(python_str) + return Value(str(value)) -def parse_marker_op(tokens: Tokenizer) -> Op: +def _parse_marker_op(tokenizer: Tokenizer) -> Op: """ - marker_op: IN | NOT IN | OP + marker_op = IN | NOT IN | OP """ - if tokens.try_read("IN"): + if tokenizer.check("IN"): + tokenizer.read() return Op("in") - elif tokens.try_read("NOT"): - tokens.read("IN", error_message="NOT token must be follewed by IN token") + elif tokenizer.check("NOT"): + tokenizer.read() + tokenizer.expect("WS", expected="whitespace after 'not'") + tokenizer.expect("IN", expected="'in' after 'not'") return Op("not in") - elif tokens.match("OP"): - return Op(tokens.read().text) + elif tokenizer.check("OP"): + return Op(tokenizer.read().text) else: - return tokens.raise_syntax_error( - message='Couldn\'t parse marker operator. Expecting one of \ - "<=, <, !=, ==, >=, >, ~=, ===, not, not in"' + return tokenizer.raise_syntax_error( + "Expected marker operator, one of " + "<=, <, !=, ==, >=, >, ~=, ===, in, not in" ) diff --git a/packaging/_tokenizer.py b/packaging/_tokenizer.py index ecae9e34..de389cb3 100644 --- a/packaging/_tokenizer.py +++ b/packaging/_tokenizer.py @@ -1,41 +1,49 @@ +import contextlib import re -from typing import Dict, Generator, NoReturn, Optional +from dataclasses import dataclass +from typing import Dict, Iterator, NoReturn, Optional, Tuple, Union from .specifiers import Specifier +@dataclass class Token: - def __init__(self, name: str, text: str, position: int) -> None: - self.name = name - self.text = text - self.position = position - - def matches(self, name: str = "") -> bool: - if name and self.name != name: - return False - return True - + name: str + text: str + position: int + + +class ParserSyntaxError(Exception): + """The provided source text could not be parsed correctly.""" + + def __init__( + self, + message: str, + *, + source: str, + span: Tuple[int, int], + ) -> None: + self.span = span + self.message = message + self.source = source -class ParseExceptionError(Exception): - """ - Parsing failed. - """ + super().__init__() - def __init__(self, message: str, position: int) -> None: - super().__init__(message) - self.position = position + def __str__(self) -> str: + marker = " " * self.span[0] + "~" * (self.span[1] - self.span[0]) + "^" + return "\n ".join([self.message, self.source, marker]) -DEFAULT_RULES = { +DEFAULT_RULES: "Dict[str, Union[str, re.Pattern[str]]]" = { "LPAREN": r"\s*\(", - "RPAREN": r"\s*\)", - "LBRACKET": r"\s*\[", - "RBRACKET": r"\s*\]", - "SEMICOLON": r"\s*;", - "COMMA": r"\s*,", + "LEFT_PARENTHESIS": r"\(", + "RIGHT_PARENTHESIS": r"\)", + "LEFT_BRACKET": r"\[", + "RIGHT_BRACKET": r"\]", + "SEMICOLON": r";", + "COMMA": r",", "QUOTED_STRING": re.compile( r""" - \s* ( ('[^']*') | @@ -44,14 +52,13 @@ def __init__(self, message: str, position: int) -> None: """, re.VERBOSE, ), - "OP": r"\s*(===|==|~=|!=|<=|>=|<|>)", - "BOOLOP": r"\s*(or|and)", - "IN": r"\s*in", - "NOT": r"\s*not", + "OP": r"(===|==|~=|!=|<=|>=|<|>)", + "BOOLOP": r"\b(or|and)\b", + "IN": r"\bin\b", + "NOT": r"\bnot\b", "VARIABLE": re.compile( r""" - \s* - ( + \b( python_version |python_full_version |os[._]name @@ -61,104 +68,119 @@ def __init__(self, message: str, position: int) -> None: |python_implementation |implementation_(name|version) |extra - ) + )\b """, re.VERBOSE, ), "VERSION": re.compile(Specifier._version_regex_str, re.VERBOSE | re.IGNORECASE), - "URL_SPEC": r"\s*@ *[^ ]+", - "IDENTIFIER": r"\s*[a-zA-Z0-9._-]+", + "AT": r"\@", + "URL": r"[^ \t]+", + "IDENTIFIER": r"\b[a-zA-Z0-9][a-zA-Z0-9._-]*\b", + "WS": r"[ \t]+", + "END": r"$", } class Tokenizer: - """Stream of tokens for a LL(1) parser. + """Context-sensitive token parsing. - Provides methods to examine the next token to be read, and to read it - (advance to the next token). + Provides methods to examine the input stream to check whether the next token + matches. """ - next_token: Optional[Token] - - def __init__(self, source: str, rules: Dict[str, object] = DEFAULT_RULES) -> None: + def __init__( + self, + source: str, + *, + rules: "Dict[str, Union[str, re.Pattern[str]]]", + ) -> None: self.source = source - self.rules = {name: re.compile(pattern) for name, pattern in rules.items()} - self.next_token = None - self.generator = self._tokenize() + self.rules: Dict[str, re.Pattern[str]] = { + name: re.compile(pattern) for name, pattern in rules.items() + } + self.next_token: Optional[Token] = None self.position = 0 - def peek(self) -> Token: - """ - Return the next token to be read. - """ - if not self.next_token: - self.next_token = next(self.generator) - return self.next_token + def consume(self, name: str) -> None: + """Move beyond provided token name, if at current position.""" + if self.check(name): + self.read() - def match(self, *name: str) -> bool: - """ - Return True if the next token matches the given arguments. - """ - token = self.peek() - return token.matches(*name) + def check(self, name: str, *, peek: bool = False) -> bool: + """Check whether the next token has the provided name. - def expect(self, *name: str, error_message: str) -> Token: + By default, if the check succeeds, the token *must* be read before + another check. If `peek` is set to `True`, the token is not loaded and + would need to be checked again. """ - Raise SyntaxError if the next token doesn't match given arguments. - """ - token = self.peek() - if not token.matches(*name): - raise self.raise_syntax_error(message=error_message) - return token + assert ( + self.next_token is None + ), f"Cannot check for {name!r}, already have {self.next_token!r}" + assert name in self.rules, f"Unknown token name: {name!r}" - def read(self, *name: str, error_message: str = "") -> Token: - """Return the next token and advance to the next token. + expression = self.rules[name] - Raise SyntaxError if the token doesn't match. - """ - result = self.expect(*name, error_message=error_message) - self.next_token = None - return result + match = expression.match(self.source, self.position) + if match is None: + return False + if not peek: + self.next_token = Token(name, match[0], self.position) + return True - def try_read(self, *name: str) -> Optional[Token]: - """read() if the next token matches the given arguments. + def expect(self, name: str, *, expected: str) -> Token: + """Expect a certain token name next, failing with a syntax error otherwise. - Do nothing if it does not match. + The token is *not* read. """ - if self.match(*name): - return self.read() - return None + if not self.check(name): + raise self.raise_syntax_error(f"Expected {expected}") + return self.read() - def raise_syntax_error(self, *, message: str) -> NoReturn: - """ - Raise SyntaxError at the given position in the marker. - """ - at = f"at position {self.position}:" - marker = " " * self.position + "^" - raise ParseExceptionError( - f"{message}\n{at}\n {self.source}\n {marker}", - self.position, + def read(self) -> Token: + """Consume the next token and return it.""" + token = self.next_token + assert token is not None + + self.position += len(token.text) + self.next_token = None + + return token + + def raise_syntax_error( + self, + message: str, + *, + span_start: Optional[int] = None, + span_end: Optional[int] = None, + ) -> NoReturn: + """Raise ParserSyntaxError at the given position.""" + span = ( + self.position if span_start is None else span_start, + self.position if span_end is None else span_end, + ) + raise ParserSyntaxError( + message, + source=self.source, + span=span, ) - def _make_token(self, name: str, text: str) -> Token: - """ - Make a token with the current position. - """ - return Token(name, text, self.position) + @contextlib.contextmanager + def enclosing_tokens(self, open_token: str, close_token: str) -> Iterator[bool]: + if self.check(open_token): + open_position = self.position + self.read() + else: + open_position = None - def _tokenize(self) -> Generator[Token, Token, None]: - """ - The main generator of tokens. - """ - while self.position < len(self.source): - for name, expression in self.rules.items(): - match = expression.match(self.source, self.position) - if match: - token_text = match[0] - - yield self._make_token(name, token_text.strip()) - self.position += len(token_text) - break - else: - raise self.raise_syntax_error(message="Unrecognized token") - yield self._make_token("END", "") + yield open_position is not None + + if open_position is None: + return + + if not self.check(close_token): + self.raise_syntax_error( + f"Expected closing {close_token}", + span_start=open_position, + ) + + self.read() diff --git a/packaging/markers.py b/packaging/markers.py index ddb0ac17..a5bf35c6 100644 --- a/packaging/markers.py +++ b/packaging/markers.py @@ -8,8 +8,8 @@ import sys from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from ._parser import MarkerAtom, MarkerList, Op, Value, Variable, parse_marker_expr -from ._tokenizer import ParseExceptionError, Tokenizer +from ._parser import MarkerAtom, MarkerList, Op, Value, Variable, parse_marker +from ._tokenizer import ParserSyntaxError from .specifiers import InvalidSpecifier, Specifier from .utils import canonicalize_name @@ -185,10 +185,11 @@ def default_environment() -> Dict[str, str]: class Marker: def __init__(self, marker: str) -> None: + # Note: We create a Marker object without calling this constructor in + # packaging.requirements.Requirement. If any additional logic is + # added here, make sure to mirror/adapt Requirement. try: - self._markers = _normalize_extra_values( - parse_marker_expr(Tokenizer(marker)) - ) + self._markers = _normalize_extra_values(parse_marker(marker)) # The attribute `_markers` can be described in terms of a recursive type: # MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]] # @@ -205,11 +206,8 @@ def __init__(self, marker: str) -> None: # (, , ) # ] # ] - except ParseExceptionError as e: - raise InvalidMarker( - f"Invalid marker: {marker!r}, parse error at " - f"{marker[e.position : e.position + 8]!r}" - ) + except ParserSyntaxError as e: + raise InvalidMarker(str(e)) from e def __str__(self) -> str: return _format_marker(self._markers) diff --git a/packaging/requirements.py b/packaging/requirements.py index 7a14a58f..a9f9b9c7 100644 --- a/packaging/requirements.py +++ b/packaging/requirements.py @@ -5,9 +5,9 @@ import urllib.parse from typing import Any, List, Optional, Set -from ._parser import parse_named_requirement -from ._tokenizer import ParseExceptionError -from .markers import InvalidMarker, Marker +from ._parser import parse_requirement +from ._tokenizer import ParserSyntaxError +from .markers import Marker, _normalize_extra_values from .specifiers import SpecifierSet @@ -32,29 +32,29 @@ class Requirement: def __init__(self, requirement_string: str) -> None: try: - req = parse_named_requirement(requirement_string) - except ParseExceptionError as e: - raise InvalidRequirement(str(e)) + parsed = parse_requirement(requirement_string) + except ParserSyntaxError as e: + raise InvalidRequirement(str(e)) from e - self.name: str = req.name - if req.url: - parsed_url = urllib.parse.urlparse(req.url) + self.name: str = parsed.name + if parsed.url: + parsed_url = urllib.parse.urlparse(parsed.url) if parsed_url.scheme == "file": - if urllib.parse.urlunparse(parsed_url) != req.url: + if urllib.parse.urlunparse(parsed_url) != parsed.url: raise InvalidRequirement("Invalid URL given") elif not (parsed_url.scheme and parsed_url.netloc) or ( not parsed_url.scheme and not parsed_url.netloc ): - raise InvalidRequirement(f"Invalid URL: {req.url}") - self.url: Optional[str] = req.url + raise InvalidRequirement(f"Invalid URL: {parsed.url}") + self.url: Optional[str] = parsed.url else: self.url = None - self.extras: Set[str] = set(req.extras if req.extras else []) - self.specifier: SpecifierSet = SpecifierSet(req.specifier) - try: - self.marker: Optional[Marker] = Marker(req.marker) if req.marker else None - except InvalidMarker as e: - raise InvalidRequirement(str(e)) + self.extras: Set[str] = set(parsed.extras if parsed.extras else []) + self.specifier: SpecifierSet = SpecifierSet(parsed.specifier) + self.marker: Optional[Marker] = None + if parsed.marker is not None: + self.marker = Marker.__new__(Marker) + self.marker._markers = _normalize_extra_values(parsed.marker) def __str__(self) -> str: parts: List[str] = [self.name] diff --git a/packaging/specifiers.py b/packaging/specifiers.py index 7543c0a8..645214ae 100644 --- a/packaging/specifiers.py +++ b/packaging/specifiers.py @@ -116,8 +116,10 @@ class Specifier(BaseSpecifier): # but included entirely as an escape hatch. (?<====) # Only match for the identity operator \s* - [^\s]* # We just match everything, except for whitespace - # since we are only testing for strict identity. + [^\s;)]* # The arbitrary version can be just about anything, + # we match everything except for whitespace, a + # semi-colon for marker support, and a closing paren + # since versions can be enclosed in them. ) | (?: diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 3360b5ed..36ea474b 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -2,217 +2,564 @@ # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. +from typing import Optional, Set + import pytest from packaging.markers import Marker from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import SpecifierSet +EQUAL_DEPENDENCIES = [ + ("packaging>20.1", "packaging>20.1"), + ( + 'requests[security, tests]>=2.8.1,==2.8.*;python_version<"2.7"', + 'requests [security,tests] >= 2.8.1, == 2.8.* ; python_version < "2.7"', + ), + ( + 'importlib-metadata; python_version<"3.8"', + "importlib-metadata; python_version<'3.8'", + ), + ( + 'appdirs>=1.4.4,<2; os_name=="posix" and extra=="testing"', + "appdirs>=1.4.4,<2; os_name == 'posix' and extra == 'testing'", + ), +] + +DIFFERENT_DEPENDENCIES = [ + ("package_one", "package_two"), + ("packaging>20.1", "packaging>=20.1"), + ("packaging>20.1", "packaging>21.1"), + ("packaging>20.1", "package>20.1"), + ( + 'requests[security,tests]>=2.8.1,==2.8.*;python_version<"2.7"', + 'requests [security,tests] >= 2.8.1 ; python_version < "2.7"', + ), + ( + 'importlib-metadata; python_version<"3.8"', + "importlib-metadata; python_version<'3.7'", + ), + ( + 'appdirs>=1.4.4,<2; os_name=="posix" and extra=="testing"', + "appdirs>=1.4.4,<2; os_name == 'posix' and extra == 'docs'", + ), +] + + +@pytest.mark.parametrize( + "name", + [ + "package", + "pAcKaGe", + "Package", + "foo-bar.quux_bAz", + "installer", + "android12", + ], +) +@pytest.mark.parametrize( + "extras", + [ + set(), + {"a"}, + {"a", "b"}, + {"a", "B", "CDEF123"}, + ], +) +@pytest.mark.parametrize( + ("url", "specifier"), + [ + (None, ""), + ("https://example.com/packagename.zip", ""), + ("ssh://user:pass%20word@example.com/packagename.zip", ""), + ("https://example.com/name;v=1.1/?query=foo&bar=baz#blah", ""), + ("git+ssh://git.example.com/MyProject", ""), + ("git+ssh://git@github.com:pypa/packaging.git", ""), + ("git+https://git.example.com/MyProject.git@master", ""), + ("git+https://git.example.com/MyProject.git@v1.0", ""), + ("git+https://git.example.com/MyProject.git@refs/pull/123/head", ""), + (None, "==={ws}arbitrarystring"), + (None, "({ws}==={ws}arbitrarystring{ws})"), + (None, "=={ws}1.0"), + (None, "({ws}=={ws}1.0{ws})"), + (None, "<={ws}1!3.0.0.rc2"), + (None, ">{ws}2.2{ws},{ws}<{ws}3"), + (None, "(>{ws}2.2{ws},{ws}<{ws}3)"), + ], +) +@pytest.mark.parametrize( + "marker", + [ + None, + "python_version{ws}>={ws}'3.3'", + '({ws}python_version{ws}>={ws}"3.4"{ws}){ws}and extra{ws}=={ws}"oursql"', + ( + "sys_platform{ws}!={ws}'linux' and(os_name{ws}=={ws}'linux' or " + "python_version{ws}>={ws}'3.3'{ws}){ws}" + ), + ], +) +@pytest.mark.parametrize("whitespace", ["", " ", "\t"]) +def test_basic_valid_requirement_parsing( + name: str, + extras: Set[str], + specifier: str, + url: Optional[str], + marker: str, + whitespace: str, +) -> None: + # GIVEN + parts = [name] + if extras: + parts.append("[") + parts.append("{ws},{ws}".format(ws=whitespace).join(sorted(extras))) + parts.append("]") + if specifier: + parts.append(specifier.format(ws=whitespace)) + if url is not None: + parts.append("@") + parts.append(url.format(ws=whitespace)) + if marker is not None: + if url is not None: + parts.append(" ;") + else: + parts.append(";") + parts.append(marker.format(ws=whitespace)) + + to_parse = whitespace.join(parts) + + # WHEN + req = Requirement(to_parse) + + # THEN + assert req.name == name + assert req.extras == extras + assert req.url == url + assert req.specifier == specifier.format(ws="").strip("()") + assert req.marker == (Marker(marker.format(ws="")) if marker else None) + + +class TestRequirementParsing: + @pytest.mark.parametrize( + "marker", + [ + "python_implementation == ''", + "platform_python_implementation == ''", + "os.name == 'linux'", + "os_name == 'linux'", + "'8' in platform.version", + "'8' not in platform.version", + ], + ) + def test_valid_marker(self, marker: str) -> None: + # GIVEN + to_parse = f"name; {marker}" + + # WHEN + Requirement(to_parse) + + @pytest.mark.parametrize( + "url", + [ + "file:///absolute/path", + "file://.", + ], + ) + def test_file_url(self, url: str) -> None: + # GIVEN + to_parse = f"name @ {url}" + + # WHEN + req = Requirement(to_parse) + + # THEN + assert req.url == url -class TestRequirements: - def test_string_specifier_marker(self): - requirement = 'name[bar]>=3; python_version == "2.7"' - req = Requirement(requirement) - assert str(req) == requirement + def test_empty_extras(self) -> None: + # GIVEN + to_parse = "name[]" - def test_string_url(self): - requirement = "name@ http://foo.com" - req = Requirement(requirement) - assert str(req) == requirement + # WHEN + req = Requirement(to_parse) - def test_string_url_with_marker(self): - requirement = 'name@ http://foo.com ; extra == "feature"' - req = Requirement(requirement) - assert str(req) == requirement + # THEN + assert req.name == "name" + assert req.extras == set() - def test_repr(self): - req = Requirement("name") - assert repr(req) == "" + def test_empty_specifier(self) -> None: + # GIVEN + to_parse = "name()" - def _assert_requirement( - self, req, name, url=None, extras=[], specifier="", marker=None - ): - assert req.name == name - assert req.url == url - assert sorted(req.extras) == sorted(extras) - assert str(req.specifier) == specifier - if marker: - assert str(req.marker) == marker - else: - assert req.marker is None - - def test_simple_names(self): - for name in ("A", "aa", "name"): - req = Requirement(name) - self._assert_requirement(req, name) - - def test_name_with_other_characters(self): - name = "foo-bar.quux_baz" - req = Requirement(name) - self._assert_requirement(req, name) - - def test_invalid_name(self): - with pytest.raises(InvalidRequirement): - Requirement("foo!") - - def test_name_with_version(self): - req = Requirement("name>=3") - self._assert_requirement(req, "name", specifier=">=3") - - def test_with_legacy_version(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("name==1.0.org1") - assert "Expected semicolon (followed by markers) or end of string" in str(e) - - def test_with_legacy_version_and_marker(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("name>=1.x.y;python_version=='2.6'") - assert "Expected semicolon (followed by markers) or end of string" in str(e) - - def test_missing_name(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("@ http://example.com") - assert "Expression must begin with package name" in str(e) - - def test_name_with_missing_version(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("name>=") - assert "Missing version" in str(e) - - def test_version_with_parens_and_whitespace(self): - req = Requirement("name (==4)") - self._assert_requirement(req, "name", specifier="==4") - - def test_version_with_missing_closing_paren(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("name(==4") - assert "Closing right parenthesis is missing" in str(e) - - def test_name_with_multiple_versions(self): - req = Requirement("name>=3,<2") - self._assert_requirement(req, "name", specifier="<2,>=3") - - def test_name_with_multiple_versions_and_whitespace(self): - req = Requirement("name >=2, <3") - self._assert_requirement(req, "name", specifier="<3,>=2") - - def test_name_with_multiple_versions_in_parenthesis(self): - req = Requirement("name (>=2,<3)") - self._assert_requirement(req, "name", specifier="<3,>=2") - - def test_name_with_no_extras_no_versions_in_parenthesis(self): - req = Requirement("name []()") - self._assert_requirement(req, "name", specifier="", extras=[]) - - def test_name_with_extra_and_multiple_versions_in_parenthesis(self): - req = Requirement("name [foo, bar](>=2,<3)") - self._assert_requirement(req, "name", specifier="<3,>=2", extras=["foo", "bar"]) - - def test_name_with_no_versions_in_parenthesis(self): - req = Requirement("name ()") - self._assert_requirement(req, "name", specifier="") - - def test_extras(self): - req = Requirement("foobar [quux,bar]") - self._assert_requirement(req, "foobar", extras=["bar", "quux"]) - - def test_empty_extras(self): - req = Requirement("foo[]") - self._assert_requirement(req, "foo") - - def test_unclosed_extras(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("foo[") - assert "Closing square bracket is missing" in str(e) - - def test_extras_without_comma(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("foobar[quux bar]") - assert "Missing comma after extra" in str(e) - - def test_url(self): - url_section = "test @ http://example.com" - req = Requirement(url_section) - self._assert_requirement(req, "test", "http://example.com", extras=[]) - - def test_url_and_marker(self): - instring = "test @ http://example.com ; os_name=='a'" - req = Requirement(instring) - self._assert_requirement( - req, "test", "http://example.com", extras=[], marker='os_name == "a"' + # WHEN + req = Requirement(to_parse) + + # THEN + assert req.name == "name" + assert req.specifier == "" + + # ---------------------------------------------------------------------------------- + # Everything below this (in this class) should be parsing failure modes + # ---------------------------------------------------------------------------------- + # Start all method names with with `test_error_` + # to make it easier to run these tests with `-k error` + + def test_error_when_empty_string(self) -> None: + # GIVEN + to_parse = "" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected package name at the start of dependency specifier\n" + " \n" + " ^" ) - def test_invalid_url(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("name @ gopher:/foo/com") - assert "Invalid URL: " in str(e.value) - assert "gopher:/foo/com" in str(e.value) - - def test_file_url(self): - req = Requirement("name @ file:///absolute/path") - self._assert_requirement(req, "name", "file:///absolute/path") - req = Requirement("name @ file://.") - self._assert_requirement(req, "name", "file://.") - - def test_invalid_file_urls(self): - with pytest.raises(InvalidRequirement): - Requirement("name @ file:.") - with pytest.raises(InvalidRequirement): - Requirement("name @ file:/.") - - def test_extras_and_url_and_marker(self): - req = Requirement("name [fred,bar] @ http://foo.com ; python_version=='2.7'") - self._assert_requirement( - req, - "name", - extras=["bar", "fred"], - url="http://foo.com", - marker='python_version == "2.7"', + def test_error_no_name(self) -> None: + # GIVEN + to_parse = "==0.0" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected package name at the start of dependency specifier\n" + " ==0.0\n" + " ^" + ) + + def test_error_when_missing_comma_in_extras(self) -> None: + # GIVEN + to_parse = "name[bar baz]" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected comma between extra names\n" + " name[bar baz]\n" + " ^" + ) + + def test_error_when_trailing_comma_in_extras(self) -> None: + # GIVEN + to_parse = "name[bar, baz,]" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected extra name after comma\n" + " name[bar, baz,]\n" + " ^" ) - def test_complex_url_and_marker(self): - url = "https://example.com/name;v=1.1/?query=foo&bar=baz#blah" - req = Requirement("foo @ %s ; python_version=='3.4'" % url) - self._assert_requirement(req, "foo", url=url, marker='python_version == "3.4"') + def test_error_when_parens_not_closed_correctly(self) -> None: + # GIVEN + to_parse = "name (>= 1.0" - def test_multiple_markers(self): - req = Requirement( - "name[quux, strange];python_version<'2.7' and " "platform_version=='2'" + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected closing RIGHT_PARENTHESIS\n" + " name (>= 1.0\n" + " ~~~~~~~^" ) - marker = 'python_version < "2.7" and platform_version == "2"' - self._assert_requirement(req, "name", extras=["strange", "quux"], marker=marker) - def test_multiple_comparison_markers(self): - req = Requirement("name; os_name=='a' and os_name=='b' or os_name=='c'") - marker = 'os_name == "a" and os_name == "b" or os_name == "c"' - self._assert_requirement(req, "name", marker=marker) + def test_error_when_bracket_not_closed_correctly(self) -> None: + # GIVEN + to_parse = "name[bar, baz >= 1.0" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) - def test_invalid_marker(self): - with pytest.raises(InvalidRequirement): - Requirement("name; foobar=='x'") + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected closing RIGHT_BRACKET\n" + " name[bar, baz >= 1.0\n" + " ~~~~~~~~~~^" + ) + + def test_error_when_extras_bracket_left_unclosed(self) -> None: + # GIVEN + to_parse = "name[bar, baz" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected closing RIGHT_BRACKET\n" + " name[bar, baz\n" + " ~~~~~~~~~^" + ) + + def test_error_no_space_after_url(self) -> None: + # GIVEN + to_parse = "name @ https://example.com/; extra == 'example'" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected end or semicolon (after URL and whitespace)\n" + " name @ https://example.com/; extra == 'example'\n" + " ~~~~~~~~~~~~~~~~~~~~~~^" + ) + + def test_error_no_url_after_at(self) -> None: + # GIVEN + to_parse = "name @ " + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected URL after @\n" + " name @ \n" + " ^" + ) + + def test_error_invalid_marker_lvalue(self) -> None: + # GIVEN + to_parse = "name; invalid_name" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected a marker variable or quoted string\n" + " name; invalid_name\n" + " ^" + ) + + def test_error_invalid_marker_rvalue(self) -> None: + # GIVEN + to_parse = "name; '3.7' <= invalid_name" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected a marker variable or quoted string\n" + " name; '3.7' <= invalid_name\n" + " ^" + ) + + def test_error_invalid_marker_notin_without_whitespace(self) -> None: + # GIVEN + to_parse = "name; '3.7' notin python_version" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected marker operator, one of <=, <, !=, ==, >=, >, ~=, ===, " + "in, not in\n" + " name; '3.7' notin python_version\n" + " ^" + ) + + def test_error_when_no_word_boundary(self) -> None: + # GIVEN + to_parse = "name; '3.6'inpython_version" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected marker operator, one of <=, <, !=, ==, >=, >, ~=, ===, " + "in, not in\n" + " name; '3.6'inpython_version\n" + " ^" + ) - def test_marker_with_missing_semicolon(self): - with pytest.raises(InvalidRequirement) as e: - Requirement('name[bar]>=3 python_version == "2.7"') - assert "Expected semicolon (followed by markers) or end of string" in str(e) + def test_error_invalid_marker_not_without_in(self) -> None: + # GIVEN + to_parse = "name; '3.7' not python_version" - def test_types(self): - req = Requirement("foobar[quux]<2,>=3; os_name=='a'") + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected 'in' after 'not'\n" + " name; '3.7' not python_version\n" + " ^" + ) + + def test_error_invalid_marker_with_invalid_op(self) -> None: + # GIVEN + to_parse = "name; '3.7' ~ python_version" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected marker operator, one of <=, <, !=, ==, >=, >, ~=, ===, " + "in, not in\n" + " name; '3.7' ~ python_version\n" + " ^" + ) + + @pytest.mark.parametrize( + "url", + [ + "gopher:/foo/com", + "file:.", + "file:/.", + ], + ) + def test_error_on_invalid_url(self, url: str) -> None: + # GIVEN + to_parse = f"name @ {url}" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert "Invalid URL" in ctx.exconly() + + def test_error_on_legacy_version_outside_triple_equals(self) -> None: + # GIVEN + to_parse = "name==1.0.org1" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected end or semicolon (after version specifier)\n" + " name==1.0.org1\n" + " ~~~~~^" + ) + + def test_error_on_missing_version_after_op(self) -> None: + # GIVEN + to_parse = "name==" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected version after operator\n" + " name==\n" + " ^" + ) + + def test_error_on_missing_op_after_name(self) -> None: + # GIVEN + to_parse = "name 1.0" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected end or semicolon (after name and no valid version specifier)\n" + " name 1.0\n" + " ^" + ) + + def test_error_on_random_char_after_specifier(self) -> None: + # GIVEN + to_parse = "name >= 1.0 #" + + # WHEN + with pytest.raises(InvalidRequirement) as ctx: + Requirement(to_parse) + + # THEN + assert ctx.exconly() == ( + "packaging.requirements.InvalidRequirement: " + "Expected end or semicolon (after version specifier)\n" + " name >= 1.0 #\n" + " ~~~~~~~^" + ) + + +class TestRequirementBehaviour: + def test_types_with_nothing(self) -> None: + # GIVEN + to_parse = "foobar" + + # WHEN + req = Requirement(to_parse) + + # THEN assert isinstance(req.name, str) assert isinstance(req.extras, set) assert req.url is None assert isinstance(req.specifier, SpecifierSet) - assert isinstance(req.marker, Marker) + assert req.marker is None - def test_types_with_nothing(self): - req = Requirement("foobar") + def test_types_with_specifier_and_marker(self) -> None: + # GIVEN + to_parse = "foobar[quux]<2,>=3; os_name=='a'" + + # WHEN + req = Requirement(to_parse) + + # THEN assert isinstance(req.name, str) assert isinstance(req.extras, set) assert req.url is None assert isinstance(req.specifier, SpecifierSet) - assert req.marker is None + assert isinstance(req.marker, Marker) - def test_types_with_url(self): + def test_types_with_url(self) -> None: req = Requirement("foobar @ http://foo.com") assert isinstance(req.name, str) assert isinstance(req.extras, set) @@ -220,77 +567,46 @@ def test_types_with_url(self): assert isinstance(req.specifier, SpecifierSet) assert req.marker is None - def test_sys_platform_linux_equal(self): - req = Requirement('something>=1.2.3; sys_platform == "foo"') - - assert req.name == "something" - assert req.marker is not None - assert req.marker.evaluate(dict(sys_platform="foo")) is True - assert req.marker.evaluate(dict(sys_platform="bar")) is False - - def test_sys_platform_linux_in(self): - req = Requirement("aviato>=1.2.3; 'f' in sys_platform") - - assert req.name == "aviato" - assert req.marker is not None - assert req.marker.evaluate(dict(sys_platform="foo")) is True - assert req.marker.evaluate(dict(sys_platform="bar")) is False - - def test_parseexception_error_msg(self): - with pytest.raises(InvalidRequirement) as e: - Requirement("toto 42") - assert "Expected semicolon (followed by markers) or end of string" in str(e) - - EQUAL_DEPENDENCIES = [ - ("packaging>20.1", "packaging>20.1"), - ( - 'requests[security, tests]>=2.8.1,==2.8.*;python_version<"2.7"', - 'requests [security,tests] >= 2.8.1, == 2.8.* ; python_version < "2.7"', - ), - ( - 'importlib-metadata; python_version<"3.8"', - "importlib-metadata; python_version<'3.8'", - ), - ( - 'appdirs>=1.4.4,<2; os_name=="posix" and extra=="testing"', - "appdirs>=1.4.4,<2; os_name == 'posix' and extra == 'testing'", - ), - ] - - DIFFERENT_DEPENDENCIES = [ - ("packaging>20.1", "packaging>=20.1"), - ("packaging>20.1", "packaging>21.1"), - ("packaging>20.1", "package>20.1"), - ( - 'requests[security,tests]>=2.8.1,==2.8.*;python_version<"2.7"', - 'requests [security,tests] >= 2.8.1 ; python_version < "2.7"', - ), - ( - 'importlib-metadata; python_version<"3.8"', - "importlib-metadata; python_version<'3.7'", - ), - ( - 'appdirs>=1.4.4,<2; os_name=="posix" and extra=="testing"', - "appdirs>=1.4.4,<2; os_name == 'posix' and extra == 'docs'", - ), - ] + @pytest.mark.parametrize( + "url_or_specifier", + ["", "@ https://url ", "!=2.0", "==2.*"], + ) + @pytest.mark.parametrize("extras", ["", "[a]", "[a,b]", "[a1,b1,b2]"]) + @pytest.mark.parametrize( + "marker", + ["", '; python_version == "3.11"', '; "3." not in python_version'], + ) + def test_str_and_repr( + self, extras: str, url_or_specifier: str, marker: str + ) -> None: + # GIVEN + to_parse = f"name{extras}{url_or_specifier}{marker}".strip() + + # WHEN + req = Requirement(to_parse) + + # THEN + assert str(req) == to_parse + assert repr(req) == f"" @pytest.mark.parametrize("dep1, dep2", EQUAL_DEPENDENCIES) - def test_equal_reqs_equal_hashes(self, dep1, dep2): - # Requirement objects created from equivalent strings should be equal. + def test_equal_reqs_equal_hashes(self, dep1: str, dep2: str) -> None: + """Requirement objects created from equivalent strings should be equal.""" + # GIVEN / WHEN req1, req2 = Requirement(dep1), Requirement(dep2) + assert req1 == req2 - # Equal Requirement objects should have the same hash. assert hash(req1) == hash(req2) @pytest.mark.parametrize("dep1, dep2", DIFFERENT_DEPENDENCIES) - def test_different_reqs_different_hashes(self, dep1, dep2): - # Requirement objects created from non-equivalent strings should differ. + def test_different_reqs_different_hashes(self, dep1: str, dep2: str) -> None: + """Requirement objects created from non-equivalent strings should differ.""" + # GIVEN / WHEN req1, req2 = Requirement(dep1), Requirement(dep2) + + # THEN assert req1 != req2 - # Different Requirement objects should have different hashes. assert hash(req1) != hash(req2) - def test_compare_reqs_to_other_objects(self): - # Requirement objects should not be comparable to other kinds of objects. + def test_compare_with_string(self) -> None: assert Requirement("packaging>=21.3") != "packaging>=21.3"