Skip to content

Commit aca9d94

Browse files
authored
SG-41387 Add parameter created_at for sg.upload (#444)
* Add parameter created_at for sg.upload
1 parent 921114c commit aca9d94

2 files changed

Lines changed: 258 additions & 3 deletions

File tree

shotgun_api3/shotgun.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2610,6 +2610,7 @@ def upload(
26102610
field_name: Optional[str] = None,
26112611
display_name: Optional[str] = None,
26122612
tag_list: Optional[str] = None,
2613+
created_at: Optional[datetime.datetime] = None,
26132614
) -> int:
26142615
"""
26152616
Upload a file to the specified entity.
@@ -2634,6 +2635,8 @@ def upload(
26342635
This field must be a File/Link field type.
26352636
:param str display_name: The display name to use for the file. Defaults to the file name.
26362637
:param str tag_list: comma-separated string of tags to assign to the file.
2638+
:param datetime.datetime created_at: Optional datetime value to set as the created_at
2639+
time for the Attachment entity. If ``None``, the server will use the current time.
26372640
:returns: Id of the Attachment entity that was created for the image.
26382641
:rtype: int
26392642
:raises: :class:`ShotgunError` on upload failure.
@@ -2662,6 +2665,13 @@ def upload(
26622665
if os.path.getsize(path) == 0:
26632666
raise ShotgunError("Path cannot be an empty file: '%s'" % path)
26642667

2668+
if created_at is not None:
2669+
if not isinstance(created_at, datetime.datetime):
2670+
raise ShotgunError(
2671+
"created_at must be a datetime.datetime instance, got '%s'"
2672+
% type(created_at)
2673+
)
2674+
26652675
is_thumbnail = field_name in [
26662676
"thumb_image",
26672677
"filmstrip_thumb_image",
@@ -2679,6 +2689,7 @@ def upload(
26792689
display_name,
26802690
tag_list,
26812691
is_thumbnail,
2692+
created_at,
26822693
)
26832694
else:
26842695
return self._upload_to_sg(
@@ -2689,6 +2700,7 @@ def upload(
26892700
display_name,
26902701
tag_list,
26912702
is_thumbnail,
2703+
created_at,
26922704
)
26932705

26942706
def _upload_to_storage(
@@ -2700,6 +2712,7 @@ def _upload_to_storage(
27002712
display_name: Optional[str],
27012713
tag_list: Optional[str],
27022714
is_thumbnail: bool,
2715+
created_at: Optional[datetime.datetime] = None,
27032716
) -> int:
27042717
"""
27052718
Internal function to upload a file to the Cloud storage and link it to the specified entity.
@@ -2712,6 +2725,7 @@ def _upload_to_storage(
27122725
:param str display_name: The display name to use for the file. Defaults to the file name.
27132726
:param str tag_list: comma-separated string of tags to assign to the file.
27142727
:param bool is_thumbnail: indicates if the attachment is a thumbnail.
2728+
:param datetime created_at: The datetime to set for the attachment.
27152729
:returns: Id of the Attachment entity that was created for the image.
27162730
:rtype: int
27172731
"""
@@ -2723,7 +2737,7 @@ def _upload_to_storage(
27232737
is_multipart_upload = os.path.getsize(path) > self._MULTIPART_UPLOAD_CHUNK_SIZE
27242738

27252739
upload_info = self._get_attachment_upload_info(
2726-
is_thumbnail, filename, is_multipart_upload
2740+
is_thumbnail, filename, is_multipart_upload, created_at=created_at
27272741
)
27282742

27292743
# Step 2: upload the file
@@ -2768,6 +2782,8 @@ def _upload_to_storage(
27682782
# None gets converted to a string and added as a tag...
27692783
if tag_list:
27702784
params["tag_list"] = tag_list
2785+
if created_at is not None:
2786+
params["created_at"] = created_at
27712787

27722788
result = self._send_form(url, params)
27732789
if not result.startswith("1"):
@@ -2790,6 +2806,7 @@ def _upload_to_sg(
27902806
display_name: Optional[str],
27912807
tag_list: Optional[str],
27922808
is_thumbnail: bool,
2809+
created_at: Optional[datetime.datetime] = None,
27932810
) -> int:
27942811
"""
27952812
Internal function to upload a file to Shotgun and link it to the specified entity.
@@ -2802,7 +2819,7 @@ def _upload_to_sg(
28022819
:param str display_name: The display name to use for the file. Defaults to the file name.
28032820
:param str tag_list: comma-separated string of tags to assign to the file.
28042821
:param bool is_thumbnail: indicates if the attachment is a thumbnail.
2805-
2822+
:param datetime created_at: The datetime to set for the attachment.
28062823
:returns: Id of the Attachment entity that was created for the image.
28072824
:rtype: int
28082825
"""
@@ -2848,6 +2865,8 @@ def _upload_to_sg(
28482865
# None gets converted to a string and added as a tag...
28492866
if tag_list:
28502867
params["tag_list"] = tag_list
2868+
if created_at is not None:
2869+
params["created_at"] = created_at.isoformat()
28512870

28522871
params["file"] = open(path, "rb")
28532872

@@ -2863,14 +2882,21 @@ def _upload_to_sg(
28632882
return attachment_id
28642883

28652884
def _get_attachment_upload_info(
2866-
self, is_thumbnail: bool, filename: str, is_multipart_upload: bool
2885+
self,
2886+
is_thumbnail: bool,
2887+
filename: str,
2888+
is_multipart_upload: bool,
2889+
created_at: Optional[datetime.datetime] = None,
28672890
) -> Dict[str, Any]:
28682891
"""
28692892
Internal function to get the information needed to upload a file to Cloud storage.
28702893
28712894
:param bool is_thumbnail: indicates if the attachment is a thumbnail.
28722895
:param str filename: name of the file that will be uploaded.
28732896
:param bool is_multipart_upload: Indicates if we want multi-part upload information back.
2897+
:param datetime created_at: Optional datetime to use as the upload timestamp.
2898+
When provided, the server generates the S3 key with this timestamp so that
2899+
the Attachment's created_at and its storage path stay in sync.
28742900
28752901
:returns: dictionary containing upload details from the server.
28762902
These details are used throughout the upload process.
@@ -2886,6 +2912,9 @@ def _get_attachment_upload_info(
28862912

28872913
params["multipart_upload"] = is_multipart_upload
28882914

2915+
if created_at is not None:
2916+
params["created_at"] = created_at.isoformat()
2917+
28892918
upload_url = "/upload/api_get_upload_link_info"
28902919
url = urllib.parse.urlunparse(
28912920
(self.config.scheme, self.config.server, upload_url, None, None, None)

tests/test_api.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,232 @@ def test_upload_to_sg(self, mock_send_form):
325325
mock_send_form.assert_called_once()
326326
self.sg.server_info["s3_direct_uploads_enabled"] = True
327327

328+
@unittest.mock.patch("shotgun_api3.Shotgun._send_form")
329+
def test_upload_to_sg_with_created_at(self, mock_send_form):
330+
"""
331+
Verify that created_at is passed (in ISO 8601 string form) as a form
332+
parameter when uploading non-thumbnail attachments via _upload_to_sg().
333+
"""
334+
self.sg.server_info["s3_direct_uploads_enabled"] = False
335+
mock_send_form.return_value = "1\n:456\nasd"
336+
this_dir, _ = os.path.split(__file__)
337+
u_path = os.path.abspath(
338+
os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0])
339+
)
340+
custom_time = datetime.datetime(2026, 2, 15, 10, 30, 0)
341+
self.sg.upload(
342+
"Version",
343+
self.version["id"],
344+
u_path,
345+
"attachments",
346+
created_at=custom_time,
347+
)
348+
mock_send_form.assert_called_once()
349+
mock_send_form_args, _ = mock_send_form.call_args
350+
params = mock_send_form_args[1]
351+
self.assertIn("created_at", params)
352+
self.assertEqual(params["created_at"], custom_time.isoformat())
353+
self.sg.server_info["s3_direct_uploads_enabled"] = True
354+
355+
@unittest.mock.patch("shotgun_api3.Shotgun._send_form")
356+
def test_upload_to_sg_without_created_at(self, mock_send_form):
357+
"""
358+
Verify that created_at is NOT included in form parameters when omitted.
359+
"""
360+
self.sg.server_info["s3_direct_uploads_enabled"] = False
361+
mock_send_form.return_value = "1\n:456\nasd"
362+
this_dir, _ = os.path.split(__file__)
363+
u_path = os.path.abspath(
364+
os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0])
365+
)
366+
self.sg.upload(
367+
"Version",
368+
self.version["id"],
369+
u_path,
370+
"attachments",
371+
)
372+
mock_send_form.assert_called_once()
373+
mock_send_form_args, _ = mock_send_form.call_args
374+
params = mock_send_form_args[1]
375+
self.assertNotIn("created_at", params)
376+
self.sg.server_info["s3_direct_uploads_enabled"] = True
377+
378+
@unittest.mock.patch("shotgun_api3.Shotgun._send_form")
379+
@unittest.mock.patch("shotgun_api3.Shotgun._upload_file_to_storage")
380+
@unittest.mock.patch("shotgun_api3.Shotgun._get_attachment_upload_info")
381+
def test_upload_to_storage_with_created_at(
382+
self, mock_get_info, mock_upload_file, mock_send_form
383+
):
384+
"""
385+
Verify that created_at is passed as a form parameter when uploading
386+
non-thumbnail attachments via _upload_to_storage() (S3/cloud path),
387+
and that it is forwarded to _get_attachment_upload_info() so the server
388+
can use it when generating the storage path.
389+
"""
390+
self.sg.server_info["s3_direct_uploads_enabled"] = True
391+
self.sg.server_info["s3_enabled_upload_types"] = {"Version": "*"}
392+
mock_get_info.return_value = {
393+
"upload_url": "https://example.com/upload",
394+
"upload_info": {"upload_type": "s3"},
395+
}
396+
mock_send_form.return_value = "1\n:456\nasd"
397+
this_dir, _ = os.path.split(__file__)
398+
u_path = os.path.abspath(
399+
os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0])
400+
)
401+
custom_time = datetime.datetime(2026, 2, 15, 10, 30, 0)
402+
self.sg.upload(
403+
"Version",
404+
self.version["id"],
405+
u_path,
406+
"attachments",
407+
created_at=custom_time,
408+
)
409+
mock_get_info.assert_called_once()
410+
_, get_info_kwargs = mock_get_info.call_args
411+
self.assertEqual(get_info_kwargs.get("created_at"), custom_time)
412+
mock_send_form.assert_called_once()
413+
mock_send_form_args, _ = mock_send_form.call_args
414+
params = mock_send_form_args[1]
415+
self.assertIn("created_at", params)
416+
self.assertEqual(params["created_at"], custom_time)
417+
418+
@unittest.mock.patch("shotgun_api3.Shotgun._send_form")
419+
@unittest.mock.patch("shotgun_api3.Shotgun._upload_file_to_storage")
420+
@unittest.mock.patch("shotgun_api3.Shotgun._get_attachment_upload_info")
421+
def test_upload_to_storage_without_created_at(
422+
self, mock_get_info, mock_upload_file, mock_send_form
423+
):
424+
"""
425+
Verify that created_at is NOT included in form parameters when omitted
426+
via _upload_to_storage() (S3/cloud path), and that None is forwarded to
427+
_get_attachment_upload_info().
428+
"""
429+
self.sg.server_info["s3_direct_uploads_enabled"] = True
430+
self.sg.server_info["s3_enabled_upload_types"] = {"Version": "*"}
431+
mock_get_info.return_value = {
432+
"upload_url": "https://example.com/upload",
433+
"upload_info": {"upload_type": "s3"},
434+
}
435+
mock_send_form.return_value = "1\n:456\nasd"
436+
this_dir, _ = os.path.split(__file__)
437+
u_path = os.path.abspath(
438+
os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0])
439+
)
440+
self.sg.upload(
441+
"Version",
442+
self.version["id"],
443+
u_path,
444+
"attachments",
445+
)
446+
mock_get_info.assert_called_once()
447+
_, get_info_kwargs = mock_get_info.call_args
448+
self.assertIsNone(get_info_kwargs.get("created_at"))
449+
mock_send_form.assert_called_once()
450+
mock_send_form_args, _ = mock_send_form.call_args
451+
params = mock_send_form_args[1]
452+
self.assertNotIn("created_at", params)
453+
454+
@unittest.mock.patch("shotgun_api3.Shotgun._send_form")
455+
def test_get_attachment_upload_info_with_created_at(self, mock_send_form):
456+
"""
457+
Verify that _get_attachment_upload_info() includes created_at in the
458+
form params (as ISO 8601 string) when sending the request to
459+
/upload/api_get_upload_link_info.
460+
"""
461+
mock_send_form.return_value = (
462+
"1\nhttps://example.com/upload\n2026-02-15T10:30:00\nAttachment\nupload-123"
463+
)
464+
custom_time = datetime.datetime(2026, 2, 15, 10, 30, 0)
465+
self.sg._get_attachment_upload_info(
466+
is_thumbnail=False,
467+
filename="example.jpg",
468+
is_multipart_upload=False,
469+
created_at=custom_time,
470+
)
471+
mock_send_form.assert_called_once()
472+
mock_send_form_args, _ = mock_send_form.call_args
473+
params = mock_send_form_args[1]
474+
self.assertIn("created_at", params)
475+
self.assertEqual(params["created_at"], custom_time.isoformat())
476+
self.assertEqual(params["upload_type"], "Attachment")
477+
self.assertEqual(params["filename"], "example.jpg")
478+
479+
@unittest.mock.patch("shotgun_api3.Shotgun._send_form")
480+
def test_get_attachment_upload_info_without_created_at(self, mock_send_form):
481+
"""
482+
Verify that _get_attachment_upload_info() does NOT include created_at
483+
in the form params when it is omitted (None).
484+
"""
485+
mock_send_form.return_value = (
486+
"1\nhttps://example.com/upload\n2026-02-15T10:30:00\nAttachment\nupload-123"
487+
)
488+
self.sg._get_attachment_upload_info(
489+
is_thumbnail=False,
490+
filename="example.jpg",
491+
is_multipart_upload=False,
492+
)
493+
mock_send_form.assert_called_once()
494+
mock_send_form_args, _ = mock_send_form.call_args
495+
params = mock_send_form_args[1]
496+
self.assertNotIn("created_at", params)
497+
498+
@unittest.mock.patch("shotgun_api3.Shotgun._send_form")
499+
def test_get_attachment_upload_info_thumbnail_with_created_at(self, mock_send_form):
500+
"""
501+
Verify that _get_attachment_upload_info() forwards created_at even when
502+
is_thumbnail=True. The implementation does not gate created_at on the
503+
thumbnail flag at this stage; the server is responsible for handling
504+
thumbnail-specific behavior.
505+
"""
506+
mock_send_form.return_value = (
507+
"1\nhttps://example.com/upload\n2026-02-15T10:30:00\nThumbnail\nupload-123"
508+
)
509+
custom_time = datetime.datetime(2026, 2, 15, 10, 30, 0)
510+
self.sg._get_attachment_upload_info(
511+
is_thumbnail=True,
512+
filename="thumb.jpg",
513+
is_multipart_upload=False,
514+
created_at=custom_time,
515+
)
516+
mock_send_form.assert_called_once()
517+
mock_send_form_args, _ = mock_send_form.call_args
518+
params = mock_send_form_args[1]
519+
self.assertEqual(params["upload_type"], "Thumbnail")
520+
self.assertIn("created_at", params)
521+
self.assertEqual(params["created_at"], custom_time.isoformat())
522+
523+
def test_upload_created_at_invalid_type(self):
524+
"""
525+
Verify that passing a non-datetime value for created_at raises ShotgunError.
526+
"""
527+
this_dir, _ = os.path.split(__file__)
528+
u_path = os.path.abspath(
529+
os.path.expanduser(glob.glob(os.path.join(this_dir, "Noëlご.jpg"))[0])
530+
)
531+
with self.assertRaisesRegex(
532+
shotgun_api3.ShotgunError,
533+
"created_at must be a datetime.datetime instance",
534+
):
535+
self.sg.upload(
536+
"Version",
537+
self.version["id"],
538+
u_path,
539+
"attachments",
540+
created_at="2026-02-15T10:30:00Z",
541+
)
542+
with self.assertRaisesRegex(
543+
shotgun_api3.ShotgunError,
544+
"created_at must be a datetime.datetime instance",
545+
):
546+
self.sg.upload(
547+
"Version",
548+
self.version["id"],
549+
u_path,
550+
"attachments",
551+
created_at=1234567890,
552+
)
553+
328554
def test_upload_thumbnail_in_create(self):
329555
"""Upload a thumbnail via the create method"""
330556
this_dir, _ = os.path.split(__file__)

0 commit comments

Comments
 (0)