1"""
2    :copyright: Copyright 2017 by the SaltStack Team, see AUTHORS for more details.
3    :license: Apache 2.0, see LICENSE for more details.
4
5
6    salt.utils.versions
7    ~~~~~~~~~~~~~~~~~~~
8
9    Version parsing based on distutils.version which works under python 3
10    because on python 3 you can no longer compare strings against integers.
11"""
12
13
14import datetime
15import inspect
16import logging
17import numbers
18import sys
19import warnings
20
21# pylint: disable=blacklisted-module
22from distutils.version import LooseVersion as _LooseVersion
23from distutils.version import StrictVersion as _StrictVersion
24
25# pylint: enable=blacklisted-module
26import salt.version
27
28log = logging.getLogger(__name__)
29
30
31class StrictVersion(_StrictVersion):
32    def parse(self, vstring):
33        _StrictVersion.parse(self, vstring)
34
35    def _cmp(self, other):
36        if isinstance(other, str):
37            other = StrictVersion(other)
38        return _StrictVersion._cmp(self, other)
39
40
41class LooseVersion(_LooseVersion):
42    def parse(self, vstring):
43        _LooseVersion.parse(self, vstring)
44
45        # Convert every part of the version to string in order to be able to compare
46        self._str_version = [
47            str(vp).zfill(8) if isinstance(vp, int) else vp for vp in self.version
48        ]
49
50    def _cmp(self, other):
51        if isinstance(other, str):
52            other = LooseVersion(other)
53
54        string_in_version = False
55        for part in self.version + other.version:
56            if not isinstance(part, int):
57                string_in_version = True
58                break
59
60        if string_in_version is False:
61            return _LooseVersion._cmp(self, other)
62
63        # If we reached this far, it means at least a part of the version contains a string
64        # In python 3, strings and integers are not comparable
65        if self._str_version == other._str_version:
66            return 0
67        if self._str_version < other._str_version:
68            return -1
69        if self._str_version > other._str_version:
70            return 1
71
72
73def _format_warning(message, category, filename, lineno, line=None):
74    """
75    Replacement for warnings.formatwarning that disables the echoing of
76    the 'line' parameter.
77    """
78    return "{}:{}: {}: {}\n".format(filename, lineno, category.__name__, message)
79
80
81def warn_until(
82    version,
83    message,
84    category=DeprecationWarning,
85    stacklevel=None,
86    _version_info_=None,
87    _dont_call_warnings=False,
88):
89    """
90    Helper function to raise a warning, by default, a ``DeprecationWarning``,
91    until the provided ``version``, after which, a ``RuntimeError`` will
92    be raised to remind the developers to remove the warning because the
93    target version has been reached.
94
95    :param version: The version info or name after which the warning becomes a ``RuntimeError``.
96                    For example ``(2019, 2)``, ``3000``, ``Hydrogen`` or an instance of
97                    :class:`salt.version.SaltStackVersion` or :class:`salt.version.SaltVersion`.
98    :param message: The warning message to be displayed.
99    :param category: The warning class to be thrown, by default
100                     ``DeprecationWarning``
101    :param stacklevel: There should be no need to set the value of
102                       ``stacklevel``. Salt should be able to do the right thing.
103    :param _version_info_: In order to reuse this function for other SaltStack
104                           projects, they need to be able to provide the
105                           version info to compare to.
106    :param _dont_call_warnings: This parameter is used just to get the
107                                functionality until the actual error is to be
108                                issued. When we're only after the salt version
109                                checks to raise a ``RuntimeError``.
110    """
111    if isinstance(version, salt.version.SaltVersion):
112        version = salt.version.SaltStackVersion(*version.info)
113    elif isinstance(version, int):
114        version = salt.version.SaltStackVersion(version)
115    elif isinstance(version, tuple):
116        version = salt.version.SaltStackVersion(*version)
117    elif isinstance(version, str):
118        if version.lower() not in salt.version.SaltStackVersion.LNAMES:
119            raise RuntimeError(
120                "Incorrect spelling for the release name in the warn_utils "
121                "call. Expecting one of these release names: {}".format(
122                    [vs.name for vs in salt.version.SaltVersionsInfo.versions()]
123                )
124            )
125        version = salt.version.SaltStackVersion.from_name(version)
126    elif not isinstance(version, salt.version.SaltStackVersion):
127        raise RuntimeError(
128            "The 'version' argument should be passed as a tuple, integer, string or "
129            "an instance of 'salt.version.SaltVersion' or "
130            "'salt.version.SaltStackVersion'."
131        )
132
133    if stacklevel is None:
134        # Attribute the warning to the calling function, not to warn_until()
135        stacklevel = 2
136
137    if _version_info_ is None:
138        _version_info_ = salt.version.__version_info__
139
140    _version_ = salt.version.SaltStackVersion(*_version_info_)
141
142    if _version_ >= version:
143        caller = inspect.getframeinfo(sys._getframe(stacklevel - 1))
144        raise RuntimeError(
145            "The warning triggered on filename '{filename}', line number "
146            "{lineno}, is supposed to be shown until version "
147            "{until_version} is released. Current version is now "
148            "{salt_version}. Please remove the warning.".format(
149                filename=caller.filename,
150                lineno=caller.lineno,
151                until_version=version.formatted_version,
152                salt_version=_version_.formatted_version,
153            ),
154        )
155
156    if _dont_call_warnings is False:
157        warnings.warn(
158            message.format(version=version.formatted_version),
159            category,
160            stacklevel=stacklevel,
161        )
162
163
164def warn_until_date(
165    date,
166    message,
167    category=DeprecationWarning,
168    stacklevel=None,
169    _current_date=None,
170    _dont_call_warnings=False,
171):
172    """
173    Helper function to raise a warning, by default, a ``DeprecationWarning``,
174    until the provided ``date``, after which, a ``RuntimeError`` will
175    be raised to remind the developers to remove the warning because the
176    target date has been reached.
177
178    :param date: A ``datetime.date`` or ``datetime.datetime`` instance.
179    :param message: The warning message to be displayed.
180    :param category: The warning class to be thrown, by default
181                     ``DeprecationWarning``
182    :param stacklevel: There should be no need to set the value of
183                       ``stacklevel``. Salt should be able to do the right thing.
184    :param _dont_call_warnings: This parameter is used just to get the
185                                functionality until the actual error is to be
186                                issued. When we're only after the date
187                                checks to raise a ``RuntimeError``.
188    """
189    _strptime_fmt = "%Y%m%d"
190    if not isinstance(date, (str, datetime.date, datetime.datetime)):
191        raise RuntimeError(
192            "The 'date' argument should be passed as a 'datetime.date()' or "
193            "'datetime.datetime()' objects or as string parserable by "
194            "'datetime.datetime.strptime()' with the following format '{}'.".format(
195                _strptime_fmt
196            )
197        )
198    elif isinstance(date, str):
199        date = datetime.datetime.strptime(date, _strptime_fmt)
200
201    # We're really not interested in the time
202    if isinstance(date, datetime.datetime):
203        date = date.date()
204
205    if stacklevel is None:
206        # Attribute the warning to the calling function, not to warn_until_date()
207        stacklevel = 2
208
209    today = _current_date or datetime.datetime.utcnow().date()
210    if today >= date:
211        caller = inspect.getframeinfo(sys._getframe(stacklevel - 1))
212        raise RuntimeError(
213            "{message} This warning(now exception) triggered on "
214            "filename '{filename}', line number {lineno}, is "
215            "supposed to be shown until {date}. Today is {today}. "
216            "Please remove the warning.".format(
217                message=message.format(date=date.isoformat(), today=today.isoformat()),
218                filename=caller.filename,
219                lineno=caller.lineno,
220                date=date.isoformat(),
221                today=today.isoformat(),
222            ),
223        )
224
225    if _dont_call_warnings is False:
226        warnings.warn(
227            message.format(date=date.isoformat(), today=today.isoformat()),
228            category,
229            stacklevel=stacklevel,
230        )
231
232
233def kwargs_warn_until(
234    kwargs,
235    version,
236    category=DeprecationWarning,
237    stacklevel=None,
238    _version_info_=None,
239    _dont_call_warnings=False,
240):
241    """
242    Helper function to raise a warning (by default, a ``DeprecationWarning``)
243    when unhandled keyword arguments are passed to function, until the
244    provided ``version_info``, after which, a ``RuntimeError`` will be raised
245    to remind the developers to remove the ``**kwargs`` because the target
246    version has been reached.
247    This function is used to help deprecate unused legacy ``**kwargs`` that
248    were added to function parameters lists to preserve backwards compatibility
249    when removing a parameter. See
250    :ref:`the deprecation development docs <deprecations>`
251    for the modern strategy for deprecating a function parameter.
252
253    :param kwargs: The caller's ``**kwargs`` argument value (a ``dict``).
254    :param version: The version info or name after which the warning becomes a
255                    ``RuntimeError``. For example ``(0, 17)`` or ``Hydrogen``
256                    or an instance of :class:`salt.version.SaltStackVersion`.
257    :param category: The warning class to be thrown, by default
258                     ``DeprecationWarning``
259    :param stacklevel: There should be no need to set the value of
260                       ``stacklevel``. Salt should be able to do the right thing.
261    :param _version_info_: In order to reuse this function for other SaltStack
262                           projects, they need to be able to provide the
263                           version info to compare to.
264    :param _dont_call_warnings: This parameter is used just to get the
265                                functionality until the actual error is to be
266                                issued. When we're only after the salt version
267                                checks to raise a ``RuntimeError``.
268    """
269    if not isinstance(version, (tuple, str, salt.version.SaltStackVersion)):
270        raise RuntimeError(
271            "The 'version' argument should be passed as a tuple, string or "
272            "an instance of 'salt.version.SaltStackVersion'."
273        )
274    elif isinstance(version, tuple):
275        version = salt.version.SaltStackVersion(*version)
276    elif isinstance(version, str):
277        version = salt.version.SaltStackVersion.from_name(version)
278
279    if stacklevel is None:
280        # Attribute the warning to the calling function,
281        # not to kwargs_warn_until() or warn_until()
282        stacklevel = 3
283
284    if _version_info_ is None:
285        _version_info_ = salt.version.__version_info__
286
287    _version_ = salt.version.SaltStackVersion(*_version_info_)
288
289    if kwargs or _version_.info >= version.info:
290        arg_names = ", ".join("'{}'".format(key) for key in kwargs)
291        warn_until(
292            version,
293            message=(
294                "The following parameter(s) have been deprecated and "
295                "will be removed in '{}': {}.".format(version.string, arg_names)
296            ),
297            category=category,
298            stacklevel=stacklevel,
299            _version_info_=_version_.info,
300            _dont_call_warnings=_dont_call_warnings,
301        )
302
303
304def version_cmp(pkg1, pkg2, ignore_epoch=False):
305    """
306    Compares two version strings using salt.utils.versions.LooseVersion. This
307    is a fallback for providers which don't have a version comparison utility
308    built into them.  Return -1 if version1 < version2, 0 if version1 ==
309    version2, and 1 if version1 > version2. Return None if there was a problem
310    making the comparison.
311    """
312    normalize = lambda x: str(x).split(":", 1)[-1] if ignore_epoch else str(x)
313    pkg1 = normalize(pkg1)
314    pkg2 = normalize(pkg2)
315
316    try:
317        # pylint: disable=no-member
318        if LooseVersion(pkg1) < LooseVersion(pkg2):
319            return -1
320        elif LooseVersion(pkg1) == LooseVersion(pkg2):
321            return 0
322        elif LooseVersion(pkg1) > LooseVersion(pkg2):
323            return 1
324    except Exception as exc:  # pylint: disable=broad-except
325        log.exception(exc)
326    return None
327
328
329def compare(ver1="", oper="==", ver2="", cmp_func=None, ignore_epoch=False):
330    """
331    Compares two version numbers. Accepts a custom function to perform the
332    cmp-style version comparison, otherwise uses version_cmp().
333    """
334    cmp_map = {"<": (-1,), "<=": (-1, 0), "==": (0,), ">=": (0, 1), ">": (1,)}
335    if oper not in ("!=",) and oper not in cmp_map:
336        log.error("Invalid operator '%s' for version comparison", oper)
337        return False
338
339    if cmp_func is None:
340        cmp_func = version_cmp
341
342    cmp_result = cmp_func(ver1, ver2, ignore_epoch=ignore_epoch)
343    if cmp_result is None:
344        return False
345
346    # Check if integer/long
347    if not isinstance(cmp_result, numbers.Integral):
348        log.error("The version comparison function did not return an integer/long.")
349        return False
350
351    if oper == "!=":
352        return cmp_result not in cmp_map["=="]
353    else:
354        # Gracefully handle cmp_result not in (-1, 0, 1).
355        if cmp_result < -1:
356            cmp_result = -1
357        elif cmp_result > 1:
358            cmp_result = 1
359
360        return cmp_result in cmp_map[oper]
361
362
363def check_boto_reqs(
364    boto_ver=None, boto3_ver=None, botocore_ver=None, check_boto=True, check_boto3=True
365):
366    """
367    Checks for the version of various required boto libs in one central location. Most
368    boto states and modules rely on a single version of the boto, boto3, or botocore libs.
369    However, some require newer versions of any of these dependencies. This function allows
370    the module to pass in a version to override the default minimum required version.
371
372    This function is useful in centralizing checks for ``__virtual__()`` functions in the
373    various, and many, boto modules and states.
374
375    boto_ver
376        The minimum required version of the boto library. Defaults to ``2.0.0``.
377
378    boto3_ver
379        The minimum required version of the boto3 library. Defaults to ``1.2.6``.
380
381    botocore_ver
382        The minimum required version of the botocore library. Defaults to ``1.3.23``.
383
384    check_boto
385        Boolean defining whether or not to check for boto deps. This defaults to ``True`` as
386        most boto modules/states rely on boto, but some do not.
387
388    check_boto3
389        Boolean defining whether or not to check for boto3 (and therefore botocore) deps.
390        This defaults to ``True`` as most boto modules/states rely on boto3/botocore, but
391        some do not.
392    """
393    if check_boto is True:
394        try:
395            # Late import so we can only load these for this function
396            import boto
397
398            has_boto = True
399        except ImportError:
400            has_boto = False
401
402        if boto_ver is None:
403            boto_ver = "2.0.0"
404
405        if not has_boto or version_cmp(boto.__version__, boto_ver) == -1:
406            return False, "A minimum version of boto {} is required.".format(boto_ver)
407
408    if check_boto3 is True:
409        try:
410            # Late import so we can only load these for this function
411            import boto3
412            import botocore
413
414            has_boto3 = True
415        except ImportError:
416            has_boto3 = False
417
418        # boto_s3_bucket module requires boto3 1.2.6 and botocore 1.3.23 for
419        # idempotent ACL operations via the fix in https://github.com/boto/boto3/issues/390
420        if boto3_ver is None:
421            boto3_ver = "1.2.6"
422        if botocore_ver is None:
423            botocore_ver = "1.3.23"
424
425        if not has_boto3 or version_cmp(boto3.__version__, boto3_ver) == -1:
426            return (
427                False,
428                "A minimum version of boto3 {} is required.".format(boto3_ver),
429            )
430        elif version_cmp(botocore.__version__, botocore_ver) == -1:
431            return (
432                False,
433                "A minimum version of botocore {} is required".format(botocore_ver),
434            )
435
436    return True
437