-
Notifications
You must be signed in to change notification settings - Fork 203
Expand file tree
/
Copy pathshotgun.py
More file actions
executable file
·2638 lines (2094 loc) · 104 KB
/
shotgun.py
File metadata and controls
executable file
·2638 lines (2094 loc) · 104 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
"""
-----------------------------------------------------------------------------
Copyright (c) 2009-2015, Shotgun Software Inc
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
- Neither the name of the Shotgun Software Inc nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import base64
import cookielib # used for attachment upload
import cStringIO # used for attachment upload
import datetime
import logging
import mimetools # used for attachment upload
import os
import re
import copy
import stat # used for attachment upload
import sys
import time
import types
import urllib
import urllib2 # used for image upload
import urlparse
import shutil # used for attachment download
# use relative import for versions >=2.5 and package import for python versions <2.5
if (sys.version_info[0] > 2) or (sys.version_info[0] == 2 and sys.version_info[1] >= 6):
from sg_26 import *
elif (sys.version_info[0] > 2) or (sys.version_info[0] == 2 and sys.version_info[1] >= 5):
from sg_25 import *
else:
from sg_24 import *
# mimetypes imported in version specific imports
mimetypes.add_type('video/webm','.webm') # webm and mp4 seem to be missing
mimetypes.add_type('video/mp4', '.mp4') # from some OS/distros
LOG = logging.getLogger("shotgun_api3")
LOG.setLevel(logging.WARN)
SG_TIMEZONE = SgTimezone()
try:
import ssl
NO_SSL_VALIDATION = False
except ImportError:
LOG.debug("ssl not found, disabling certificate validation")
NO_SSL_VALIDATION = True
# ----------------------------------------------------------------------------
# Version
__version__ = "3.0.24.dev"
# ----------------------------------------------------------------------------
# Errors
class ShotgunError(Exception):
"""Base for all Shotgun API Errors"""
pass
class ShotgunFileDownloadError(ShotgunError):
"""Exception for file download-related errors"""
pass
class Fault(ShotgunError):
"""Exception when server side exception detected."""
pass
class AuthenticationFault(Fault):
"""Exception when the server side reports an error related to authentication"""
pass
class MissingTwoFactorAuthenticationFault(Fault):
"""Exception when the server side reports an error related to missing
two factor authentication credentials
"""
pass
# ----------------------------------------------------------------------------
# API
class ServerCapabilities(object):
"""Container for the servers capabilities, such as version and paging.
"""
def __init__(self, host, meta):
"""ServerCapabilities.__init__
:param host: Host name for the server excluding protocol.
:param meta: dict of meta data for the server returned from the
info api method.
"""
#Server host name
self.host = host
self.server_info = meta
#Version from server is major.minor.rev or major.minor.rev."Dev"
#Store version as triple and check dev flag
try:
self.version = meta.get("version", None)
except AttributeError:
self.version = None
if not self.version:
raise ShotgunError("The Shotgun Server didn't respond with a version number. "
"This may be because you are running an older version of "
"Shotgun against a more recent version of the Shotgun API. "
"For more information, please contact Shotgun Support.")
if len(self.version) > 3 and self.version[3] == "Dev":
self.is_dev = True
else:
self.is_dev = False
self.version = tuple(self.version[:3])
self._ensure_json_supported()
def _ensure_support(self, feature, raise_hell=True):
"""Checks the server version supports a given feature, raises an
exception if it does not.
:param feature: dict supported version and human label { 'version': (int, int, int), 'label': str }
:raises ShotgunError: The current server version does not [feature]
"""
if not self.version or self.version < feature['version']:
if raise_hell:
raise ShotgunError(
"%s requires server version %s or higher, "\
"server is %s" % (feature['label'], _version_str(feature['version']), _version_str(self.version))
)
return False
else:
return True
def _ensure_json_supported(self):
"""Wrapper for ensure_support"""
self._ensure_support({
'version': (2, 4, 0),
'label': 'JSON API'
})
def ensure_include_archived_projects(self):
"""Wrapper for ensure_support"""
self._ensure_support({
'version': (5, 3, 14),
'label': 'include_archived_projects parameter'
})
def ensure_per_project_customization(self):
"""Wrapper for ensure_support"""
return self._ensure_support({
'version': (5, 4, 4),
'label': 'project parameter'
}, True)
def __str__(self):
return "ServerCapabilities: host %s, version %s, is_dev %s"\
% (self.host, self.version, self.is_dev)
class ClientCapabilities(object):
"""Container for the client capabilities.
Detects the current client platform and works out the SG field
used for local data paths.
"""
def __init__(self):
system = sys.platform.lower()
if system == 'darwin':
self.platform = "mac"
elif system.startswith('linux'):
self.platform = 'linux'
elif system == 'win32':
self.platform = 'windows'
else:
self.platform = None
if self.platform:
self.local_path_field = "local_path_%s" % (self.platform)
else:
self.local_path_field = None
self.py_version = ".".join(str(x) for x in sys.version_info[:2])
def __str__(self):
return "ClientCapabilities: platform %s, local_path_field %s, "\
"py_verison %s" % (self.platform, self.local_path_field,
self.py_version)
class _Config(object):
"""Container for the client configuration."""
def __init__(self):
self.max_rpc_attempts = 3
# From http://docs.python.org/2.6/library/httplib.html:
# If the optional timeout parameter is given, blocking operations
# (like connection attempts) will timeout after that many seconds
# (if it is not given, the global default timeout setting is used)
self.timeout_secs = None
self.api_ver = 'api3'
self.convert_datetimes_to_utc = True
self.records_per_page = 500
self.api_key = None
self.script_name = None
self.user_login = None
self.user_password = None
self.auth_token = None
self.sudo_as_login = None
# uuid as a string
self.session_uuid = None
self.scheme = None
self.server = None
self.api_path = None
# The raw_http_proxy reflects the exact string passed in
# to the Shotgun constructor. This can be useful if you
# need to construct a Shotgun API instance based on
# another Shotgun API instance.
self.raw_http_proxy = None
# if a proxy server is being used, the proxy_handler
# below will contain a urllib2.ProxyHandler instance
# which can be used whenever a request needs to be made.
self.proxy_handler = None
self.proxy_server = None
self.proxy_port = 8080
self.proxy_user = None
self.proxy_pass = None
self.session_token = None
self.authorization = None
self.no_ssl_validation = False
class Shotgun(object):
"""Shotgun Client Connection"""
# reg ex from
# http://underground.infovark.com/2008/07/22/iso-date-validation-regex/
# Note a length check is done before checking the reg ex
_DATE_PATTERN = re.compile(
"^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$")
_DATE_TIME_PATTERN = re.compile(
"^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])"\
"(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3})?)?$")
def __init__(self,
base_url,
script_name=None,
api_key=None,
convert_datetimes_to_utc=True,
http_proxy=None,
ensure_ascii=True,
connect=True,
ca_certs=None,
login=None,
password=None,
sudo_as_login=None,
session_token=None,
auth_token=None):
"""Initializes a new instance of the Shotgun client.
:param base_url: http or https url to the shotgun server.
:param script_name: name of the client script, used to authenticate
to the server. If script_name is provided, then api_key must be as
well and neither login nor password can be provided.
:param api_key: key assigned to the client script, used to
authenticate to the server. If api_key is provided, then script_name
must be as well and neither login nor password can be provided.
:param convert_datetimes_to_utc: If True date time values are
converted from local time to UTC time before been sent to the server.
Datetimes received from the server are converted back to local time.
If False the client should use UTC date time values.
Default is True.
:param http_proxy: Optional, URL for the http proxy server, on the
form [username:pass@]proxy.com[:8080]
:param connect: If True, connect to the server. Only used for testing.
:param ca_certs: Optional path to an external SSL certificates file. By
default, the Shotgun API will use its own built-in certificates file
which stores root certificates for the most common Certificate
Authorities (CAs). If you are using a corporate or internal CA, or are
packaging an application into an executable, it may be necessary to
point to your own certificates file. You can do this by passing in the
full path to the file via this parameter or by setting the environment
variable `SHOTGUN_API_CACERTS`. In the case both are set, this
parameter will take precedence.
:param login: The login to use to authenticate to the server. If login
is provided, then password must be as well and neither script_name nor
api_key can be provided.
:param password: The password for the login to use to authenticate to
the server. If password is provided, then login must be as well and
neither script_name nor api_key can be provided.
:param sudo_as_login: A user login string for the user whose permissions will
be applied to all actions and who will be logged as the user performing
all actions. Note that logged events will have an additional extra meta-data parameter
'sudo_actual_user' indicating the script or user that actually authenticated.
:param session_token: The session token to use to authenticate to the server. This
can be used as an alternative to authenticating with a script user or regular user.
You retrieve the session token by running the get_session_token() method.
:param auth_token: The authentication token required to authenticate to
a server with two factor authentication turned on. If auth_token is provided,
then login and password must be as well and neither script_name nor api_key
can be provided. Note that these tokens can be short lived so a session is
established right away if an auth_token is provided. A
MissingTwoFactorAuthenticationFault will be raised if the auth_token is invalid.
"""
# verify authentication arguments
if session_token is not None:
if script_name is not None or api_key is not None:
raise ValueError("cannot provide both session_token "
"and script_name/api_key")
if login is not None or password is not None:
raise ValueError("cannot provide both session_token "
"and login/password")
if login is not None or password is not None:
if script_name is not None or api_key is not None:
raise ValueError("cannot provide both login/password "
"and script_name/api_key")
if login is None:
raise ValueError("password provided without login")
if password is None:
raise ValueError("login provided without password")
if script_name is not None or api_key is not None:
if script_name is None:
raise ValueError("api_key provided without script_name")
if api_key is None:
raise ValueError("script_name provided without api_key")
if auth_token is not None:
if login is None or password is None:
raise ValueError("must provide a user login and password with an auth_token")
if script_name is not None or api_key is not None:
raise ValueError("cannot provide an auth_code with script_name/api_key")
# Can't use 'all' with python 2.4
if len([x for x in [session_token, script_name, api_key, login, password] if x]) == 0:
if connect:
raise ValueError("must provide login/password, session_token or script_name/api_key")
self.config = _Config()
self.config.api_key = api_key
self.config.script_name = script_name
self.config.user_login = login
self.config.user_password = password
self.config.auth_token = auth_token
self.config.session_token = session_token
self.config.sudo_as_login = sudo_as_login
self.config.convert_datetimes_to_utc = convert_datetimes_to_utc
self.config.no_ssl_validation = NO_SSL_VALIDATION
self.config.raw_http_proxy = http_proxy
self._connection = None
if ca_certs is not None:
self.__ca_certs = ca_certs
else:
self.__ca_certs = os.environ.get('SHOTGUN_API_CACERTS')
self.base_url = (base_url or "").lower()
self.config.scheme, self.config.server, api_base, _, _ = \
urlparse.urlsplit(self.base_url)
if self.config.scheme not in ("http", "https"):
raise ValueError("base_url must use http or https got '%s'" %
self.base_url)
self.config.api_path = urlparse.urljoin(urlparse.urljoin(
api_base or "/", self.config.api_ver + "/"), "json")
# if the service contains user information strip it out
# copied from the xmlrpclib which turned the user:password into
# and auth header
auth, self.config.server = urllib.splituser(self.config.server)
if auth:
auth = base64.encodestring(urllib.unquote(auth))
self.config.authorization = "Basic " + auth.strip()
# foo:bar@123.456.789.012:3456
if http_proxy:
# check if we're using authentication
p = http_proxy.split("@", 1)
if len(p) > 1:
self.config.proxy_user, self.config.proxy_pass = \
p[0].split(":", 1)
proxy_server = p[1]
else:
proxy_server = http_proxy
proxy_netloc_list = proxy_server.split(":", 1)
self.config.proxy_server = proxy_netloc_list[0]
if len(proxy_netloc_list) > 1:
try:
self.config.proxy_port = int(proxy_netloc_list[1])
except ValueError:
raise ValueError("Invalid http_proxy address '%s'. Valid " \
"format is '123.456.789.012' or '123.456.789.012:3456'"\
". If no port is specified, a default of %d will be "\
"used." % (http_proxy, self.config.proxy_port))
# now populate self.config.proxy_handler
if self.config.proxy_user and self.config.proxy_pass:
auth_string = "%s:%s@" % (self.config.proxy_user, self.config.proxy_pass)
else:
auth_string = ""
proxy_addr = "http://%s%s:%d" % (auth_string, self.config.proxy_server, self.config.proxy_port)
self.config.proxy_handler = urllib2.ProxyHandler({self.config.scheme : proxy_addr})
self._ensure_ascii = ensure_ascii
self.client_caps = ClientCapabilities()
# this relies on self.client_caps being set first
self.reset_user_agent()
self._server_caps = None
# test to ensure the the server supports the json API
# call to server will only be made once and will raise error
if connect:
self.server_caps
# When using auth_token in a 2FA scenario we need to switch to session-based
# authentication because the auth token will no longer be valid after a first use.
if self.config.auth_token is not None:
self.config.session_token = self.get_session_token()
self.config.user_login = None
self.config.user_password = None
self.config.auth_token = None
# ========================================================================
# API Functions
@property
def server_info(self):
"""Returns server information."""
return self.server_caps.server_info
@property
def server_caps(self):
"""
:returns: ServerCapabilities that describe the server the client is
connected to.
"""
if not self._server_caps or (
self._server_caps.host != self.config.server):
self._server_caps = ServerCapabilities(self.config.server,
self.info())
return self._server_caps
def connect(self):
"""Forces the client to connect to the server if it is not already
connected.
NOTE: The client will automatically connect to the server. Only
call this function if you wish to confirm the client can connect.
"""
self._get_connection()
self.info()
return
def close(self):
"""Closes the current connection to the server.
If the client needs to connect again it will do so automatically.
"""
self._close_connection()
return
def info(self):
"""Calls the Info function on the Shotgun API to get the server meta.
:returns: dict of the server meta data.
"""
return self._call_rpc("info", None, include_auth_params=False)
def find_one(self, entity_type, filters, fields=None, order=None,
filter_operator=None, retired_only=False, include_archived_projects=True):
"""Calls the find() method and returns the first result, or None.
:param entity_type: Required, entity type (string) to find.
:param filters: Required, list of filters to apply.
:param fields: Optional list of fields from the matched entities to
return. Defaults to id.
:param order: Optional list of fields to order the results by, list
has the form [{'field_name':'foo','direction':'asc or desc'},]
:param filter_operator: Optional operator to apply to the filters,
supported values are 'all' and 'any'. Defaults to 'all'.
:param limit: Optional, number of entities to return per page.
Defaults to 0 which returns all entities that match.
:param page: Optional, page of results to return. By default all
results are returned. Use together with limit.
:param retired_only: Optional, flag to return only entities that have
been retried. Defaults to False which returns only entities which
have not been retired.
:returns: Dictionary of requested Shotgun fields and values.
"""
results = self.find(entity_type, filters, fields, order,
filter_operator, 1, retired_only, include_archived_projects=include_archived_projects)
if results:
return results[0]
return None
def find(self, entity_type, filters, fields=None, order=None,
filter_operator=None, limit=0, retired_only=False, page=0,
include_archived_projects=True):
"""Find entities matching the given filters.
:param entity_type: Required, entity type (string) to find.
:param filters: Required, list of filters to apply.
:param fields: Optional list of fields from the matched entities to
return. Defaults to id.
:param order: Optional list of fields to order the results by, list
has the form [{'field_name':'foo','direction':'asc or desc'},]
:param filter_operator: Optional operator to apply to the filters,
supported values are 'all' and 'any'. Defaults to 'all'.
:param limit: Optional, number of entities to return per page.
Defaults to 0 which returns all entities that match.
:param page: Optional, page of results to return. By default all
results are returned. Use together with limit.
:param retired_only: Optional, flag to return only entities that have
been retried. Defaults to False which returns only entities which
have not been retired.
:param include_archived_projects: Optional, flag to include entities
whose projects have been archived
:returns: list of the dicts for each entity with the requested fields,
and their id and type.
"""
if not isinstance(limit, int) or limit < 0:
raise ValueError("limit parameter must be a positive integer")
if not isinstance(page, int) or page < 0:
raise ValueError("page parameter must be a positive integer")
if isinstance(filters, (list, tuple)):
filters = _translate_filters(filters, filter_operator)
elif filter_operator:
#TODO:Not sure if this test is correct, replicated from prev api
raise ShotgunError("Deprecated: Use of filter_operator for find()"
" is not valid any more. See the documentation on find()")
if not include_archived_projects:
# This defaults to True on the server (no argument is sent)
# So we only need to check the server version if it is False
self.server_caps.ensure_include_archived_projects()
params = self._construct_read_parameters(entity_type,
fields,
filters,
retired_only,
order,
include_archived_projects)
if limit and limit <= self.config.records_per_page:
params["paging"]["entities_per_page"] = limit
# If page isn't set and the limit doesn't require pagination,
# then trigger the faster code path.
if page == 0:
page = 1
if self.server_caps.version and self.server_caps.version >= (3, 3, 0):
params['api_return_image_urls'] = True
# if page is specified, then only return the page of records requested
if page != 0:
# No paging_info needed, so optimize it out.
params["return_paging_info"] = False
params["paging"]["current_page"] = page
records = self._call_rpc("read", params).get("entities", [])
return self._parse_records(records)
records = []
result = self._call_rpc("read", params)
while result.get("entities"):
records.extend(result.get("entities"))
if limit and len(records) >= limit:
records = records[:limit]
break
if len(records) == result["paging_info"]["entity_count"]:
break
params['paging']['current_page'] += 1
result = self._call_rpc("read", params)
return self._parse_records(records)
def _construct_read_parameters(self,
entity_type,
fields,
filters,
retired_only,
order,
include_archived_projects):
params = {}
params["type"] = entity_type
params["return_fields"] = fields or ["id"]
params["filters"] = filters
params["return_only"] = (retired_only and 'retired') or "active"
params["return_paging_info"] = True
params["paging"] = { "entities_per_page": self.config.records_per_page,
"current_page": 1 }
if include_archived_projects is False:
# Defaults to True on the server, so only pass it if it's False
params["include_archived_projects"] = False
if order:
sort_list = []
for sort in order:
if sort.has_key('column'):
# TODO: warn about deprecation of 'column' param name
sort['field_name'] = sort['column']
sort.setdefault("direction", "asc")
sort_list.append({
'field_name': sort['field_name'],
'direction' : sort['direction']
})
params['sorts'] = sort_list
return params
def _add_project_param(self, params, project_entity):
if project_entity and self.server_caps.ensure_per_project_customization():
params["project"] = project_entity
return params
def summarize(self,
entity_type,
filters,
summary_fields,
filter_operator=None,
grouping=None,
include_archived_projects=True):
"""Summarize column data returned by a query.
This provides the same functionality as the summaries in the UI.
You can specify one or more fields to summarize, choose the summary
type for each, and optionally group the results which will return
summary information for each group as well as the total for the query.
:param entity_type: The entity type to summarize
:param filters: An array of conditions used to filter the find query.
Uses the same syntax as for example the find() method.
:param summary_fields: A list of dictionaries with the following keys:
- field: Which field you are summarizing
- type: The type of summary you are performing on the field.
Summary types can be any of [record_count, count, sum,
maximum, minimum, average, earliest, latest, percentage,
status_percentage, status_list, checked, unchecked]
depending on the type of field you're summarizing.
:param filter_operator: Controls how the filters are matched.
There are only two valid options: all and any.
You cannot currently combine the two options
in the same query. Defaults to "all".
:param grouping: Optional list of dicts with the following keys:
- field: a string indicating the field on entity_type to
group results by.
- type: a string indicating the type of grouping to perform
for each group. Valid types depend on the type of field
you are grouping on and can be one of [exact, tens, hundreds,
thousands, tensofthousands, hundredsofthousands, millions,
day, week, month, quarter, year, clustered_date, oneday,
fivedays, entitytype, firstletter].
- direction: a string that sets the order to display the
grouped results. Valid direction options are asc (default)
and desc.
:returns: dict object containing grouping and summaries keys.
- grouping: list of dictionaries containing grouping
information:
- group_name: Display name of the value
that defines the group.
- group_value: Data representation of the value
that defines the group.
- summaries: see summary key
- groups: For nested groups. This structure will be
repeated with the same structure as defined
in the top-level grouping key.
- summaries: Dict of key/value pairs where the key is the
field name and the value is the summary value
requested for that field.
"""
if not isinstance(grouping, list) and grouping is not None:
msg = "summarize() 'grouping' parameter must be a list or None"
raise ValueError(msg)
if isinstance(filters, (list, tuple)):
filters = _translate_filters(filters, filter_operator)
if not include_archived_projects:
# This defaults to True on the server (no argument is sent)
# So we only need to check the server version if it is False
self.server_caps.ensure_include_archived_projects()
params = {"type": entity_type,
"summaries": summary_fields,
"filters": filters}
if include_archived_projects is False:
# Defaults to True on the server, so only pass it if it's False
params["include_archived_projects"] = False
if grouping is not None:
params['grouping'] = grouping
records = self._call_rpc('summarize', params)
return records
def create(self, entity_type, data, return_fields=None):
"""Create a new entity of the specified entity_type.
:param entity_type: Required, entity type (string) to create.
:param data: Required, dict fields to set on the new entity.
:param return_fields: Optional list of fields from the new entity
to return. Defaults to 'id' field.
:returns: dict of the requested fields.
"""
data = data.copy()
if not return_fields:
return_fields = ["id"]
upload_image = None
if 'image' in data:
upload_image = data.pop('image')
upload_filmstrip_image = None
if 'filmstrip_image' in data:
if not self.server_caps.version or self.server_caps.version < (3, 1, 0):
raise ShotgunError("Filmstrip thumbnail support requires server version 3.1 or "\
"higher, server is %s" % (self.server_caps.version,))
upload_filmstrip_image = data.pop('filmstrip_image')
params = {
"type" : entity_type,
"fields" : self._dict_to_list(data),
"return_fields" : return_fields
}
record = self._call_rpc("create", params, first=True)
result = self._parse_records(record)[0]
if upload_image:
image_id = self.upload_thumbnail(entity_type, result['id'],
upload_image)
image = self.find_one(entity_type, [['id', 'is', result.get('id')]],
fields=['image'])
result['image'] = image.get('image')
if upload_filmstrip_image:
filmstrip_id = self.upload_filmstrip_thumbnail(entity_type, result['id'], upload_filmstrip_image)
filmstrip = self.find_one(entity_type,
[['id', 'is', result.get('id')]],
fields=['filmstrip_image'])
result['filmstrip_image'] = filmstrip.get('filmstrip_image')
return result
def update(self, entity_type, entity_id, data):
"""Updates the specified entity with the supplied data.
:param entity_type: Required, entity type (string) to update.
:param entity_id: Required, id of the entity to update.
:param data: Required, dict fields to update on the entity.
:returns: dict of the fields updated, with the entity_type and
id added.
"""
data = data.copy()
upload_image = None
if 'image' in data and data['image'] is not None:
upload_image = data.pop('image')
upload_filmstrip_image = None
if 'filmstrip_image' in data:
if not self.server_caps.version or self.server_caps.version < (3, 1, 0):
raise ShotgunError("Filmstrip thumbnail support requires server version 3.1 or "\
"higher, server is %s" % (self.server_caps.version,))
upload_filmstrip_image = data.pop('filmstrip_image')
if data:
params = {
"type" : entity_type,
"id" : entity_id,
"fields" : self._dict_to_list(data)
}
record = self._call_rpc("update", params)
result = self._parse_records(record)[0]
else:
result = {'id': entity_id, 'type': entity_type}
if upload_image:
image_id = self.upload_thumbnail(entity_type, entity_id,
upload_image)
image = self.find_one(entity_type, [['id', 'is', result.get('id')]],
fields=['image'])
result['image'] = image.get('image')
if upload_filmstrip_image:
filmstrip_id = self.upload_filmstrip_thumbnail(entity_type, result['id'], upload_filmstrip_image)
filmstrip = self.find_one(entity_type,
[['id', 'is', result.get('id')]],
fields=['filmstrip_image'])
result['filmstrip_image'] = filmstrip.get('filmstrip_image')
return result
def delete(self, entity_type, entity_id):
"""Retire the specified entity.
The entity can be brought back to life using the revive function.
:param entity_type: Required, entity type (string) to delete.
:param entity_id: Required, id of the entity to delete.
:returns: True if the entity was deleted, False otherwise e.g. if the
entity has previously been deleted.
"""
params = {
"type" : entity_type,
"id" : entity_id
}
return self._call_rpc("delete", params)
def revive(self, entity_type, entity_id):
"""Revive an entity that has previously been deleted.
:param entity_type: Required, entity type (string) to revive.
:param entity_id: Required, id of the entity to revive.
:returns: True if the entity was revived, False otherwise e.g. if the
entity has previously been revived (or was not deleted).
"""
params = {
"type" : entity_type,
"id" : entity_id
}
return self._call_rpc("revive", params)
def batch(self, requests):
"""Make a batch request of several create, update and delete calls.
All requests are performed within a transaction, so either all will
complete or none will.
:param requests: A list of dict's of the form which have a
request_type key and also specifies:
- create: entity_type, data dict of fields to set
- update: entity_type, entity_id, data dict of fields to set
- delete: entity_type and entity_id
:returns: A list of values for each operation, create and update
requests return a dict of the fields updated. Delete requests
return True if the entity was deleted.
"""
if not isinstance(requests, list):
raise ShotgunError("batch() expects a list. Instead was sent "\
"a %s" % type(requests))
# If we have no requests, just return an empty list immediately.
# Nothing to process means nothing to get results of.
if len(requests) == 0:
return []
calls = []
def _required_keys(message, required_keys, data):
missing = set(required_keys) - set(data.keys())
if missing:
raise ShotgunError("%s missing required key: %s. "\
"Value was: %s." % (message, ", ".join(missing), data))
for req in requests:
_required_keys("Batched request",
['request_type', 'entity_type'],
req)
request_params = {'request_type': req['request_type'],
"type" : req["entity_type"]}
if req["request_type"] == "create":
_required_keys("Batched create request", ['data'], req)
request_params['fields'] = self._dict_to_list(req["data"])
request_params["return_fields"] = req.get("return_fields") or["id"]
elif req["request_type"] == "update":
_required_keys("Batched update request",
['entity_id', 'data'],
req)
request_params['id'] = req['entity_id']
request_params['fields'] = self._dict_to_list(req["data"])
elif req["request_type"] == "delete":
_required_keys("Batched delete request", ['entity_id'], req)
request_params['id'] = req['entity_id']
else:
raise ShotgunError("Invalid request_type '%s' for batch" % (
req["request_type"]))
calls.append(request_params)
records = self._call_rpc("batch", calls)
return self._parse_records(records)
def work_schedule_read(self, start_date, end_date, project=None, user=None):
"""Get the work day rules for a given date range.
reasons:
STUDIO_WORK_WEEK
STUDIO_EXCEPTION
PROJECT_WORK_WEEK
PROJECT_EXCEPTION
USER_WORK_WEEK
USER_EXCEPTION
:param start_date: Start date of date range.
:type start_date: str (YYYY-MM-DD)
:param end_date: End date of date range.
:type end_date: str (YYYY-MM-DD)
:param dict project: Project entity to query WorkDayRules for. (optional)
:param dict user: User entity to query WorkDayRules for. (optional)
"""