Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
===========================
Expand Down Expand Up @@ -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
**********
Expand Down
157 changes: 157 additions & 0 deletions shotgun_api3/shotgun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions tests/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()