1# This file is dual licensed under the terms of the Apache License, Version 2# 2.0, and the BSD License. See the LICENSE file in the root of this repository 3# for complete details. 4 5import re 6import string 7import urllib.parse 8from typing import List, Optional as TOptional, Set 9 10from pyparsing import ( # noqa 11 Combine, 12 Literal as L, 13 Optional, 14 ParseException, 15 Regex, 16 Word, 17 ZeroOrMore, 18 originalTextFor, 19 stringEnd, 20 stringStart, 21) 22 23from .markers import MARKER_EXPR, Marker 24from .specifiers import LegacySpecifier, Specifier, SpecifierSet 25 26 27class InvalidRequirement(ValueError): 28 """ 29 An invalid requirement was found, users should refer to PEP 508. 30 """ 31 32 33ALPHANUM = Word(string.ascii_letters + string.digits) 34 35LBRACKET = L("[").suppress() 36RBRACKET = L("]").suppress() 37LPAREN = L("(").suppress() 38RPAREN = L(")").suppress() 39COMMA = L(",").suppress() 40SEMICOLON = L(";").suppress() 41AT = L("@").suppress() 42 43PUNCTUATION = Word("-_.") 44IDENTIFIER_END = ALPHANUM | (ZeroOrMore(PUNCTUATION) + ALPHANUM) 45IDENTIFIER = Combine(ALPHANUM + ZeroOrMore(IDENTIFIER_END)) 46 47NAME = IDENTIFIER("name") 48EXTRA = IDENTIFIER 49 50URI = Regex(r"[^ ]+")("url") 51URL = AT + URI 52 53EXTRAS_LIST = EXTRA + ZeroOrMore(COMMA + EXTRA) 54EXTRAS = (LBRACKET + Optional(EXTRAS_LIST) + RBRACKET)("extras") 55 56VERSION_PEP440 = Regex(Specifier._regex_str, re.VERBOSE | re.IGNORECASE) 57VERSION_LEGACY = Regex(LegacySpecifier._regex_str, re.VERBOSE | re.IGNORECASE) 58 59VERSION_ONE = VERSION_PEP440 ^ VERSION_LEGACY 60VERSION_MANY = Combine( 61 VERSION_ONE + ZeroOrMore(COMMA + VERSION_ONE), joinString=",", adjacent=False 62)("_raw_spec") 63_VERSION_SPEC = Optional((LPAREN + VERSION_MANY + RPAREN) | VERSION_MANY) 64_VERSION_SPEC.setParseAction(lambda s, l, t: t._raw_spec or "") 65 66VERSION_SPEC = originalTextFor(_VERSION_SPEC)("specifier") 67VERSION_SPEC.setParseAction(lambda s, l, t: t[1]) 68 69MARKER_EXPR = originalTextFor(MARKER_EXPR())("marker") 70MARKER_EXPR.setParseAction( 71 lambda s, l, t: Marker(s[t._original_start : t._original_end]) 72) 73MARKER_SEPARATOR = SEMICOLON 74MARKER = MARKER_SEPARATOR + MARKER_EXPR 75 76VERSION_AND_MARKER = VERSION_SPEC + Optional(MARKER) 77URL_AND_MARKER = URL + Optional(MARKER) 78 79NAMED_REQUIREMENT = NAME + Optional(EXTRAS) + (URL_AND_MARKER | VERSION_AND_MARKER) 80 81REQUIREMENT = stringStart + NAMED_REQUIREMENT + stringEnd 82# pyparsing isn't thread safe during initialization, so we do it eagerly, see 83# issue #104 84REQUIREMENT.parseString("x[]") 85 86 87class Requirement: 88 """Parse a requirement. 89 90 Parse a given requirement string into its parts, such as name, specifier, 91 URL, and extras. Raises InvalidRequirement on a badly-formed requirement 92 string. 93 """ 94 95 # TODO: Can we test whether something is contained within a requirement? 96 # If so how do we do that? Do we need to test against the _name_ of 97 # the thing as well as the version? What about the markers? 98 # TODO: Can we normalize the name and extra name? 99 100 def __init__(self, requirement_string: str) -> None: 101 try: 102 req = REQUIREMENT.parseString(requirement_string) 103 except ParseException as e: 104 raise InvalidRequirement( 105 f'Parse error at "{ requirement_string[e.loc : e.loc + 8]!r}": {e.msg}' 106 ) 107 108 self.name: str = req.name 109 if req.url: 110 parsed_url = urllib.parse.urlparse(req.url) 111 if parsed_url.scheme == "file": 112 if urllib.parse.urlunparse(parsed_url) != req.url: 113 raise InvalidRequirement("Invalid URL given") 114 elif not (parsed_url.scheme and parsed_url.netloc) or ( 115 not parsed_url.scheme and not parsed_url.netloc 116 ): 117 raise InvalidRequirement(f"Invalid URL: {req.url}") 118 self.url: TOptional[str] = req.url 119 else: 120 self.url = None 121 self.extras: Set[str] = set(req.extras.asList() if req.extras else []) 122 self.specifier: SpecifierSet = SpecifierSet(req.specifier) 123 self.marker: TOptional[Marker] = req.marker if req.marker else None 124 125 def __str__(self) -> str: 126 parts: List[str] = [self.name] 127 128 if self.extras: 129 formatted_extras = ",".join(sorted(self.extras)) 130 parts.append(f"[{formatted_extras}]") 131 132 if self.specifier: 133 parts.append(str(self.specifier)) 134 135 if self.url: 136 parts.append(f"@ {self.url}") 137 if self.marker: 138 parts.append(" ") 139 140 if self.marker: 141 parts.append(f"; {self.marker}") 142 143 return "".join(parts) 144 145 def __repr__(self) -> str: 146 return f"<Requirement('{self}')>" 147