poetry-plugin-ivcap 0.5.0__tar.gz → 0.5.2__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.5.0
3
+ Version: 0.5.2
4
4
  Summary: A custom Poetry command for IVCAP deployments
5
5
  License: MIT
6
6
  Author: Max Ott
@@ -19,7 +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
+ DEF_IVCAP_URL = "https://develop.ivcap.net"
23
23
 
24
24
  DOCKER_BUILD_TEMPLATE = """
25
25
  docker buildx build
@@ -34,7 +34,8 @@ docker buildx build
34
34
  DOCKER_LAMBDA_RUN_TEMPLATE = """
35
35
  docker run -it
36
36
  -p #PORT#:#PORT#
37
- -e IVCAP_BASE_URL=#IVCAP_BASE_URL#
37
+ -e IVCAP_URL=#IVCAP_URL#
38
+ -e IVCAP_JWT=#IVCAP_JWT#
38
39
  --platform=linux/#ARCH#
39
40
  --rm \
40
41
  #NAME#_#ARCH#:#TAG#
@@ -42,7 +43,7 @@ DOCKER_LAMBDA_RUN_TEMPLATE = """
42
43
 
43
44
  DOCKER_BATCH_RUN_TEMPLATE = """
44
45
  docker run -it
45
- -e IVCAP_BASE_URL=#IVCAP_BASE_URL#
46
+ -e IVCAP_URL=#IVCAP_URL#
46
47
  --platform=linux/#ARCH#
47
48
  -v #PROJECT_DIR#:/data
48
49
  --rm \
@@ -8,20 +8,25 @@ import os
8
8
  import re
9
9
  import sys
10
10
  import tempfile
11
- from typing import Dict, List, Optional
12
- from pydantic import BaseModel, Field
11
+ from typing import Any, Dict, List, Optional
12
+ from pydantic import BaseModel, Field, SkipValidation
13
13
  import subprocess
14
14
 
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
15
+ from .constants import (
16
+ DEF_IVCAP_URL, DEF_PORT, DOCKER_BATCH_RUN_TEMPLATE, DOCKER_BUILD_TEMPLATE, DOCKER_BUILD_TEMPLATE_OPT,
17
+ DOCKER_LAMBDA_RUN_TEMPLATE, DOCKER_RUN_OPT, DOCKER_RUN_TEMPLATE_OPT, PLUGIN_NAME, SERVICE_TYPE_OPT
18
+ )
16
19
  from .util import command_exists, get_name, get_version
20
+ from .types import BaseConfig
17
21
 
18
- class DockerConfig(BaseModel):
22
+ class DockerConfig(BaseConfig):
19
23
  name: Optional[str] = Field(None)
20
24
  tag: Optional[str] = Field(None)
21
25
  arch: Optional[str] = Field(None)
22
26
  version: Optional[str] = Field(None)
23
27
  dockerfile: Optional[str] = Field("Dockerfile")
24
28
  project_dir: Optional[str] = Field(os.getcwd())
29
+ line: SkipValidation[Any]
25
30
 
26
31
  @property
27
32
  def docker_name(self) -> str:
@@ -66,10 +71,10 @@ class DockerConfig(BaseModel):
66
71
  port_in_args = False
67
72
  port = str(pdata.get("port", DEF_PORT))
68
73
 
69
- base_url = os.environ.get("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
70
74
  t = template.strip()\
71
75
  .replace("#DOCKER_NAME#", self.docker_name)\
72
- .replace("#IVCAP_BASE_URL#", base_url)\
76
+ .replace("#IVCAP_URL#", self.ivcap_url)\
77
+ .replace("#IVCAP_JWT#", self.ivcap_jwt)\
73
78
  .replace("#NAME#", self.name)\
74
79
  .replace("#TAG#", self.tag)\
75
80
  .replace("#PORT#", port)\
@@ -84,7 +89,6 @@ class DockerConfig(BaseModel):
84
89
  cmd.extend(["--port", port])
85
90
  return cmd
86
91
 
87
-
88
92
  def docker_build(data: dict, line, arch = None) -> None:
89
93
  check_docker_cmd(line)
90
94
  config = docker_cfg(data, line, arch)
@@ -102,19 +106,26 @@ def docker_run(data: dict, args, line) -> None:
102
106
  check_docker_cmd(line)
103
107
  config = docker_cfg(data, line)
104
108
  build_run = config.from_run_template(data, args, line)
105
- line(f"<info>INFO: {' '.join(build_run)}</info>")
109
+ log_run(build_run, line)
106
110
  process = subprocess.Popen(build_run, stdout=sys.stdout, stderr=sys.stderr)
111
+ print(">>>> 2")
107
112
  exit_code = process.wait()
113
+ print(">>>> 3")
108
114
  if exit_code != 0:
109
115
  line(f"<error>ERROR: Docker run failed with exit code {exit_code}</error>")
110
116
  else:
111
117
  line("<info>INFO: Docker run completed successfully</info>")
112
118
 
119
+ mask_token = re.compile(r'''(?<!\w)(IVCAP_JWT=)(?:(["'])(.*?)\2|(\S+))''')
120
+ def log_run(cmd, line):
121
+ masked_cmd = mask_token.sub("IVCAP_JWT=***", ' '.join(cmd))
122
+ line(f"<info>INFO: {masked_cmd}</info>")
123
+
113
124
  def docker_cfg(data: dict, line, arch = None) -> DockerConfig:
114
125
  name = get_name(data)
115
126
 
116
127
  pdata = data.get("tool", {}).get(PLUGIN_NAME, {})
117
- config = DockerConfig(name=name, **pdata.get("docker", {}))
128
+ config = DockerConfig(name=name, line=line, **pdata.get("docker", {}))
118
129
  if arch:
119
130
  # override architecture if provided
120
131
  config.arch = arch
@@ -16,8 +16,7 @@ import requests
16
16
  import time
17
17
  import json
18
18
 
19
-
20
- from .constants import DEF_POLICY, PLUGIN_NAME, POLICY_OPT, SERVICE_FILE_OPT, SERVICE_ID_OPT, DEF_IVCAP_BASE_URL
19
+ from .constants import DEF_POLICY, PLUGIN_NAME, POLICY_OPT, SERVICE_FILE_OPT, SERVICE_ID_OPT, DEF_IVCAP_URL
21
20
 
22
21
  from .docker import docker_cfg, docker_build, docker_push
23
22
  from .util import command_exists, get_name, string_to_number
@@ -56,7 +55,7 @@ def service_register(data, line):
56
55
  cmd = ["poetry", "run", "python", service, "--print-service-description"]
57
56
  line(f"<debug>Running: {' '.join(cmd)} </debug>")
58
57
  env = os.environ.copy()
59
- env.setdefault("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
58
+ env.setdefault("IVCAP_URL", DEF_IVCAP_URL)
60
59
  svc = subprocess.check_output(cmd, env=env).decode()
61
60
 
62
61
  svc = svc.replace("#DOCKER_IMG#", pkg.strip())\
@@ -94,7 +93,7 @@ def tool_register(data, line):
94
93
  cmd = ["poetry", "run", "python", service, "--print-tool-description"]
95
94
  line(f"<debug>Running: {' '.join(cmd)} </debug>")
96
95
  env = os.environ.copy()
97
- env.setdefault("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
96
+ env.setdefault("IVCAP_URL", DEF_IVCAP_URL)
98
97
  svc = subprocess.check_output(cmd, env=env).decode()
99
98
 
100
99
  service_id = get_service_id(data, False, line)
@@ -158,13 +157,15 @@ def exec_job(data, args, is_silent, line):
158
157
  pa = p.parse_args(args)
159
158
  timeout = 0 if pa.stream else pa.timeout
160
159
  # 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}")
160
+ token = pa.auth_token
161
+ if not token:
162
+ try:
163
+ token = subprocess.check_output(
164
+ ["ivcap", "--silent", "context", "get", "access-token", "--refresh-token"],
165
+ text=True
166
+ ).strip()
167
+ except Exception as e:
168
+ raise RuntimeError(f"Failed to get IVCAP access token: {e}")
168
169
 
169
170
  # Get IVCAP deployment URL
170
171
  try:
@@ -211,15 +212,14 @@ def exec_job(data, args, is_silent, line):
211
212
  location = f"{payload.get('location')}"
212
213
  job_id = payload.get("job-id")
213
214
  retry_later = payload.get("retry-later", 5)
214
- if not is_silent:
215
- line(f"<debug>Job '{job_id}' accepted, but no result yet. Polling in {retry_later} seconds.</debug>")
216
- if pa.stream:
217
- stream_result(location, token, pa)
218
- else:
219
- poll_for_result(location, retry_later, token, is_silent, line)
220
-
221
215
  except Exception as e:
222
216
  line(f"<error>Failed to handle 202 response: {e}</error>")
217
+
218
+ if pa.stream:
219
+ stream_result(location, job_id, token, pa, is_silent, line)
220
+ else:
221
+ poll_for_result(location, job_id, retry_later, token, is_silent, line)
222
+
223
223
  else:
224
224
  handle_response(response, line)
225
225
 
@@ -248,7 +248,9 @@ def handle_response(resp, line):
248
248
  line(f"<warning>Headers: {str(resp.headers)}</warning>")
249
249
  return "unknown"
250
250
 
251
- def poll_for_result(location, retry_later, token, is_silent, line):
251
+ def poll_for_result(location, job_id, retry_later, token, is_silent, line):
252
+ if not is_silent:
253
+ line(f"<debug>Job '{job_id}' accepted, but no result yet. Polling in {retry_later} seconds.</debug>")
252
254
  while True:
253
255
  time.sleep(retry_later)
254
256
  poll_headers = {
@@ -273,35 +275,97 @@ def poll_for_result(location, retry_later, token, is_silent, line):
273
275
  else:
274
276
  break
275
277
 
276
- def stream_result(location, token, pa):
278
+ CONNECT_TIMEOUT = 5
279
+ READ_TIMEOUT = 120 # ensure server sends heartbeat within this window
280
+
281
+ def stream_result(location, job_id, token, pa, is_silent, line):
277
282
  """
278
283
  Stream the result content from the given location using the provided token.
279
284
  """
285
+ if not is_silent:
286
+ line(f"<debug>Job '{job_id}' accepted, Waiting for events now.</debug>")
280
287
  headers = {
281
288
  "Authorization": f"Bearer {token}",
282
- "Accept": "text/event-stream"
289
+ "Accept": "text/event-stream",
290
+ "Cache-Control": "no-cache",
291
+ "Connection": "keep-alive",
283
292
  }
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):
293
+ url = location + "/events"
294
+ session = requests.Session()
295
+ last_event_id = None
296
+ backoff = 1.0
297
+ while True:
298
+ try:
299
+ if last_event_id:
300
+ headers["Last-Event-ID"] = last_event_id
301
+ with session.get(url, stream=True, headers=headers, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT)) as r:
302
+ r.raise_for_status()
303
+ # Reset backoff on successful connect
304
+ backoff = 1.0
305
+ for row in r.iter_lines(decode_unicode=True, chunk_size=1):
306
+ if row is None:
307
+ continue
308
+ if row.startswith(":"):
309
+ # comment/heartbeat
310
+ continue
311
+ if row.startswith("id:"):
312
+ last_event_id = row[3:].strip()
313
+ continue
314
+ if print_sse_row(row, pa, line): # raw SSE lines (e.g., "data: {...}", "event: message")
315
+ return # done
316
+
317
+ except (requests.exceptions.ChunkedEncodingError,
318
+ requests.exceptions.ConnectionError,
319
+ requests.exceptions.ReadTimeout) as e:
320
+ if not is_silent:
321
+ line(f"<debug>stream error: {e}; reconnecting in {backoff:.1f}s</debug>")
322
+ time.sleep(backoff)
323
+ backoff = min(backoff * 2, 30.0) # cap backoff
324
+ continue
325
+ except requests.HTTPError as e:
326
+ # Non-200 or similar; backoff and retry
327
+ if not is_silent:
328
+ line(f"<debug>http error: {e}; reconnecting in {backoff:.1f}s</debug>")
329
+ time.sleep(backoff)
330
+ backoff = min(backoff * 2, 60.0)
331
+ except Exception:
332
+ line(f"<error>Failed to fetch events: {type(e)} - {e}</error>")
333
+ break
334
+
335
+ # except requests.exceptions.ChunkedEncodingError as ce:
336
+ # line(f"<error>Chunked encoding error: {ce}</error>")
337
+ # except Exception as e:
338
+ # line(f"<error>Failed to fetch events: {type(e)} - {e}</error>")
339
+
340
+ def print_sse_row(row, pa, line) -> bool:
295
341
  if pa.raw_events:
296
342
  print(row)
343
+ return False # continue streaming
297
344
  elif row.startswith("data: "):
298
345
  # JSON data
299
346
  print("----")
300
347
  try:
301
348
  data = json.loads(row[6:])
302
349
  print(json.dumps(data, indent=2, sort_keys=True))
350
+ return check_if_done(data)
303
351
  except json.JSONDecodeError as e:
304
- print(f"Failed to decode JSON: {e}")
352
+ line(f"<error>Failed to decode JSON: {e}</error>")
353
+
354
+
355
+ def check_if_done(data) -> bool:
356
+ # {
357
+ # "Data": {
358
+ # "job-urn": "urn:ivcap:job:3e466031-ec8b-44eb-aec6-dc2f8212bec3",
359
+ # "status": "succeeded"
360
+ # },
361
+ # "Type": "ivcap.job.status"
362
+ # }
363
+ if "Type" in data and data["Type"] == "ivcap.job.status":
364
+ if "Data" in data and "status" in data["Data"]:
365
+ status = data["Data"]["status"]
366
+ if status in ["succeeded", "failed", "error"]:
367
+ return True
368
+ return False # continue streaming
305
369
 
306
370
  def exec_parser():
307
371
  p = argparse.ArgumentParser(prog="poetry ivcap job-exec request_file --")
@@ -310,6 +374,7 @@ def exec_parser():
310
374
  help="include result content in the response")
311
375
  p.add_argument("--stream", action="store_true", help="stream the result content")
312
376
  p.add_argument("--raw-events", action="store_true", help="print raw SSE events")
377
+ p.add_argument("--auth-token", help="alternative auth token to use")
313
378
  return p
314
379
 
315
380
  def nonneg_int(s: str) -> int:
@@ -10,8 +10,9 @@ 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 DEF_IVCAP_BASE_URL, DOCKER_BUILD_TEMPLATE_OPT, DOCKER_RUN_TEMPLATE_OPT, PLUGIN_CMD, PLUGIN_NAME
13
+ from poetry_plugin_ivcap.constants import DEF_IVCAP_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
+ from poetry_plugin_ivcap.types import BaseConfig
15
16
  from poetry_plugin_ivcap.util import get_version
16
17
 
17
18
  from .ivcap import create_service_id, exec_job, get_service_id, service_register, tool_register
@@ -127,7 +128,9 @@ Configurable options in pyproject.toml:
127
128
 
128
129
  env = os.environ.copy()
129
130
  env["VERSION"] = get_version(data, None, line)
130
- env.setdefault("IVCAP_BASE_URL", DEF_IVCAP_BASE_URL)
131
+ cfg = BaseConfig(line=line)
132
+ env.setdefault("IVCAP_URL", cfg.ivcap_url)
133
+ env.setdefault("IVCAP_JWT", cfg.ivcap_jwt)
131
134
 
132
135
  cmd = ["poetry", "run", "python", service]
133
136
  cmd.extend(args)
@@ -0,0 +1,38 @@
1
+ #
2
+ # Copyright (c) 2025 Commonwealth Scientific and Industrial Research Organisation (CSIRO). All rights reserved.
3
+ # Use of this source code is governed by a BSD-style license that can be
4
+ # found in the LICENSE file. See the AUTHORS file for names of contributors.
5
+ #
6
+ import os
7
+ import sys
8
+ from typing import Any
9
+ from pydantic import BaseModel, SkipValidation
10
+ import subprocess
11
+
12
+ from .constants import DEF_IVCAP_URL
13
+
14
+ class BaseConfig(BaseModel):
15
+ line: SkipValidation[Any]
16
+
17
+ @property
18
+ def ivcap_url(self) -> str:
19
+ base_url = os.environ.get("IVCAP_URL")
20
+ if not base_url:
21
+ cmd = ["ivcap", "context", "get", "url"]
22
+ self.line(f"<debug>Running: {' '.join(cmd)} </debug>")
23
+ base_url = subprocess.check_output(cmd).decode().strip()
24
+ if not base_url:
25
+ base_url = DEF_IVCAP_URL
26
+ return base_url
27
+
28
+ @property
29
+ def ivcap_jwt(self) -> str:
30
+ jwt = os.environ.get("IVCAP_JWT")
31
+ if not jwt:
32
+ cmd = ["ivcap", "context", "get", "access-token", "--refresh-token"]
33
+ self.line(f"<debug>Running: {' '.join(cmd)} </debug>")
34
+ jwt = subprocess.check_output(cmd).decode().strip()
35
+ if not jwt:
36
+ self.line("<error>ERROR: IVCAP JWT is not set. Please run 'ivcap login' first.</error>")
37
+ sys.exit(1)
38
+ return jwt
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "poetry-plugin-ivcap"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  description = "A custom Poetry command for IVCAP deployments"
5
5
  authors = ["Max Ott <max.ott@csiro.au>"]
6
6
  license = "MIT"