ciocore 5.1.1__py2.py3-none-any.whl → 10.0.0b3__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. ciocore/VERSION +1 -1
  2. ciocore/__init__.py +23 -1
  3. ciocore/api_client.py +655 -160
  4. ciocore/auth/__init__.py +5 -3
  5. ciocore/cli.py +501 -0
  6. ciocore/common.py +15 -13
  7. ciocore/conductor_submit.py +77 -60
  8. ciocore/config.py +127 -13
  9. ciocore/data.py +162 -77
  10. ciocore/docsite/404.html +746 -0
  11. ciocore/docsite/apidoc/api_client/index.html +3605 -0
  12. ciocore/docsite/apidoc/apidoc/index.html +909 -0
  13. ciocore/docsite/apidoc/config/index.html +1652 -0
  14. ciocore/docsite/apidoc/data/index.html +1553 -0
  15. ciocore/docsite/apidoc/hardware_set/index.html +2460 -0
  16. ciocore/docsite/apidoc/package_environment/index.html +1507 -0
  17. ciocore/docsite/apidoc/package_tree/index.html +2386 -0
  18. ciocore/docsite/assets/_mkdocstrings.css +16 -0
  19. ciocore/docsite/assets/images/favicon.png +0 -0
  20. ciocore/docsite/assets/javascripts/bundle.471ce7a9.min.js +29 -0
  21. ciocore/docsite/assets/javascripts/bundle.471ce7a9.min.js.map +7 -0
  22. ciocore/docsite/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
  23. ciocore/docsite/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
  24. ciocore/docsite/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
  25. ciocore/docsite/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
  26. ciocore/docsite/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
  27. ciocore/docsite/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
  28. ciocore/docsite/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
  29. ciocore/docsite/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
  30. ciocore/docsite/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
  31. ciocore/docsite/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
  32. ciocore/docsite/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
  33. ciocore/docsite/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
  34. ciocore/docsite/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
  35. ciocore/docsite/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
  36. ciocore/docsite/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
  37. ciocore/docsite/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
  38. ciocore/docsite/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
  39. ciocore/docsite/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
  40. ciocore/docsite/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
  41. ciocore/docsite/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
  42. ciocore/docsite/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
  43. ciocore/docsite/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
  44. ciocore/docsite/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
  45. ciocore/docsite/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
  46. ciocore/docsite/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
  47. ciocore/docsite/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
  48. ciocore/docsite/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
  49. ciocore/docsite/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
  50. ciocore/docsite/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
  51. ciocore/docsite/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
  52. ciocore/docsite/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
  53. ciocore/docsite/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
  54. ciocore/docsite/assets/javascripts/lunr/tinyseg.js +206 -0
  55. ciocore/docsite/assets/javascripts/lunr/wordcut.js +6708 -0
  56. ciocore/docsite/assets/javascripts/workers/search.b8dbb3d2.min.js +42 -0
  57. ciocore/docsite/assets/javascripts/workers/search.b8dbb3d2.min.js.map +7 -0
  58. ciocore/docsite/assets/stylesheets/main.3cba04c6.min.css +1 -0
  59. ciocore/docsite/assets/stylesheets/main.3cba04c6.min.css.map +1 -0
  60. ciocore/docsite/assets/stylesheets/palette.06af60db.min.css +1 -0
  61. ciocore/docsite/assets/stylesheets/palette.06af60db.min.css.map +1 -0
  62. ciocore/docsite/cmdline/docs/index.html +871 -0
  63. ciocore/docsite/cmdline/downloader/index.html +934 -0
  64. ciocore/docsite/cmdline/packages/index.html +878 -0
  65. ciocore/docsite/cmdline/uploader/index.html +995 -0
  66. ciocore/docsite/how-to-guides/index.html +869 -0
  67. ciocore/docsite/index.html +895 -0
  68. ciocore/docsite/logo.png +0 -0
  69. ciocore/docsite/objects.inv +0 -0
  70. ciocore/docsite/search/search_index.json +1 -0
  71. ciocore/docsite/sitemap.xml +3 -0
  72. ciocore/docsite/sitemap.xml.gz +0 -0
  73. ciocore/docsite/stylesheets/extra.css +26 -0
  74. ciocore/docsite/stylesheets/tables.css +167 -0
  75. ciocore/downloader/base_downloader.py +644 -0
  76. ciocore/downloader/download_runner_base.py +47 -0
  77. ciocore/downloader/job_downloader.py +119 -0
  78. ciocore/{downloader.py → downloader/legacy_downloader.py} +12 -9
  79. ciocore/downloader/log.py +73 -0
  80. ciocore/downloader/logging_download_runner.py +87 -0
  81. ciocore/downloader/perpetual_downloader.py +63 -0
  82. ciocore/downloader/registry.py +97 -0
  83. ciocore/downloader/reporter.py +135 -0
  84. ciocore/exceptions.py +8 -2
  85. ciocore/file_utils.py +51 -50
  86. ciocore/hardware_set.py +449 -0
  87. ciocore/loggeria.py +89 -20
  88. ciocore/package_environment.py +110 -48
  89. ciocore/package_query.py +182 -0
  90. ciocore/package_tree.py +319 -258
  91. ciocore/retry.py +0 -0
  92. ciocore/uploader/_uploader.py +547 -364
  93. ciocore/uploader/thread_queue_job.py +176 -0
  94. ciocore/uploader/upload_stats/__init__.py +3 -4
  95. ciocore/uploader/upload_stats/stats_formats.py +10 -4
  96. ciocore/validator.py +34 -2
  97. ciocore/worker.py +174 -151
  98. ciocore-10.0.0b3.dist-info/METADATA +928 -0
  99. ciocore-10.0.0b3.dist-info/RECORD +128 -0
  100. {ciocore-5.1.1.dist-info → ciocore-10.0.0b3.dist-info}/WHEEL +1 -1
  101. ciocore-10.0.0b3.dist-info/entry_points.txt +2 -0
  102. tests/instance_type_fixtures.py +175 -0
  103. tests/package_fixtures.py +205 -0
  104. tests/test_api_client.py +297 -12
  105. tests/test_base_downloader.py +104 -0
  106. tests/test_cli.py +149 -0
  107. tests/test_common.py +1 -7
  108. tests/test_config.py +40 -18
  109. tests/test_data.py +162 -173
  110. tests/test_downloader.py +118 -0
  111. tests/test_hardware_set.py +139 -0
  112. tests/test_job_downloader.py +213 -0
  113. tests/test_package_query.py +38 -0
  114. tests/test_package_tree.py +91 -291
  115. tests/test_submit.py +44 -18
  116. tests/test_uploader.py +1 -4
  117. ciocore/__about__.py +0 -10
  118. ciocore/cli/conductor.py +0 -191
  119. ciocore/compat.py +0 -15
  120. ciocore-5.1.1.data/scripts/conductor +0 -19
  121. ciocore-5.1.1.data/scripts/conductor.bat +0 -13
  122. ciocore-5.1.1.dist-info/METADATA +0 -408
  123. ciocore-5.1.1.dist-info/RECORD +0 -47
  124. tests/mocks/api_client_mock.py +0 -51
  125. /ciocore/{cli → downloader}/__init__.py +0 -0
  126. {ciocore-5.1.1.dist-info → ciocore-10.0.0b3.dist-info}/top_level.txt +0 -0
ciocore/api_client.py CHANGED
@@ -1,4 +1,11 @@
1
+ """
2
+ The api_client module is used to make requests to the Conductor API.
3
+ """
4
+
1
5
  import base64
6
+ import collections
7
+ import datetime
8
+ import hashlib
2
9
  import importlib
3
10
  import json
4
11
  import jwt
@@ -9,21 +16,16 @@ import requests
9
16
  import socket
10
17
  import time
11
18
  import sys
19
+ import platform
20
+
21
+ from urllib import parse
12
22
 
13
- try:
14
- from urllib import parse
15
- except ImportError:
16
- import urlparse as parse
17
-
18
23
  import ciocore
19
24
 
20
25
  from ciocore import config
21
26
  from ciocore import common, auth
22
27
 
23
-
24
- from ciocore.common import CONDUCTOR_LOGGER_NAME
25
-
26
- logger = logging.getLogger(CONDUCTOR_LOGGER_NAME)
28
+ logger = logging.getLogger(__name__)
27
29
 
28
30
  # A convenience tuple of network exceptions that can/should likely be retried by the retry decorator
29
31
  try:
@@ -40,17 +42,43 @@ except AttributeError:
40
42
  )
41
43
 
42
44
 
45
+ def truncate_middle(s, max_length):
46
+ """
47
+ Truncate the string `s` to `max_length` by removing characters from the middle.
48
+
49
+ :param s: The original string to be truncated.
50
+ :type s: str
51
+ :param max_length: The maximum allowed length of the string after truncation.
52
+ :type max_length: int
53
+ :return: The truncated string.
54
+ :rtype: str
55
+ """
56
+
57
+ if len(s) <= max_length:
58
+ # String is already at or below the maximum length, return it as is
59
+ return s
60
+
61
+ # Calculate the number of characters to keep from the start and end of the string
62
+ num_keep_front = (max_length // 2)
63
+ num_keep_end = max_length - num_keep_front - 1 # -1 for the ellipsis
64
+
65
+ # Construct the truncated string
66
+ return s[:num_keep_front] + '~' + s[-num_keep_end:]
67
+
43
68
 
44
69
  # TODO: appspot_dot_com_cert = os.path.join(common.base_dir(),'auth','appspot_dot_com_cert2') load
45
70
  # appspot.com cert into requests lib verify = appspot_dot_com_cert
46
71
 
47
-
48
72
  class ApiClient:
73
+ """
74
+ The ApiClient class is a wrapper around the requests library that handles authentication and retries.
75
+ """
76
+
49
77
  http_verbs = ["PUT", "POST", "GET", "DELETE", "HEAD", "PATCH"]
50
-
78
+
51
79
  USER_AGENT_TEMPLATE = "client {client_name}/{client_version} (ciocore {ciocore_version}; {runtime} {runtime_version}; {platform} {platform_details}; {hostname} {pid}; {python_path})"
52
80
  USER_AGENT_MAX_PATH_LENGTH = 1024
53
-
81
+
54
82
  user_agent_header = None
55
83
 
56
84
  def __init__(self):
@@ -61,17 +89,17 @@ class ApiClient:
61
89
  method=verb, url=conductor_url, headers=headers, params=params, data=data
62
90
  )
63
91
 
64
- logger.debug("verb: %s", verb)
65
- logger.debug("conductor_url: %s", conductor_url)
66
- logger.debug("headers: %s", headers)
67
- logger.debug("params: %s", params)
68
- logger.debug("data: %s", data)
92
+ logger.debug(f"verb: {verb}")
93
+ logger.debug(f"conductor_url: {conductor_url}")
94
+ logger.debug(f"headers: {headers}")
95
+ logger.debug(f"params: {params}")
96
+ logger.debug(f"data: {data}")
69
97
 
70
98
  # If we get 300s/400s debug out the response. TODO(lws): REMOVE THIS
71
- if response.status_code and 300 <= response.status_code < 500:
99
+ if 300 <= response.status_code < 500:
72
100
  logger.debug("***** ERROR!! *****")
73
- logger.debug("Reason: %s" % response.reason)
74
- logger.debug("Text: %s" % response.text)
101
+ logger.debug(f"Reason: {response.reason}")
102
+ logger.debug(f"Text: {response.text}")
75
103
 
76
104
  # trigger an exception to be raised for 4XX or 5XX http responses
77
105
  if raise_on_error:
@@ -85,37 +113,38 @@ class ApiClient:
85
113
  url,
86
114
  headers=None,
87
115
  params=None,
88
- json=None,
116
+ json_payload=None,
89
117
  data=None,
90
118
  stream=False,
91
119
  remove_headers_list=None,
92
120
  raise_on_error=True,
93
121
  tries=5,
94
122
  ):
95
-
96
123
  """
97
- Primarily used to removed enforced headers by requests.Request. Requests 2.x will add
98
- Transfer-Encoding: chunked with file like object that is 0 bytes, causing s3 failures (501)
99
- - https://github.com/psf/requests/issues/4215#issuecomment-319521235
100
-
101
- To get around this bug make_prepared_request has functionality to remove the enforced header
102
- that would occur when using requests.request(...). Requests 3.x resolves this issue, when
103
- client is built to use Requests 3.x this function can be deprecated.
104
-
105
- args:
106
- verb: (str) of HTTP verbs
107
- url: (str) url
108
- headers: (dict)
109
- params: (dict)
110
- json: (dict)
111
- data: (varies)
112
- stream: (bool)
113
- remove_headers_list: list of headers to remove i.e ["Transfer-Encoding"]
114
- raise_on_error: (bool)
115
- tries: (int) number of attempts to perform request
116
-
117
-
118
- return: request.Response
124
+ Make a request to the Conductor API.
125
+
126
+ Deprecated:
127
+ Primarily used to removed enforced headers by requests.Request. Requests 2.x will add
128
+ Transfer-Encoding: chunked with file like object that is 0 bytes, causing s3 failures (501)
129
+ - https://github.com/psf/requests/issues/4215#issuecomment-319521235
130
+
131
+ To get around this bug make_prepared_request has functionality to remove the enforced header
132
+ that would occur when using requests.request(...). Requests 3.x resolves this issue, when
133
+ client is built to use Requests 3.x this function can be removed.
134
+
135
+ Returns:
136
+ requests.Response: The response object.
137
+
138
+ Args:
139
+ verb (str): The HTTP verb to use.
140
+ url (str): The URL to make the request to.
141
+ headers (dict): A dictionary of headers to send with the request.
142
+ params (dict): A dictionary of query parameters to send with the request.
143
+ json (dict): A JSON payload to send with the request.
144
+ stream (bool): Whether or not to stream the response.
145
+ remove_headers_list (list): A list of headers to remove from the request.
146
+ raise_on_error (bool): Whether or not to raise an exception if the request fails.
147
+ tries (int): The number of times to retry the request.
119
148
  """
120
149
 
121
150
  req = requests.Request(
@@ -123,7 +152,7 @@ class ApiClient:
123
152
  url=url,
124
153
  headers=headers,
125
154
  params=params,
126
- json=json,
155
+ json=json_payload,
127
156
  data=data,
128
157
  )
129
158
  prepped = req.prepare()
@@ -133,9 +162,11 @@ class ApiClient:
133
162
  prepped.headers.pop(header, None)
134
163
 
135
164
  # Create a retry wrapper function
136
- retry_wrapper = common.DecRetry(retry_exceptions=CONNECTION_EXCEPTIONS, tries=tries)
165
+ retry_wrapper = common.DecRetry(
166
+ retry_exceptions=CONNECTION_EXCEPTIONS, tries=tries
167
+ )
137
168
 
138
- # requests sessions potentially not thread-safe, but need to potentially removed enforced
169
+ # requests sessions potentially not thread-safe, but need to removed enforced
139
170
  # headers by using a prepared request.create which can only be done through an
140
171
  # request.Session object. Create Session object per call of make_prepared_request, it will
141
172
  # not benefit from connection pooling reuse. https://github.com/psf/requests/issues/1871
@@ -151,6 +182,7 @@ class ApiClient:
151
182
  logger.debug("url: %s", prepped.url)
152
183
  logger.debug("headers: %s", prepped.headers)
153
184
  logger.debug("params: %s", req.params)
185
+ logger.debug("response: %s", response)
154
186
 
155
187
  # trigger an exception to be raised for 4XX or 5XX http responses
156
188
  if raise_on_error:
@@ -168,12 +200,26 @@ class ApiClient:
168
200
  conductor_url=None,
169
201
  raise_on_error=True,
170
202
  tries=5,
171
- use_api_key=False
203
+ use_api_key=False,
172
204
  ):
173
205
  """
174
- verb: PUT, POST, GET, DELETE, HEAD, PATCH
206
+ Make a request to the Conductor API.
207
+
208
+ Args:
209
+ uri_path (str): The path to the resource to request.
210
+ headers (dict): A dictionary of headers to send with the request.
211
+ params (dict): A dictionary of query parameters to send with the request.
212
+ data (dict): A dictionary of data to send with the request.
213
+ verb (str): The HTTP verb to use.
214
+ conductor_url (str): The Conductor URL.
215
+ raise_on_error (bool): Whether or not to raise an exception if the request fails.
216
+ tries (int): The number of times to retry the request.
217
+ use`_api_key (bool): Whether or not to use the API key for authentication.
218
+
219
+ Returns:
220
+ tuple(str, int): The response text and status code.
175
221
  """
176
- cfg = config.config().config
222
+ cfg = config.get()
177
223
  # TODO: set Content Content-Type to json if data arg
178
224
  if not headers:
179
225
  headers = {"Content-Type": "application/json", "Accept": "application/json"}
@@ -184,10 +230,10 @@ class ApiClient:
184
230
  raise Exception("Error: Could not get conductor credentials!")
185
231
 
186
232
  headers["Authorization"] = "Bearer %s" % bearer_token
187
-
233
+
188
234
  if not ApiClient.user_agent_header:
189
235
  self.register_client("ciocore")
190
-
236
+
191
237
  headers["User-Agent"] = ApiClient.user_agent_header
192
238
 
193
239
  # Construct URL
@@ -203,7 +249,9 @@ class ApiClient:
203
249
  assert verb in self.http_verbs, "Invalid http verb: %s" % verb
204
250
 
205
251
  # Create a retry wrapper function
206
- retry_wrapper = common.DecRetry(retry_exceptions=CONNECTION_EXCEPTIONS, tries=tries)
252
+ retry_wrapper = common.DecRetry(
253
+ retry_exceptions=CONNECTION_EXCEPTIONS, tries=tries
254
+ )
207
255
 
208
256
  # wrap the request function with the retry wrapper
209
257
  wrapped_func = retry_wrapper(self._make_request)
@@ -214,102 +262,56 @@ class ApiClient:
214
262
  )
215
263
 
216
264
  return response.text, response.status_code
217
-
218
- @classmethod
219
- def _get_user_agent_header(cls, client_name, client_version=None):
220
- '''
221
- Generates the http User Agent header that includes helpful debug info.
222
-
223
- The final component is the path to the python executable (MD5 hex).
224
-
225
- ex: 'ciomaya/0.3.7 (ciocore 4.3.2; python 3.9.5; linux 3.10.0-1160.53.1.el7.x86_64; 0ee7123c2365d7a0d126de5a70f19727)'
226
-
227
- :param client_name: The name of the client to be used in the header. If it's importable it
228
- will be queried for its __version__ (unless client_version is supplied)
229
- :type client_name: str
230
-
231
- :param client_version: The version to use in the header if client_name can't be queried for
232
- __version__ (or it needs to be overridden)
233
- :type client_version: str [default = None]
234
-
235
- :return: The value for the User Agent header
236
- :rtype: str
237
- '''
238
-
239
- try:
240
- client_module = importlib.import_module(client_name)
241
-
242
- except ImportError:
243
- logger.warning("Unable to import module '{}'. Won't query for version details.")
244
- client_version = 'unknown'
245
-
246
- if not client_version:
247
- try:
248
- client_version = client_module.__version__
249
-
250
- except AttributeError:
251
- logger.warning("Module '{}' has no __version__ attribute. Setting version to 'N/A' in the user agent")
252
- client_version = 'unknown'
253
-
254
- ciocore_version = ciocore.__version__
255
-
256
- python_path = sys.executable
257
-
258
- # If the length of the path is longer than allowed, truncate the middle
259
- if len(python_path) > cls.USER_AGENT_MAX_PATH_LENGTH:
260
-
261
- first_half = int(cls.USER_AGENT_MAX_PATH_LENGTH/2)
262
- second_half = len(python_path) - int(cls.USER_AGENT_MAX_PATH_LENGTH/2)
263
-
264
- print(first_half, second_half)
265
- python_path = "{}...{}".format(python_path[0:first_half], python_path[second_half:-1])
266
-
267
- python_path_encoded = base64.b64encode(python_path.encode('utf-8')).decode('utf-8')
268
-
269
- if platform.system() == "Linux":
270
- platform_details = platform.release()
271
-
272
- elif platform.system() == "Windows":
273
- platform_details = platform.version()
274
-
275
- elif platform.system() == "Darwin":
276
- platform_details = platform.mac_ver()[0]
277
-
278
- else:
279
- raise ValueError("Unrecognized platform '{}'".format(platform.release()))
280
-
281
- pid = base64.b64encode(str(os.getpid()).encode('utf-8')).decode('utf-8')
282
- hostname = base64.b64encode(socket.gethostname().encode('utf-8')).decode('utf-8')
283
-
284
- return cls.USER_AGENT_TEMPLATE.format(client_name=client_name,
285
- client_version=client_version,
286
- ciocore_version=ciocore_version,
287
- runtime='python',
288
- runtime_version=platform.python_version(),
289
- platform=sys.platform,
290
- platform_details=platform_details,
291
- pid=pid,
292
- hostname=hostname,
293
- python_path=python_path_encoded
294
- )
295
-
265
+
296
266
  @classmethod
297
267
  def register_client(cls, client_name, client_version=None):
298
- cls.user_agent_header = cls._get_user_agent_header(client_name, client_version)
268
+ """
269
+ Generates the http User Agent header that includes helpful debug info.
270
+ """
271
+
272
+ # Use the provided client_version.
273
+ if not client_version:
274
+ client_version = 'unknown'
275
+
276
+ python_version = platform.python_version()
277
+ system_info = platform.system()
278
+ release_info = platform.release()
299
279
 
280
+
281
+ # Get the MD5 hex digest of the path to the python executable
282
+ python_executable_path = truncate_middle(sys.executable.encode('utf-8'), cls.USER_AGENT_MAX_PATH_LENGTH)
283
+ md5_hash = hashlib.md5(python_executable_path).hexdigest()
284
+
285
+ user_agent = (
286
+ f"{client_name}/{client_version} "
287
+ f"(python {python_version}; {system_info} {release_info}; {md5_hash})"
288
+ )
289
+ cls.user_agent_header = user_agent
300
290
 
291
+ return user_agent
292
+
301
293
 
302
294
  def read_conductor_credentials(use_api_key=False):
303
295
  """
304
- Read the conductor credentials file, if it exists. This will contain a bearer token from either
305
- the user or the API key (if that's desired). If the credentials file doesn't exist, or is
306
- expired, or is from a different domain, try and fetch a new one in the API key scenario or
307
- prompt the user to log in. Args: use_api_key: Whether or not to use the API key
296
+ Read the conductor credentials file.
297
+
298
+ If the credentials file exists, it will contain a bearer token from either
299
+ the user or the API key.
300
+
301
+ If the credentials file doesn't exist, or is
302
+ expired, or is from a different domain, we try to fetch a new one in the API key scenario or
303
+ prompt the user to log in.
304
+
305
+ Args:
306
+ use_api_key (bool): Whether or not to try to use the API key
308
307
 
309
- Returns: A Bearer token in the event of a success or None if things couldn't get figured out
308
+ Returns:
309
+ A Bearer token in the event of a success or None
310
310
 
311
311
  """
312
- cfg = config.config().config
312
+
313
+ cfg = config.get()
314
+
313
315
  logger.debug("Reading conductor credentials...")
314
316
  if use_api_key:
315
317
  if not cfg.get("api_key"):
@@ -333,38 +335,40 @@ def read_conductor_credentials(use_api_key=False):
333
335
 
334
336
  else:
335
337
  auth.run(creds_file, cfg["url"])
336
-
337
338
  if not os.path.exists(creds_file):
338
339
  return None
339
340
 
340
341
  logger.debug("Reading credentials file...")
341
- with open(creds_file) as fp:
342
+ with open(creds_file, "r", encoding="utf-8") as fp:
342
343
  file_contents = json.loads(fp.read())
343
-
344
344
  expiration = file_contents.get("expiration")
345
-
346
345
  same_domain = creds_same_domain(file_contents)
347
-
348
346
  if same_domain and expiration and expiration >= int(time.time()):
349
347
  return file_contents["access_token"]
350
-
351
348
  logger.debug("Credentials have expired or are from a different domain")
352
-
353
349
  if use_api_key:
354
350
  logger.debug("Refreshing API key bearer token!")
355
351
  get_api_key_bearer_token(creds_file)
356
352
  else:
357
353
  logger.debug("Sending to auth page...")
358
354
  auth.run(creds_file, cfg["url"])
359
-
360
355
  # Re-read the creds file, since it has been re-upped
361
- with open(creds_file) as fp:
356
+ with open(creds_file, "r", encoding="utf-8") as fp:
362
357
  file_contents = json.loads(fp.read())
363
358
  return file_contents["access_token"]
364
359
 
365
360
 
366
361
  def get_api_key_bearer_token(creds_file=None):
367
- cfg = config.config().config
362
+ """
363
+ Get a bearer token from the API key.
364
+
365
+ Args:
366
+ creds_file (str): The path to the credentials file. If not provided, the bearer token will not be written to disk.
367
+
368
+ Returns:
369
+ A dictionary containing the bearer token and other information.
370
+ """
371
+ cfg = config.get()
368
372
  url = "{}/api/oauth_jwt".format(cfg["url"])
369
373
  response = requests.get(
370
374
  url,
@@ -396,7 +400,15 @@ def get_api_key_bearer_token(creds_file=None):
396
400
 
397
401
 
398
402
  def get_creds_path(api_key=False):
399
-
403
+ """
404
+ Get the path to the credentials file.
405
+
406
+ Args:
407
+ api_key (bool): Whether or not to use the API key.
408
+
409
+ Returns:
410
+ str: The path to the credentials file.
411
+ """
400
412
  creds_dir = os.path.join(os.path.expanduser("~"), ".config", "conductor")
401
413
  if api_key:
402
414
  creds_file = os.path.join(creds_dir, "api_key_credentials")
@@ -409,19 +421,31 @@ def get_bearer_token(refresh=False):
409
421
  """
410
422
  Return the bearer token.
411
423
 
424
+ Args:
425
+ refresh (bool): Whether or not to refresh the token.
426
+
412
427
  TODO: Thread safe multiproc caching, like it used to be pre-python3.7.
413
428
  """
414
429
  return read_conductor_credentials(True)
415
430
 
416
431
 
417
432
  def creds_same_domain(creds):
418
- cfg = config.config().config
433
+ """
434
+ Check if the creds are for the same domain as the config.
435
+
436
+ Args:
437
+ creds (dict): The credentials dictionary.
438
+
439
+ Returns:
440
+ bool: Whether or not the creds are for the same domain as the config.
441
+ """
442
+ cfg = config.get()
419
443
  """Ensure the creds file refers to the domain in config"""
420
444
  token = creds.get("access_token")
421
445
  if not token:
422
446
  return False
423
447
 
424
- decoded = jwt.decode(creds["access_token"], verify=False)
448
+ decoded = jwt.decode(creds["access_token"], algorithms=["HS256"], options={"verify_signature": False})
425
449
  audience_domain = decoded.get("aud")
426
450
  return (
427
451
  audience_domain
@@ -432,17 +456,30 @@ def creds_same_domain(creds):
432
456
  def account_id_from_jwt(token):
433
457
  """
434
458
  Fetch the accounts id from a jwt token value.
459
+
460
+ Args:
461
+ token (str): The jwt token.
462
+
463
+ Returns:
464
+ str: The account id.
435
465
  """
436
- payload = jwt.decode(token, verify=False)
466
+ payload = jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False})
467
+
437
468
  return payload.get("account")
438
469
 
439
470
 
440
471
  def account_name_from_jwt(token):
441
472
  """
442
473
  Fetch the accounts name from a jwt token value.
474
+
475
+ Args:
476
+ token (str): The jwt token.
477
+
478
+ Returns:
479
+ str: The account name.
443
480
  """
444
- cfg = config.config().config
445
481
  account_id = account_id_from_jwt(token)
482
+ cfg = config.get()
446
483
  if account_id:
447
484
  url = "%s/api/v1/accounts/%s" % (cfg["api_url"], account_id)
448
485
  response = requests.get(url, headers={"authorization": "Bearer %s" % token})
@@ -452,30 +489,47 @@ def account_name_from_jwt(token):
452
489
  return None
453
490
 
454
491
 
455
- def request_instance_types(as_dict=False):
492
+ def request_instance_types(as_dict=False, filter_param=""):
456
493
  """
457
494
  Get the list of available instances types.
495
+
496
+ Args:
497
+ as_dict (bool): Whether or not to return the instance types as a dictionary.
498
+ filter_param (string): complex RHS string query ex:
499
+ "cpu=gte:8:int,operating_system=ne:windows,gpu.gpu_count=eq:1:int"
500
+
501
+ Returns:
502
+ list: The list of instance types.
458
503
  """
459
504
  api = ApiClient()
460
505
  response, response_code = api.make_request(
461
- "api/v1/instance-types", use_api_key=True, raise_on_error=False
506
+ "api/v1/instance-types", use_api_key=True, raise_on_error=False,
507
+ params={"filter":filter_param}
462
508
  )
463
509
  if response_code not in (200,):
464
510
  msg = "Failed to get instance types"
465
- msg += "\nError %s ...\n%s" % (response_code, response)
511
+ msg += "\nAPI responded with status code %s\n" % (response_code)
466
512
  raise Exception(msg)
467
513
 
468
514
  instance_types = json.loads(response).get("data", [])
469
515
  logger.debug("Found available instance types: %s", instance_types)
470
516
 
471
517
  if as_dict:
472
- return dict([(instance["description"], instance) for instance in instance_types])
518
+ return dict(
519
+ [(instance["description"], instance) for instance in instance_types]
520
+ )
473
521
  return instance_types
474
522
 
475
523
 
476
524
  def request_projects(statuses=("active",)):
477
525
  """
478
- Query Conductor for all client Projects that are in the given state(s)
526
+ Query Conductor for all client Projects that are in the given status(es).
527
+
528
+ Args:
529
+ statuses (tuple): The statuses to filter for.
530
+
531
+ Returns:
532
+ list: The list of project names.
479
533
  """
480
534
  api = ApiClient()
481
535
 
@@ -504,6 +558,9 @@ def request_projects(statuses=("active",)):
504
558
  def request_software_packages():
505
559
  """
506
560
  Query Conductor for all software packages for the currently available sidecar.
561
+
562
+ Returns:
563
+ list: The list of software packages.
507
564
  """
508
565
  api = ApiClient()
509
566
 
@@ -516,4 +573,442 @@ def request_software_packages():
516
573
  msg = "Failed to get software packages for latest sidecar"
517
574
  msg += "\nError %s ...\n%s" % (response_code, response)
518
575
  raise Exception(msg)
519
- return json.loads(response).get("data", [])
576
+
577
+ software = json.loads(response).get("data", [])
578
+ software = [sw for sw in software if not ("3dsmax" in sw["product"] and sw["platform"] == "linux")]
579
+ return software
580
+
581
+ def request_extra_environment():
582
+ """
583
+ Query Conductor for extra environment.
584
+ """
585
+ api = ApiClient()
586
+
587
+ uri = "api/v1/integrations/env-vars-configs"
588
+ response, response_code = api.make_request(
589
+ uri_path=uri, verb="GET", raise_on_error=False, use_api_key=True
590
+ )
591
+
592
+ if response_code not in [200]:
593
+ msg = "Failed to get extra environment"
594
+ msg += "\nError %s ...\n%s" % (response_code, response)
595
+ raise Exception(msg)
596
+
597
+ all_accounts = json.loads(response).get("data", [])
598
+
599
+ token = read_conductor_credentials(True)
600
+ if not token:
601
+ raise Exception("Error: Could not get conductor credentials!")
602
+ account_id = str(account_id_from_jwt(token))
603
+
604
+ if not account_id:
605
+ raise Exception("Error: Could not get account id from jwt!")
606
+ account_env = next((account for account in all_accounts if account["account_id"] == account_id), None)
607
+ if not account_env:
608
+ raise Exception("Error: Could not get account environment!")
609
+ return account_env.get("env", [])
610
+
611
+
612
+
613
+
614
+ def get_jobs(first_jid, last_jid=None):
615
+ """
616
+ Query Conductor for all jobs between the given job ids.
617
+
618
+ Returns:
619
+ list: The list of jobs.
620
+
621
+ Raises:
622
+ Exception: If the request fails.
623
+
624
+ Examples:
625
+ >>> from ciocore import api_client
626
+ >>> jobs = api_client.get_jobs(1959)
627
+ >>> len(jobs)
628
+ 1
629
+ >>> jobs[0]["jid"]
630
+ '01959'
631
+ >>> jobs = api_client.get_jobs(1959, 1961)
632
+ >>> len(jobs)
633
+ 3
634
+ """
635
+ if last_jid is None:
636
+ last_jid = first_jid
637
+ low = str(int(first_jid) - 1).zfill(5)
638
+ high = str(int(last_jid) + 1).zfill(5)
639
+ api = ApiClient()
640
+ uri = "api/v1/jobs"
641
+
642
+ response, response_code = api.make_request(
643
+ uri_path=uri,
644
+ verb="GET",
645
+ raise_on_error=False,
646
+ use_api_key=True,
647
+ params={"filter": f"jid_gt_{low},jid_lt_{high}"},
648
+ )
649
+
650
+ if response_code not in [200]:
651
+ msg = f"Failed to get jobs {first_jid}-{last_jid}"
652
+ msg += "\nError %s ...\n%s" % (response_code, response)
653
+ raise Exception(msg)
654
+ jobs = json.loads(response).get("data")
655
+ return jobs
656
+
657
+
658
+ def get_log(job_id, task_id):
659
+ """
660
+ Get the log for the given job and task.
661
+
662
+ Args:
663
+ job_id (str): The job id.
664
+ task_id (str): The task id.
665
+
666
+ Returns:
667
+ list: A list of logs.
668
+
669
+ Raises:
670
+ Exception: If the request fails.
671
+
672
+ Examples:
673
+ >>> from ciocore import api_client
674
+ >>> logs = api_client.get_log(1959, 0)
675
+ {
676
+ "logs": [
677
+ {
678
+ "container_id": "j-5669544198668288-5619559933149184-5095331660038144-stde",
679
+ "instance_name": "renderer-5669544198668288-170062309438-62994",
680
+ "log": [
681
+ "Blender 2.93.0 (hash 84da05a8b806 built 2021-06-02 11:29:24)",
682
+ ...
683
+ ...
684
+ "Saved: '/var/folders/8r/46lmjdmj50x_0swd9klwptzm0000gq/T/blender_bmw/renders/render_0001.png'",
685
+ " Time: 00:29.22 (Saving: 00:00.32)",
686
+ "",
687
+ "",
688
+ "Blender quit"
689
+ ],
690
+ "timestamp": "1.700623521101516E9"
691
+ }
692
+ ],
693
+ "new_num_lines": [
694
+ 144
695
+ ],
696
+ "status_description": "",
697
+ "task_status": "success"
698
+ }
699
+ """
700
+ job_id = str(job_id).zfill(5)
701
+ task_id = str(task_id).zfill(3)
702
+
703
+ api = ApiClient()
704
+ uri = f"get_log_file?job={job_id}&task={task_id}&num_lines[]=0"
705
+
706
+ response, response_code = api.make_request(
707
+ uri_path=uri, verb="GET", raise_on_error=False, use_api_key=True
708
+ )
709
+
710
+ if response_code not in [200]:
711
+ msg = f"Failed to get log for job {job_id} task {task_id}"
712
+ msg += "\nError %s ...\n%s" % (response_code, response)
713
+ raise Exception(msg)
714
+
715
+ return response
716
+
717
+
718
+ def kill_jobs(*job_ids):
719
+ """
720
+ Kill the given jobs.
721
+
722
+ Args:
723
+ job_ids (list): The list of job ids.
724
+
725
+ Returns:
726
+ dict: The response.
727
+
728
+ Examples:
729
+ >>> from ciocore import api_client
730
+ >>> api_client.kill_jobs("03095","03094")
731
+ {'body': 'success', 'message': "Jobs [u'03095', u'03094'] have been kill."}
732
+
733
+ """
734
+ job_ids = [str(job_id).zfill(5) for job_id in job_ids]
735
+ api = ApiClient()
736
+ payload = {
737
+ "action": "kill",
738
+ "jobids": job_ids,
739
+ }
740
+ response, response_code = api.make_request(
741
+ uri_path="jobs_multi",
742
+ verb="PUT",
743
+ raise_on_error=False,
744
+ use_api_key=True,
745
+ data=json.dumps(payload)
746
+ )
747
+
748
+ if response_code not in [200]:
749
+ msg = f"Failed to kill jobs {job_ids}"
750
+ msg += "\nError %s ...\n%s" % (response_code, response)
751
+ raise Exception(msg)
752
+
753
+ return json.loads(response)
754
+
755
+
756
+ def kill_tasks(job_id, *task_ids):
757
+ """
758
+ Kill the given tasks.
759
+
760
+ Args:
761
+ job_id (str): The job id.
762
+ task_ids (list): The list of task ids.
763
+
764
+ Returns:
765
+ dict: The response.
766
+
767
+ Examples:
768
+ >>> from ciocore import api_client
769
+ >>> api_client.kill_tasks("03096", *range(50,56))
770
+ {'body': 'success', 'message': ' 6 Tasks set to "kill"\n\t050\n\t051\n\t052\n\t053\n\t054\n\t055'}
771
+ """
772
+
773
+ job_id = str(job_id).zfill(5)
774
+ task_ids = [str(task_id).zfill(3) for task_id in task_ids]
775
+ api = ApiClient()
776
+ payload = {
777
+ "action": "kill",
778
+ "jobid": job_id,
779
+ "taskids": task_ids,
780
+ }
781
+ response, response_code = api.make_request(
782
+ uri_path="tasks_multi",
783
+ verb="PUT",
784
+ raise_on_error=False,
785
+ use_api_key=True,
786
+ data=json.dumps(payload)
787
+ )
788
+
789
+ if response_code not in [200]:
790
+ msg = f"Failed to kill tasks {task_ids} of job {job_id}"
791
+ msg += "\nError %s ...\n%s" % (response_code, response)
792
+ raise Exception(msg)
793
+
794
+ return json.loads(response)
795
+
796
+ def _get_compute_usage(start_time, end_time):
797
+ """
798
+ Query the account usage to get the raw compute data. Private method.
799
+
800
+ Compute includes licenses, instances and Conductor cost. Everything involved
801
+ with running a job.
802
+
803
+ Please use the public method api_client.get_compute_usage() instead.
804
+
805
+ Args:
806
+ start_time (datetime.datetime): The first day to include in the report. Only the date is considered and it's assumed to be in UTC.
807
+ end_time (datetime.datetime): The last day to include in the report. Only the date is considered and it's assumed to be in UTC.
808
+
809
+ Returns:
810
+ list: A list of billing entries
811
+
812
+ Examples:
813
+ >>> from ciocore import api_client
814
+ >>> api_client._get_compute_usage(start_time, end_time)
815
+ [
816
+ {
817
+ "cores": 0.5,
818
+ "instance_cost": 0.019999999552965164,
819
+ "license_cost": 0.019999999552965164,
820
+ "minutes": 6.9700000286102295,
821
+ "self_link": 0,
822
+ "start_time": "Tue, 09 Jan 2024 18:00:00 GMT"
823
+ },
824
+ {
825
+ "cores": 0.4,
826
+ "instance_cost": 0.019999999552965164,
827
+ "license_cost": 0.019999999552965164,
828
+ "minutes": 6.960000038146973,
829
+ "self_link": 1,
830
+ "start_time": "Tue, 09 Jan 2024 19:00:00 GMT"
831
+ }]
832
+ """
833
+
834
+ api = ApiClient()
835
+
836
+ payload = {
837
+ "filter": json.dumps(
838
+ [{"field": "start_time", "op": ">=", "value": start_time.date().isoformat()},
839
+ {"field": "start_time", "op": "<", "value": end_time.date().isoformat()}]),
840
+ "group_by": json.dumps(["start_time"]),
841
+ "order_by": "start_time"
842
+ }
843
+
844
+ response, response_code = api.make_request(
845
+ uri_path="billing/get_usage",
846
+ verb="GET",
847
+ raise_on_error=False,
848
+ use_api_key=True,
849
+ params=payload
850
+ )
851
+
852
+ if response_code not in [200]:
853
+ msg = f"Failed to query compute usage"
854
+ msg += "\nError %s ...\n%s" % (response_code, response)
855
+ raise Exception(msg)
856
+
857
+ return json.loads(response)['data']
858
+
859
+ def _get_storage_usage(start_time, end_time):
860
+ """
861
+ Query the account usage to get the raw storage data. Private method.
862
+
863
+ Please use the public method api_client.get_storage_usage() instead.
864
+
865
+ Args:
866
+ start_time (datetime.datetime): The first day to include in the report. Only the date is considered and it's assumed to be in UTC.
867
+ end_time (datetime.datetime): The last day to include in the report. Only the date is considered and it's assumed to be in UTC.
868
+
869
+ Returns:
870
+ dict: A dict of billing details related to storage
871
+
872
+ Examples:
873
+ >>> from ciocore import api_client
874
+ >>> api_client._get_storage_usage(start_time, end_time)
875
+ {
876
+ "cost": "28.96",
877
+ "cost_per_day": [
878
+ 4.022,
879
+ 4.502,
880
+ 4.502,
881
+ 5.102,
882
+ 5.102,
883
+ 5.732
884
+ ],
885
+ "currency": "USD",
886
+ "daily_price": "0.006",
887
+ "end_date": "2024-01-07",
888
+ "gibs_per_day": [
889
+ 679.714,
890
+ 750.34,
891
+ 750.34,
892
+ 850.36,
893
+ 850.35,
894
+ 955.32
895
+ ],
896
+ "gibs_used": "806.07",
897
+ "monthly_price": "0.18",
898
+ "start_date": "2024-01-01",
899
+ "storage_unit": "GiB"
900
+ }
901
+ ]
902
+ }
903
+ """
904
+
905
+ api = ApiClient()
906
+
907
+ # Add one day to the end time as the query is exclusive of the last day but
908
+ # we want consistency with _get_compute_usage()
909
+ payload = {
910
+ "start": start_time.date().isoformat(),
911
+ "end": (end_time.date() + datetime.timedelta(days=1)).isoformat()
912
+ }
913
+
914
+ response, response_code = api.make_request(
915
+ uri_path="billing/get_storage_usage",
916
+ verb="GET",
917
+ raise_on_error=False,
918
+ use_api_key=True,
919
+ params=payload
920
+ )
921
+
922
+ if response_code not in [200]:
923
+ msg = f"Failed to query storage usage"
924
+ msg += "\nError %s ...\n%s" % (response_code, response)
925
+ raise Exception(msg)
926
+
927
+ return json.loads(response)['data'][0]
928
+
929
+ def get_compute_usage(start_time, end_time):
930
+ '''
931
+ Query the compute usage for an account.
932
+
933
+ Compute includes licenses, instances and Conductor cost. Everything involved
934
+ with running a job.
935
+
936
+ Args:
937
+ start_time (datetime.datetime): The first day to include in the report. Only the date is considered and it's assumed to be in UTC.
938
+ end_time (datetime.datetime): The last day to include in the report. Only the date is considered and it's assumed to be in UTC.
939
+
940
+ Returns:
941
+ dict: Each key is a date (UTC). The value is a dict with values for:
942
+ - cost: The total accumulated compute cost for the day
943
+ - corehours: The total accumulated core hours for the day
944
+ - walltime: The number of minutes that instances (regardless of type) were running
945
+
946
+ Examples:
947
+ >>> from ciocore import api_client
948
+ >>> api_client.get_compute_usage(start_time, end_time)
949
+ { '2024-01-09': { 'cost': 0.08,
950
+ 'corehours': 0.9,
951
+ 'walltime': 13.93},
952
+ '2024-01-16': { 'cost': 0.12,
953
+ 'corehours': 0.9613,
954
+ 'walltime': 7.21}}
955
+ '''
956
+ date_format = "%a, %d %b %Y %H:%M:%S %Z"
957
+ data = _get_compute_usage(start_time, end_time)
958
+
959
+ # Create a nested default dictionary with initial float values of 0.0
960
+ results = collections.defaultdict(lambda: collections.defaultdict(float))
961
+
962
+ for entry in data:
963
+ entry_start_date = datetime.datetime.strptime(entry['start_time'], date_format).date().isoformat()
964
+
965
+ results[entry_start_date]['walltime'] += entry['minutes']
966
+ results[entry_start_date]['corehours'] += entry['cores']
967
+ results[entry_start_date]['cost'] += entry['license_cost'] + entry['instance_cost']
968
+
969
+ # Round the data to avoid FP errors
970
+ results[entry_start_date]['walltime'] = round(results[entry_start_date]['walltime'], 4)
971
+ results[entry_start_date]['corehours'] = round(results[entry_start_date]['corehours'], 4)
972
+ results[entry_start_date]['cost'] = round(results[entry_start_date]['cost'], 4)
973
+
974
+ return results
975
+
976
+ def get_storage_usage(start_time, end_time):
977
+ '''
978
+ Query the storage usage for an account.
979
+
980
+ Storage is calculated twice a day (UTC) and the average is used.
981
+
982
+ Args:
983
+ start_time (datetime.datetime): The first day to include in the report. Only the date is considered and it's assumed to be in UTC.
984
+ end_time (datetime.datetime): The last day to include in the report. Only the date is considered and it's assumed to be in UTC.
985
+
986
+ Returns:
987
+ dict: Each key is a date (UTC). The value is a dict with values for:
988
+ - cost: The cost of accumulated storage for that one day
989
+ - GiB: The total amount of storage used on that day
990
+
991
+ Examples:
992
+ >>> from ciocore import api_client
993
+ >>> api_client.get_storage_usage(start_time, end_time)
994
+ { '2024-01-01': {'cost': 4.022, 'GiB': 679.714},
995
+ '2024-01-02': {'cost': 4.502, 'GiB': 750.34},
996
+ '2024-01-03': {'cost': 4.502, 'GiB': 750.34}}
997
+ '''
998
+ one_day = datetime.timedelta(days=1)
999
+
1000
+ data = _get_storage_usage(start_time, end_time)
1001
+
1002
+ results = {}
1003
+
1004
+ entry_date = datetime.date.fromisoformat(data['start_date'])
1005
+
1006
+ for cnt, entry in enumerate(data["cost_per_day"]):
1007
+
1008
+ entry_start_date = entry_date.isoformat()
1009
+ results[entry_start_date] = {'cost': float(entry), 'GiB': float(data['gibs_per_day'][cnt])}
1010
+ entry_date += one_day
1011
+
1012
+ return results
1013
+
1014
+