poetry-plugin-ivcap 0.4.1__tar.gz → 0.5.0__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.1
3
+ Version: 0.5.0
4
4
  Summary: A custom Poetry command for IVCAP deployments
5
5
  License: MIT
6
6
  Author: Max Ott
@@ -19,6 +19,7 @@ DOCKER_BUILD_TEMPLATE_OPT = "docker-build-template"
19
19
 
20
20
  DEF_POLICY = "urn:ivcap:policy:ivcap.base.metadata"
21
21
  DEF_PORT = 8000
22
+ DEF_IVCAP_BASE_URL = "https://develop.ivcap.net"
22
23
 
23
24
  DOCKER_BUILD_TEMPLATE = """
24
25
  docker buildx build
@@ -33,6 +34,7 @@ docker buildx build
33
34
  DOCKER_LAMBDA_RUN_TEMPLATE = """
34
35
  docker run -it
35
36
  -p #PORT#:#PORT#
37
+ -e IVCAP_BASE_URL=#IVCAP_BASE_URL#
36
38
  --platform=linux/#ARCH#
37
39
  --rm \
38
40
  #NAME#_#ARCH#:#TAG#
@@ -40,6 +42,7 @@ DOCKER_LAMBDA_RUN_TEMPLATE = """
40
42
 
41
43
  DOCKER_BATCH_RUN_TEMPLATE = """
42
44
  docker run -it
45
+ -e IVCAP_BASE_URL=#IVCAP_BASE_URL#
43
46
  --platform=linux/#ARCH#
44
47
  -v #PROJECT_DIR#:/data
45
48
  --rm \
@@ -12,7 +12,7 @@ from typing import Dict, List, Optional
12
12
  from pydantic import BaseModel, Field
13
13
  import subprocess
14
14
 
15
- from .constants import DEF_PORT, DOCKER_BATCH_RUN_TEMPLATE, DOCKER_BUILD_TEMPLATE, DOCKER_BUILD_TEMPLATE_OPT, DOCKER_LAMBDA_RUN_TEMPLATE, DOCKER_RUN_OPT, DOCKER_RUN_TEMPLATE_OPT, PLUGIN_NAME, SERVICE_TYPE_OPT
15
+ from .constants import DEF_IVCAP_BASE_URL, DEF_PORT, DOCKER_BATCH_RUN_TEMPLATE, DOCKER_BUILD_TEMPLATE, DOCKER_BUILD_TEMPLATE_OPT, DOCKER_LAMBDA_RUN_TEMPLATE, DOCKER_RUN_OPT, DOCKER_RUN_TEMPLATE_OPT, PLUGIN_NAME, SERVICE_TYPE_OPT
16
16
  from .util import command_exists, get_name, get_version
17
17
 
18
18
  class DockerConfig(BaseModel):
@@ -66,8 +66,10 @@ class DockerConfig(BaseModel):
66
66
  port_in_args = False
67
67
  port = str(pdata.get("port", DEF_PORT))
68
68
 
69
+ base_url = os.environ.get("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
69
70
  t = template.strip()\
70
71
  .replace("#DOCKER_NAME#", self.docker_name)\
72
+ .replace("#IVCAP_BASE_URL#", base_url)\
71
73
  .replace("#NAME#", self.name)\
72
74
  .replace("#TAG#", self.tag)\
73
75
  .replace("#PORT#", port)\
@@ -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,8 +13,11 @@ import uuid
12
13
  import humanize
13
14
  import subprocess
14
15
  import requests
16
+ import time
17
+ import json
15
18
 
16
- from .constants import DEF_POLICY, PLUGIN_NAME, POLICY_OPT, SERVICE_FILE_OPT, SERVICE_ID_OPT
19
+
20
+ from .constants import DEF_POLICY, PLUGIN_NAME, POLICY_OPT, SERVICE_FILE_OPT, SERVICE_ID_OPT, DEF_IVCAP_BASE_URL
17
21
 
18
22
  from .docker import docker_cfg, docker_build, docker_push
19
23
  from .util import command_exists, get_name, string_to_number
@@ -51,7 +55,9 @@ def service_register(data, line):
51
55
 
52
56
  cmd = ["poetry", "run", "python", service, "--print-service-description"]
53
57
  line(f"<debug>Running: {' '.join(cmd)} </debug>")
54
- svc = subprocess.check_output(cmd).decode()
58
+ env = os.environ.copy()
59
+ env.setdefault("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
60
+ svc = subprocess.check_output(cmd, env=env).decode()
55
61
 
56
62
  svc = svc.replace("#DOCKER_IMG#", pkg.strip())\
57
63
  .replace("#SERVICE_ID#", service_id)
@@ -87,7 +93,9 @@ def tool_register(data, line):
87
93
 
88
94
  cmd = ["poetry", "run", "python", service, "--print-tool-description"]
89
95
  line(f"<debug>Running: {' '.join(cmd)} </debug>")
90
- svc = subprocess.check_output(cmd).decode()
96
+ env = os.environ.copy()
97
+ env.setdefault("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
98
+ svc = subprocess.check_output(cmd, env=env).decode()
91
99
 
92
100
  service_id = get_service_id(data, False, line)
93
101
  svc = svc.replace("#SERVICE_ID#", service_id)
@@ -139,20 +147,16 @@ def exec_job(data, args, is_silent, line):
139
147
  requests.Response: The response object from the API call.
140
148
  """
141
149
  # Parse 'args' for run options
150
+ p = exec_parser()
142
151
  if not isinstance(args, list) or len(args) < 1:
143
- raise Exception("args must be a list with at least one element")
144
- file_name = args[0]
145
- timeout = 20 # default timeout
146
- if len(args) == 1:
147
- pass # only file_name provided
148
- elif len(args) == 3 and args[1] == '--timeout':
149
- try:
150
- timeout = int(args[2])
151
- except ValueError:
152
- raise Exception("Timeout value must be an integer")
153
- else:
154
- raise Exception("args must be [file_name] or [file_name, '--timeout', value]")
155
-
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
156
160
  # Get access token using ivcap CLI
157
161
  try:
158
162
  token = subprocess.check_output(
@@ -198,29 +202,6 @@ def exec_job(data, args, is_silent, line):
198
202
  except Exception as e:
199
203
  raise RuntimeError(f"Job submission failed: {e}")
200
204
 
201
- # Handle response according to requirements
202
- import time
203
- import json
204
-
205
- def handle_response(resp):
206
- content_type = resp.headers.get("content-type", "")
207
- if resp.status_code >= 300:
208
- line(f"<warning>WARNING: Received status code {resp.status_code}</warning>")
209
- line(f"<info>Headers: {str(resp.headers)}</info>")
210
- line(f"<info>Body: {str(resp.text)}</info>")
211
- elif resp.status_code == 200:
212
- if "application/json" in content_type:
213
- try:
214
- parsed = resp.json()
215
- print(json.dumps(parsed, indent=2, sort_keys=True))
216
- except Exception as e:
217
- line(f"<warning>Failed to parse JSON response: {e}</warning>")
218
- line(f"<warning>Headers: {str(resp.headers)}</warning>")
219
- else:
220
- line(f"<info>Headers: {str(resp.headers)}</info>")
221
- else:
222
- line(f"<warning>Received status code {resp.status_code}</warning>")
223
- line(f"<warning>Headers: {str(resp.headers)}</warning>")
224
205
 
225
206
  if response.status_code == 202:
226
207
  try:
@@ -229,32 +210,113 @@ def exec_job(data, args, is_silent, line):
229
210
  # location = f"{payload.get('location')}/output"
230
211
  location = f"{payload.get('location')}"
231
212
  job_id = payload.get("job-id")
232
- retry_later = payload.get("retry-later", 10)
213
+ retry_later = payload.get("retry-later", 5)
233
214
  if not is_silent:
234
215
  line(f"<debug>Job '{job_id}' accepted, but no result yet. Polling in {retry_later} seconds.</debug>")
235
- while True:
236
- time.sleep(retry_later)
237
- poll_headers = {
238
- "Authorization": f"Bearer {token}"
239
- }
240
- poll_resp = requests.get(location, headers=poll_headers)
241
- if poll_resp.status_code == 202:
242
- try:
243
- poll_payload = poll_resp.json()
244
- location = poll_payload.get("location", location)
245
- retry_later = poll_payload.get("retry-later", retry_later)
246
- if not is_silent:
247
- line(f"<debug>Still processing. Next poll in {retry_later} seconds.</debug>")
248
- except Exception as e:
249
- line(f"<error>Failed to parse polling response: {e}</error>")
250
- break
251
- else:
252
- handle_response(poll_resp)
253
- break
216
+ if pa.stream:
217
+ stream_result(location, token, pa)
218
+ else:
219
+ poll_for_result(location, retry_later, token, is_silent, line)
220
+
254
221
  except Exception as e:
255
222
  line(f"<error>Failed to handle 202 response: {e}</error>")
256
223
  else:
257
- handle_response(response)
224
+ handle_response(response, line)
225
+
226
+ def handle_response(resp, line):
227
+ content_type = resp.headers.get("content-type", "")
228
+ if resp.status_code >= 300:
229
+ line(f"<warning>WARNING: Received status code {resp.status_code}</warning>")
230
+ line(f"<info>Headers: {str(resp.headers)}</info>")
231
+ line(f"<info>Body: {str(resp.text)}</info>")
232
+ elif resp.status_code == 200:
233
+ if "application/json" in content_type:
234
+ try:
235
+ parsed = resp.json()
236
+ status = parsed.get("status")
237
+ if status and (not status in ["succeeded", "failed", "error"]):
238
+ return status
239
+ print(json.dumps(parsed, indent=2, sort_keys=True))
240
+ return None
241
+ except Exception as e:
242
+ line(f"<warning>Failed to parse JSON response: {e}</warning>")
243
+ line(f"<warning>Headers: {str(resp.headers)}</warning>")
244
+ else:
245
+ line(f"<info>Headers: {str(resp.headers)}</info>")
246
+ else:
247
+ line(f"<warning>Received status code {resp.status_code}</warning>")
248
+ line(f"<warning>Headers: {str(resp.headers)}</warning>")
249
+ return "unknown"
250
+
251
+ def poll_for_result(location, retry_later, token, is_silent, line):
252
+ while True:
253
+ time.sleep(retry_later)
254
+ poll_headers = {
255
+ "Authorization": f"Bearer {token}"
256
+ }
257
+ poll_resp = requests.get(location, headers=poll_headers)
258
+ if poll_resp.status_code == 202:
259
+ try:
260
+ poll_payload = poll_resp.json()
261
+ location = poll_payload.get("location", location)
262
+ retry_later = poll_payload.get("retry-later", retry_later)
263
+ if not is_silent:
264
+ line(f"<debug>Still processing. Next poll in {retry_later} seconds.</debug>")
265
+ except Exception as e:
266
+ line(f"<error>Failed to parse polling response: {e}</error>")
267
+ break
268
+ else:
269
+ status = handle_response(poll_resp, line)
270
+ if status:
271
+ if not is_silent:
272
+ line(f"<debug>Status: '{status}'. Next poll in {retry_later} seconds.</debug>")
273
+ else:
274
+ break
275
+
276
+ def stream_result(location, token, pa):
277
+ """
278
+ Stream the result content from the given location using the provided token.
279
+ """
280
+ headers = {
281
+ "Authorization": f"Bearer {token}",
282
+ "Accept": "text/event-stream"
283
+ }
284
+ with requests.get(location + "/events", stream=True, headers=headers, timeout=(5, 65)) as r:
285
+ r.raise_for_status()
286
+ for row in r.iter_lines(decode_unicode=True, chunk_size=1):
287
+ if row is None:
288
+ continue
289
+ if row.startswith(":"):
290
+ # comment/heartbeat
291
+ continue
292
+ print_sse_row(row, pa) # raw SSE lines (e.g., "data: {...}", "event: message")
293
+
294
+ def print_sse_row(row, pa):
295
+ if pa.raw_events:
296
+ print(row)
297
+ elif row.startswith("data: "):
298
+ # JSON data
299
+ print("----")
300
+ try:
301
+ data = json.loads(row[6:])
302
+ print(json.dumps(data, indent=2, sort_keys=True))
303
+ except json.JSONDecodeError as e:
304
+ print(f"Failed to decode JSON: {e}")
305
+
306
+ def exec_parser():
307
+ p = argparse.ArgumentParser(prog="poetry ivcap job-exec request_file --")
308
+ p.add_argument("--timeout", type=nonneg_int, default=5, help="[5] seconds; 0 allowed")
309
+ p.add_argument("--with-result-content", dest="with_result_content", action="store_true",
310
+ help="include result content in the response")
311
+ p.add_argument("--stream", action="store_true", help="stream the result content")
312
+ p.add_argument("--raw-events", action="store_true", help="print raw SSE events")
313
+ return p
314
+
315
+ def nonneg_int(s: str) -> int:
316
+ v = int(s)
317
+ if v < 0:
318
+ raise argparse.ArgumentTypeError("timeout must be >= 0")
319
+ return v
258
320
 
259
321
  def get_account_id(data, line, is_silent=False):
260
322
  check_ivcap_cmd(line)
@@ -10,7 +10,7 @@ from cleo.helpers import argument, option
10
10
  import subprocess
11
11
  from importlib.metadata import version
12
12
 
13
- from poetry_plugin_ivcap.constants import DOCKER_BUILD_TEMPLATE_OPT, DOCKER_RUN_TEMPLATE_OPT, PLUGIN_CMD, PLUGIN_NAME
13
+ from poetry_plugin_ivcap.constants import DEF_IVCAP_BASE_URL, DOCKER_BUILD_TEMPLATE_OPT, DOCKER_RUN_TEMPLATE_OPT, PLUGIN_CMD, PLUGIN_NAME
14
14
  from poetry_plugin_ivcap.constants import PORT_OPT, SERVICE_FILE_OPT, SERVICE_ID_OPT, SERVICE_TYPE_OPT, POLICY_OPT
15
15
  from poetry_plugin_ivcap.util import get_version
16
16
 
@@ -127,6 +127,7 @@ Configurable options in pyproject.toml:
127
127
 
128
128
  env = os.environ.copy()
129
129
  env["VERSION"] = get_version(data, None, line)
130
+ env.setdefault("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
130
131
 
131
132
  cmd = ["poetry", "run", "python", service]
132
133
  cmd.extend(args)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "poetry-plugin-ivcap"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "A custom Poetry command for IVCAP deployments"
5
5
  authors = ["Max Ott <max.ott@csiro.au>"]
6
6
  license = "MIT"