Merge branch '2016.11' into '2017.7'

Conflicts:
  - pkg/windows/buildenv/salt.bat
  - salt/modules/pillar.py
  - salt/utils/openstack/nova.py
  - tests/unit/cloud/clouds/test_dimensiondata.py
  - tests/unit/cloud/clouds/test_gce.py
This commit is contained in:
rallytime 2017-06-14 13:31:42 -06:00
commit 16a2747d7d
29 changed files with 328 additions and 74 deletions

View File

@ -8,4 +8,6 @@ Set SaltDir=%SaltDir:~0,-1%
Set Python=%SaltDir%\bin\python.exe
Set Script=%SaltDir%\bin\Scripts\salt-call
:: Launch Script
"%Python%" "%Script%" %*

View File

@ -8,4 +8,6 @@ Set SaltDir=%SaltDir:~0,-1%
Set Python=%SaltDir%\bin\python.exe
Set Script=%SaltDir%\bin\Scripts\salt-cp
:: Launch Script
"%Python%" "%Script%" %*

View File

@ -0,0 +1,13 @@
@ echo off
:: Script for invoking salt-key
:: Accepts all parameters that salt-key accepts
:: Define Variables
Set SaltDir=%~dp0
Set SaltDir=%SaltDir:~0,-1%
Set Python=%SaltDir%\bin\python.exe
Set Script=%SaltDir%\bin\Scripts\salt-key
:: Launch Script
"%Python%" "%Script%" %*

View File

@ -0,0 +1,13 @@
@ echo off
:: Script for starting the Salt-Master
:: Accepts all parameters that Salt-Master accepts
:: Define Variables
Set SaltDir=%~dp0
Set SaltDir=%SaltDir:~0,-1%
Set Python=%SaltDir%\bin\python.exe
Set Script=%SaltDir%\bin\Scripts\salt-master
:: Launch Script
"%Python%" "%Script%" %*

View File

@ -8,6 +8,9 @@ Set SaltDir=%SaltDir:~0,-1%
Set Python=%SaltDir%\bin\python.exe
Set Script=%SaltDir%\bin\Scripts\salt-minion
:: Stop the Salt Minion service
net stop salt-minion
:: Launch Script
"%Python%" "%Script%" -l debug

View File

@ -1 +1,2 @@
net start salt-minion
:: Start the Salt Minion service
net start salt-minion

View File

@ -8,4 +8,6 @@ Set SaltDir=%SaltDir:~0,-1%
Set Python=%SaltDir%\bin\python.exe
Set Script=%SaltDir%\bin\Scripts\salt-minion
:: Launch Script
"%Python%" "%Script%" %*

View File

@ -0,0 +1,13 @@
@ echo off
:: Script for invoking salt-run
:: Accepts all parameters that salt-run accepts
:: Define Variables
Set SaltDir=%~dp0
Set SaltDir=%SaltDir:~0,-1%
Set Python=%SaltDir%\bin\python.exe
Set Script=%SaltDir%\bin\Scripts\salt-run
:: Launch Script
"%Python%" "%Script%" %*

View File

@ -33,14 +33,20 @@ import salt.config as config
from salt.cloud.libcloudfuncs import * # pylint: disable=redefined-builtin,wildcard-import,unused-wildcard-import
from salt.utils import namespaced_function
from salt.exceptions import SaltCloudSystemExit
from distutils.version import LooseVersion as _LooseVersion
# CloudStackNetwork will be needed during creation of a new node
# pylint: disable=import-error
try:
from libcloud.compute.drivers.cloudstack import CloudStackNetwork
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
# This work-around for Issue #32743 is no longer needed for libcloud >= 1.4.0.
# However, older versions of libcloud must still be supported with this work-around.
# This work-around can be removed when the required minimum version of libcloud is
# 2.0.0 (See PR #40837 - which is implemented in Salt Oxygen).
if _LooseVersion(libcloud.__version__) < _LooseVersion('1.4.0'):
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
HAS_LIBS = True
except ImportError:
HAS_LIBS = False

View File

@ -27,9 +27,11 @@ from __future__ import absolute_import
import logging
import socket
import pprint
from distutils.version import LooseVersion as _LooseVersion
# Import libcloud
try:
import libcloud
from libcloud.compute.base import NodeState
from libcloud.compute.base import NodeAuthPassword
from libcloud.compute.types import Provider
@ -37,9 +39,15 @@ try:
from libcloud.loadbalancer.base import Member
from libcloud.loadbalancer.types import Provider as Provider_lb
from libcloud.loadbalancer.providers import get_driver as get_driver_lb
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
# This work-around for Issue #32743 is no longer needed for libcloud >= 1.4.0.
# However, older versions of libcloud must still be supported with this work-around.
# This work-around can be removed when the required minimum version of libcloud is
# 2.0.0 (See PR #40837 - which is implemented in Salt Oxygen).
if _LooseVersion(libcloud.__version__) < _LooseVersion('1.4.0'):
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
HAS_LIBCLOUD = True
except ImportError:
HAS_LIBCLOUD = False

View File

@ -3963,7 +3963,46 @@ def volume_create(**kwargs):
def create_volume(kwargs=None, call=None, wait_to_finish=False):
'''
Create a volume
Create a volume.
zone
The availability zone used to create the volume. Required. String.
size
The size of the volume, in GiBs. Defaults to ``10``. Integer.
snapshot
The snapshot-id from which to create the volume. Integer.
type
The volume type. This can be gp2 for General Purpose SSD, io1 for Provisioned
IOPS SSD, st1 for Throughput Optimized HDD, sc1 for Cold HDD, or standard for
Magnetic volumes. String.
iops
The number of I/O operations per second (IOPS) to provision for the volume,
with a maximum ratio of 50 IOPS/GiB. Only valid for Provisioned IOPS SSD
volumes. Integer.
This option will only be set if ``type`` is also specified as ``io1``.
encrypted
Specifies whether the volume will be encrypted. Boolean.
If ``snapshot`` is also given in the list of kwargs, then this value is ignored
since volumes that are created from encrypted snapshots are also automatically
encrypted.
tags
The tags to apply to the volume during creation. Dictionary.
call
The ``create_volume`` function must be called with ``-f`` or ``--function``.
String.
wait_to_finish
Whether or not to wait for the volume to be available. Boolean. Defaults to
``False``.
CLI Examples:

View File

@ -54,10 +54,12 @@ import pprint
import logging
import msgpack
from ast import literal_eval
from distutils.version import LooseVersion as _LooseVersion
# Import 3rd-party libs
# pylint: disable=import-error
try:
import libcloud
from libcloud.compute.types import Provider
from libcloud.compute.providers import get_driver
from libcloud.loadbalancer.types import Provider as Provider_lb
@ -66,9 +68,14 @@ try:
ResourceInUseError,
ResourceNotFoundError,
)
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
# This work-around for Issue #32743 is no longer needed for libcloud >= 1.4.0.
# However, older versions of libcloud must still be supported with this work-around.
# This work-around can be removed when the required minimum version of libcloud is
# 2.0.0 (See PR #40837 - which is implemented in Salt Oxygen).
if _LooseVersion(libcloud.__version__) < _LooseVersion('1.4.0'):
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
HAS_LIBCLOUD = True
except ImportError:
HAS_LIBCLOUD = False

View File

@ -711,14 +711,29 @@ def request_instance(vm_=None, call=None):
search_global=False,
default={})
if floating_ip_conf.get('auto_assign', False):
pool = floating_ip_conf.get('pool', 'public')
floating_ip = None
for fl_ip, opts in six.iteritems(conn.floating_ip_list()):
if opts['fixed_ip'] is None and opts['pool'] == pool:
floating_ip = fl_ip
break
if floating_ip is None:
floating_ip = conn.floating_ip_create(pool)['ip']
if floating_ip_conf.get('ip_address', None) is not None:
ip_address = floating_ip_conf.get('ip_address', None)
try:
fl_ip_dict = conn.floating_ip_show(ip_address)
floating_ip = fl_ip_dict['ip']
except Exception as err:
raise SaltCloudSystemExit(
'Error assigning floating_ip for {0} on Nova\n\n'
'The following exception was thrown by libcloud when trying to '
'assign a floating ip: {1}\n'.format(
vm_['name'], err
)
)
else:
pool = floating_ip_conf.get('pool', 'public')
for fl_ip, opts in six.iteritems(conn.floating_ip_list()):
if opts['fixed_ip'] is None and opts['pool'] == pool:
floating_ip = fl_ip
break
if floating_ip is None:
floating_ip = conn.floating_ip_create(pool)['ip']
def __query_node_data(vm_):
try:
@ -765,7 +780,7 @@ def request_instance(vm_=None, call=None):
raise SaltCloudSystemExit(
'Error assigning floating_ip for {0} on Nova\n\n'
'The following exception was thrown by libcloud when trying to '
'assing a floating ip: {1}\n'.format(
'assign a floating ip: {1}\n'.format(
vm_['name'], exc
)
)

View File

@ -143,9 +143,11 @@ import os
import logging
import socket
import pprint
from distutils.version import LooseVersion as _LooseVersion
# Import libcloud
try:
import libcloud
from libcloud.compute.base import NodeState
HAS_LIBCLOUD = True
except ImportError:
@ -156,9 +158,14 @@ HAS014 = False
try:
from libcloud.compute.drivers.openstack import OpenStackNetwork
from libcloud.compute.drivers.openstack import OpenStack_1_1_FloatingIpPool
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
# This work-around for Issue #32743 is no longer needed for libcloud >= 1.4.0.
# However, older versions of libcloud must still be supported with this work-around.
# This work-around can be removed when the required minimum version of libcloud is
# 2.0.0 (See PR #40837 - which is implemented in Salt Oxygen).
if _LooseVersion(libcloud.__version__) < _LooseVersion('1.4.0'):
# See https://github.com/saltstack/salt/issues/32743
import libcloud.security
libcloud.security.CA_CERTS_PATH.append('/etc/ssl/certs/YaST-CA.pem')
HAS014 = True
except Exception:
pass

View File

@ -518,7 +518,7 @@ def _virtual(osdata):
'\'virtual\' grain.'
)
# Check if enable_lspci is True or False
if __opts__.get('enable_lspci', True) is False:
if __opts__.get('enable_lspci', True) is True:
# /proc/bus/pci does not exists, lspci will fail
if os.path.exists('/proc/bus/pci'):
_cmds += ['lspci']

View File

@ -4,6 +4,11 @@ Connection module for Amazon VPC
.. versionadded:: 2014.7.0
:depends:
- boto >= 2.8.0
- boto3 >= 1.2.6
:configuration: This module accepts explicit VPC credentials but can also
utilize IAM roles assigned to the instance through Instance Profiles.
Dynamic credentials are then automatically obtained from AWS API and no
@ -69,8 +74,6 @@ Connection module for Amazon VPC
error:
message: error message
:depends: boto
.. versionadded:: 2016.11.0
Functions to request, accept, delete and describe VPC peering connections.

View File

@ -30,6 +30,7 @@ log = logging.getLogger(__name__)
def get(key,
default=KeyError,
merge=False,
merge_nested_lists=None,
delimiter=DEFAULT_TARGET_DELIM,
pillarenv=None,
saltenv=None):
@ -54,7 +55,7 @@ def get(key,
pkg:apache
merge : False
merge : ``False``
If ``True``, the retrieved values will be merged into the passed
default. When the default and the retrieved value are both
dictionaries, the dictionaries will be recursively merged.
@ -65,6 +66,18 @@ def get(key,
then merging will be skipped and the retrieved value will be
returned. Earlier releases raised an error in these cases.
merge_nested_lists
If set to ``False``, lists nested within the retrieved pillar
dictionary will *overwrite* lists in ``default``. If set to ``True``,
nested lists will be *merged* into lists in ``default``. If unspecified
(the default), this option is inherited from the
:conf_minion:`pillar_merge_lists` minion config option.
.. note::
This option is ignored when ``merge`` is set to ``False``.
.. versionadded:: 2016.11.6
delimiter
Specify an alternate delimiter to use when traversing a nested dict.
This is useful for when the desired key contains a colon. See CLI
@ -104,7 +117,8 @@ def get(key,
if not __opts__.get('pillar_raise_on_missing'):
if default is KeyError:
default = ''
opt_merge_lists = __opts__.get('pillar_merge_lists', False)
opt_merge_lists = __opts__.get('pillar_merge_lists', False) if \
merge_nested_lists is None else merge_nested_lists
pillar_dict = __pillar__ \
if all(x is None for x in (saltenv, pillarenv)) \
else items(saltenv=saltenv, pillarenv=pillarenv)

View File

@ -712,7 +712,7 @@ def bootstrap(version='develop',
.. versionchanged:: 2016.11.0
.. deprecated:: 2016.11.0
.. deprecated:: Oxygen
script_args
Any additional arguments that you want to pass to the script.

View File

@ -983,9 +983,12 @@ class State(object):
errors.append('Missing "name" data')
if data['name'] and not isinstance(data['name'], six.string_types):
errors.append(
'ID \'{0}\' in SLS \'{1}\' is not formed as a string, but is '
'a {2}'.format(
data['name'], data['__sls__'], type(data['name']).__name__)
'ID \'{0}\' {1}is not formed as a string, but is a {2}'.format(
data['name'],
'in SLS \'{0}\' '.format(data['__sls__'])
if '__sls__' in data else '',
type(data['name']).__name__
)
)
if errors:
return errors

View File

@ -584,7 +584,8 @@ def extracted(name,
.. versionchanged:: 2016.11.0
If omitted, the archive format will be guessed based on the value
of the ``source`` argument.
of the ``source`` argument. If the minion is running a release
older than 2016.11.0, this option is required.
.. _tarfile: https://docs.python.org/2/library/tarfile.html
.. _zipfile: https://docs.python.org/2/library/zipfile.html

View File

@ -5,11 +5,14 @@ Manage VPCs
.. versionadded:: 2015.8.0
:depends:
- boto >= 2.8.0
- boto3 >= 1.2.6
Create and destroy VPCs. Be aware that this interacts with Amazon's services,
and so may incur charges.
This module uses ``boto``, which can be installed via package, or pip.
This module accepts explicit vpc credentials but can also utilize
IAM roles assigned to the instance through Instance Profiles. Dynamic
credentials are then automatically obtained from AWS API and no further
@ -147,6 +150,8 @@ import logging
import salt.ext.six as six
import salt.utils.dictupdate as dictupdate
__virtualname__ = 'boto_vpc'
log = logging.getLogger(__name__)
@ -154,7 +159,14 @@ def __virtual__():
'''
Only load if boto is available.
'''
return 'boto_vpc' if 'boto_vpc.exists' in __salt__ else False
boto_version = '2.8.0'
boto3_version = '1.2.6'
if 'boto_vpc.exists' in __salt__:
return __virtualname__
else:
return False, 'The following libraries are required to run the boto_vpc state module: ' \
'boto >= {0} and boto3 >= {1}.'.format(boto_version,
boto3_version)
def present(name, cidr_block, instance_tenancy=None, dns_support=None,

View File

@ -2178,19 +2178,31 @@ def parse_docstring(docstring):
return ret
def print_cli(msg):
def print_cli(msg, retries=10, step=0.01):
'''
Wrapper around print() that suppresses tracebacks on broken pipes (i.e.
when salt output is piped to less and less is stopped prematurely).
'''
try:
while retries:
try:
print(msg)
except UnicodeEncodeError:
print(msg.encode('utf-8'))
except IOError as exc:
if exc.errno != errno.EPIPE:
raise
try:
print(msg)
except UnicodeEncodeError:
print(msg.encode('utf-8'))
except IOError as exc:
err = "{0}".format(exc)
if exc.errno != errno.EPIPE:
if (
("temporarily unavailable" in err or
exc.errno in (errno.EAGAIN,)) and
retries
):
time.sleep(step)
retries -= 1
continue
else:
raise
break
def safe_walk(top, topdown=True, onerror=None, followlinks=True, _seen=None):

View File

@ -27,8 +27,13 @@ def update(dest, upd, recursive_update=True, merge_lists=False):
on a manual merge (helpful for non-dict types like FunctionWrapper)
If merge_lists=True, will aggregate list object types instead of replace.
This behavior is only activated when recursive_update=True. By default
merge_lists=False.
The list in ``upd`` is added to the list in ``dest``, so the resulting list
is ``dest[key] + upd[key]``. This behavior is only activated when
recursive_update=True. By default merge_lists=False.
.. versionchanged: 2016.11.6
When merging lists, duplicate values are removed. Values already
present in the ``dest`` list are not added from the ``upd`` list.
'''
if (not isinstance(dest, collections.Mapping)) \
or (not isinstance(upd, collections.Mapping)):
@ -50,7 +55,9 @@ def update(dest, upd, recursive_update=True, merge_lists=False):
elif isinstance(dest_subkey, list) \
and isinstance(val, list):
if merge_lists:
dest[key] = dest.get(key, []) + val
merged = copy.deepcopy(dest_subkey)
merged.extend([x for x in val if x not in merged])
dest[key] = merged
else:
dest[key] = upd[key]
else:

View File

@ -45,6 +45,7 @@ log = logging.getLogger(__name__)
# Version added to novaclient.client.Client function
NOVACLIENT_MINVER = '2.6.1'
NOVACLIENT_MAXVER = '6.0.1'
# dict for block_device_mapping_v2
CLIENT_BDM2_KEYS = {
@ -65,8 +66,12 @@ def check_nova():
if HAS_NOVA:
novaclient_ver = _LooseVersion(novaclient.__version__)
min_ver = _LooseVersion(NOVACLIENT_MINVER)
if novaclient_ver >= min_ver:
max_ver = _LooseVersion(NOVACLIENT_MAXVER)
if novaclient_ver >= min_ver and novaclient_ver <= max_ver:
return HAS_NOVA
elif novaclient_ver > max_ver:
log.debug('Older novaclient version required. Maximum: {0}'.format(NOVACLIENT_MAXVER))
return False
log.debug('Newer novaclient version required. Minimum: {0}'.format(NOVACLIENT_MINVER))
return False
@ -1145,7 +1150,14 @@ class SaltNova(object):
floating_ips = nt_ks.floating_ips.list()
for floating_ip in floating_ips:
if floating_ip.ip == ip:
return floating_ip
response = {
'ip': floating_ip.ip,
'fixed_ip': floating_ip.fixed_ip,
'id': floating_ip.id,
'instance_id': floating_ip.instance_id,
'pool': floating_ip.pool
}
return response
return {}
def floating_ip_create(self, pool=None):

View File

@ -887,21 +887,28 @@ class Schedule(object):
try:
# Only attempt to return data to the master
# if the scheduled job is running on a minion.
if '__role' in self.opts and self.opts['__role'] == 'minion':
if 'return_job' in data and not data['return_job']:
pass
else:
# Send back to master so the job is included in the job list
mret = ret.copy()
mret['jid'] = 'req'
if data.get('return_job') == 'nocache':
# overwrite 'req' to signal to master that this job shouldn't be stored
mret['jid'] = 'nocache'
event = salt.utils.event.get_event('minion', opts=self.opts, listen=False)
load = {'cmd': '_return', 'id': self.opts['id']}
for key, value in six.iteritems(mret):
load[key] = value
event.fire_event(load, '__schedule_return')
if 'return_job' in data and not data['return_job']:
pass
else:
# Send back to master so the job is included in the job list
mret = ret.copy()
mret['jid'] = 'req'
if data.get('return_job') == 'nocache':
# overwrite 'req' to signal to master that
# this job shouldn't be stored
mret['jid'] = 'nocache'
load = {'cmd': '_return', 'id': self.opts['id']}
for key, value in six.iteritems(mret):
load[key] = value
if '__role' in self.opts and self.opts['__role'] == 'minion':
event = salt.utils.event.get_event('minion',
opts=self.opts,
listen=False)
elif '__role' in self.opts and self.opts['__role'] == 'master':
event = salt.utils.event.get_master_event(self.opts,
self.opts['sock_dir'])
event.fire_event(load, '__schedule_return')
log.debug('schedule.handle_func: Removing {0}'.format(proc_fn))
os.unlink(proc_fn)

View File

@ -2,6 +2,7 @@
'''For running command line executables with a timeout'''
from __future__ import absolute_import
import shlex
import subprocess
import threading
import salt.exceptions
@ -40,13 +41,29 @@ class TimedProc(object):
try:
self.process = subprocess.Popen(args, **kwargs)
except TypeError:
str_args = []
for arg in args:
if not isinstance(arg, six.string_types):
str_args.append(str(arg))
else:
str_args.append(arg)
args = str_args
if not kwargs.get('shell', False):
if not isinstance(args, (list, tuple)):
try:
args = shlex.split(args)
except AttributeError:
args = shlex.split(str(args))
str_args = []
for arg in args:
if not isinstance(arg, six.string_types):
str_args.append(str(arg))
else:
str_args.append(arg)
args = str_args
else:
if not isinstance(args, (list, tuple, six.string_types)):
# Handle corner case where someone does a 'cmd.run 3'
args = str(args)
# Ensure that environment variables are strings
for key, val in six.iteritems(kwargs.get('env', {})):
if not isinstance(val, six.string_types):
kwargs['env'][key] = str(val)
if not isinstance(key, six.string_types):
kwargs['env'][str(key)] = kwargs['env'].pop(key)
self.process = subprocess.Popen(args, **kwargs)
self.command = args

View File

@ -31,8 +31,13 @@ VM_NAME = 'winterfell'
# Use certifi if installed
try:
if HAS_LIBCLOUD:
import certifi
libcloud.security.CA_CERTS_PATH.append(certifi.where())
# This work-around for Issue #32743 is no longer needed for libcloud >= 1.4.0.
# However, older versions of libcloud must still be supported with this work-around.
# This work-around can be removed when the required minimum version of libcloud is
# 2.0.0 (See PR #40837 - which is implemented in Salt Oxygen).
if LooseVersion(libcloud.__version__) < LooseVersion('1.4.0'):
import certifi
libcloud.security.CA_CERTS_PATH.append(certifi.where())
except (ImportError, NameError):
pass

View File

@ -36,8 +36,13 @@ DUMMY_TOKEN = {
# Use certifi if installed
try:
if HAS_LIBCLOUD:
import certifi
libcloud.security.CA_CERTS_PATH.append(certifi.where())
# This work-around for Issue #32743 is no longer needed for libcloud >= 1.4.0.
# However, older versions of libcloud must still be supported with this work-around.
# This work-around can be removed when the required minimum version of libcloud is
# 2.0.0 (See PR #40837 - which is implemented in Salt Oxygen).
if LooseVersion(libcloud.__version__) < LooseVersion('1.4.0'):
import certifi
libcloud.security.CA_CERTS_PATH.append(certifi.where())
except ImportError:
pass

View File

@ -39,6 +39,14 @@ class UtilDictupdateTestCase(TestCase):
mdict['A'] = [1, 2, 3, 4]
self.assertEqual(res, mdict)
# level 1 value changes (list merge, remove duplicates, preserve order)
mdict = copy.deepcopy(self.dict1)
mdict['A'] = [1, 2]
res = dictupdate.update(copy.deepcopy(mdict), {'A': [4, 3, 2, 1]},
merge_lists=True)
mdict['A'] = [1, 2, 4, 3]
self.assertEqual(res, mdict)
# level 2 value changes
mdict = copy.deepcopy(self.dict1)
mdict['C']['D'] = 'Z'
@ -61,6 +69,15 @@ class UtilDictupdateTestCase(TestCase):
mdict['C']['D'] = ['a', 'b', 'c', 'd']
self.assertEqual(res, mdict)
# level 2 value changes (list merge, remove duplicates, preserve order)
mdict = copy.deepcopy(self.dict1)
mdict['C']['D'] = ['a', 'b']
res = dictupdate.update(copy.deepcopy(mdict),
{'C': {'D': ['d', 'c', 'b', 'a']}},
merge_lists=True)
mdict['C']['D'] = ['a', 'b', 'd', 'c']
self.assertEqual(res, mdict)
# level 3 value changes
mdict = copy.deepcopy(self.dict1)
mdict['C']['F']['G'] = 'Z'
@ -86,6 +103,14 @@ class UtilDictupdateTestCase(TestCase):
mdict['C']['F']['G'] = ['a', 'b', 'c', 'd']
self.assertEqual(res, mdict)
# level 3 value changes (list merge, remove duplicates, preserve order)
mdict = copy.deepcopy(self.dict1)
mdict['C']['F']['G'] = ['a', 'b']
res = dictupdate.update(copy.deepcopy(mdict),
{'C': {'F': {'G': ['d', 'c', 'b', 'a']}}}, merge_lists=True)
mdict['C']['F']['G'] = ['a', 'b', 'd', 'c']
self.assertEqual(res, mdict)
# replace a sub-dictionary
mdict = copy.deepcopy(self.dict1)
mdict['C'] = 'Z'