mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-04-24 03:11:24 -07:00
Move type checking methods out of basic.py and add unit tests (#53687)
* Move check_type_str() out of basic.py * Move check_type_list() out of basic.py * Move safe_eval() out of basic.py * Move check_type_dict() out of basic.py * Move json importing code to common location * Move check_type_bool() out of basic.py * Move _check_type_int() out of basic.py * Move _check_type_float() out of basic.py * Move _check_type_path() out of basic.py * Move _check_type_raw() out of basic.py * Move _check_type_bytes() out of basic.py * Move _check_type_bits() out of basic.py * Create text.formatters.py Move human_to_bytes, bytes_to_human, and _lenient_lowercase out of basic.py into text.formatters.py Change references in modules to point to function at new location * Move _check_type_jsonarg() out of basic.py * Rename json related functions and put them in common.text.converters Move formatters.py to common.text.formatters.py and update references in modules. * Rework check_type_str() Add allow_conversion option to make the function more self-contained. Move the messaging back to basic.py since those error messages are more relevant to using this function in the context of AnsibleModule and not when using the function in isolation. * Add unit tests for type checking functions * Change _lenient_lowercase to lenient_lowercase per feedback
This commit is contained in:
parent
bb61d7527f
commit
ff88bd82b5
26 changed files with 957 additions and 326 deletions
|
@ -4,18 +4,6 @@
|
|||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
SIZE_RANGES = {
|
||||
'Y': 1 << 80,
|
||||
'Z': 1 << 70,
|
||||
'E': 1 << 60,
|
||||
'P': 1 << 50,
|
||||
'T': 1 << 40,
|
||||
'G': 1 << 30,
|
||||
'M': 1 << 20,
|
||||
'K': 1 << 10,
|
||||
'B': 1,
|
||||
}
|
||||
|
||||
FILE_ATTRIBUTES = {
|
||||
'A': 'noatime',
|
||||
'a': 'append',
|
||||
|
@ -93,18 +81,27 @@ except ImportError:
|
|||
# Python2 & 3 way to get NoneType
|
||||
NoneType = type(None)
|
||||
|
||||
from ansible.module_utils._text import to_native, to_bytes, to_text
|
||||
from ansible.module_utils.common.text.converters import (
|
||||
jsonify,
|
||||
container_to_bytes as json_dict_unicode_to_bytes,
|
||||
container_to_text as json_dict_bytes_to_unicode,
|
||||
)
|
||||
|
||||
from ansible.module_utils.common.text.formatters import (
|
||||
lenient_lowercase,
|
||||
bytes_to_human,
|
||||
human_to_bytes,
|
||||
SIZE_RANGES,
|
||||
)
|
||||
|
||||
try:
|
||||
import json
|
||||
# Detect the python-json library which is incompatible
|
||||
try:
|
||||
if not isinstance(json.loads, types.FunctionType) or not isinstance(json.dumps, types.FunctionType):
|
||||
raise ImportError
|
||||
except AttributeError:
|
||||
raise ImportError
|
||||
except ImportError:
|
||||
print('\n{"msg": "Error: ansible requires the stdlib json and was not found!", "failed": true}')
|
||||
from ansible.module_utils.common._json_compat import json
|
||||
except ImportError as e:
|
||||
print('\n{{"msg": "Error: ansible requires the stdlib json: {0}", "failed": true}}'.format(to_native(e)))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
AVAILABLE_HASH_ALGORITHMS = dict()
|
||||
try:
|
||||
import hashlib
|
||||
|
@ -182,12 +179,22 @@ from ansible.module_utils.common.validation import (
|
|||
check_required_one_of,
|
||||
check_required_together,
|
||||
count_terms,
|
||||
check_type_bool,
|
||||
check_type_bits,
|
||||
check_type_bytes,
|
||||
check_type_float,
|
||||
check_type_int,
|
||||
check_type_jsonarg,
|
||||
check_type_list,
|
||||
check_type_dict,
|
||||
check_type_path,
|
||||
check_type_raw,
|
||||
check_type_str,
|
||||
safe_eval,
|
||||
)
|
||||
from ansible.module_utils._text import to_native, to_bytes, to_text
|
||||
from ansible.module_utils.common._utils import get_all_subclasses as _get_all_subclasses
|
||||
from ansible.module_utils.parsing.convert_bool import BOOLEANS, BOOLEANS_FALSE, BOOLEANS_TRUE, boolean
|
||||
|
||||
|
||||
# Note: When getting Sequence from collections, it matches with strings. If
|
||||
# this matters, make sure to check for strings before checking for sequencetype
|
||||
SEQUENCETYPE = frozenset, KeysView, Sequence
|
||||
|
@ -306,45 +313,6 @@ def get_all_subclasses(cls):
|
|||
# End compat shims
|
||||
|
||||
|
||||
def json_dict_unicode_to_bytes(d, encoding='utf-8', errors='surrogate_or_strict'):
|
||||
''' Recursively convert dict keys and values to byte str
|
||||
|
||||
Specialized for json return because this only handles, lists, tuples,
|
||||
and dict container types (the containers that the json module returns)
|
||||
'''
|
||||
|
||||
if isinstance(d, text_type):
|
||||
return to_bytes(d, encoding=encoding, errors=errors)
|
||||
elif isinstance(d, dict):
|
||||
return dict(map(json_dict_unicode_to_bytes, iteritems(d), repeat(encoding), repeat(errors)))
|
||||
elif isinstance(d, list):
|
||||
return list(map(json_dict_unicode_to_bytes, d, repeat(encoding), repeat(errors)))
|
||||
elif isinstance(d, tuple):
|
||||
return tuple(map(json_dict_unicode_to_bytes, d, repeat(encoding), repeat(errors)))
|
||||
else:
|
||||
return d
|
||||
|
||||
|
||||
def json_dict_bytes_to_unicode(d, encoding='utf-8', errors='surrogate_or_strict'):
|
||||
''' Recursively convert dict keys and values to byte str
|
||||
|
||||
Specialized for json return because this only handles, lists, tuples,
|
||||
and dict container types (the containers that the json module returns)
|
||||
'''
|
||||
|
||||
if isinstance(d, binary_type):
|
||||
# Warning, can traceback
|
||||
return to_text(d, encoding=encoding, errors=errors)
|
||||
elif isinstance(d, dict):
|
||||
return dict(map(json_dict_bytes_to_unicode, iteritems(d), repeat(encoding), repeat(errors)))
|
||||
elif isinstance(d, list):
|
||||
return list(map(json_dict_bytes_to_unicode, d, repeat(encoding), repeat(errors)))
|
||||
elif isinstance(d, tuple):
|
||||
return tuple(map(json_dict_bytes_to_unicode, d, repeat(encoding), repeat(errors)))
|
||||
else:
|
||||
return d
|
||||
|
||||
|
||||
def _remove_values_conditions(value, no_log_strings, deferred_removals):
|
||||
"""
|
||||
Helper function for :meth:`remove_values`.
|
||||
|
@ -528,73 +496,6 @@ def heuristic_log_sanitize(data, no_log_values=None):
|
|||
return output
|
||||
|
||||
|
||||
def bytes_to_human(size, isbits=False, unit=None):
|
||||
|
||||
base = 'Bytes'
|
||||
if isbits:
|
||||
base = 'bits'
|
||||
suffix = ''
|
||||
|
||||
for suffix, limit in sorted(iteritems(SIZE_RANGES), key=lambda item: -item[1]):
|
||||
if (unit is None and size >= limit) or unit is not None and unit.upper() == suffix[0]:
|
||||
break
|
||||
|
||||
if limit != 1:
|
||||
suffix += base[0]
|
||||
else:
|
||||
suffix = base
|
||||
|
||||
return '%.2f %s' % (size / limit, suffix)
|
||||
|
||||
|
||||
def human_to_bytes(number, default_unit=None, isbits=False):
|
||||
|
||||
'''
|
||||
Convert number in string format into bytes (ex: '2K' => 2048) or using unit argument.
|
||||
example: human_to_bytes('10M') <=> human_to_bytes(10, 'M')
|
||||
'''
|
||||
m = re.search(r'^\s*(\d*\.?\d*)\s*([A-Za-z]+)?', str(number), flags=re.IGNORECASE)
|
||||
if m is None:
|
||||
raise ValueError("human_to_bytes() can't interpret following string: %s" % str(number))
|
||||
try:
|
||||
num = float(m.group(1))
|
||||
except Exception:
|
||||
raise ValueError("human_to_bytes() can't interpret following number: %s (original input string: %s)" % (m.group(1), number))
|
||||
|
||||
unit = m.group(2)
|
||||
if unit is None:
|
||||
unit = default_unit
|
||||
|
||||
if unit is None:
|
||||
''' No unit given, returning raw number '''
|
||||
return int(round(num))
|
||||
range_key = unit[0].upper()
|
||||
try:
|
||||
limit = SIZE_RANGES[range_key]
|
||||
except Exception:
|
||||
raise ValueError("human_to_bytes() failed to convert %s (unit = %s). The suffix must be one of %s" % (number, unit, ", ".join(SIZE_RANGES.keys())))
|
||||
|
||||
# default value
|
||||
unit_class = 'B'
|
||||
unit_class_name = 'byte'
|
||||
# handling bits case
|
||||
if isbits:
|
||||
unit_class = 'b'
|
||||
unit_class_name = 'bit'
|
||||
# check unit value if more than one character (KB, MB)
|
||||
if len(unit) > 1:
|
||||
expect_message = 'expect %s%s or %s' % (range_key, unit_class, range_key)
|
||||
if range_key == 'B':
|
||||
expect_message = 'expect %s or %s' % (unit_class, unit_class_name)
|
||||
|
||||
if unit_class_name in unit.lower():
|
||||
pass
|
||||
elif unit[1] != unit_class:
|
||||
raise ValueError("human_to_bytes() failed to convert %s. Value is not a valid string (%s)" % (number, expect_message))
|
||||
|
||||
return int(round(num * limit))
|
||||
|
||||
|
||||
def _load_params():
|
||||
''' read the modules parameters and store them globally.
|
||||
|
||||
|
@ -659,44 +560,6 @@ def env_fallback(*args, **kwargs):
|
|||
raise AnsibleFallbackNotFound
|
||||
|
||||
|
||||
def _lenient_lowercase(lst):
|
||||
"""Lowercase elements of a list.
|
||||
|
||||
If an element is not a string, pass it through untouched.
|
||||
"""
|
||||
lowered = []
|
||||
for value in lst:
|
||||
try:
|
||||
lowered.append(value.lower())
|
||||
except AttributeError:
|
||||
lowered.append(value)
|
||||
return lowered
|
||||
|
||||
|
||||
def _json_encode_fallback(obj):
|
||||
if isinstance(obj, Set):
|
||||
return list(obj)
|
||||
elif isinstance(obj, datetime.datetime):
|
||||
return obj.isoformat()
|
||||
raise TypeError("Cannot json serialize %s" % to_native(obj))
|
||||
|
||||
|
||||
def jsonify(data, **kwargs):
|
||||
for encoding in ("utf-8", "latin-1"):
|
||||
try:
|
||||
return json.dumps(data, encoding=encoding, default=_json_encode_fallback, **kwargs)
|
||||
# Old systems using old simplejson module does not support encoding keyword.
|
||||
except TypeError:
|
||||
try:
|
||||
new_data = json_dict_bytes_to_unicode(data, encoding=encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return json.dumps(new_data, default=_json_encode_fallback, **kwargs)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
raise UnicodeError('Invalid unicode encoding encountered')
|
||||
|
||||
|
||||
def missing_required_lib(library, reason=None, url=None):
|
||||
hostname = platform.node()
|
||||
msg = "Failed to import the required Python library (%s) on %s's Python %s." % (library, hostname, sys.executable)
|
||||
|
@ -1714,7 +1577,7 @@ class AnsibleModule(object):
|
|||
# the value. If we can't figure this out, module author is responsible.
|
||||
lowered_choices = None
|
||||
if param[k] == 'False':
|
||||
lowered_choices = _lenient_lowercase(choices)
|
||||
lowered_choices = lenient_lowercase(choices)
|
||||
overlap = BOOLEANS_FALSE.intersection(choices)
|
||||
if len(overlap) == 1:
|
||||
# Extract from a set
|
||||
|
@ -1722,7 +1585,7 @@ class AnsibleModule(object):
|
|||
|
||||
if param[k] == 'True':
|
||||
if lowered_choices is None:
|
||||
lowered_choices = _lenient_lowercase(choices)
|
||||
lowered_choices = lenient_lowercase(choices)
|
||||
overlap = BOOLEANS_TRUE.intersection(choices)
|
||||
if len(overlap) == 1:
|
||||
(param[k],) = overlap
|
||||
|
@ -1740,160 +1603,59 @@ class AnsibleModule(object):
|
|||
self.fail_json(msg=msg)
|
||||
|
||||
def safe_eval(self, value, locals=None, include_exceptions=False):
|
||||
|
||||
# do not allow method calls to modules
|
||||
if not isinstance(value, string_types):
|
||||
# already templated to a datavaluestructure, perhaps?
|
||||
if include_exceptions:
|
||||
return (value, None)
|
||||
return value
|
||||
if re.search(r'\w\.\w+\(', value):
|
||||
if include_exceptions:
|
||||
return (value, None)
|
||||
return value
|
||||
# do not allow imports
|
||||
if re.search(r'import \w+', value):
|
||||
if include_exceptions:
|
||||
return (value, None)
|
||||
return value
|
||||
try:
|
||||
result = literal_eval(value)
|
||||
if include_exceptions:
|
||||
return (result, None)
|
||||
else:
|
||||
return result
|
||||
except Exception as e:
|
||||
if include_exceptions:
|
||||
return (value, e)
|
||||
return value
|
||||
return safe_eval(value, locals, include_exceptions)
|
||||
|
||||
def _check_type_str(self, value):
|
||||
if isinstance(value, string_types):
|
||||
return value
|
||||
opts = {
|
||||
'error': False,
|
||||
'warn': False,
|
||||
'ignore': True
|
||||
}
|
||||
|
||||
# Ignore, warn, or error when converting to a string.
|
||||
# The current default is to warn. Change this in Anisble 2.12 to error.
|
||||
common_msg = 'quote the entire value to ensure it does not change.'
|
||||
if self._string_conversion_action == 'error':
|
||||
msg = common_msg.capitalize()
|
||||
raise TypeError(msg)
|
||||
elif self._string_conversion_action == 'warn':
|
||||
msg = ('The value {0!r} (type {0.__class__.__name__}) in a string field was converted to {1!r} (type string). '
|
||||
'If this does not look like what you expect, {2}').format(value, to_text(value), common_msg)
|
||||
self.warn(msg)
|
||||
return to_native(value, errors='surrogate_or_strict')
|
||||
allow_conversion = opts.get(self._string_conversion_action, True)
|
||||
try:
|
||||
return check_type_str(value, allow_conversion)
|
||||
except TypeError:
|
||||
common_msg = 'quote the entire value to ensure it does not change.'
|
||||
if self._string_conversion_action == 'error':
|
||||
msg = common_msg.capitalize()
|
||||
raise TypeError(to_native(msg))
|
||||
elif self._string_conversion_action == 'warn':
|
||||
msg = ('The value {0!r} (type {0.__class__.__name__}) in a string field was converted to {1!r} (type string). '
|
||||
'If this does not look like what you expect, {2}').format(value, to_text(value), common_msg)
|
||||
self.warn(to_native(msg))
|
||||
return to_native(value, errors='surrogate_or_strict')
|
||||
|
||||
def _check_type_list(self, value):
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
|
||||
if isinstance(value, string_types):
|
||||
return value.split(",")
|
||||
elif isinstance(value, int) or isinstance(value, float):
|
||||
return [str(value)]
|
||||
|
||||
raise TypeError('%s cannot be converted to a list' % type(value))
|
||||
return check_type_list(value)
|
||||
|
||||
def _check_type_dict(self, value):
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
|
||||
if isinstance(value, string_types):
|
||||
if value.startswith("{"):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
(result, exc) = self.safe_eval(value, dict(), include_exceptions=True)
|
||||
if exc is not None:
|
||||
raise TypeError('unable to evaluate string as dictionary')
|
||||
return result
|
||||
elif '=' in value:
|
||||
fields = []
|
||||
field_buffer = []
|
||||
in_quote = False
|
||||
in_escape = False
|
||||
for c in value.strip():
|
||||
if in_escape:
|
||||
field_buffer.append(c)
|
||||
in_escape = False
|
||||
elif c == '\\':
|
||||
in_escape = True
|
||||
elif not in_quote and c in ('\'', '"'):
|
||||
in_quote = c
|
||||
elif in_quote and in_quote == c:
|
||||
in_quote = False
|
||||
elif not in_quote and c in (',', ' '):
|
||||
field = ''.join(field_buffer)
|
||||
if field:
|
||||
fields.append(field)
|
||||
field_buffer = []
|
||||
else:
|
||||
field_buffer.append(c)
|
||||
|
||||
field = ''.join(field_buffer)
|
||||
if field:
|
||||
fields.append(field)
|
||||
return dict(x.split("=", 1) for x in fields)
|
||||
else:
|
||||
raise TypeError("dictionary requested, could not parse JSON or key=value")
|
||||
|
||||
raise TypeError('%s cannot be converted to a dict' % type(value))
|
||||
return check_type_dict(value)
|
||||
|
||||
def _check_type_bool(self, value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, string_types) or isinstance(value, int):
|
||||
return self.boolean(value)
|
||||
|
||||
raise TypeError('%s cannot be converted to a bool' % type(value))
|
||||
return check_type_bool(value)
|
||||
|
||||
def _check_type_int(self, value):
|
||||
if isinstance(value, integer_types):
|
||||
return value
|
||||
|
||||
if isinstance(value, string_types):
|
||||
return int(value)
|
||||
|
||||
raise TypeError('%s cannot be converted to an int' % type(value))
|
||||
return check_type_int(value)
|
||||
|
||||
def _check_type_float(self, value):
|
||||
if isinstance(value, float):
|
||||
return value
|
||||
|
||||
if isinstance(value, (binary_type, text_type, int)):
|
||||
return float(value)
|
||||
|
||||
raise TypeError('%s cannot be converted to a float' % type(value))
|
||||
return check_type_float(value)
|
||||
|
||||
def _check_type_path(self, value):
|
||||
value = self._check_type_str(value)
|
||||
return os.path.expanduser(os.path.expandvars(value))
|
||||
return check_type_path(value)
|
||||
|
||||
def _check_type_jsonarg(self, value):
|
||||
# Return a jsonified string. Sometimes the controller turns a json
|
||||
# string into a dict/list so transform it back into json here
|
||||
if isinstance(value, (text_type, binary_type)):
|
||||
return value.strip()
|
||||
else:
|
||||
if isinstance(value, (list, tuple, dict)):
|
||||
return self.jsonify(value)
|
||||
raise TypeError('%s cannot be converted to a json string' % type(value))
|
||||
return check_type_jsonarg(value)
|
||||
|
||||
def _check_type_raw(self, value):
|
||||
return value
|
||||
return check_type_raw(value)
|
||||
|
||||
def _check_type_bytes(self, value):
|
||||
try:
|
||||
self.human_to_bytes(value)
|
||||
except ValueError:
|
||||
raise TypeError('%s cannot be converted to a Byte value' % type(value))
|
||||
return check_type_bytes(value)
|
||||
|
||||
def _check_type_bits(self, value):
|
||||
try:
|
||||
self.human_to_bytes(value, isbits=True)
|
||||
except ValueError:
|
||||
raise TypeError('%s cannot be converted to a Bit value' % type(value))
|
||||
return check_type_bits(value)
|
||||
|
||||
def _handle_options(self, argument_spec=None, params=None):
|
||||
''' deal with options to create sub spec '''
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue