From 91805a0b330de856f5f13490f27ee5401dc33ab2 Mon Sep 17 00:00:00 2001 From: Jerry Cheng Date: Mon, 23 Feb 2026 12:53:33 -0500 Subject: [PATCH 1/3] Add parameter created_at for sg.upload --- shotgun_api3/shotgun.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/shotgun_api3/shotgun.py b/shotgun_api3/shotgun.py index 0fb627e1..839a3db5 100644 --- a/shotgun_api3/shotgun.py +++ b/shotgun_api3/shotgun.py @@ -2610,6 +2610,7 @@ def upload( field_name: Optional[str] = None, display_name: Optional[str] = None, tag_list: Optional[str] = None, + created_at: Optional[datetime.datetime] = None, ) -> int: """ Upload a file to the specified entity. @@ -2634,6 +2635,8 @@ def upload( This field must be a File/Link field type. :param str display_name: The display name to use for the file. Defaults to the file name. :param str tag_list: comma-separated string of tags to assign to the file. + :param datetime.datetime created_at: Optional datetime value to set as the created_at + time for the Attachment entity. If ``None``, the server will use the current time. :returns: Id of the Attachment entity that was created for the image. :rtype: int :raises: :class:`ShotgunError` on upload failure. @@ -2662,6 +2665,12 @@ def upload( if os.path.getsize(path) == 0: raise ShotgunError("Path cannot be an empty file: '%s'" % path) + if created_at is not None: + if not isinstance(created_at, datetime.datetime): + raise ShotgunError( + "created_at must be a datetime.datetime instance, got '%s'" % type(created_at) + ) + is_thumbnail = field_name in [ "thumb_image", "filmstrip_thumb_image", @@ -2679,6 +2688,7 @@ def upload( display_name, tag_list, is_thumbnail, + created_at, ) else: return self._upload_to_sg( @@ -2689,6 +2699,7 @@ def upload( display_name, tag_list, is_thumbnail, + created_at, ) def _upload_to_storage( @@ -2700,6 +2711,7 @@ def _upload_to_storage( display_name: Optional[str], tag_list: Optional[str], is_thumbnail: bool, + created_at: Optional[datetime.datetime] = None, ) -> int: """ Internal function to upload a file to the Cloud storage and link it to the specified entity. @@ -2712,6 +2724,7 @@ def _upload_to_storage( :param str display_name: The display name to use for the file. Defaults to the file name. :param str tag_list: comma-separated string of tags to assign to the file. :param bool is_thumbnail: indicates if the attachment is a thumbnail. + :param datetime created_at: The datetime to set for the attachment. :returns: Id of the Attachment entity that was created for the image. :rtype: int """ @@ -2768,6 +2781,8 @@ def _upload_to_storage( # None gets converted to a string and added as a tag... if tag_list: params["tag_list"] = tag_list + if created_at is not None: + params["created_at"] = created_at result = self._send_form(url, params) if not result.startswith("1"): @@ -2790,6 +2805,7 @@ def _upload_to_sg( display_name: Optional[str], tag_list: Optional[str], is_thumbnail: bool, + created_at: Optional[datetime.datetime] = None, ) -> int: """ Internal function to upload a file to Shotgun and link it to the specified entity. @@ -2848,6 +2864,8 @@ def _upload_to_sg( # None gets converted to a string and added as a tag... if tag_list: params["tag_list"] = tag_list + if created_at is not None: + params["created_at"] = created_at params["file"] = open(path, "rb") @@ -4852,4 +4870,4 @@ def _optimize_filter_field( elif recursive and isinstance(field_value, list): return [_optimize_filter_field(fv, recursive=False) for fv in field_value] - return field_value + return field_value \ No newline at end of file From 526d53f131e445e78b73827906db53b67fc657f2 Mon Sep 17 00:00:00 2001 From: Jerry Cheng Date: Mon, 23 Feb 2026 17:09:01 -0500 Subject: [PATCH 2/3] Run black on shotgun.py --- shotgun_api3/shotgun.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shotgun_api3/shotgun.py b/shotgun_api3/shotgun.py index 839a3db5..1c4e1fad 100644 --- a/shotgun_api3/shotgun.py +++ b/shotgun_api3/shotgun.py @@ -2668,7 +2668,8 @@ def upload( if created_at is not None: if not isinstance(created_at, datetime.datetime): raise ShotgunError( - "created_at must be a datetime.datetime instance, got '%s'" % type(created_at) + "created_at must be a datetime.datetime instance, got '%s'" + % type(created_at) ) is_thumbnail = field_name in [ @@ -4870,4 +4871,4 @@ def _optimize_filter_field( elif recursive and isinstance(field_value, list): return [_optimize_filter_field(fv, recursive=False) for fv in field_value] - return field_value \ No newline at end of file + return field_value From d704298ad84e30bd4b910e71122228bc9d76e9f0 Mon Sep 17 00:00:00 2001 From: Jerry Cheng Date: Sun, 1 Mar 2026 20:14:10 -0500 Subject: [PATCH 3/3] Add testcases --- shotgun_api3/shotgun.py | 2 +- tests/test_api.py | 150 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/shotgun_api3/shotgun.py b/shotgun_api3/shotgun.py index 1c4e1fad..5e3fd6f4 100644 --- a/shotgun_api3/shotgun.py +++ b/shotgun_api3/shotgun.py @@ -2819,7 +2819,7 @@ def _upload_to_sg( :param str display_name: The display name to use for the file. Defaults to the file name. :param str tag_list: comma-separated string of tags to assign to the file. :param bool is_thumbnail: indicates if the attachment is a thumbnail. - + :param datetime created_at: The datetime to set for the attachment. :returns: Id of the Attachment entity that was created for the image. :rtype: int """ diff --git a/tests/test_api.py b/tests/test_api.py index d0e8407e..56bf0b39 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -325,6 +325,156 @@ def test_upload_to_sg(self, mock_send_form): mock_send_form.assert_called_once() self.sg.server_info["s3_direct_uploads_enabled"] = True + @unittest.mock.patch("shotgun_api3.Shotgun._send_form") + def test_upload_to_sg_with_created_at(self, mock_send_form): + """ + Verify that created_at is passed as a form parameter when uploading + non-thumbnail attachments via _upload_to_sg(). + """ + self.sg.server_info["s3_direct_uploads_enabled"] = False + mock_send_form.return_value = "1\n:456\nasd" + this_dir, _ = os.path.split(__file__) + u_path = os.path.abspath( + os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0]) + ) + custom_time = datetime.datetime(2026, 2, 15, 10, 30, 0) + self.sg.upload( + "Version", + self.version["id"], + u_path, + "attachments", + created_at=custom_time, + ) + mock_send_form.assert_called_once() + mock_send_form_args, _ = mock_send_form.call_args + params = mock_send_form_args[1] + self.assertIn("created_at", params) + self.assertEqual(params["created_at"], custom_time) + self.sg.server_info["s3_direct_uploads_enabled"] = True + + @unittest.mock.patch("shotgun_api3.Shotgun._send_form") + def test_upload_to_sg_without_created_at(self, mock_send_form): + """ + Verify that created_at is NOT included in form parameters when omitted. + """ + self.sg.server_info["s3_direct_uploads_enabled"] = False + mock_send_form.return_value = "1\n:456\nasd" + this_dir, _ = os.path.split(__file__) + u_path = os.path.abspath( + os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0]) + ) + self.sg.upload( + "Version", + self.version["id"], + u_path, + "attachments", + ) + mock_send_form.assert_called_once() + mock_send_form_args, _ = mock_send_form.call_args + params = mock_send_form_args[1] + self.assertNotIn("created_at", params) + self.sg.server_info["s3_direct_uploads_enabled"] = True + + @unittest.mock.patch("shotgun_api3.Shotgun._send_form") + @unittest.mock.patch("shotgun_api3.Shotgun._upload_file_to_storage") + @unittest.mock.patch("shotgun_api3.Shotgun._get_attachment_upload_info") + def test_upload_to_storage_with_created_at( + self, mock_get_info, mock_upload_file, mock_send_form + ): + """ + Verify that created_at is passed as a form parameter when uploading + non-thumbnail attachments via _upload_to_storage() (S3/cloud path). + """ + self.sg.server_info["s3_direct_uploads_enabled"] = True + self.sg.server_info["s3_enabled_upload_types"] = {"Version": "*"} + mock_get_info.return_value = { + "upload_url": "https://example.com/upload", + "upload_info": {"upload_type": "s3"}, + } + mock_send_form.return_value = "1\n:456\nasd" + this_dir, _ = os.path.split(__file__) + u_path = os.path.abspath( + os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0]) + ) + custom_time = datetime.datetime(2026, 2, 15, 10, 30, 0) + self.sg.upload( + "Version", + self.version["id"], + u_path, + "attachments", + created_at=custom_time, + ) + mock_get_info.assert_called_once() + mock_send_form.assert_called_once() + mock_send_form_args, _ = mock_send_form.call_args + params = mock_send_form_args[1] + self.assertIn("created_at", params) + self.assertEqual(params["created_at"], custom_time) + + @unittest.mock.patch("shotgun_api3.Shotgun._send_form") + @unittest.mock.patch("shotgun_api3.Shotgun._upload_file_to_storage") + @unittest.mock.patch("shotgun_api3.Shotgun._get_attachment_upload_info") + def test_upload_to_storage_without_created_at( + self, mock_get_info, mock_upload_file, mock_send_form + ): + """ + Verify that created_at is NOT included in form parameters when omitted + via _upload_to_storage() (S3/cloud path). + """ + self.sg.server_info["s3_direct_uploads_enabled"] = True + self.sg.server_info["s3_enabled_upload_types"] = {"Version": "*"} + mock_get_info.return_value = { + "upload_url": "https://example.com/upload", + "upload_info": {"upload_type": "s3"}, + } + mock_send_form.return_value = "1\n:456\nasd" + this_dir, _ = os.path.split(__file__) + u_path = os.path.abspath( + os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0]) + ) + self.sg.upload( + "Version", + self.version["id"], + u_path, + "attachments", + ) + mock_get_info.assert_called_once() + mock_send_form.assert_called_once() + mock_send_form_args, _ = mock_send_form.call_args + params = mock_send_form_args[1] + self.assertNotIn("created_at", params) + + def test_upload_created_at_invalid_type(self): + """ + Verify that passing a non-datetime value for created_at raises ShotgunError. + """ + this_dir, _ = os.path.split(__file__) + u_path = os.path.abspath( + os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0]) + ) + with self.assertRaisesRegex( + shotgun_api3.ShotgunError, + "created_at must be a datetime.datetime instance", + ): + self.sg.upload( + "Version", + self.version["id"], + u_path, + "attachments", + created_at="2026-02-15T10:30:00Z", + ) + with self.assertRaisesRegex( + shotgun_api3.ShotgunError, + "created_at must be a datetime.datetime instance", + ): + self.sg.upload( + "Version", + self.version["id"], + u_path, + "attachments", + created_at=1234567890, + ) + def test_upload_thumbnail_in_create(self): """Upload a thumbnail via the create method""" this_dir, _ = os.path.split(__file__)