sliderule-cli 5.6.3__tar.gz → 5.7.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.
Files changed (25) hide show
  1. {sliderule_cli-5.6.3/src/sliderule_cli.egg-info → sliderule_cli-5.7.0}/PKG-INFO +1 -1
  2. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0/src/sliderule_cli.egg-info}/PKG-INFO +1 -1
  3. sliderule_cli-5.7.0/src/sliderule_cli.egg-info/scm_version.json +8 -0
  4. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_provisioner/main.py +14 -6
  5. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_runner/main.py +141 -78
  6. sliderule_cli-5.6.3/src/sliderule_cli.egg-info/scm_version.json +0 -8
  7. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/.gitignore +0 -0
  8. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/LICENSE +0 -0
  9. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/README.md +0 -0
  10. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/pyproject.toml +0 -0
  11. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/setup.cfg +0 -0
  12. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cli.egg-info/SOURCES.txt +0 -0
  13. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cli.egg-info/dependency_links.txt +0 -0
  14. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cli.egg-info/entry_points.txt +0 -0
  15. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cli.egg-info/requires.txt +0 -0
  16. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cli.egg-info/scm_file_list.json +0 -0
  17. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cli.egg-info/top_level.txt +0 -0
  18. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cluster/__init__.py +0 -0
  19. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_cluster/main.py +0 -0
  20. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_provisioner/__init__.py +0 -0
  21. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_recorder/__init__.py +0 -0
  22. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_recorder/environment.yml +0 -0
  23. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_recorder/main.py +0 -0
  24. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_runner/__init__.py +0 -0
  25. {sliderule_cli-5.6.3 → sliderule_cli-5.7.0}/src/sliderule_runner/database.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sliderule-cli
3
- Version: 5.6.3
3
+ Version: 5.7.0
4
4
  Summary: SlideRule Command Line Tool
5
5
  License-Expression: BSD-3-Clause
6
6
  Requires-Python: >=3.9
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sliderule-cli
3
- Version: 5.6.3
3
+ Version: 5.7.0
4
4
  Summary: SlideRule Command Line Tool
5
5
  License-Expression: BSD-3-Clause
6
6
  Requires-Python: >=3.9
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "5.7.0",
3
+ "distance": 0,
4
+ "node": "g084d9e1b17c9db584fa57119be4c8c89da45b645",
5
+ "dirty": false,
6
+ "branch": "main",
7
+ "node_date": "2026-10-22"
8
+ }
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  import json
2
3
  import argparse
3
4
  from datetime import datetime
@@ -72,10 +73,16 @@ class Tool:
72
73
  # S3 Access
73
74
  def s3access(self):
74
75
  credentials = self.session.provisioner.s3access()
75
- print(f'export AWS_ACCESS_KEY_ID={credentials["access_key_id"]}')
76
- print(f'export AWS_SECRET_ACCESS_KEY={credentials["secret_access_key"]}')
77
- print(f'export AWS_SESSION_TOKEN={credentials["session_token"]}')
78
- print(f'export AWS_CREDENTIAL_EXPIRATION={credentials["expiration"]}')
76
+ fmt = {
77
+ "bash": "export {}='{}'",
78
+ "powershell": "$env:{}='{}'",
79
+ "cmd": 'set "{}={}"',
80
+ }[self.args.shell]
81
+ for name, key in [("AWS_ACCESS_KEY_ID", "access_key_id"),
82
+ ("AWS_SECRET_ACCESS_KEY", "secret_access_key"),
83
+ ("AWS_SESSION_TOKEN", "session_token"),
84
+ ("AWS_CREDENTIAL_EXPIRATION", "expiration")]:
85
+ print(fmt.format(name, credentials[key]))
79
86
 
80
87
  # Authenticate
81
88
  def authenticate(self):
@@ -127,7 +134,7 @@ def main():
127
134
 
128
135
  # report
129
136
  report = subparsers.add_parser("report", parents=[common], help="report metadata of active clusters")
130
- report.add_argument('--kind', type=str, default="cluster", help="'cluster' for cluster report, 'test' for test report")
137
+ report.add_argument('--kind', type=str, default="clusters", help="'clusters' for cluster report, 'test' for test report")
131
138
  report.set_defaults(func=Tool.report)
132
139
 
133
140
  # test
@@ -141,6 +148,7 @@ def main():
141
148
 
142
149
  # s3access
143
150
  s3access = subparsers.add_parser("s3access", parents=[common], help="sets AWS credentials by outputting shell commands to 'eval' into current environment")
151
+ s3access.add_argument('--shell', type=str, default="bash", choices=["bash", "powershell", "cmd"])
144
152
  s3access.set_defaults(func=Tool.s3access)
145
153
 
146
154
  # authenticate
@@ -160,7 +168,7 @@ def main():
160
168
  print(f'{json.dumps(result, indent=2)}')
161
169
  except Exception as e:
162
170
  if args.verbose: raise
163
- print(f"Unhandled error: {e}")
171
+ print(f"Unhandled error: {e}", file=sys.stderr)
164
172
 
165
173
  # running via direct invocation
166
174
  if __name__ == "__main__": main()
@@ -1,4 +1,5 @@
1
1
  import importlib
2
+ import sys
2
3
  import json
3
4
  import boto3
4
5
  import random
@@ -6,6 +7,7 @@ import string
6
7
  import argparse
7
8
  import traceback
8
9
  from sliderule import sliderule
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
11
  from .database import Database, JobState, QueuePriority, JobStatus
10
12
  from pathlib import Path
11
13
 
@@ -28,21 +30,31 @@ class Tool:
28
30
  # open database
29
31
  self.database = Database(args.database)
30
32
  # create sliderule session
31
- self.session = sliderule.create_session(domain=args.domain, verbose=args.verbose)
32
- self.session.authenticate() # gives privileges to access SlideRule Runner
33
+ if args.net:
34
+ self.session = sliderule.create_session(domain=args.domain, verbose=args.verbose, rethrow=True)
35
+ self.session.authenticate() # gives privileges to access SlideRule Runner
33
36
  # aws clients
34
37
  self.s3 = boto3.client("s3", region_name="us-west-2")
35
38
 
39
+ # Split out bucket and key from url
40
+ def __parse_url(self, url):
41
+ path = url.split("s3://")[-1]
42
+ bucket = path.split("/")[0]
43
+ key = '/'.join(path.split("/")[1:])
44
+ return bucket, key
45
+
36
46
  # Load Remote File from S3
37
- def __load_remote_file(self, bucket, key):
47
+ def __load_remote_file(self, bucket, key, parse_json=True):
38
48
  obj = self.s3.get_object(Bucket=bucket, Key=key)
39
49
  contents = obj["Body"].read().decode("utf-8")
40
- try:
41
- return json.loads(contents)
42
- except Exception:
43
- return contents
50
+ if parse_json:
51
+ try:
52
+ return json.loads(contents)
53
+ except Exception:
54
+ pass
55
+ return contents
44
56
 
45
- # Get Results for a Job Run
57
+ # Get Result for a Job Run
46
58
  # output of job must include the following fields for this to work:
47
59
  # {
48
60
  # "status": <boolean status of run>,
@@ -50,44 +62,51 @@ class Tool:
50
62
  # "stop": <stop time in seconds of run>,
51
63
  # "outputs": [<output1>, <output2>, ... <outputN>]
52
64
  # }
53
- def __get_results(self, run_url):
54
- results = []
55
- bucket = run_url.split("s3://")[-1].split("/")[0]
56
- prefix = "/".join(run_url.split("s3://")[-1].split("/")[1:])
57
- receipt = self.__load_remote_file(bucket, f"{prefix}/receipt.json") # {"name": ..., "username": ... "args": <path to arg file>, "environment": ...}
58
- args_bucket = receipt["args"].split("s3://")[-1].split("/")[0]
59
- args_file = "/".join(receipt["args"].split("s3://")[-1].split("/")[1:])
60
- args_content = self.__load_remote_file(args_bucket, args_file)
61
- if isinstance(args_content, list):
62
- args_list = args_content
63
- elif isinstance(args_content, dict) or isinstance(args_content, str):
64
- args_list = [args_content]
65
- for i in tqdm(range(len(args_list)), total=len(args_list), desc=f"{run_url}", unit="granule"):
66
- result_file = f"{prefix}/result{len(args_list) > 1 and str(i) or ''}.json"
65
+ def __get_result(self, results, i):
66
+ result = results[i]
67
+ bucket, result_file = self.__parse_url(result["file"])
68
+ try:
69
+ rsps = self.__load_remote_file(bucket, result_file)
67
70
  try:
68
- result = {
69
- "file": f"s3://{bucket}/{result_file}",
70
- "environment": receipt["environment"],
71
- "arg": args_list[i]
71
+ result |= {
72
+ "status": rsps["status"] and JobStatus.SUCCESS or JobStatus.FAILURE,
73
+ "duration": rsps["status"] and (rsps["stop"] - rsps["start"]) or 0.0,
74
+ "outputs": rsps["outputs"]
72
75
  }
73
- rsps = self.__load_remote_file(bucket, result_file)
74
- try:
75
- result |= {
76
- "status": rsps["status"] and JobStatus.SUCCESS or JobStatus.FAILURE,
77
- "duration": rsps["status"] and (rsps["stop"] - rsps["start"]) or 0.0,
78
- "outputs": rsps["outputs"]
79
- }
80
- except Exception as e:
81
- result |= {
82
- "status": JobStatus.UNSUPPORTED,
83
- "result": rsps
84
- }
85
76
  except Exception as e:
86
77
  result |= {
87
- "status": JobStatus.ERROR,
78
+ "status": JobStatus.UNSUPPORTED,
79
+ "result": rsps,
88
80
  "error": f"{e}"
89
81
  }
90
- results.append(result)
82
+ except Exception as e:
83
+ result |= {
84
+ "status": JobStatus.ERROR,
85
+ "error": f"{e}"
86
+ }
87
+
88
+ # Get all results for a submission
89
+ def __get_results(self, run_url, job_size):
90
+ bucket, prefix = self.__parse_url(run_url)
91
+ receipt = self.__load_remote_file(bucket, f"{prefix}/receipt.json") # {"name": ..., "username": ... "args": <path to arg file>, "environment": ...}
92
+ args_bucket, args_file = self.__parse_url(receipt["args"])
93
+ args_content = self.__load_remote_file(args_bucket, args_file, job_size > 1)
94
+ if isinstance(args_content, list):
95
+ args_list = args_content
96
+ else:
97
+ args_list = [args_content]
98
+ if len(args_list) != job_size:
99
+ print(f"Incorrect number of arguments {len(args_list)}, expected {job_size}")
100
+ return None
101
+ results = [{
102
+ "file": f"s3://{bucket}/{prefix}/result{len(args_list) > 1 and str(i) or ''}.json",
103
+ "environment": receipt["environment"],
104
+ "arg": args_list[i]
105
+ } for i in range(len(args_list))]
106
+ with ThreadPoolExecutor(max_workers=self.args.concurrency) as executor:
107
+ futures = [executor.submit(self.__get_result, results, i) for i in range(len(results))]
108
+ for future in tqdm(as_completed(futures), total=len(results), desc=f"{run_url}", unit="job"):
109
+ future.result()
91
110
  return results
92
111
 
93
112
  # Archive Database
@@ -95,6 +114,15 @@ class Tool:
95
114
  self.database.write(self.args.archive)
96
115
  self.database.remove()
97
116
 
117
+ # Prune Database
118
+ def prune_database(self):
119
+ name = self.args.name
120
+ if name in self.database.submissions:
121
+ print(f"Pruning submission {name} from database")
122
+ del self.database.submissions[name]
123
+ else:
124
+ print(f"Unable to find submission {name} in database")
125
+
98
126
  # Submit Job
99
127
  def submit_job(self):
100
128
  # pull out arguments
@@ -107,6 +135,7 @@ class Tool:
107
135
  memory = self.args.memory
108
136
  image = self.args.image
109
137
  queue = self.args.queue
138
+ secrets_file = self.args.secrets
110
139
  # read script
111
140
  with open(script_file, "r") as file:
112
141
  script = file.read()
@@ -115,17 +144,27 @@ class Tool:
115
144
  arguments = [str(arguments_input)]
116
145
  else:
117
146
  with open(arguments_input, "r") as file:
118
- arguments = [line.strip() for line in file.readlines()]
119
- # process job in batches
147
+ arguments = [line.strip() for line in file.readlines() if line.strip()]
148
+ # read secrets
149
+ if secrets_file:
150
+ with open(secrets_file, "r") as file:
151
+ secret_values = json.load(file)
152
+ else:
153
+ secret_values = None
154
+ # process job in batches
120
155
  for i in range(0, len(arguments), batch_size):
121
156
  # build and check name
122
157
  job_name = f"{name}_{i}"
123
158
  if job_name in self.database.submissions:
124
- unique = ''.join(random.choices(string.ascii_lowercase, k=3))
125
- job_name = f"{name}_{unique}_{i}"
159
+ if self.args.dups_ok:
160
+ unique = ''.join(random.choices(string.ascii_lowercase, k=3))
161
+ job_name = f"{name}_{unique}_{i}"
162
+ else:
163
+ print(f"Skipping duplicate submission: {job_name}")
164
+ continue
126
165
  # submit & save job
127
166
  args_list = arguments[i:i+batch_size]
128
- rsps = self.session.runner.submit(name=job_name, script=script, args=args_list, optional_args={"vcpus":vcpus, "memory":memory, "image":image, "queue":queue})
167
+ rsps = self.session.runner.submit(name=job_name, script=script, args=args_list, optional_args={"vcpus":vcpus, "memory":memory, "image":image, "queue":queue}, secret_values=secret_values)
129
168
  self.database.submissions[job_name] = rsps | {"complete": False, "job_size": len(args_list)}
130
169
  print(f"Submitted job {job_name} using script {script_file} with {len(args_list)} entries: {rsps}")
131
170
 
@@ -160,27 +199,32 @@ class Tool:
160
199
  queue = self.args.queue
161
200
  for name,job in self.database.submissions.items():
162
201
  complete = job["complete"]
163
- print(f"Statusing {name} ...")
202
+ sys.stdout.write(f"Statusing {name} ... ")
203
+ sys.stdout.flush()
164
204
  if not complete:
165
- if job["job_size"] > 1: # array processing, use parent job id
205
+ if job["job_size"] > 1: # array processing
166
206
  queue_status = self.session.runner.queue(job_id=job["job_id"], queue=queue, verbose=True)
167
207
  child_jobs = queue_status["jobs"]
168
208
  self.database.submissions[name]["jobs"] = {}
169
209
  for child_job in child_jobs:
170
210
  self.database.submissions[name]["jobs"][int(child_job["index"])] = child_job
171
- else: # single job, use name
172
- queue_status = self.session.runner.queue(name=name, queue=queue, verbose=False)
211
+ else: # single job
212
+ queue_status = self.session.runner.queue(job_id=job["job_id"], queue=queue, verbose=False)
173
213
  # report
174
214
  report = queue_status["report"]
175
215
  self.database.submissions[name]["status"] = report
176
216
  jobs_in_progress = sum([report[s] for s in [JobState.SUBMITTED, JobState.PENDING, JobState.RUNNABLE, JobState.STARTING, JobState.RUNNING]])
177
217
  jobs_complete = sum([report[s] for s in [JobState.SUCCEEDED, JobState.FAILED]])
178
218
  if jobs_in_progress == 0 and jobs_complete > 0:
179
- print(f"Job {name} complete, reading results ...")
180
- self.database.submissions[name]["complete"] = True
181
- self.database.submissions[name]["results"] = self.__get_results(job["run_url"])
219
+ print("complete")
220
+ results = self.__get_results(job["run_url"], self.database.submissions[name]["job_size"])
221
+ if results:
222
+ self.database.submissions[name]["results"] = results
223
+ self.database.submissions[name]["complete"] = True
182
224
  else:
183
- print(f"Job {name} still pending")
225
+ print(f"still pending")
226
+ else:
227
+ print("") # flush with new line
184
228
  if self.args.verbose:
185
229
  print(",".join([f"{c:>30}" for c in ["NAME"]] + [f"{c:>10}" for c in ["INDEX", "STATUS"]]))
186
230
  for name,job in self.database.submissions.items():
@@ -193,21 +237,20 @@ class Tool:
193
237
 
194
238
  # Generate Report
195
239
  def generate_report(self):
196
- print(",".join([f"{c:>30}" for c in ["name"]] + [f"{c:>12}" for c in ["processed"]] + [f"{c:>12}" for c in list(JobStatus)] + [f"{c:>12}" for c in ["duration"]]))
240
+ print(",".join([f"{c:>30}" for c in ["name"]] + [f"{c:>12}" for c in list(JobStatus)] + [f"{c:>12}" for c in ["duration"]]))
197
241
  for name,submission in self.database.submissions.items():
198
242
  if self.args.name and self.args.name != name: continue
199
243
  stats = {status.value: 0 for status in JobStatus}
200
- duration = {"avg": 0.0, "total": 0.0}
201
- processed = 0
244
+ duration = {"avg": 0.0, "total": 0.0, "cnt": 0}
202
245
  if submission["complete"]:
203
246
  for result in submission["results"]:
204
247
  stats[result["status"]] += 1
205
- if "duration" in result:
248
+ if ("duration" in result) and (result["status"] == JobStatus.SUCCESS):
206
249
  duration["total"] += result["duration"]
207
- processed += 1
208
- if processed > 0:
209
- duration["avg"] = duration["total"] / processed
210
- print(",".join([f"{c:>30}" for c in [name]] + [f"{c:>12}" for c in [processed]] + [f"{c:>12}" for c in [stats[status] for status in list(JobStatus)]] + [f"{c:>12.3}" for c in [duration["avg"]]]))
250
+ duration["cnt"] += 1
251
+ if duration["cnt"] > 0:
252
+ duration["avg"] = duration["total"] / duration["cnt"]
253
+ print(",".join([f"{c:>30}" for c in [name]] + [f"{c:>12}" for c in [stats[status] for status in list(JobStatus)]] + [f"{c:>12.3}" for c in [duration["avg"]]]))
211
254
 
212
255
  # Cancel Job
213
256
  def cancel_job(self):
@@ -231,7 +274,7 @@ class Tool:
231
274
 
232
275
  # Finish
233
276
  def finish(self):
234
- if not self.args.dryrun and "archive" not in self.args:
277
+ if not self.args.ro:
235
278
  # save database
236
279
  self.database.write()
237
280
 
@@ -241,6 +284,14 @@ class Tool:
241
284
 
242
285
  def main():
243
286
 
287
+ # ----------------------------------------------------------------------------
288
+ # The sliderule-runner has a set of commands along with common options. Each
289
+ # command has three attributes:
290
+ # func - the function to call when that subcommand is issued
291
+ # ro - "read-only", i.e. is the database written to or only read from
292
+ # net - "network", i.e. does a sliderule session need to be created
293
+ # ----------------------------------------------------------------------------
294
+
244
295
  # command line arguments
245
296
  parser = argparse.ArgumentParser(prog="sliderule-runner", description="""SlideRule Runner""")
246
297
  subparsers = parser.add_subparsers(dest="command", required=True)
@@ -251,51 +302,58 @@ def main():
251
302
  common.add_argument('--database', type=Path, default=Path.home() / ".cache" / "sliderule" / "runner_database.json")
252
303
  common.add_argument('--queue', type=QueuePriority, default=QueuePriority.DEFAULT, choices=list(QueuePriority))
253
304
  common.add_argument('--verbose', action='store_true', default=False)
254
- common.add_argument('--dryrun', action='store_true', default=False)
255
305
 
256
306
  # archive
257
307
  archive = subparsers.add_parser("archive", parents=[common], help="save and clear the database")
258
308
  archive.add_argument('archive', metavar="<full path to archive file>")
259
- archive.set_defaults(func=Tool.archive_database)
309
+ archive.set_defaults(func=Tool.archive_database, ro=True, net=False)
310
+
311
+ # prune
312
+ prune = subparsers.add_parser("prune", parents=[common], help="remove a submission from the database")
313
+ prune.add_argument('name', type=str, metavar="<name>")
314
+ prune.set_defaults(func=Tool.prune_database, ro=False, net=False)
260
315
 
261
316
  # submit
262
317
  submit = subparsers.add_parser("submit", parents=[common], help="submit a job")
263
- submit.add_argument('name', metavar="<name>")
264
- submit.add_argument('script', metavar="<script.lua>", type=Path)
265
- submit.add_argument('arguments', metavar="<arguments.txt>", type=Path)
318
+ submit.add_argument('name', type=str, metavar="<name>")
319
+ submit.add_argument('script', type=str, metavar="<script file>")
320
+ submit.add_argument('arguments', type=str, metavar="<arguments file>")
266
321
  submit.add_argument('--arg_as_str', action='store_true', default=False) # changes arguments to be just a string instead of a file
322
+ submit.add_argument('--dups_ok', action='store_true', default=False) # allow a duplicate submission (will provide random unique qualitfier to name)
267
323
  submit.add_argument('--vcpus', type=int, default=4)
268
324
  submit.add_argument('--memory', type=int, default=16000)
269
325
  submit.add_argument('--batch_size', type=int, default=10000)
270
- submit.add_argument('--image', type=str, default="sliderule:latest")
271
- submit.set_defaults(func=Tool.submit_job)
326
+ submit.add_argument('--image', type=str, default="pysr:latest")
327
+ submit.add_argument('--secrets', type=Path, default=None) # contents of file must be json
328
+ submit.set_defaults(func=Tool.submit_job, ro=False, net=True)
272
329
 
273
330
  # scrape
274
331
  scrape = subparsers.add_parser("scrape", parents=[common], help="generate list of arguments from jobs with provided job status")
275
332
  scrape.add_argument('--status', type=JobStatus, nargs='+', default=[JobStatus.FAILURE, JobStatus.UNSUPPORTED, JobStatus.ERROR])
276
333
  scrape.add_argument('--name', type=str, default=None) # name of submission
277
334
  scrape.add_argument('--output', type=str, default=None)
278
- scrape.set_defaults(func=Tool.scrape_submissions)
335
+ scrape.set_defaults(func=Tool.scrape_submissions, ro=True, net=False)
279
336
 
280
337
  # status
281
338
  status = subparsers.add_parser("status", parents=[common], help="display status of submitted jobs")
282
- status.set_defaults(func=Tool.get_status)
339
+ status.add_argument('--concurrency',type=int, default=20)
340
+ status.set_defaults(func=Tool.get_status, ro=False, net=True)
283
341
 
284
342
  # report
285
343
  report = subparsers.add_parser("report", parents=[common], help="generate report of completed jobs")
286
344
  report.add_argument('--name', type=str, default=None) # name of submission
287
- report.set_defaults(func=Tool.generate_report)
345
+ report.set_defaults(func=Tool.generate_report, ro=True, net=False)
288
346
 
289
347
  # cancel
290
348
  cancel = subparsers.add_parser("cancel", parents=[common], help="cancel a submitted job")
291
349
  cancel.add_argument('--name', type=str, required=True) # name of submission
292
- cancel.set_defaults(func=Tool.cancel_job)
350
+ cancel.set_defaults(func=Tool.cancel_job, ro=True, net=True)
293
351
 
294
352
  # logs
295
353
  logs = subparsers.add_parser("logs", parents=[common], help="get log messages for a child job")
296
- logs.add_argument('--name', type=str, required=True) # name of submission
297
- logs.add_argument('--index', type=int, default=None) # job index
298
- logs.set_defaults(func=Tool.get_logs)
354
+ logs.add_argument('--name', type=str, required=True) # name of submission
355
+ logs.add_argument('--index', type=int, default=None) # job index
356
+ logs.set_defaults(func=Tool.get_logs, ro=True, net=True)
299
357
 
300
358
  # parse command line
301
359
  args = parser.parse_args()
@@ -304,13 +362,18 @@ def main():
304
362
  tool = Tool(args)
305
363
 
306
364
  # route command
365
+ exit_status = 0
307
366
  try:
308
367
  args.func(tool)
309
- tool.finish() # only execute if tool function completed successfully
310
368
  except Exception as e:
311
- if args.verbose: raise
312
369
  print(f"Unhandled error: {e}")
313
370
  traceback.print_exc()
371
+ exit_status = 1
372
+ finally:
373
+ tool.finish()
374
+
375
+ # return back to caller
376
+ sys.exit(exit_status)
314
377
 
315
378
  # running via direct invocation
316
379
  if __name__ == "__main__": main()
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "5.6.3",
3
- "distance": 0,
4
- "node": "ge620b5509bb5a993af03131885c3a6f23922cb24",
5
- "dirty": false,
6
- "branch": "main",
7
- "node_date": "2026-09-30"
8
- }
File without changes
File without changes
File without changes
File without changes