From 6a3992a3e11b949a5948319f78b403314ac83d87 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 5 Aug 2021 17:54:58 +0200 Subject: [PATCH] =?UTF-8?q?add=20support=20for=20multiple=20deadline=20ser?= =?UTF-8?q?vers=20=F0=9F=96=A5=EF=B8=8F=F0=9F=96=A5=EF=B8=8F=F0=9F=96=A5?= =?UTF-8?q?=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maya/plugins/create/create_render.py | 172 ++++++++++++------ openpype/modules/deadline/deadline_module.py | 10 +- .../plugins/publish/submit_maya_deadline.py | 16 +- .../plugins/publish/submit_nuke_deadline.py | 16 +- .../plugins/publish/submit_publish_job.py | 23 ++- .../publish/validate_deadline_connection.py | 27 +-- .../validate_expected_and_rendered_files.py | 17 +- .../defaults/project_settings/deadline.json | 1 + .../defaults/project_settings/maya.json | 12 +- .../defaults/project_settings/unreal.json | 3 +- .../defaults/system_settings/modules.json | 4 +- openpype/settings/entities/__init__.py | 4 +- openpype/settings/entities/enum_entity.py | 51 ++++++ .../schema_project_deadline.json | 6 + .../schemas/schema_maya_create.json | 24 ++- .../schemas/system_schema/schema_modules.json | 8 +- 16 files changed, 274 insertions(+), 120 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index cbca091365..76cac5fe25 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -4,6 +4,7 @@ import os import json import appdirs import requests +import six from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup @@ -12,7 +13,13 @@ from openpype.hosts.maya.api import ( lib, plugin ) -from openpype.api import (get_system_settings, get_asset) +from openpype.api import ( + get_system_settings, + get_project_settings, + get_asset) +from openpype.modules import ModulesManager + +from avalon.api import Session class CreateRender(plugin.Creator): @@ -83,6 +90,19 @@ class CreateRender(plugin.Creator): def __init__(self, *args, **kwargs): """Constructor.""" super(CreateRender, self).__init__(*args, **kwargs) + project_settings = get_project_settings(Session["AVALON_PROJECT"]) + try: + self.deadline_servers = ( + project_settings["deadline"] + ["deadline_servers"] + ) + except AttributeError: + # Handle situation were we had only one url for deadline. + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + deadline_url = deadline_module.deadline_url + self.deadline_servers = {"default": deadline_url} def process(self): """Entry point.""" @@ -94,10 +114,10 @@ class CreateRender(plugin.Creator): use_selection = self.options.get("useSelection") with lib.undo_chunk(): self._create_render_settings() - instance = super(CreateRender, self).process() + self.instance = super(CreateRender, self).process() # create namespace with instance index = 1 - namespace_name = "_{}".format(str(instance)) + namespace_name = "_{}".format(str(self.instance)) try: cmds.namespace(rm=namespace_name) except RuntimeError: @@ -105,12 +125,19 @@ class CreateRender(plugin.Creator): pass while cmds.namespace(exists=namespace_name): - namespace_name = "_{}{}".format(str(instance), index) + namespace_name = "_{}{}".format(str(self.instance), index) index += 1 namespace = cmds.namespace(add=namespace_name) - cmds.setAttr("{}.machineList".format(instance), lock=True) + # add Deadline server selection list + cmds.scriptJob( + attributeChange=[ + "{}.deadlineServers".format(self.instance), + self._deadline_webservice_changed + ]) + + cmds.setAttr("{}.machineList".format(self.instance), lock=True) self._rs = renderSetup.instance() layers = self._rs.getRenderLayers() if use_selection: @@ -122,7 +149,7 @@ class CreateRender(plugin.Creator): render_set = cmds.sets( n="{}:{}".format(namespace, layer.name())) sets.append(render_set) - cmds.sets(sets, forceElement=instance) + cmds.sets(sets, forceElement=self.instance) # if no render layers are present, create default one with # asterisk selector @@ -138,62 +165,56 @@ class CreateRender(plugin.Creator): renderer = 'renderman' self._set_default_renderer_settings(renderer) + return self.instance + + def _deadline_webservice_changed(self): + """Refresh Deadline server dependent options.""" + # get selected server + webservice = self.deadline_servers[ + self.server_aliases[ + cmds.getAttr("{}.deadlineServers".format(self.instance)) + ] + ] + pools = self._get_deadline_pools(webservice) + cmds.deleteAttr("{}.primaryPool".format(self.instance)) + cmds.deleteAttr("{}.secondaryPool".format(self.instance)) + cmds.addAttr(self.instance, longName="primaryPool", + attributeType="enum", + enumName=":".join(pools)) + cmds.addAttr(self.instance, longName="secondaryPool", + attributeType="enum", + enumName=":".join(["-"] + pools)) + + def _get_deadline_pools(self, webservice): + # type: (str) -> list + """Get pools from Deadline. + Args: + webservice (str): Server url. + Returns: + list: Pools. + Throws: + RuntimeError: If deadline webservice is unreachable. + + """ + argument = "{}/api/pools?NamesOnly=true".format(webservice) + try: + response = self._requests_get(argument) + except requests.exceptions.ConnectionError as exc: + msg = 'Cannot connect to deadline web service' + self.log.error(msg) + six.reraise(exc, RuntimeError('{} - {}'.format(msg, exc))) + if not response.ok: + self.log.warning("No pools retrieved") + return [] + + return response.json() def _create_render_settings(self): # get pools - pools = [] - - system_settings = get_system_settings()["modules"] - - deadline_enabled = system_settings["deadline"]["enabled"] - muster_enabled = system_settings["muster"]["enabled"] - deadline_url = system_settings["deadline"]["DEADLINE_REST_URL"] - muster_url = system_settings["muster"]["MUSTER_REST_URL"] - - if deadline_enabled and muster_enabled: - self.log.error( - "Both Deadline and Muster are enabled. " "Cannot support both." - ) - raise RuntimeError("Both Deadline and Muster are enabled") - - if deadline_enabled: - argument = "{}/api/pools?NamesOnly=true".format(deadline_url) - try: - response = self._requests_get(argument) - except requests.exceptions.ConnectionError as e: - msg = 'Cannot connect to deadline web service' - self.log.error(msg) - raise RuntimeError('{} - {}'.format(msg, e)) - if not response.ok: - self.log.warning("No pools retrieved") - else: - pools = response.json() - self.data["primaryPool"] = pools - # We add a string "-" to allow the user to not - # set any secondary pools - self.data["secondaryPool"] = ["-"] + pools - - if muster_enabled: - self.log.info(">>> Loading Muster credentials ...") - self._load_credentials() - self.log.info(">>> Getting pools ...") - try: - pools = self._get_muster_pools() - except requests.exceptions.HTTPError as e: - if e.startswith("401"): - self.log.warning("access token expired") - self._show_login() - raise RuntimeError("Access token expired") - except requests.exceptions.ConnectionError: - self.log.error("Cannot connect to Muster API endpoint.") - raise RuntimeError("Cannot connect to {}".format(muster_url)) - pool_names = [] - for pool in pools: - self.log.info(" - pool: {}".format(pool["name"])) - pool_names.append(pool["name"]) - - self.data["primaryPool"] = pool_names + pool_names = [] + self.server_aliases = self.deadline_servers.keys() + self.data["deadlineServers"] = self.server_aliases self.data["suspendPublishJob"] = False self.data["review"] = True self.data["extendFrames"] = False @@ -212,6 +233,41 @@ class CreateRender(plugin.Creator): # Disable for now as this feature is not working yet # self.data["assScene"] = False + system_settings = get_system_settings()["modules"] + + deadline_enabled = system_settings["deadline"]["enabled"] + muster_enabled = system_settings["muster"]["enabled"] + muster_url = system_settings["muster"]["MUSTER_REST_URL"] + + if deadline_enabled and muster_enabled: + self.log.error( + "Both Deadline and Muster are enabled. " "Cannot support both." + ) + raise RuntimeError("Both Deadline and Muster are enabled") + + if deadline_enabled: + pool_names = self._get_deadline_pools( + self.deadline_servers["default"]) + + if muster_enabled: + self.log.info(">>> Loading Muster credentials ...") + self._load_credentials() + self.log.info(">>> Getting pools ...") + try: + pools = self._get_muster_pools() + except requests.exceptions.HTTPError as e: + if e.startswith("401"): + self.log.warning("access token expired") + self._show_login() + raise RuntimeError("Access token expired") + except requests.exceptions.ConnectionError: + self.log.error("Cannot connect to Muster API endpoint.") + raise RuntimeError("Cannot connect to {}".format(muster_url)) + for pool in pools: + self.log.info(" - pool: {}".format(pool["name"])) + pool_names.append(pool["name"]) + + self.data["primaryPool"] = pool_names self.options = {"useSelection": False} # Force no content def _load_credentials(self): diff --git a/openpype/modules/deadline/deadline_module.py b/openpype/modules/deadline/deadline_module.py index 2a2fba41d6..8329b3151b 100644 --- a/openpype/modules/deadline/deadline_module.py +++ b/openpype/modules/deadline/deadline_module.py @@ -10,7 +10,15 @@ class DeadlineModule(PypeModule, IPluginPaths): # This module is always enabled deadline_settings = modules_settings[self.name] self.enabled = deadline_settings["enabled"] - self.deadline_url = deadline_settings["DEADLINE_REST_URL"] + deadline_url = deadline_settings.get("DEADLINE_REST_URL") + if not deadline_url: + deadline_url = deadline_settings.get("deadline_urls", {}).get("default") # noqa: E501 + if not deadline_url: + self.enabled = False + self.log.warning(("default Deadline Webservice URL " + "not specified. Disabling module.")) + return + self.deadline_url = deadline_url def get_global_environments(self): """Deadline global environments for OpenPype implementation.""" diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index a652da7786..f8577e24fa 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -36,6 +36,7 @@ from avalon import api import pyblish.api from openpype.hosts.maya.api import lib +from openpype.modules import ModulesManager # Documentation for keys available at: # https://docs.thinkboxsoftware.com @@ -264,12 +265,15 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): self._instance = instance self.payload_skeleton = copy.deepcopy(payload_skeleton_template) - self._deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) + + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + self.deadline_url = deadline_module.deadline_url + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + self.deadline_url = instance.data.get("deadlineUrl") + assert self.deadline_url, "Requires Deadline Webservice URL" self._job_info = ( context.data["project_settings"].get( diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index fed98d8a08..1624423715 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -4,6 +4,7 @@ import getpass from avalon import api from avalon.vendor import requests +from openpype.modules import ModulesManager import re import pyblish.api import nuke @@ -42,13 +43,14 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): node = instance[0] context = instance.context - deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) - assert deadline_url, "Requires DEADLINE_REST_URL" + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + self.deadline_url = deadline_module.deadline_url + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + self.deadline_url = instance.data.get("deadlineUrl") + assert self.deadline_url, "Requires Deadline Webservice URL" self.deadline_url = "{}/api/jobs".format(deadline_url) self._comment = context.data.get("comment", "") diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 41f8337fd8..ed838e64ed 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -5,7 +5,7 @@ import os import json import re from copy import copy, deepcopy -import sys +from openpype.modules import ModulesManager import openpype.api from avalon import api, io @@ -615,14 +615,16 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): instance["families"] = families def process(self, instance): + # type: (pyblish.api.Instance) -> None """Process plugin. Detect type of renderfarm submission and create and post dependend job in case of Deadline. It creates json file with metadata needed for publishing in directory of render. - :param instance: Instance data - :type instance: dict + Args: + instance (pyblish.api.Instance): Instance data. + """ data = instance.data.copy() context = instance.context @@ -908,13 +910,14 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): } if submission_type == "deadline": - self.deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) - assert self.deadline_url, "Requires DEADLINE_REST_URL" + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + self.deadline_url = deadline_module.deadline_url + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + self.deadline_url = instance.data.get("deadlineUrl") + assert self.deadline_url, "Requires Deadline Webservice URL" self._submit_deadline_post_job(instance, render_job, instances) diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index 9b10619c0b..1dba94d822 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -1,11 +1,11 @@ import pyblish.api from avalon.vendor import requests -from openpype.plugin import contextplugin_should_run +from openpype.modules import ModulesManager import os -class ValidateDeadlineConnection(pyblish.api.ContextPlugin): +class ValidateDeadlineConnection(pyblish.api.InstancePlugin): """Validate Deadline Web Service is running""" label = "Validate Deadline Web Service" @@ -13,18 +13,19 @@ class ValidateDeadlineConnection(pyblish.api.ContextPlugin): hosts = ["maya", "nuke"] families = ["renderlayer"] - def process(self, context): + def process(self, instance): - # Workaround bug pyblish-base#250 - if not contextplugin_should_run(self, context): - return - - deadline_url = ( - context.data["system_settings"] - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + deadline_url = deadline_module.deadline_url + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + deadline_url = instance.data.get("deadlineUrl") + self.log.info( + "We have deadline URL on instance {}".format( + deadline_url)) + assert deadline_url, "Requires Deadline Webservice URL" # Check response response = self._requests_get(deadline_url) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index c71b5106ec..ca82c54fb8 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -4,9 +4,9 @@ import pyblish.api from avalon.vendor import requests -from openpype.api import get_system_settings from openpype.lib.abstract_submit_deadline import requests_get from openpype.lib.delivery import collect_frames +from openpype.modules import ModulesManager class ValidateExpectedFiles(pyblish.api.InstancePlugin): @@ -129,13 +129,14 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): Might be different than job info saved in metadata.json if user manually changes job pre/during rendering. """ - deadline_url = ( - get_system_settings() - ["modules"] - ["deadline"] - ["DEADLINE_REST_URL"] - ) - assert deadline_url, "Requires DEADLINE_REST_URL" + manager = ModulesManager() + deadline_module = manager.modules_by_name["deadline"] + # get default deadline webservice url from deadline module + deadline_url = deadline_module.deadline_url + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + deadline_url = instance.data.get("deadlineUrl") + assert deadline_url, "Requires Deadline Webservice URL" url = "{}/api/jobs?JobID={}".format(deadline_url, job_id) try: diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 2dba20d63c..81d611af1e 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -1,4 +1,5 @@ { + "deadline_servers": [], "publish": { "ValidateExpectedFiles": { "enabled": true, diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 1db6cdf9f1..e19c03b139 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -31,6 +31,12 @@ "Main" ] }, + "CreateRender": { + "enabled": true, + "defaults": [ + "Main" + ] + }, "CreateAnimation": { "enabled": true, "defaults": [ @@ -81,12 +87,6 @@ "Main" ] }, - "CreateRender": { - "enabled": true, - "defaults": [ - "Main" - ] - }, "CreateRenderSetup": { "enabled": true, "defaults": [ diff --git a/openpype/settings/defaults/project_settings/unreal.json b/openpype/settings/defaults/project_settings/unreal.json index 46b9ca2a18..dad61cd1f0 100644 --- a/openpype/settings/defaults/project_settings/unreal.json +++ b/openpype/settings/defaults/project_settings/unreal.json @@ -1,6 +1,5 @@ { "project_setup": { - "dev_mode": true, - "install_unreal_python_engine": false + "dev_mode": true } } \ No newline at end of file diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 1b74b4695c..3a70b90590 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -140,7 +140,9 @@ }, "deadline": { "enabled": true, - "DEADLINE_REST_URL": "http://localhost:8082" + "deadline_urls": { + "default": "http://127.0.0.1:8082" + } }, "muster": { "enabled": false, diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index c0eef15e69..9cda702e9a 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -105,7 +105,8 @@ from .enum_entity import ( AppsEnumEntity, ToolsEnumEntity, TaskTypeEnumEntity, - ProvidersEnum + ProvidersEnum, + DeadlineUrlEnumEntity ) from .list_entity import ListEntity @@ -160,6 +161,7 @@ __all__ = ( "ToolsEnumEntity", "TaskTypeEnumEntity", "ProvidersEnum", + "DeadlineUrlEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 4f6a2886bc..7b3de1ffe7 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -423,3 +423,54 @@ class ProvidersEnum(BaseEnumEntity): self._current_value = value_on_not_set self.value_on_not_set = value_on_not_set + + +class DeadlineUrlEnumEntity(BaseEnumEntity): + schema_types = ["deadline_url-enum"] + + def _item_initalization(self): + self.multiselection = self.schema_data.get("multiselection", True) + + self.enum_items = [] + self.valid_keys = set() + + if self.multiselection: + self.valid_value_types = (list,) + self.value_on_not_set = [] + else: + for key in self.valid_keys: + if self.value_on_not_set is NOT_SET: + self.value_on_not_set = key + break + + self.valid_value_types = (STRING_TYPE,) + + # GUI attribute + self.placeholder = self.schema_data.get("placeholder") + + def _get_enum_values(self): + system_settings_entity = self.get_entity_from_path("system_settings") + + valid_keys = set() + enum_items_list = [] + deadline_urls_entity = (system_settings_entity + ["modules"] + ["deadline"] + ["deadline_urls"] + ) + for server_name, url_entity in deadline_urls_entity.items(): + enum_items_list.append( + {server_name: "{}: {}".format(server_name, url_entity.value)}) + valid_keys.add(server_name) + return enum_items_list, valid_keys + + def set_override_state(self, *args, **kwargs): + super(DeadlineUrlEnumEntity, self).set_override_state(*args, **kwargs) + + self.enum_items, self.valid_keys = self._get_enum_values() + new_value = [] + for key in self._current_value: + if key in self.valid_keys: + new_value.append(key) + self._current_value = new_value + diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 27eeaef559..bd14d2ea9d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -5,6 +5,12 @@ "collapsible": true, "is_file": true, "children": [ + { + "type": "deadline_url-enum", + "key": "deadline_servers", + "label": "Deadline Webservice URLs", + "multiselect": true + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index d728f1def3..44a35af7c1 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -29,6 +29,26 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CreateRender", + "label": "Create Render", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + } + ] + }, { "type": "schema_template", "name": "template_create_plugin", @@ -65,10 +85,6 @@ "key": "CreatePointCache", "label": "Create Cache" }, - { - "key": "CreateRender", - "label": "Create Render" - }, { "key": "CreateRenderSetup", "label": "Create Render Setup" diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 7d734ff4fd..75c08b2cd9 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -130,9 +130,11 @@ "label": "Enabled" }, { - "type": "text", - "key": "DEADLINE_REST_URL", - "label": "Deadline Resl URL" + "type": "dict-modifiable", + "object_type": "text", + "key": "deadline_urls", + "required_keys": ["default"], + "label": "Deadline Webservice URLs" } ] },