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