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