From c272359598c0236cfb6b468847a14b90643e2024 Mon Sep 17 00:00:00 2001 From: fauverick Date: Mon, 3 Aug 2026 12:31:24 -0400 Subject: [PATCH] SG-43999: add custom entity config functions --- docs/reference.rst | 21 ++++++ shotgun_api3/shotgun.py | 157 ++++++++++++++++++++++++++++++++++++++++ tests/test_unit.py | 117 ++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+) diff --git a/docs/reference.rst b/docs/reference.rst index 0c7f248f1..6d742f0a8 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -105,6 +105,16 @@ The documentation for all of the methods you'll need in your scripts lives in he Shotgun.schema Shotgun.entity_types +.. rubric:: Custom Entity Configuration + +.. autosummary:: + :nosignatures: + + Shotgun.custom_entity_read + Shotgun.custom_entity_enable + Shotgun.custom_entity_update + Shotgun.custom_entity_disable + Connection & Authentication =========================== @@ -192,6 +202,17 @@ Methods allow you to introspect and modify the Shotgun schema. .. automethod:: Shotgun.schema .. automethod:: Shotgun.entity_types +Custom Entity Configuration +=========================== + +Methods to read and configure Custom Entities at the site level. They require administrator +privileges and a server running v8.88.0 or higher. + +.. automethod:: Shotgun.custom_entity_read +.. automethod:: Shotgun.custom_entity_enable +.. automethod:: Shotgun.custom_entity_update +.. automethod:: Shotgun.custom_entity_disable + ********** Exceptions ********** diff --git a/shotgun_api3/shotgun.py b/shotgun_api3/shotgun.py index a51d4a46d..2507eb71b 100644 --- a/shotgun_api3/shotgun.py +++ b/shotgun_api3/shotgun.py @@ -329,6 +329,14 @@ def ensure_return_image_urls_support(self) -> bool: {"version": (3, 3, 0), "label": "return thumbnail URLs"}, False ) + def ensure_custom_entity_config_support(self) -> None: + """ + Ensures server has support for the custom entity config API (read, enable, update, disable), added in v8.88.0. + """ + self._ensure_support( + {"version": (8, 88, 0), "label": "custom entity config API"} + ) + def __str__(self) -> str: return "ServerCapabilities: host %s, version %s, is_dev %s" % ( self.host, @@ -3671,6 +3679,155 @@ def user_subscriptions_create( return response.get("status") == "success" + def custom_entity_read(self, entity_type: str) -> Dict[str, Any]: + """ + Read the current configuration of a Custom Entity. + + >>> sg.custom_entity_read("CustomEntity08") + { + "entity_type": "CustomEntity08", + "enabled": True, + "display_name": "My Shots", + "entity_config": {"enable_tasks": True, ...} + } + + :param str entity_type: The Custom Entity type to read, in its singular + CamelCase form (e.g. ``"CustomEntity08"``). Required. + :returns: The entity config snapshot dict with ``entity_type``, ``enabled``, + ``display_name``, and ``entity_config``. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + return self._call_rpc("custom_entity_read", {"entity_type": entity_type}) + + def custom_entity_enable( + self, + entity_type: str, + display_name: Optional[str] = None, + entity_config: Optional[Dict[str, bool]] = None, + ) -> Dict[str, Any]: + """ + Enable a Custom Entity. + + >>> sg.custom_entity_enable( + ... "CustomEntity08", + ... display_name="My Shots", + ... entity_config={"enable_tasks": True}, + ... ) + { + "entity_type": "CustomEntity08", + "enabled": True, + "display_name": "My Shots", + "entity_config": {"enable_tasks": True, ...} + } + + :param str entity_type: The Custom Entity type to enable, in its singular + CamelCase form (e.g. ``"CustomEntity08"``). Required. + :param str display_name: Optional display name for the entity. + :param dict entity_config: Optional dict of feature flag booleans. Only the + flags present are mutated; omitted flags are left unchanged. + Keys and boolean values are passed through as-is. Recognized flags: + - ``enable_tasks`` (default: ``False``) + - ``enable_versions`` (default: ``False``) + - ``enable_publishes`` (default: ``False``) + - ``enable_detail_page`` (default: ``True``) + - ``include_in_search`` (default: ``False``) + - ``include_in_global_menu`` (default: ``True``) + :returns: The entity config snapshot dict with ``entity_type``, ``enabled``, + ``display_name``, and ``entity_config``. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid or already + enabled (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + params = {"entity_type": entity_type} + if display_name is not None: + params["display_name"] = display_name + if entity_config is not None: + params["entity_config"] = entity_config + + return self._call_rpc("custom_entity_enable", params) + + def custom_entity_update( + self, + entity_type: str, + display_name: Optional[str] = None, + entity_config: Optional[Dict[str, bool]] = None, + ) -> Dict[str, Any]: + """ + Update an already-enabled Custom Entity's display name and/or feature flags. + + >>> sg.custom_entity_update("CustomEntity08", display_name="Episode") + { + "entity_type": "CustomEntity08", + "enabled": True, + "display_name": "Renamed", + "entity_config": {...} + } + + :param str entity_type: The Custom Entity type to update, in its singular + CamelCase form. The entity must already be enabled. Required. + :param str display_name: Optional new display name for the entity. + :param dict entity_config: Optional dict of feature flag booleans. Only the + flags present are mutated; omitted flags are left unchanged. Keys and + boolean values are passed through as-is. + :returns: The updated entity config snapshot dict. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid or not + enabled (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + params = {"entity_type": entity_type} + if display_name is not None: + params["display_name"] = display_name + if entity_config is not None: + params["entity_config"] = entity_config + + return self._call_rpc("custom_entity_update", params) + + def custom_entity_disable( + self, entity_type: str, force: bool = False + ) -> Dict[str, Any]: + """ + Disable an enabled Custom Entity, clearing its feature flags. + + Disabling a Custom Entity that has existing records does **not** delete the + data, but it does make the data inaccessible: the records will not appear in + the UI, will not be returned via the API, and any fields on other entities + that link to it become broken references. Because this is destructive in + effect, the server refuses to disable an entity that still has records unless + ``force`` is set, and the error reports how many records were found. + + >>> sg.custom_entity_disable("CustomEntity08") + { + "entity_type": "CustomEntity08", + "enabled": False, + "display_name": "My Shots" + } + + :param str entity_type: The Custom Entity type to disable, in its singular + CamelCase form. The entity must already be enabled. Required. + :param bool force: Disable the entity even though it still has records. + Defaults to ``False``, which makes the call fail rather than render + existing data unreachable. + :returns: The entity config snapshot dict with ``enabled`` set to ``False``. + :rtype: dict + :raises shotgun_api3.ShotgunError: if the entity type is invalid or not + enabled (fault code 104), or if the entity still has records and + ``force`` was not set (fault code 104). + """ + self.server_caps.ensure_custom_entity_config_support() + + params = {"entity_type": entity_type} + if force: + params["force"] = True + + return self._call_rpc("custom_entity_disable", params) + def _build_opener(self, handler) -> urllib.request.OpenerDirector: """ Build urllib2 opener with appropriate proxy handler. diff --git a/tests/test_unit.py b/tests/test_unit.py index 786a83f02..f45c6f4c0 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -854,5 +854,122 @@ def test_urlib(self): assert response is not None +class CustomEntityConfigTestBase(unittest.TestCase): + """Shared setup for the custom entity config API test cases. + + The custom_entity_* methods are gated on server version 8.88.0""" + + def setUp(self): + self.sg = api.Shotgun( + "http://server_path", "script_name", "api_key", connect=False + ) + self.set_server_version([8, 88, 0]) + + def set_server_version(self, version): + self.sg._server_caps = api.shotgun.ServerCapabilities( + self.sg.config.server, {"version": version} + ) + + +class TestShotgunCustomEntityRead(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_read""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_entity_type_sent(self, call_rpc): + self.sg.custom_entity_read("CustomEntity08") + self.assertEqual("custom_entity_read", call_rpc.call_args[0][0]) + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + +class TestShotgunCustomEntityEnable(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_enable""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_optional_params_omitted_by_default(self, call_rpc): + self.sg.custom_entity_enable("CustomEntity08") + self.assertEqual("custom_entity_enable", call_rpc.call_args[0][0]) + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_optional_params_sent_when_set(self, call_rpc): + entity_config = {"enable_tasks": True, "include_in_search": False} + self.sg.custom_entity_enable( + "CustomEntity08", display_name="My Shots", entity_config=entity_config + ) + self.assertEqual( + { + "entity_type": "CustomEntity08", + "display_name": "My Shots", + "entity_config": entity_config, + }, + call_rpc.call_args[0][1], + ) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_empty_optional_params_sent(self, call_rpc): + """Empty values are distinct from omitted ones and must reach the server.""" + self.sg.custom_entity_enable( + "CustomEntity08", display_name="", entity_config={} + ) + self.assertEqual( + {"entity_type": "CustomEntity08", "display_name": "", "entity_config": {}}, + call_rpc.call_args[0][1], + ) + + +class TestShotgunCustomEntityUpdate(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_update""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_optional_params_omitted_by_default(self, call_rpc): + self.sg.custom_entity_update("CustomEntity08") + self.assertEqual("custom_entity_update", call_rpc.call_args[0][0]) + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_display_name_sent_without_entity_config(self, call_rpc): + self.sg.custom_entity_update("CustomEntity08", display_name="Episode") + self.assertEqual( + {"entity_type": "CustomEntity08", "display_name": "Episode"}, + call_rpc.call_args[0][1], + ) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_entity_config_sent_without_display_name(self, call_rpc): + entity_config = {"enable_versions": False} + self.sg.custom_entity_update("CustomEntity08", entity_config=entity_config) + self.assertEqual( + {"entity_type": "CustomEntity08", "entity_config": entity_config}, + call_rpc.call_args[0][1], + ) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_empty_optional_params_sent(self, call_rpc): + """Empty values are distinct from omitted ones and must reach the server.""" + self.sg.custom_entity_update( + "CustomEntity08", display_name="", entity_config={} + ) + self.assertEqual( + {"entity_type": "CustomEntity08", "display_name": "", "entity_config": {}}, + call_rpc.call_args[0][1], + ) + + +class TestShotgunCustomEntityDisable(CustomEntityConfigTestBase): + """Test case for Shotgun.custom_entity_disable""" + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_force_omitted_by_default(self, call_rpc): + self.sg.custom_entity_disable("CustomEntity08") + self.assertEqual({"entity_type": "CustomEntity08"}, call_rpc.call_args[0][1]) + + @mock.patch("shotgun_api3.Shotgun._call_rpc") + def test_force_sent_when_set(self, call_rpc): + self.sg.custom_entity_disable("CustomEntity08", force=True) + self.assertEqual( + {"entity_type": "CustomEntity08", "force": True}, call_rpc.call_args[0][1] + ) + + if __name__ == "__main__": unittest.main()