1import re
2from collections import namedtuple
3
4from six import string_types
5
6from conans.errors import ConanException, InvalidNameException
7from conans.model.version import Version
8
9
10def _split_pair(pair, split_char):
11    if not pair or pair == split_char:
12        return None, None
13    if split_char not in pair:
14        return None
15
16    words = pair.split(split_char)
17    if len(words) != 2:
18        raise ConanException("The reference has too many '{}'".format(split_char))
19    else:
20        return words
21
22
23def _noneize(text):
24    if not text or text == "_":
25        return None
26    return text
27
28
29def get_reference_fields(arg_reference, user_channel_input=False):
30    # FIXME: The partial references meaning user/channel should be disambiguated at 2.0
31    """
32    :param arg_reference: String with a complete reference, or
33        only user/channel (if user_channel_input)
34        only name/version (if not pattern_is_user_channel)
35    :param user_channel_input: Two items means user/channel or not.
36    :return: name, version, user and channel, in a tuple
37    """
38
39    if not arg_reference:
40        return None, None, None, None, None
41
42    revision = None
43
44    if "#" in arg_reference:
45        tmp = arg_reference.split("#", 1)
46        revision = tmp[1]
47        arg_reference = tmp[0]
48
49    if "@" in arg_reference:
50        name_version, user_channel = _split_pair(arg_reference, "@")
51        # FIXME: Conan 2.0
52        #  In conan now "xxx@conan/stable" means that xxx is the version, I would say it should
53        #  be the name
54        name, version = _split_pair(name_version, "/") or (None, name_version)
55        user, channel = _split_pair(user_channel, "/") or (user_channel, None)
56
57        return _noneize(name), _noneize(version), _noneize(user), _noneize(channel), \
58               _noneize(revision)
59    else:
60        if user_channel_input:
61            # x/y is user and channel
62            el1, el2 = _split_pair(arg_reference, "/") or (arg_reference, None)
63            return None, None, _noneize(el1), _noneize(el2), _noneize(revision)
64        else:
65            # x/y is name and version
66            el1, el2 = _split_pair(arg_reference, "/") or (arg_reference, None)
67            return _noneize(el1), _noneize(el2), None, None, _noneize(revision)
68
69
70def check_valid_ref(reference, strict_mode=True):
71    """
72    :param reference: string to be analyzed if it is a reference or not
73    :param strict_mode: Only if the reference contains the "@" is valid, used to disambiguate"""
74    try:
75        if not reference:
76            return False
77        if strict_mode:
78            if "@" not in reference:
79                return False
80            if "*" in reference:
81                ref = ConanFileReference.loads(reference, validate=True)
82                if "*" in ref.name or "*" in ref.user or "*" in ref.channel:
83                    return False
84                if str(ref.version).startswith("["):  # It is a version range
85                    return True
86                return False
87        ConanFileReference.loads(reference, validate=True)
88        return True
89    except ConanException:
90        return False
91
92
93class ConanName(object):
94    _max_chars = 51
95    _min_chars = 2
96    _validation_pattern = re.compile("^[a-zA-Z0-9_][a-zA-Z0-9_\+\.-]{%s,%s}$"
97                                     % (_min_chars - 1, _max_chars - 1))
98
99    _validation_revision_pattern = re.compile("^[a-zA-Z0-9]{1,%s}$" % _max_chars)
100
101    @staticmethod
102    def raise_invalid_name_error(value, reference_token=None):
103        if len(value) > ConanName._max_chars:
104            reason = "is too long. Valid names must contain at most %s characters."\
105                     % ConanName._max_chars
106        elif len(value) < ConanName._min_chars:
107            reason = "is too short. Valid names must contain at least %s characters."\
108                     % ConanName._min_chars
109        else:
110            reason = ("is an invalid name. Valid names MUST begin with a "
111                      "letter, number or underscore, have between %s-%s chars, including "
112                      "letters, numbers, underscore, dot and dash"
113                      % (ConanName._min_chars, ConanName._max_chars))
114        message = "Value provided{ref_token}, '{value}' (type {type}), {reason}".format(
115            ref_token=" for {}".format(reference_token) if reference_token else "",
116            value=value, type=type(value).__name__, reason=reason
117        )
118        raise InvalidNameException(message)
119
120    @staticmethod
121    def raise_invalid_version_error(name, version):
122        message = ("Package {} has an invalid version number: '{}'. Valid names "
123                   "MUST begin with a letter, number or underscore, have "
124                   "between {}-{} chars, including letters, numbers, "
125                   "underscore, dot and dash").format(
126            name,
127            version,
128            ConanName._min_chars,
129            ConanName._max_chars
130        )
131        raise InvalidNameException(message)
132
133    @staticmethod
134    def validate_string(value, reference_token=None):
135        """Check for string"""
136        if not isinstance(value, string_types):
137            message = "Value provided{ref_token}, '{value}' (type {type}), {reason}".format(
138                ref_token=" for {}".format(reference_token) if reference_token else "",
139                value=value, type=type(value).__name__,
140                reason="is not a string"
141            )
142            raise InvalidNameException(message)
143
144    @staticmethod
145    def validate_name(name, reference_token=None):
146        """Check for name compliance with pattern rules"""
147        ConanName.validate_string(name, reference_token=reference_token)
148        if name == "*":
149            return
150        if ConanName._validation_pattern.match(name) is None:
151            ConanName.raise_invalid_name_error(name, reference_token=reference_token)
152
153    @staticmethod
154    def validate_version(version, pkg_name):
155        ConanName.validate_string(version)
156        if version == "*":
157            return
158        if ConanName._validation_pattern.match(version) is None:
159            if (
160                (version.startswith("[") and version.endswith("]"))
161                or (version.startswith("(") and version.endswith(")"))
162            ):
163                return
164            ConanName.raise_invalid_version_error(pkg_name, version)
165
166    @staticmethod
167    def validate_revision(revision):
168        if ConanName._validation_revision_pattern.match(revision) is None:
169            raise InvalidNameException("The revision field, must contain only letters "
170                                       "and numbers with a length between 1 and "
171                                       "%s" % ConanName._max_chars)
172
173
174class ConanFileReference(namedtuple("ConanFileReference", "name version user channel revision")):
175    """ Full reference of a package recipes, e.g.:
176    opencv/2.4.10@lasote/testing
177    """
178
179    def __new__(cls, name, version, user, channel, revision=None, validate=True):
180        """Simple name creation.
181        @param name:        string containing the desired name
182        @param version:     string containing the desired version
183        @param user:        string containing the user name
184        @param channel:     string containing the user channel
185        @param revision:    string containing the revision (optional)
186        """
187        if (user and not channel) or (channel and not user):
188            raise InvalidNameException("Specify the 'user' and the 'channel' or neither of them")
189
190        version = Version(version) if version is not None else None
191        user = _noneize(user)
192        channel = _noneize(channel)
193
194        obj = super(cls, ConanFileReference).__new__(cls, name, version, user, channel, revision)
195        if validate:
196            obj._validate()
197        return obj
198
199    def _validate(self):
200        if self.name is not None:
201            ConanName.validate_name(self.name, reference_token="package name")
202        if self.version is not None:
203            ConanName.validate_version(self.version, self.name)
204        if self.user is not None:
205            ConanName.validate_name(self.user, reference_token="user name")
206        if self.channel is not None:
207            ConanName.validate_name(self.channel, reference_token="channel")
208        if self.revision is not None:
209            ConanName.validate_revision(self.revision)
210
211        if not self.name or not self.version:
212            raise InvalidNameException("Specify the 'name' and the 'version'")
213
214        if (self.user and not self.channel) or (self.channel and not self.user):
215            raise InvalidNameException("Specify the 'user' and the 'channel' or neither of them")
216
217    @staticmethod
218    def loads(text, validate=True):
219        """ Parses a text string to generate a ConanFileReference object
220        """
221        name, version, user, channel, revision = get_reference_fields(text)
222        ref = ConanFileReference(name, version, user, channel, revision, validate=validate)
223        return ref
224
225    @staticmethod
226    def load_dir_repr(dir_repr):
227        name, version, user, channel = dir_repr.split("/")
228        if user == "_":
229            user = None
230        if channel == "_":
231            channel = None
232        return ConanFileReference(name, version, user, channel)
233
234    def __str__(self):
235        if self.name is None and self.version is None:
236            return ""
237        if self.user is None and self.channel is None:
238            return "%s/%s" % (self.name, self.version)
239        return "%s/%s@%s/%s" % (self.name, self.version, self.user, self.channel)
240
241    def __repr__(self):
242        str_rev = "#%s" % self.revision if self.revision else ""
243        user_channel = "@%s/%s" % (self.user, self.channel) if self.user or self.channel else ""
244        return "%s/%s%s%s" % (self.name, self.version, user_channel, str_rev)
245
246    def full_str(self):
247        str_rev = "#%s" % self.revision if self.revision else ""
248        return "%s%s" % (str(self), str_rev)
249
250    def dir_repr(self):
251        return "/".join([self.name, self.version, self.user or "_", self.channel or "_"])
252
253    def copy_with_rev(self, revision):
254        return ConanFileReference(self.name, self.version, self.user, self.channel, revision,
255                                  validate=False)
256
257    def copy_clear_rev(self):
258        return ConanFileReference(self.name, self.version, self.user, self.channel, None,
259                                  validate=False)
260
261    def __lt__(self, other):
262        def de_noneize(ref):
263            return ref.name, ref.version, ref.user or "", ref.channel or "", ref.revision or ""
264
265        return de_noneize(self) < de_noneize(other)
266
267    def is_compatible_with(self, new_ref):
268        """Returns true if the new_ref is completing the RREV field of this object but
269         having the rest equal """
270        if repr(self) == repr(new_ref):
271            return True
272        if self.copy_clear_rev() != new_ref.copy_clear_rev():
273            return False
274
275        return self.revision is None
276
277
278class PackageReference(namedtuple("PackageReference", "ref id revision")):
279    """ Full package reference, e.g.:
280    opencv/2.4.10@lasote/testing, fe566a677f77734ae
281    """
282
283    def __new__(cls, ref, package_id, revision=None, validate=True):
284        if "#" in package_id:
285            package_id, revision = package_id.rsplit("#", 1)
286        obj = super(cls, PackageReference).__new__(cls, ref, package_id, revision)
287        if validate:
288            obj.validate()
289        return obj
290
291    def validate(self):
292        if self.revision:
293            ConanName.validate_revision(self.revision)
294
295    @staticmethod
296    def loads(text, validate=True):
297        text = text.strip()
298        tmp = text.split(":")
299        try:
300            ref = ConanFileReference.loads(tmp[0].strip(), validate=validate)
301            package_id = tmp[1].strip()
302        except IndexError:
303            raise ConanException("Wrong package reference %s" % text)
304        return PackageReference(ref, package_id, validate=validate)
305
306    def __repr__(self):
307        str_rev = "#%s" % self.revision if self.revision else ""
308        tmp = "%s:%s%s" % (repr(self.ref), self.id, str_rev)
309        return tmp
310
311    def __str__(self):
312        return "%s:%s" % (self.ref, self.id)
313
314    def __lt__(self, other):
315        # We need this operator to sort prefs to compute the package_id
316        # package_id() -> ConanInfo.package_id() -> RequirementsInfo.sha() -> sorted(prefs) -> lt
317        me = self.ref, self.id, self.revision or ""
318        other = other.ref, other.id, other.revision or ""
319        return me < other
320
321    def full_str(self):
322        str_rev = "#%s" % self.revision if self.revision else ""
323        tmp = "%s:%s%s" % (self.ref.full_str(), self.id, str_rev)
324        return tmp
325
326    def copy_with_revs(self, revision, p_revision):
327        return PackageReference(self.ref.copy_with_rev(revision), self.id, p_revision)
328
329    def copy_clear_prev(self):
330        return self.copy_with_revs(self.ref.revision, None)
331
332    def copy_clear_revs(self):
333        return self.copy_with_revs(None, None)
334
335    def is_compatible_with(self, new_ref):
336        """Returns true if the new_ref is completing the PREV field of this object but
337         having the rest equal """
338        if repr(self) == repr(new_ref):
339            return True
340        if not self.ref.is_compatible_with(new_ref.ref) or self.id != new_ref.id:
341            return False
342
343        return self.revision is None  # Only the revision is different and we don't have one
344