From 418d72e37c518a67abee10cf8094a633e59e1caf Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio <123113322+carlos-villavicencio-adsk@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:26:07 -0500 Subject: [PATCH 1/6] Remove Python 3.7 from CI (#377) * Remove Python 3.7 from CI * Update changes from backend --- azure-pipelines-templates/run-tests.yml | 2 -- tests/test_api.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/azure-pipelines-templates/run-tests.yml b/azure-pipelines-templates/run-tests.yml index 831c276ec..bc402c42c 100644 --- a/azure-pipelines-templates/run-tests.yml +++ b/azure-pipelines-templates/run-tests.yml @@ -44,8 +44,6 @@ jobs: strategy: matrix: # We support these versions of Python. - Python37: - python.version: '3.7' Python39: python.version: '3.9' Python310: diff --git a/tests/test_api.py b/tests/test_api.py index 0e611316a..d07442e96 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1004,6 +1004,7 @@ def test_preferences_read(self): resp = self.sg.preferences_read() expected = { + "creative_review_settings": "", "date_component_order": "month_day", "duration_units": "days", "format_currency_fields_decimal_options": "$1,000.99", From 17620c9eaa058545cb75796679b15c3654a0a3e7 Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio <123113322+carlos-villavicencio-adsk@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:57:58 -0500 Subject: [PATCH 2/6] SG-37203 Apply mockgun improvements (#376) * Apply #217 * Fix dict * Apply #376 * Apply #364 --- shotgun_api3/lib/mockgun/mockgun.py | 46 +++++- tests/test_mockgun.py | 231 +++++++++++++++++++++++++++- 2 files changed, 266 insertions(+), 11 deletions(-) diff --git a/shotgun_api3/lib/mockgun/mockgun.py b/shotgun_api3/lib/mockgun/mockgun.py index 36b98dd5d..18e4a142c 100644 --- a/shotgun_api3/lib/mockgun/mockgun.py +++ b/shotgun_api3/lib/mockgun/mockgun.py @@ -293,6 +293,25 @@ def find( # handle the ordering of the recordset if order: # order: [{"field_name": "code", "direction": "asc"}, ... ] + def sort_none(k, order_field): + """ + Handle sorting of None consistently. + Note: Doesn't handle [checkbox, serializable, url]. + """ + field_type = self._get_field_type(k["type"], order_field) + value = k[order_field] + if value is not None: + return value + elif field_type in ("number", "percent", "duration"): + return 0 + elif field_type == "float": + return 0.0 + elif field_type in ("text", "entity_type", "date", "list", "status_list"): + return "" + elif field_type == "date_time": + return datetime.datetime(datetime.MINYEAR, 1, 1) + return None + for order_entry in order: if "field_name" not in order_entry: raise ValueError("Order clauses must be list of dicts with keys 'field_name' and 'direction'!") @@ -305,7 +324,11 @@ def find( else: raise ValueError("Unknown ordering direction") - results = sorted(results, key=lambda k: k[order_field], reverse=desc_order) + results = sorted( + results, + key=lambda k: sort_none(k, order_field), + reverse=desc_order, + ) if fields is None: fields = set(["type", "id"]) @@ -608,6 +631,20 @@ def _compare(self, field_type, lval, operator, rval): if operator == "is": return lval == rval elif field_type == "text": + # Some operations expect a list but can deal with a single value + if operator in ("in", "not_in") and not isinstance(rval, list): + rval = [rval] + # Some operation expect a string but can deal with None + elif operator in ("starts_with", "ends_with", "contains", "not_contains"): + lval = lval or '' + rval = rval or '' + # Shotgun string comparison is case insensitive + lval = lval.lower() if lval is not None else None + if isinstance(rval, list): + rval = [val.lower() if val is not None else None for val in rval] + else: + rval = rval.lower() if rval is not None else None + if operator == "is": return lval == rval elif operator == "is_not": @@ -617,7 +654,7 @@ def _compare(self, field_type, lval, operator, rval): elif operator == "contains": return rval in lval elif operator == "not_contains": - return lval not in rval + return rval not in lval elif operator == "starts_with": return lval.startswith(rval) elif operator == "ends_with": @@ -831,7 +868,10 @@ def _update_row(self, entity_type, row, data, multi_entity_update_modes=None): update_mode = multi_entity_update_modes.get(field, "set") if multi_entity_update_modes else "set" if update_mode == "add": - row[field] += [{"type": item["type"], "id": item["id"]} for item in data[field]] + for item in data[field]: + new_item = {"type": item["type"], "id": item["id"]} + if new_item not in row[field]: + row[field].append(new_item) elif update_mode == "remove": row[field] = [ item diff --git a/tests/test_mockgun.py b/tests/test_mockgun.py index 1395355fa..e7e4295e4 100644 --- a/tests/test_mockgun.py +++ b/tests/test_mockgun.py @@ -35,6 +35,7 @@ and can be run on their own by typing "python test_mockgun.py". """ +import datetime import re import os import unittest @@ -188,14 +189,171 @@ def setUp(self): self._mockgun = Mockgun( "https://test.shotgunstudio.com", login="user", password="1234" ) - self._user = self._mockgun.create("HumanUser", {"login": "user"}) + self._user1 = self._mockgun.create("HumanUser", {"login": "user"}) + self._user2 = self._mockgun.create("HumanUser", {"login": None}) + + def test_operator_is(self): + """ + Ensure is operator work. + """ + actual = self._mockgun.find("HumanUser", [["login", "is", "user"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_is_none(self): + """ + Ensure is operator work when used with None. + """ + actual = self._mockgun.find("HumanUser", [["login", "is", None]]) + expected = [{"type": "HumanUser", "id": self._user2["id"]}] + self.assertEqual(expected, actual) + + def test_operator_is_case_sensitivity(self): + """ + Ensure is operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "is", "USER"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_is_not(self): + """ + Ensure the is_not operator works. + """ + actual = self._mockgun.find("HumanUser", [["login", "is_not", "user"]]) + expected = [{"type": "HumanUser", "id": self._user2["id"]}] + self.assertEqual(expected, actual) + + def test_operator_is_not_none(self): + """ + Ensure the is_not operator works when used with None. + """ + actual = self._mockgun.find("HumanUser", [["login", "is_not", None]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_is_not_case_sensitivity(self): + """ + Ensure the is_not operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "is_not", "USER"]]) + expected = [{"type": "HumanUser", "id": self._user2["id"]}] + self.assertEqual(expected, actual) + + def test_operator_in(self): + """ + Ensure the in operator works. + """ + actual = self._mockgun.find("HumanUser", [["login", "in", ["user"]]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_in_none(self): + """ + Ensure the in operator works with a list containing None. + """ + actual = self._mockgun.find("HumanUser", [["login", "in", [None]]]) + expected = [{"type": "HumanUser", "id": self._user2["id"]}] + self.assertEqual(expected, actual) + + def test_operator_in_case_sensitivity(self): + """ + Ensure the in operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "in", ["USER"]]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_not_in(self): + """ + Ensure the not_in operator works. + """ + actual = self._mockgun.find("HumanUser", [["login", "not_in", ["foo"]]]) + expected = [ + {"type": "HumanUser", "id": self._user1["id"]}, + {"type": "HumanUser", "id": self._user2["id"]}, + ] + self.assertEqual(expected, actual) + + def test_operator_not_in_none(self): + """ + Ensure the not_not operator works with a list containing None. + """ + actual = self._mockgun.find("HumanUser", [["login", "not_in", [None]]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_not_in_case_sensitivity(self): + """ + Ensure the not_in operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "not_in", ["USER"]]]) + expected = [{"type": "HumanUser", "id": self._user2["id"]}] + self.assertEqual(expected, actual) def test_operator_contains(self): """ - Ensures contains operator works. + Ensures the contains operator works. """ - item = self._mockgun.find_one("HumanUser", [["login", "contains", "se"]]) - self.assertTrue(item) + actual = self._mockgun.find("HumanUser", [["login", "contains", "se"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_contains_case_sensitivity(self): + """ + Ensure the contains operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "contains", "SE"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_not_contains(self): + """ + Ensure the not_contains operator works. + """ + actual = self._mockgun.find("HumanUser", [["login", "not_contains", "user"]]) + expected = [{"type": "HumanUser", "id": self._user2["id"]}] + self.assertEqual(expected, actual) + + def test_operator_not_contains_case_sensitivity(self): + """ + Ensure the not_contains operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "not_contains", "USER"]]) + expected = [{"type": "HumanUser", "id": self._user2["id"]}] + self.assertEqual(expected, actual) + + def test_operator_starts_with(self): + """ + Ensure the starts_with operator works. + """ + actual = self._mockgun.find("HumanUser", [["login", "starts_with", "us"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_starts_with_case_sensitivity(self): + """ + Ensure the starts_with operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "starts_with", "US"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_ends_with(self): + """ + Ensure the ends_with operator works. + """ + actual = self._mockgun.find("HumanUser", [["login", "ends_with", "er"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) + + def test_operator_ends_with_case_sensitivity(self): + """ + Ensure the starts_with operator is case insensitive. + """ + actual = self._mockgun.find("HumanUser", [["login", "ends_with", "ER"]]) + expected = [{"type": "HumanUser", "id": self._user1["id"]}] + self.assertEqual(expected, actual) class TestMultiEntityFieldComparison(unittest.TestCase): @@ -345,10 +503,12 @@ def test_update_add(self): """ Ensures that "add" multi_entity_update_mode works. """ + # Attempts to add _version2 + # It already exists on the playlist and should not be duplicated self._mockgun.update( "Playlist", self._add_playlist["id"], - {"versions": [self._version3]}, + {"versions": [self._version2, self._version3]}, multi_entity_update_modes={"versions": "add"}, ) @@ -429,15 +589,29 @@ def setUp(self): self._prj2_link = self._mockgun.create("Project", {"name": "prj2"}) self._shot1 = self._mockgun.create( - "Shot", {"code": "shot1", "project": self._prj1_link} + "Shot", + { + "code": "shot1", + "project": self._prj1_link, + "description": "a", + "sg_cut_order": 2, + }, ) self._shot2 = self._mockgun.create( - "Shot", {"code": "shot2", "project": self._prj1_link} + "Shot", {"code": "shot2", "project": self._prj1_link, "sg_cut_order": 1} ) self._shot3 = self._mockgun.create( - "Shot", {"code": "shot3", "project": self._prj2_link} + "Shot", {"code": "shot3", "project": self._prj2_link, "description": "b"} + ) + + self._user1 = self._mockgun.create( + "HumanUser", {"login": "user1", "password_strength": 0.2} + ) + + self._user2 = self._mockgun.create( + "HumanUser", {"login": "user2", "created_at": datetime.datetime(2025, 1, 1)} ) def test_simple_filter_operators(self): @@ -468,6 +642,47 @@ def test_simple_filter_operators(self): self.assertEqual(len(shots), 0) + def test_ordered_filter_operator(self): + """ + Test use of the order feature of filter_operator on supported data types. + """ + find_args = ["Shot", [], ["code"]] + + # str field + shots = self._mockgun.find( + *find_args, order=[{"field_name": "description", "direction": "asc"}] + ) + self.assertEqual([s["code"] for s in shots], ["shot2", "shot1", "shot3"]) + + shots = self._mockgun.find( + *find_args, order=[{"field_name": "description", "direction": "desc"}] + ) + self.assertEqual([s["code"] for s in shots], ["shot3", "shot1", "shot2"]) + + # int field + shots = self._mockgun.find( + *find_args, order=[{"field_name": "sg_cut_order", "direction": "asc"}] + ) + self.assertEqual([s["code"] for s in shots], ["shot3", "shot2", "shot1"]) + + # float field + users = self._mockgun.find( + "HumanUser", + [], + ["login"], + order=[{"field_name": "password_strength", "direction": "asc"}], + ) + self.assertEqual([u["login"] for u in users], ["user2", "user1"]) + + # date_time field + users = self._mockgun.find( + "HumanUser", + [], + ["login"], + order=[{"field_name": "created_at", "direction": "asc"}], + ) + self.assertEqual([u["login"] for u in users], ["user1", "user2"]) + def test_nested_filter_operators(self): """ Tests a the use of the filter_operator nested From 7f5f9c15119682de3141e54c6ca181fa6ed5479d Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio <123113322+carlos-villavicencio-adsk@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:13:46 -0500 Subject: [PATCH 3/6] Fix `creative_review_settings` test (#378) --- tests/test_api.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index d07442e96..788c9751b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1004,7 +1004,6 @@ def test_preferences_read(self): resp = self.sg.preferences_read() expected = { - "creative_review_settings": "", "date_component_order": "month_day", "duration_units": "days", "format_currency_fields_decimal_options": "$1,000.99", @@ -1027,6 +1026,12 @@ def test_preferences_read(self): self.assertIn("view_master_settings", resp) resp.pop("view_master_settings") + # Simply make sure creative review settings are there. These change frequently and we + # don't want to have the test break because Creative Review changed or because we didn't + # update the test. + self.assertIn("creative_review_settings", resp) + resp.pop("creative_review_settings") + self.assertEqual(expected, resp) # all filtered From 5b3cf59fe13c96cc0639e6a501b5f84adf515e0e Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio <123113322+carlos-villavicencio-adsk@users.noreply.github.com> Date: Thu, 15 May 2025 10:18:20 -0500 Subject: [PATCH 4/6] Fix reStructuredText format (#380) * Fix rst issue * Revert line * Update labels --- HISTORY.rst | 2 +- docs/cookbook/examples/basic_create_shot_task_template.rst | 4 ++-- docs/cookbook/examples/basic_delete_shot.rst | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 54b30f217..ad4ebda7a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -61,7 +61,7 @@ v3.5.1 (2024 Apr 3) - Mockgun: add support for ``add_user_agent`` and ``set_session_uuid`` methods v3.5.0 (2024 Mar 26) -=================== +==================== - Rebranding component for Flow Production Tracking v3.4.2 (2024 Feb 6) diff --git a/docs/cookbook/examples/basic_create_shot_task_template.rst b/docs/cookbook/examples/basic_create_shot_task_template.rst index ab6248227..18722be96 100644 --- a/docs/cookbook/examples/basic_create_shot_task_template.rst +++ b/docs/cookbook/examples/basic_create_shot_task_template.rst @@ -42,8 +42,8 @@ created. wish to create by default on this Shot. We found the specific template we wanted to assign in the previous block by searching -Result ------- +Create Shot Result +------------------ The variable ``result`` now contains the dictionary of the new Shot that was created. :: diff --git a/docs/cookbook/examples/basic_delete_shot.rst b/docs/cookbook/examples/basic_delete_shot.rst index 4f2e91018..c79215e25 100644 --- a/docs/cookbook/examples/basic_delete_shot.rst +++ b/docs/cookbook/examples/basic_delete_shot.rst @@ -7,8 +7,8 @@ Deleting an entity in Flow Production Tracking is pretty straight-forward. No ex result = sg.delete("Shot", 40435) -Result ------- +Delete Shot Result +------------------ If the Shot was deleted successfully ``result`` will contain:: True From 8b8fdef211c5d19c4a15e17199b796249c416fb8 Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio <123113322+carlos-villavicencio-adsk@users.noreply.github.com> Date: Thu, 15 May 2025 11:04:39 -0500 Subject: [PATCH 5/6] SG-27368 Fix more RSTs files for Sphinx (#381) * Fix more RSTs files for Sphinx * Update ref * Remove duplicate label --- docs/cookbook/examples/basic_create_shot.rst | 4 ++-- docs/cookbook/examples/basic_delete_shot.rst | 4 ++-- docs/cookbook/examples/basic_find_shot.rst | 4 ++-- docs/cookbook/examples/basic_update_shot.rst | 4 ++-- docs/cookbook/examples/svn_integration.rst | 2 +- docs/cookbook/tasks/split_tasks.rst | 4 ++-- docs/cookbook/tasks/updating_tasks.rst | 6 +++--- docs/reference.rst | 1 - 8 files changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/cookbook/examples/basic_create_shot.rst b/docs/cookbook/examples/basic_create_shot.rst index 7513305fa..f7d412f89 100644 --- a/docs/cookbook/examples/basic_create_shot.rst +++ b/docs/cookbook/examples/basic_create_shot.rst @@ -51,8 +51,8 @@ this dictionary represents. It does not correspond to any field in Flow Producti Flow Production Tracking will *always* return the ``id`` and ``type`` keys in the dictionary when there are results representing an entity. -The Complete Example --------------------- +The Complete Example for creating a Shot +---------------------------------------- :: #!/usr/bin/env python diff --git a/docs/cookbook/examples/basic_delete_shot.rst b/docs/cookbook/examples/basic_delete_shot.rst index c79215e25..886b962d3 100644 --- a/docs/cookbook/examples/basic_delete_shot.rst +++ b/docs/cookbook/examples/basic_delete_shot.rst @@ -13,8 +13,8 @@ If the Shot was deleted successfully ``result`` will contain:: True -The Complete Example --------------------- +The Complete Example for deleting a Shot +---------------------------------------- :: #!/usr/bin/env python diff --git a/docs/cookbook/examples/basic_find_shot.rst b/docs/cookbook/examples/basic_find_shot.rst index 945eb1be6..4f5d73934 100644 --- a/docs/cookbook/examples/basic_find_shot.rst +++ b/docs/cookbook/examples/basic_find_shot.rst @@ -37,8 +37,8 @@ easier to read. So we'll add that to the import section of our script.:: import shotgun_api3 from pprint import pprint # useful for debugging -The Complete Example --------------------- +The Complete Example for finding a Shot +--------------------------------------- :: #!/usr/bin/env python diff --git a/docs/cookbook/examples/basic_update_shot.rst b/docs/cookbook/examples/basic_update_shot.rst index c2413c3ee..4e2055d49 100644 --- a/docs/cookbook/examples/basic_update_shot.rst +++ b/docs/cookbook/examples/basic_update_shot.rst @@ -40,8 +40,8 @@ It does not correspond to any field in Flow Production Tracking. Flow Production Tracking will *always* return the ``id`` and ``type`` keys in the dictionary when there are results representing an entity. -The Complete Example --------------------- +The Complete Example for updating a Shot +---------------------------------------- :: #!/usr/bin/env python diff --git a/docs/cookbook/examples/svn_integration.rst b/docs/cookbook/examples/svn_integration.rst index 8b0a6ce46..c676fd8ea 100644 --- a/docs/cookbook/examples/svn_integration.rst +++ b/docs/cookbook/examples/svn_integration.rst @@ -130,7 +130,7 @@ Explanation of selected lines: - line ``14``: This should be the URL to your instance of Flow Production Tracking. - lines ``15-16``: Make sure you get these values from the "Scripts" page in the Admin section of - the Flow Production Tracking web application. If you're not sure how to do this, check out :doc:`authentication`. + the Flow Production Tracking web application. If you're not sure how to do this, check out :ref:`authentication`. - line ``17``: This is the address of Trac, our web-based interface that we use with Subversion. You may use a different interface, or none at all, so feel free to adjust this line or ignore it as your case may be. diff --git a/docs/cookbook/tasks/split_tasks.rst b/docs/cookbook/tasks/split_tasks.rst index d16c50e94..96f639037 100644 --- a/docs/cookbook/tasks/split_tasks.rst +++ b/docs/cookbook/tasks/split_tasks.rst @@ -62,8 +62,8 @@ How Do Splits Influence Dates And Dates Influence Splits - In the case of a shorter duration splits, starting with the latest ones, will be either removed or shortened until the new duration is met. -Examples -======== +Examples for splitting Tasks +============================ Throughout the following examples, each successive one will build on the previous. start_date, due_date and duration being ignored diff --git a/docs/cookbook/tasks/updating_tasks.rst b/docs/cookbook/tasks/updating_tasks.rst index c7c216e3f..db2433e79 100644 --- a/docs/cookbook/tasks/updating_tasks.rst +++ b/docs/cookbook/tasks/updating_tasks.rst @@ -32,9 +32,9 @@ General Rules first, then ``due_date`` (otherwise setting ``duration`` will change ``due_date`` after it is set). -******** -Examples -******** +*************************** +Examples for updating Tasks +*************************** The following examples show what the resulting Task object will look like after being run on the initial Task object listed under the header of each section. diff --git a/docs/reference.rst b/docs/reference.rst index 77241f052..5f3888a52 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -42,7 +42,6 @@ The documentation for all of the methods you'll need in your scripts lives in he Shotgun.close Shotgun.authenticate_human_user Shotgun.get_session_token - Shotgun.set_up_auth_cookie Shotgun.add_user_agent Shotgun.reset_user_agent Shotgun.set_session_uuid From a1de54efe3880afc3e5ab1aa67b37fb36b741e69 Mon Sep 17 00:00:00 2001 From: Carlos Villavicencio <123113322+carlos-villavicencio-adsk@users.noreply.github.com> Date: Thu, 22 May 2025 12:17:35 -0500 Subject: [PATCH 6/6] Packaging for v3.8.3 (#384) --- HISTORY.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index ad4ebda7a..10e34bb55 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -4,6 +4,17 @@ Flow Production Tracking Python API Changelog Here you can see the full list of changes between each Python API release. +v3.8.3 (2025 May 22) +==================== + +- Add improvements to Mockgun. + Ensure string comparison are case insensitive. + Ignore duplicate entities when ``multi_entity_update_mode`` is added. + Support for ``None`` in mockgun when using ordering. + Thank you rlessardrodeofx, slingshotsys, and MHendricks for your contributions. +- Minor fixes on unit tests and documentation. + + v3.8.2 (2025 Mar 11) ====================