1# (c) 2018, Matt Martz <matt@sivel.net>
2# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
3# -*- coding: utf-8 -*-
4from __future__ import (absolute_import, division, print_function)
5__metaclass__ = type
6
7import datetime
8import re
9
10from distutils.version import LooseVersion
11
12import astroid
13
14from pylint.interfaces import IAstroidChecker
15from pylint.checkers import BaseChecker
16from pylint.checkers.utils import check_messages
17
18from ansible.module_utils.six import string_types
19from ansible.release import __version__ as ansible_version_raw
20from ansible.utils.version import SemanticVersion
21
22MSGS = {
23    'E9501': ("Deprecated version (%r) found in call to Display.deprecated "
24              "or AnsibleModule.deprecate",
25              "ansible-deprecated-version",
26              "Used when a call to Display.deprecated specifies a version "
27              "less than or equal to the current version of Ansible",
28              {'minversion': (2, 6)}),
29    'E9502': ("Display.deprecated call without a version or date",
30              "ansible-deprecated-no-version",
31              "Used when a call to Display.deprecated does not specify a "
32              "version or date",
33              {'minversion': (2, 6)}),
34    'E9503': ("Invalid deprecated version (%r) found in call to "
35              "Display.deprecated or AnsibleModule.deprecate",
36              "ansible-invalid-deprecated-version",
37              "Used when a call to Display.deprecated specifies an invalid "
38              "Ansible version number",
39              {'minversion': (2, 6)}),
40    'E9504': ("Deprecated version (%r) found in call to Display.deprecated "
41              "or AnsibleModule.deprecate",
42              "collection-deprecated-version",
43              "Used when a call to Display.deprecated specifies a collection "
44              "version less than or equal to the current version of this "
45              "collection",
46              {'minversion': (2, 6)}),
47    'E9505': ("Invalid deprecated version (%r) found in call to "
48              "Display.deprecated or AnsibleModule.deprecate",
49              "collection-invalid-deprecated-version",
50              "Used when a call to Display.deprecated specifies an invalid "
51              "collection version number",
52              {'minversion': (2, 6)}),
53    'E9506': ("No collection name found in call to Display.deprecated or "
54              "AnsibleModule.deprecate",
55              "ansible-deprecated-no-collection-name",
56              "The current collection name in format `namespace.name` must "
57              "be provided as collection_name when calling Display.deprecated "
58              "or AnsibleModule.deprecate (`ansible.builtin` for ansible-core)",
59              {'minversion': (2, 6)}),
60    'E9507': ("Wrong collection name (%r) found in call to "
61              "Display.deprecated or AnsibleModule.deprecate",
62              "wrong-collection-deprecated",
63              "The name of the current collection must be passed to the "
64              "Display.deprecated resp. AnsibleModule.deprecate calls "
65              "(`ansible.builtin` for ansible-core)",
66              {'minversion': (2, 6)}),
67    'E9508': ("Expired date (%r) found in call to Display.deprecated "
68              "or AnsibleModule.deprecate",
69              "ansible-deprecated-date",
70              "Used when a call to Display.deprecated specifies a date "
71              "before today",
72              {'minversion': (2, 6)}),
73    'E9509': ("Invalid deprecated date (%r) found in call to "
74              "Display.deprecated or AnsibleModule.deprecate",
75              "ansible-invalid-deprecated-date",
76              "Used when a call to Display.deprecated specifies an invalid "
77              "date. It must be a string in format `YYYY-MM-DD` (ISO 8601)",
78              {'minversion': (2, 6)}),
79    'E9510': ("Both version and date found in call to "
80              "Display.deprecated or AnsibleModule.deprecate",
81              "ansible-deprecated-both-version-and-date",
82              "Only one of version and date must be specified",
83              {'minversion': (2, 6)}),
84    'E9511': ("Removal version (%r) must be a major release, not a minor or "
85              "patch release (see the specification at https://semver.org/)",
86              "removal-version-must-be-major",
87              "Used when a call to Display.deprecated or "
88              "AnsibleModule.deprecate for a collection specifies a version "
89              "which is not of the form x.0.0",
90              {'minversion': (2, 6)}),
91}
92
93
94ANSIBLE_VERSION = LooseVersion('.'.join(ansible_version_raw.split('.')[:3]))
95
96
97def _get_expr_name(node):
98    """Funciton to get either ``attrname`` or ``name`` from ``node.func.expr``
99
100    Created specifically for the case of ``display.deprecated`` or ``self._display.deprecated``
101    """
102    try:
103        return node.func.expr.attrname
104    except AttributeError:
105        # If this fails too, we'll let it raise, the caller should catch it
106        return node.func.expr.name
107
108
109def parse_isodate(value):
110    msg = 'Expected ISO 8601 date string (YYYY-MM-DD)'
111    if not isinstance(value, string_types):
112        raise ValueError(msg)
113    # From Python 3.7 in, there is datetime.date.fromisoformat(). For older versions,
114    # we have to do things manually.
115    if not re.match('^[0-9]{4}-[0-9]{2}-[0-9]{2}$', value):
116        raise ValueError(msg)
117    try:
118        return datetime.datetime.strptime(value, '%Y-%m-%d').date()
119    except ValueError:
120        raise ValueError(msg)
121
122
123class AnsibleDeprecatedChecker(BaseChecker):
124    """Checks for Display.deprecated calls to ensure that the ``version``
125    has not passed or met the time for removal
126    """
127
128    __implements__ = (IAstroidChecker,)
129    name = 'deprecated'
130    msgs = MSGS
131
132    options = (
133        ('collection-name', {
134            'default': None,
135            'type': 'string',
136            'metavar': '<name>',
137            'help': 'The collection\'s name used to check collection names in deprecations.',
138        }),
139        ('collection-version', {
140            'default': None,
141            'type': 'string',
142            'metavar': '<version>',
143            'help': 'The collection\'s version number used to check deprecations.',
144        }),
145    )
146
147    def __init__(self, *args, **kwargs):
148        self.collection_version = None
149        self.collection_name = None
150        super(AnsibleDeprecatedChecker, self).__init__(*args, **kwargs)
151
152    def set_option(self, optname, value, action=None, optdict=None):
153        super(AnsibleDeprecatedChecker, self).set_option(optname, value, action, optdict)
154        if optname == 'collection-version' and value is not None:
155            self.collection_version = SemanticVersion(self.config.collection_version)
156        if optname == 'collection-name' and value is not None:
157            self.collection_name = self.config.collection_name
158
159    def _check_date(self, node, date):
160        if not isinstance(date, str):
161            self.add_message('invalid-date', node=node, args=(date,))
162            return
163
164        try:
165            date_parsed = parse_isodate(date)
166        except ValueError:
167            self.add_message('ansible-invalid-deprecated-date', node=node, args=(date,))
168            return
169
170        if date_parsed < datetime.date.today():
171            self.add_message('ansible-deprecated-date', node=node, args=(date,))
172
173    def _check_version(self, node, version, collection_name):
174        if not isinstance(version, (str, float)):
175            self.add_message('invalid-version', node=node, args=(version,))
176            return
177
178        version_no = str(version)
179
180        if collection_name == 'ansible.builtin':
181            # Ansible-base
182            try:
183                if not version_no:
184                    raise ValueError('Version string should not be empty')
185                loose_version = LooseVersion(str(version_no))
186                if ANSIBLE_VERSION >= loose_version:
187                    self.add_message('ansible-deprecated-version', node=node, args=(version,))
188            except ValueError:
189                self.add_message('ansible-invalid-deprecated-version', node=node, args=(version,))
190        elif collection_name:
191            # Collections
192            try:
193                if not version_no:
194                    raise ValueError('Version string should not be empty')
195                semantic_version = SemanticVersion(version_no)
196                if collection_name == self.collection_name and self.collection_version is not None:
197                    if self.collection_version >= semantic_version:
198                        self.add_message('collection-deprecated-version', node=node, args=(version,))
199                if semantic_version.major != 0 and (semantic_version.minor != 0 or semantic_version.patch != 0):
200                    self.add_message('removal-version-must-be-major', node=node, args=(version,))
201            except ValueError:
202                self.add_message('collection-invalid-deprecated-version', node=node, args=(version,))
203
204    @check_messages(*(MSGS.keys()))
205    def visit_call(self, node):
206        version = None
207        date = None
208        collection_name = None
209        try:
210            if (node.func.attrname == 'deprecated' and 'display' in _get_expr_name(node) or
211                    node.func.attrname == 'deprecate' and _get_expr_name(node)):
212                if node.keywords:
213                    for keyword in node.keywords:
214                        if len(node.keywords) == 1 and keyword.arg is None:
215                            # This is likely a **kwargs splat
216                            return
217                        if keyword.arg == 'version':
218                            if isinstance(keyword.value.value, astroid.Name):
219                                # This is likely a variable
220                                return
221                            version = keyword.value.value
222                        if keyword.arg == 'date':
223                            if isinstance(keyword.value.value, astroid.Name):
224                                # This is likely a variable
225                                return
226                            date = keyword.value.value
227                        if keyword.arg == 'collection_name':
228                            if isinstance(keyword.value.value, astroid.Name):
229                                # This is likely a variable
230                                return
231                            collection_name = keyword.value.value
232                if not version and not date:
233                    try:
234                        version = node.args[1].value
235                    except IndexError:
236                        self.add_message('ansible-deprecated-no-version', node=node)
237                        return
238                if version and date:
239                    self.add_message('ansible-deprecated-both-version-and-date', node=node)
240
241                if collection_name:
242                    this_collection = collection_name == (self.collection_name or 'ansible.builtin')
243                    if not this_collection:
244                        self.add_message('wrong-collection-deprecated', node=node, args=(collection_name,))
245                elif self.collection_name is not None:
246                    self.add_message('ansible-deprecated-no-collection-name', node=node)
247
248                if date:
249                    self._check_date(node, date)
250                elif version:
251                    self._check_version(node, version, collection_name)
252        except AttributeError:
253            # Not the type of node we are interested in
254            pass
255
256
257def register(linter):
258    """required method to auto register this checker """
259    linter.register_checker(AnsibleDeprecatedChecker(linter))
260