poetry-plugin-ivcap 0.4.2__tar.gz → 0.5.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: poetry-plugin-ivcap
3
- Version: 0.4.2
3
+ Version: 0.5.1
4
4
  Summary: A custom Poetry command for IVCAP deployments
5
5
  License: MIT
6
6
  Author: Max Ott
@@ -3,6 +3,7 @@
3
3
  # Use of this source code is governed by a BSD-style license that can be
4
4
  # found in the LICENSE file. See the AUTHORS file for names of contributors.
5
5
  #
6
+ import argparse
6
7
  import os
7
8
  import re
8
9
  import subprocess
@@ -12,6 +13,9 @@ import uuid
12
13
  import humanize
13
14
  import subprocess
14
15
  import requests
16
+ import time
17
+ import json
18
+
15
19
 
16
20
  from .constants import DEF_POLICY, PLUGIN_NAME, POLICY_OPT, SERVICE_FILE_OPT, SERVICE_ID_OPT, DEF_IVCAP_BASE_URL
17
21
 
@@ -143,28 +147,26 @@ def exec_job(data, args, is_silent, line):
143
147
  requests.Response: The response object from the API call.
144
148
  """
145
149
  # Parse 'args' for run options
150
+ p = exec_parser()
146
151
  if not isinstance(args, list) or len(args) < 1:
147
- raise Exception("args must be a list with at least one element")
148
- file_name = args[0]
149
- timeout = 5 # default timeout
150
- if len(args) == 1:
151
- pass # only file_name provided
152
- elif len(args) == 3 and args[1] == '--timeout':
153
- try:
154
- timeout = int(args[2])
155
- except ValueError:
156
- raise Exception("Timeout value must be an integer")
157
- else:
158
- raise Exception("args must be [file_name] or [file_name, '--timeout', value]")
159
-
152
+ p.error("missing request file name")
153
+ # raise Exception("args must be a list with at least one element")
154
+ file_name = args.pop(0)
155
+ if file_name == "-h" or file_name == "--help":
156
+ p.print_help()
157
+ return
158
+ pa = p.parse_args(args)
159
+ timeout = 0 if pa.stream else pa.timeout
160
160
  # Get access token using ivcap CLI
161
- try:
162
- token = subprocess.check_output(
163
- ["ivcap", "--silent", "context", "get", "access-token", "--refresh-token"],
164
- text=True
165
- ).strip()
166
- except Exception as e:
167
- raise RuntimeError(f"Failed to get IVCAP access token: {e}")
161
+ token = pa.auth_token
162
+ if not token:
163
+ try:
164
+ token = subprocess.check_output(
165
+ ["ivcap", "--silent", "context", "get", "access-token", "--refresh-token"],
166
+ text=True
167
+ ).strip()
168
+ except Exception as e:
169
+ raise RuntimeError(f"Failed to get IVCAP access token: {e}")
168
170
 
169
171
  # Get IVCAP deployment URL
170
172
  try:
@@ -202,34 +204,6 @@ def exec_job(data, args, is_silent, line):
202
204
  except Exception as e:
203
205
  raise RuntimeError(f"Job submission failed: {e}")
204
206
 
205
- # Handle response according to requirements
206
- import time
207
- import json
208
-
209
- def handle_response(resp):
210
- content_type = resp.headers.get("content-type", "")
211
- if resp.status_code >= 300:
212
- line(f"<warning>WARNING: Received status code {resp.status_code}</warning>")
213
- line(f"<info>Headers: {str(resp.headers)}</info>")
214
- line(f"<info>Body: {str(resp.text)}</info>")
215
- elif resp.status_code == 200:
216
- if "application/json" in content_type:
217
- try:
218
- parsed = resp.json()
219
- status = parsed.get("status")
220
- if status and (not status in ["succeeded", "failed", "error"]):
221
- return status
222
- print(json.dumps(parsed, indent=2, sort_keys=True))
223
- return None
224
- except Exception as e:
225
- line(f"<warning>Failed to parse JSON response: {e}</warning>")
226
- line(f"<warning>Headers: {str(resp.headers)}</warning>")
227
- else:
228
- line(f"<info>Headers: {str(resp.headers)}</info>")
229
- else:
230
- line(f"<warning>Received status code {resp.status_code}</warning>")
231
- line(f"<warning>Headers: {str(resp.headers)}</warning>")
232
- return "unknown"
233
207
 
234
208
  if response.status_code == 202:
235
209
  try:
@@ -239,36 +213,176 @@ def exec_job(data, args, is_silent, line):
239
213
  location = f"{payload.get('location')}"
240
214
  job_id = payload.get("job-id")
241
215
  retry_later = payload.get("retry-later", 5)
242
- if not is_silent:
243
- line(f"<debug>Job '{job_id}' accepted, but no result yet. Polling in {retry_later} seconds.</debug>")
244
- while True:
245
- time.sleep(retry_later)
246
- poll_headers = {
247
- "Authorization": f"Bearer {token}"
248
- }
249
- poll_resp = requests.get(location, headers=poll_headers)
250
- if poll_resp.status_code == 202:
251
- try:
252
- poll_payload = poll_resp.json()
253
- location = poll_payload.get("location", location)
254
- retry_later = poll_payload.get("retry-later", retry_later)
255
- if not is_silent:
256
- line(f"<debug>Still processing. Next poll in {retry_later} seconds.</debug>")
257
- except Exception as e:
258
- line(f"<error>Failed to parse polling response: {e}</error>")
259
- break
260
- else:
261
- status = handle_response(poll_resp)
262
- if status:
263
- if not is_silent:
264
- line(f"<debug>Status: '{status}'. Next poll in {retry_later} seconds.</debug>")
265
- else:
266
- break
267
-
268
216
  except Exception as e:
269
217
  line(f"<error>Failed to handle 202 response: {e}</error>")
218
+
219
+ if pa.stream:
220
+ stream_result(location, job_id, token, pa, is_silent, line)
221
+ else:
222
+ poll_for_result(location, job_id, retry_later, token, is_silent, line)
223
+
270
224
  else:
271
- handle_response(response)
225
+ handle_response(response, line)
226
+
227
+ def handle_response(resp, line):
228
+ content_type = resp.headers.get("content-type", "")
229
+ if resp.status_code >= 300:
230
+ line(f"<warning>WARNING: Received status code {resp.status_code}</warning>")
231
+ line(f"<info>Headers: {str(resp.headers)}</info>")
232
+ line(f"<info>Body: {str(resp.text)}</info>")
233
+ elif resp.status_code == 200:
234
+ if "application/json" in content_type:
235
+ try:
236
+ parsed = resp.json()
237
+ status = parsed.get("status")
238
+ if status and (not status in ["succeeded", "failed", "error"]):
239
+ return status
240
+ print(json.dumps(parsed, indent=2, sort_keys=True))
241
+ return None
242
+ except Exception as e:
243
+ line(f"<warning>Failed to parse JSON response: {e}</warning>")
244
+ line(f"<warning>Headers: {str(resp.headers)}</warning>")
245
+ else:
246
+ line(f"<info>Headers: {str(resp.headers)}</info>")
247
+ else:
248
+ line(f"<warning>Received status code {resp.status_code}</warning>")
249
+ line(f"<warning>Headers: {str(resp.headers)}</warning>")
250
+ return "unknown"
251
+
252
+ def poll_for_result(location, job_id, retry_later, token, is_silent, line):
253
+ if not is_silent:
254
+ line(f"<debug>Job '{job_id}' accepted, but no result yet. Polling in {retry_later} seconds.</debug>")
255
+ while True:
256
+ time.sleep(retry_later)
257
+ poll_headers = {
258
+ "Authorization": f"Bearer {token}"
259
+ }
260
+ poll_resp = requests.get(location, headers=poll_headers)
261
+ if poll_resp.status_code == 202:
262
+ try:
263
+ poll_payload = poll_resp.json()
264
+ location = poll_payload.get("location", location)
265
+ retry_later = poll_payload.get("retry-later", retry_later)
266
+ if not is_silent:
267
+ line(f"<debug>Still processing. Next poll in {retry_later} seconds.</debug>")
268
+ except Exception as e:
269
+ line(f"<error>Failed to parse polling response: {e}</error>")
270
+ break
271
+ else:
272
+ status = handle_response(poll_resp, line)
273
+ if status:
274
+ if not is_silent:
275
+ line(f"<debug>Status: '{status}'. Next poll in {retry_later} seconds.</debug>")
276
+ else:
277
+ break
278
+
279
+ CONNECT_TIMEOUT = 5
280
+ READ_TIMEOUT = 120 # ensure server sends heartbeat within this window
281
+
282
+ def stream_result(location, job_id, token, pa, is_silent, line):
283
+ """
284
+ Stream the result content from the given location using the provided token.
285
+ """
286
+ if not is_silent:
287
+ line(f"<debug>Job '{job_id}' accepted, Waiting for events now.</debug>")
288
+ headers = {
289
+ "Authorization": f"Bearer {token}",
290
+ "Accept": "text/event-stream",
291
+ "Cache-Control": "no-cache",
292
+ "Connection": "keep-alive",
293
+ }
294
+ url = location + "/events"
295
+ session = requests.Session()
296
+ last_event_id = None
297
+ backoff = 1.0
298
+ while True:
299
+ try:
300
+ if last_event_id:
301
+ headers["Last-Event-ID"] = last_event_id
302
+ with session.get(url, stream=True, headers=headers, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT)) as r:
303
+ r.raise_for_status()
304
+ # Reset backoff on successful connect
305
+ backoff = 1.0
306
+ for row in r.iter_lines(decode_unicode=True, chunk_size=1):
307
+ if row is None:
308
+ continue
309
+ if row.startswith(":"):
310
+ # comment/heartbeat
311
+ continue
312
+ if row.startswith("id:"):
313
+ last_event_id = row[3:].strip()
314
+ continue
315
+ if print_sse_row(row, pa, line): # raw SSE lines (e.g., "data: {...}", "event: message")
316
+ return # done
317
+
318
+ except (requests.exceptions.ChunkedEncodingError,
319
+ requests.exceptions.ConnectionError,
320
+ requests.exceptions.ReadTimeout) as e:
321
+ if not is_silent:
322
+ line(f"<debug>stream error: {e}; reconnecting in {backoff:.1f}s</debug>")
323
+ time.sleep(backoff)
324
+ backoff = min(backoff * 2, 30.0) # cap backoff
325
+ continue
326
+ except requests.HTTPError as e:
327
+ # Non-200 or similar; backoff and retry
328
+ if not is_silent:
329
+ line(f"<debug>http error: {e}; reconnecting in {backoff:.1f}s</debug>")
330
+ time.sleep(backoff)
331
+ backoff = min(backoff * 2, 60.0)
332
+ except Exception:
333
+ line(f"<error>Failed to fetch events: {type(e)} - {e}</error>")
334
+ break
335
+
336
+ # except requests.exceptions.ChunkedEncodingError as ce:
337
+ # line(f"<error>Chunked encoding error: {ce}</error>")
338
+ # except Exception as e:
339
+ # line(f"<error>Failed to fetch events: {type(e)} - {e}</error>")
340
+
341
+ def print_sse_row(row, pa, line) -> bool:
342
+ if pa.raw_events:
343
+ print(row)
344
+ return False # continue streaming
345
+ elif row.startswith("data: "):
346
+ # JSON data
347
+ print("----")
348
+ try:
349
+ data = json.loads(row[6:])
350
+ print(json.dumps(data, indent=2, sort_keys=True))
351
+ return check_if_done(data)
352
+ except json.JSONDecodeError as e:
353
+ line(f"<error>Failed to decode JSON: {e}</error>")
354
+
355
+
356
+ def check_if_done(data) -> bool:
357
+ # {
358
+ # "Data": {
359
+ # "job-urn": "urn:ivcap:job:3e466031-ec8b-44eb-aec6-dc2f8212bec3",
360
+ # "status": "succeeded"
361
+ # },
362
+ # "Type": "ivcap.job.status"
363
+ # }
364
+ if "Type" in data and data["Type"] == "ivcap.job.status":
365
+ if "Data" in data and "status" in data["Data"]:
366
+ status = data["Data"]["status"]
367
+ if status in ["succeeded", "failed", "error"]:
368
+ return True
369
+ return False # continue streaming
370
+
371
+ def exec_parser():
372
+ p = argparse.ArgumentParser(prog="poetry ivcap job-exec request_file --")
373
+ p.add_argument("--timeout", type=nonneg_int, default=5, help="[5] seconds; 0 allowed")
374
+ p.add_argument("--with-result-content", dest="with_result_content", action="store_true",
375
+ help="include result content in the response")
376
+ p.add_argument("--stream", action="store_true", help="stream the result content")
377
+ p.add_argument("--raw-events", action="store_true", help="print raw SSE events")
378
+ p.add_argument("--auth-token", help="alternative auth token to use")
379
+ return p
380
+
381
+ def nonneg_int(s: str) -> int:
382
+ v = int(s)
383
+ if v < 0:
384
+ raise argparse.ArgumentTypeError("timeout must be >= 0")
385
+ return v
272
386
 
273
387
  def get_account_id(data, line, is_silent=False):
274
388
  check_ivcap_cmd(line)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "poetry-plugin-ivcap"
3
- version = "0.4.2"
3
+ version = "0.5.1"
4
4
  description = "A custom Poetry command for IVCAP deployments"
5
5
  authors = ["Max Ott <max.ott@csiro.au>"]
6
6
  license = "MIT"