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_configure
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_configure
.. automethod:: Shotgun.custom_entity_disable

**********
Exceptions
**********
Expand Down
154 changes: 154 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, configure, 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,152 @@ 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) -> Dict[str, Any]:
"""
Enable a Custom Entity.

This call is idempotent: if the entity is already enabled the current
snapshot is returned without error. To set the display name or feature
flags use :meth:`custom_entity_configure` after enabling.

>>> sg.custom_entity_enable("CustomEntity08")
{
"entity_type": "CustomEntity08",
"enabled": True,
"display_name": "Custom Entity08",
"entity_config": {
"enable_tasks": False,
"enable_versions": False,
"enable_publishes": False,
"enable_detail_page": True,
"include_in_search": False,
"include_in_global_menu": True,
}
}

:param str entity_type: The Custom Entity type to enable, 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_enable", {"entity_type": entity_type})

def custom_entity_configure(
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.

Only the keys present in ``entity_config`` are mutated; omitted flags
keep their current values.

>>> sg.custom_entity_configure("CustomEntity08", display_name="Episode")
{
"entity_type": "CustomEntity08",
"enabled": True,
"display_name": "Episode",
"entity_config": {...}
}

:param str entity_type: The Custom Entity type to configure, in its singular
CamelCase form (e.g. ``"CustomEntity08"``). 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. Recognized flags:
``enable_tasks``, ``enable_versions``, ``enable_publishes``,
``enable_detail_page``, ``include_in_search``, ``include_in_global_menu``.
:returns: The updated 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 not
yet 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_configure", params)

def custom_entity_disable(
self, entity_type: str, force: bool = False
) -> Dict[str, Any]:
"""
Disable a Custom Entity, clearing its feature flags.

This call is idempotent: if the entity is already disabled the current
snapshot is returned without error and the record guard is not checked.

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 ``True``, 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 (e.g. ``"CustomEntity08"``). Required.
:param bool force: Disable the entity even if it still has active records.
Defaults to ``False``, which makes the call fail rather than render
existing data unreachable. Only a literal ``True`` is accepted; any
other truthy value is treated as ``False``.
:returns: The entity config snapshot dict with ``enabled`` set to ``False``.
:rtype: dict
:raises shotgun_api3.ShotgunError: if the entity type is invalid
(fault code 104), or if the entity has active 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
91 changes: 91 additions & 0 deletions tests/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,5 +854,96 @@ 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_entity_type_sent(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])


class TestShotgunCustomEntityConfigure(CustomEntityConfigTestBase):
"""Test case for Shotgun.custom_entity_configure"""

@mock.patch("shotgun_api3.Shotgun._call_rpc")
def test_optional_params_omitted_by_default(self, call_rpc):
self.sg.custom_entity_configure("CustomEntity08")
self.assertEqual("custom_entity_configure", 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_configure("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_configure("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_configure(
"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()