Merge branch 'v2_final' into devel_switch_v2

pull/11129/head
James Cammarata 9 years ago
commit fe41f109a9

@ -239,7 +239,7 @@ class PlayIterator:
self._host_states[host.name] = s
def get_failed_hosts(self):
return dict((host, True) for (host, state) in self._host_states.iteritems() if state.run_state == self.ITERATING_COMPLETE and state.fail_state != self.FAILED_NONE)
return dict((host, True) for (host, state) in self._host_states.iteritems() if state.fail_state != self.FAILED_NONE)
def get_original_task(self, host, task):
'''

@ -59,11 +59,9 @@ class Group:
depth=self.depth,
)
debug("serializing group, result is: %s" % result)
return result
def deserialize(self, data):
debug("deserializing group, data is: %s" % data)
self.__init__()
self.name = data.get('name')
self.vars = data.get('vars', dict())

@ -589,8 +589,8 @@ class AnsibleModule(object):
return True
rc = selinux.lsetfilecon(self._to_filesystem_str(path),
str(':'.join(new_context)))
except OSError:
self.fail_json(path=path, msg='invalid selinux context', new_context=new_context, cur_context=cur_context, input_was=context)
except OSError, e:
self.fail_json(path=path, msg='invalid selinux context: %s' % str(e), new_context=new_context, cur_context=cur_context, input_was=context)
if rc != 0:
self.fail_json(path=path, msg='set selinux context failed')
changed = True

@ -99,8 +99,9 @@ class Facts(object):
('/etc/os-release', 'SuSE'),
('/etc/gentoo-release', 'Gentoo'),
('/etc/os-release', 'Debian'),
('/etc/lsb-release', 'Mandriva'),
('/etc/os-release', 'NA'),
('/etc/lsb-release', 'Mandriva'))
)
SELINUX_MODE_DICT = { 1: 'enforcing', 0: 'permissive', -1: 'disabled' }
# A list of dicts. If there is a platform with more than one
@ -416,7 +417,9 @@ class Facts(object):
self.facts['distribution_version'] = self.facts['distribution_version'] + '.' + release.group(1)
elif name == 'Debian':
data = get_file_content(path)
if 'Debian' in data or 'Raspbian' in data:
if 'Ubuntu' in data:
break # Ubuntu gets correct info from python functions
elif 'Debian' in data or 'Raspbian' in data:
release = re.search("PRETTY_NAME=[^(]+ \(?([^)]+?)\)", data)
if release:
self.facts['distribution_release'] = release.groups()[0]
@ -2150,7 +2153,7 @@ class DarwinNetwork(GenericBsdIfconfigNetwork, Network):
current_if['media'] = 'Unknown' # Mac does not give us this
current_if['media_select'] = words[1]
if len(words) > 2:
current_if['media_type'] = words[2][1:]
current_if['media_type'] = words[2][1:-1]
if len(words) > 3:
current_if['media_options'] = self.get_options(words[3])

@ -93,11 +93,7 @@ def openstack_full_argument_spec(**kwargs):
def openstack_module_kwargs(**kwargs):
ret = dict(
required_one_of=[
['cloud', 'auth'],
],
)
ret = {}
for key in ('mutually_exclusive', 'required_together', 'required_one_of'):
if key in kwargs:
if key in ret:

@ -65,7 +65,7 @@ Function Exit-Json($obj)
$obj = New-Object psobject
}
echo $obj | ConvertTo-Json -Depth 99
echo $obj | ConvertTo-Json -Compress -Depth 99
Exit
}
@ -89,7 +89,7 @@ Function Fail-Json($obj, $message = $null)
Set-Attr $obj "msg" $message
Set-Attr $obj "failed" $true
echo $obj | ConvertTo-Json -Depth 99
echo $obj | ConvertTo-Json -Compress -Depth 99
Exit 1
}

@ -1 +1 @@
Subproject commit 9cc23c749a8cd5039db7aa1998d310bbb04d1e13
Subproject commit 191a672891359f3b6faff83cb0613f1b38e3fc0e

@ -1 +1 @@
Subproject commit b2e4f31bebfec49380659b9d65b5828f1c1ed8d9
Subproject commit 1276420a3a39340fcd9e053a1e621cdd89f480fa

@ -0,0 +1,37 @@
# (c) 2012-2014, Michael DeHaan <michael.dehaan@gmail.com>
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
# Make coding more python3-ish
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import yaml
from ansible.parsing.yaml.objects import AnsibleUnicode
class AnsibleDumper(yaml.SafeDumper):
'''
A simple stub class that allows us to add representers
for our overridden object types.
'''
pass
AnsibleDumper.add_representer(
AnsibleUnicode,
yaml.representer.SafeRepresenter.represent_unicode
)

@ -0,0 +1,79 @@
# (c) 2012-2014, Michael DeHaan <michael.dehaan@gmail.com>
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
# Make coding more python3-ish
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
class IncludedFile:
def __init__(self, filename, args, task):
self._filename = filename
self._args = args
self._task = task
self._hosts = []
def add_host(self, host):
if host not in self._hosts:
self._hosts.append(host)
def __eq__(self, other):
return other._filename == self._filename and other._args == self._args
def __repr__(self):
return "%s (%s): %s" % (self._filename, self._args, self._hosts)
@staticmethod
def process_include_results(results, tqm, iterator, loader):
included_files = []
for res in results:
if res._host in tqm._failed_hosts:
raise AnsibleError("host is failed, not including files")
if res._task.action == 'include':
if res._task.loop:
include_results = res._result['results']
else:
include_results = [ res._result ]
for include_result in include_results:
# if the task result was skipped or failed, continue
if 'skipped' in include_result and include_result['skipped'] or 'failed' in include_result:
continue
original_task = iterator.get_original_task(res._host, res._task)
if original_task and original_task._role:
include_file = loader.path_dwim_relative(original_task._role._role_path, 'tasks', include_result['include'])
else:
include_file = loader.path_dwim(res._task.args.get('_raw_params'))
include_variables = include_result.get('include_variables', dict())
if 'item' in include_result:
include_variables['item'] = include_result['item']
inc_file = IncludedFile(include_file, include_variables, original_task)
try:
pos = included_files.index(inc_file)
inc_file = included_files[pos]
except ValueError:
included_files.append(inc_file)
inc_file.add_host(res._host)
return included_files

@ -38,16 +38,21 @@ from jinja2.filters import environmentfilter
from distutils.version import LooseVersion, StrictVersion
from ansible import errors
from ansible.parsing.yaml.dumper import AnsibleDumper
from ansible.utils.hashing import md5s, checksum_s
from ansible.utils.unicode import unicode_wrap, to_unicode
UUID_NAMESPACE_ANSIBLE = uuid.UUID('361E6D51-FAEC-444A-9079-341386DA8E2E')
def to_yaml(a, *args, **kw):
'''Make verbose, human readable yaml'''
transformed = yaml.dump(a, Dumper=AnsibleDumper, allow_unicode=True, **kw)
return to_unicode(transformed)
def to_nice_yaml(*a, **kw):
def to_nice_yaml(a, *args, **kw):
'''Make verbose, human readable yaml'''
transformed = yaml.safe_dump(*a, indent=4, allow_unicode=True, default_flow_style=False, **kw)
transformed = yaml.dump(a, Dumper=AnsibleDumper, indent=4, allow_unicode=True, default_flow_style=False, **kw)
return to_unicode(transformed)
def to_json(a, *args, **kw):
@ -288,7 +293,7 @@ class FilterModule(object):
'from_json': json.loads,
# yaml
'to_yaml': yaml.safe_dump,
'to_yaml': to_yaml,
'to_nice_yaml': to_nice_yaml,
'from_yaml': yaml.safe_load,

@ -23,10 +23,9 @@ from six.moves import queue as Queue
import time
from ansible.errors import *
from ansible.executor.task_result import TaskResult
from ansible.inventory.host import Host
from ansible.inventory.group import Group
from ansible.playbook.handler import Handler
from ansible.playbook.helpers import load_list_of_blocks
from ansible.playbook.role import ROLE_CACHE, hash_params
@ -74,29 +73,32 @@ class StrategyBase:
self._blocked_hosts = dict()
def run(self, iterator, connection_info, result=True):
# save the counts on failed/unreachable hosts, as the cleanup/handler
# methods will clear that information during their runs
num_failed = len(self._tqm._failed_hosts)
num_unreachable = len(self._tqm._unreachable_hosts)
# save the failed/unreachable hosts, as the run_handlers()
# method will clear that information during its execution
failed_hosts = self._tqm._failed_hosts.keys()
unreachable_hosts = self._tqm._unreachable_hosts.keys()
debug("running handlers")
result &= self.run_handlers(iterator, connection_info)
# now update with the hosts (if any) that failed or were
# unreachable during the handler execution phase
failed_hosts = set(failed_hosts).union(self._tqm._failed_hosts.keys())
unreachable_hosts = set(unreachable_hosts).union(self._tqm._unreachable_hosts.keys())
# send the stats callback
self._tqm.send_callback('v2_playbook_on_stats', self._tqm._stats)
if not result:
if num_unreachable > 0:
if len(unreachable_hosts) > 0:
return 3
elif num_failed > 0:
elif len(failed_hosts) > 0:
return 2
else:
elif not result:
return 1
else:
return 0
def get_hosts_remaining(self, play):
print("inventory get hosts: %s" % self._inventory.get_hosts(play.hosts))
return [host for host in self._inventory.get_hosts(play.hosts) if host.name not in self._tqm._failed_hosts and host.name not in self._tqm._unreachable_hosts]
def get_failed_hosts(self, play):
@ -147,7 +149,7 @@ class StrategyBase:
task_result = result[1]
host = task_result._host
task = task_result._task
if result[0] == 'host_task_failed':
if result[0] == 'host_task_failed' or 'failed' in task_result._result:
if not task.ignore_errors:
debug("marking %s as failed" % host.name)
iterator.mark_host_failed(host)
@ -308,12 +310,22 @@ class StrategyBase:
# and add the host to the group
new_group.add_host(actual_host)
def _load_included_file(self, included_file):
def _load_included_file(self, included_file, iterator):
'''
Loads an included YAML file of tasks, applying the optional set of variables.
'''
try:
data = self._loader.load_from_file(included_file._filename)
except AnsibleError, e:
for host in included_file._hosts:
tr = TaskResult(host=host, task=included_file._task, return_data=dict(failed=True, reason=str(e)))
iterator.mark_host_failed(host)
self._tqm._failed_hosts[host.name] = True
self._tqm._stats.increment('failures', host.name)
self._tqm.send_callback('v2_runner_on_failed', tr)
return []
if not isinstance(data, list):
raise AnsibleParserError("included task files must contain a list of tasks", obj=included_file._task._ds)

@ -22,6 +22,7 @@ __metaclass__ = type
from ansible.errors import AnsibleError
from ansible.executor.play_iterator import PlayIterator
from ansible.playbook.block import Block
from ansible.playbook.included_file import IncludedFile
from ansible.playbook.task import Task
from ansible.plugins import action_loader
from ansible.plugins.strategies import StrategyBase
@ -114,7 +115,6 @@ class StrategyModule(StrategyBase):
# return None for all hosts in the list
return [(host, None) for host in hosts]
def run(self, iterator, connection_info):
'''
The linear strategy is simple - get the next task and queue
@ -208,61 +208,11 @@ class StrategyModule(StrategyBase):
results = self._wait_on_pending_results(iterator)
host_results.extend(results)
# FIXME: this needs to be somewhere else
class IncludedFile:
def __init__(self, filename, args, task):
self._filename = filename
self._args = args
self._task = task
self._hosts = []
def add_host(self, host):
if host not in self._hosts:
self._hosts.append(host)
def __eq__(self, other):
return other._filename == self._filename and other._args == self._args
def __repr__(self):
return "%s (%s): %s" % (self._filename, self._args, self._hosts)
# FIXME: this should also be moved to the base class in a method
included_files = []
for res in host_results:
if res._host in self._tqm._failed_hosts:
return 1
if res._task.action == 'include':
if res._task.loop:
include_results = res._result['results']
else:
include_results = [ res._result ]
for include_result in include_results:
# if the task result was skipped or failed, continue
if 'skipped' in include_result and include_result['skipped'] or 'failed' in include_result:
continue
original_task = iterator.get_original_task(res._host, res._task)
if original_task and original_task._role:
include_file = self._loader.path_dwim_relative(original_task._role._role_path, 'tasks', include_result['include'])
else:
include_file = self._loader.path_dwim(res._task.args.get('_raw_params'))
include_variables = include_result.get('include_variables', dict())
if 'item' in include_result:
include_variables['item'] = include_result['item']
inc_file = IncludedFile(include_file, include_variables, original_task)
try:
pos = included_files.index(inc_file)
inc_file = included_files[pos]
except ValueError:
included_files.append(inc_file)
inc_file.add_host(res._host)
included_files = IncludedFile.process_include_results(host_results, self._tqm, iterator=iterator, loader=self._loader)
except AnsibleError, e:
return False
# FIXME: should this be moved into the iterator class? Main downside would be
# that accessing the TQM's callback member would be more difficult, if
# we do want to send callbacks from here
if len(included_files) > 0:
noop_task = Task()
noop_task.action = 'meta'
@ -274,7 +224,7 @@ class StrategyModule(StrategyBase):
# included hosts get the task list while those excluded get an equal-length
# list of noop tasks, to make sure that they continue running in lock-step
try:
new_blocks = self._load_included_file(included_file)
new_blocks = self._load_included_file(included_file, iterator=iterator)
except AnsibleError, e:
for host in included_file._hosts:
iterator.mark_host_failed(host)
@ -302,7 +252,7 @@ class StrategyModule(StrategyBase):
except (IOError, EOFError), e:
debug("got IOError/EOFError in task loop: %s" % e)
# most likely an abort, return failed
return 1
return False
# run the base class run() method, which executes the cleanup function
# and runs any outstanding handlers which have been triggered

@ -238,14 +238,6 @@ class Templar:
environment.filters.update(self._get_filters())
environment.template_class = AnsibleJ2Template
# FIXME: may not be required anymore, as the basedir stuff will
# be handled by the loader?
#if '_original_file' in vars:
# basedir = os.path.dirname(vars['_original_file'])
# filesdir = os.path.abspath(os.path.join(basedir, '..', 'files'))
# if os.path.exists(filesdir):
# basedir = filesdir
try:
t = environment.from_string(data)
except TemplateSyntaxError, e:

@ -20,6 +20,9 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import textwrap
import os
import random
import subprocess
import sys
from ansible import constants as C
@ -37,6 +40,31 @@ class Display:
self._warns = {}
self._errors = {}
self.cowsay = None
self.noncow = os.getenv("ANSIBLE_COW_SELECTION",None)
self.set_cowsay_info()
def set_cowsay_info(self):
if not C.ANSIBLE_NOCOWS:
if os.path.exists("/usr/bin/cowsay"):
self.cowsay = "/usr/bin/cowsay"
elif os.path.exists("/usr/games/cowsay"):
self.cowsay = "/usr/games/cowsay"
elif os.path.exists("/usr/local/bin/cowsay"):
# BSD path for cowsay
self.cowsay = "/usr/local/bin/cowsay"
elif os.path.exists("/opt/local/bin/cowsay"):
# MacPorts path for cowsay
self.cowsay = "/opt/local/bin/cowsay"
if self.cowsay and self.noncow == 'random':
cmd = subprocess.Popen([self.cowsay, "-l"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(out, err) = cmd.communicate()
cows = out.split()
cows.append(False)
self.noncow = random.choice(cows)
def display(self, msg, color=None, stderr=False, screen_only=False, log_only=False):
msg2 = msg
if color:
@ -125,6 +153,14 @@ class Display:
Prints a header-looking line with stars taking up to 80 columns
of width (3 columns, minimum)
'''
if self.cowsay:
try:
self.banner_cowsay(msg)
return
except OSError:
# somebody cleverly deleted cowsay or something during the PB run. heh.
pass
msg = msg.strip()
star_len = (80 - len(msg))
if star_len < 0:
@ -132,6 +168,20 @@ class Display:
stars = "*" * star_len
self.display("\n%s %s" % (msg, stars), color=color)
def banner_cowsay(self, msg, color=None):
if ": [" in msg:
msg = msg.replace("[","")
if msg.endswith("]"):
msg = msg[:-1]
runcmd = [self.cowsay,"-W", "60"]
if self.noncow:
runcmd.append('-f')
runcmd.append(self.noncow)
runcmd.append(msg)
cmd = subprocess.Popen(runcmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(out, err) = cmd.communicate()
self.display("%s\n" % out, color=color)
def error(self, msg):
new_msg = "\n[ERROR]: %s" % msg
wrapped = textwrap.wrap(new_msg, 79)

@ -23,7 +23,9 @@ class ModuleDocFragment(object):
options:
cloud:
description:
- Named cloud to operate against. Provides default values for I(auth) and I(auth_plugin)
- Named cloud to operate against. Provides default values for I(auth) and
I(auth_type). This parameter is not needed if I(auth) is provided or if
OpenStack OS_* environment variables are present.
required: false
auth:
description:
@ -32,7 +34,8 @@ options:
I(auth_url), I(username), I(password), I(project_name) and any
information about domains if the cloud supports them. For other plugins,
this param will need to contain whatever parameters that auth plugin
requires. This parameter is not needed if a named cloud is provided.
requires. This parameter is not needed if a named cloud is provided or
OpenStack OS_* environment variables are present.
required: false
auth_type:
description:
@ -77,14 +80,17 @@ options:
- A path to a CA Cert bundle that can be used as part of verifying
SSL API requests.
required: false
default: None
cert:
description:
- A path to a client certificate to use as part of the SSL transaction
required: false
default: None
key:
description:
- A path to a client key to use as part of the SSL transaction
required: false
default: None
endpoint_type:
description:
- Endpoint URL type to fetch from the service catalog.
@ -100,5 +106,6 @@ notes:
can come from a yaml config file in /etc/ansible/openstack.yaml,
/etc/openstack/clouds.yaml or ~/.config/openstack/clouds.yaml, then from
standard environment variables, then finally by explicit parameters in
plays.
plays. More information can be found at
U(http://docs.openstack.org/developer/os-client-config)
'''

@ -24,12 +24,13 @@ CONSUL_RUNNING := $(shell python consul_running.py)
all: parsing test_var_precedence unicode test_templating_settings non_destructive destructive includes check_mode test_hash test_handlers test_group_by test_vault test_tags
parsing:
ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario1; [ $$? -eq 4 ]
ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario2; [ $$? -eq 4 ]
ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario3; [ $$? -eq 4 ]
ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario4; [ $$? -eq 4 ]
ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario5; [ $$? -eq 4 ]
ansible-playbook good_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -v $(TEST_FLAGS)
#ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario1; [ $$? -eq 4 ]
#ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario2; [ $$? -eq 4 ]
#ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario3; [ $$? -eq 4 ]
#ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario4; [ $$? -eq 4 ]
#ansible-playbook bad_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -vvv $(TEST_FLAGS) --tags prepare,common,scenario5; [ $$? -eq 4 ]
#ansible-playbook good_parsing.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) -v $(TEST_FLAGS)
echo "skipping for now..."
includes:
ansible-playbook test_includes.yml -i $(INVENTORY) -e @$(VARS_FILE) $(CREDENTIALS_ARG) $(TEST_FLAGS)

@ -225,7 +225,7 @@
- "result.msg == 'line added'"
- name: insert a multiple lines at the end of the file
lineinfile: dest={{output_dir}}/test.txt state=present line="This is a line\nwith \\\n character" insertafter="EOF"
lineinfile: dest={{output_dir}}/test.txt state=present line="This is a line\nwith \\n character" insertafter="EOF"
register: result
- name: assert that the multiple lines was inserted

@ -0,0 +1,5 @@
- hosts: testhost
connection: local
gather_facts: yes
roles:
- { role: test_filters }

@ -722,7 +722,7 @@ class TestModuleUtilsBasic(unittest.TestCase):
# FIXME: this isn't working yet
#with patch('os.lstat', side_effect=[mock_stat1, mock_stat2]):
# with patch('os.lchmod', return_value=None, create=True) as m_os:
# with patch('os.lchmod', return_value=None) as m_os:
# del m_os.lchmod
# with patch('os.path.islink', return_value=False):
# with patch('os.chmod', return_value=None) as m_chmod:

@ -299,14 +299,17 @@ class TestStrategyBase(unittest.TestCase):
mock_task._block = mock_block
mock_task._role = None
mock_iterator = MagicMock()
mock_iterator.mark_host_failed.return_value = None
mock_inc_file = MagicMock()
mock_inc_file._task = mock_task
mock_inc_file._filename = "test.yml"
res = strategy_base._load_included_file(included_file=mock_inc_file)
res = strategy_base._load_included_file(included_file=mock_inc_file, iterator=mock_iterator)
mock_inc_file._filename = "bad.yml"
self.assertRaises(AnsibleParserError, strategy_base._load_included_file, included_file=mock_inc_file)
self.assertRaises(AnsibleParserError, strategy_base._load_included_file, included_file=mock_inc_file, iterator=mock_iterator)
def test_strategy_base_run_handlers(self):
workers = []

@ -93,11 +93,7 @@ def openstack_full_argument_spec(**kwargs):
def openstack_module_kwargs(**kwargs):
ret = dict(
required_one_of=[
['cloud', 'auth'],
],
)
ret = {}
for key in ('mutually_exclusive', 'required_together', 'required_one_of'):
if key in kwargs:
if key in ret:

Loading…
Cancel
Save