hpcpy 0.7.3__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.
hpcpy/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Top-level package for hpcpy."""
2
+
3
+ from . import _version
4
+ from hpcpy.client.client_factory import ClientFactory
5
+ from hpcpy.client.direct import DirectClient
6
+ from hpcpy.client.pbs import PBSClient
7
+ from hpcpy.client.slurm import SlurmClient
8
+ from typing import Union
9
+
10
+ __version__ = _version.get_versions()["version"]
11
+
12
+
13
+ def get_client(*args, **kwargs) -> Union[PBSClient, SlurmClient, DirectClient]:
14
+ """Get a client object specific for the current scheduler.
15
+
16
+ Returns
17
+ -------
18
+ Union[PBSClient, SlurmClient, DirectClient]
19
+ Client object for this scheduler.
20
+
21
+ """
22
+ return ClientFactory().get_client(*args, **kwargs)
hpcpy/_version.py ADDED
@@ -0,0 +1,21 @@
1
+
2
+ # This file was generated by 'versioneer.py' (0.29) from
3
+ # revision-control system data, or from the parent directory name of an
4
+ # unpacked source archive. Distribution tarballs contain a pre-generated copy
5
+ # of this file.
6
+
7
+ import json
8
+
9
+ version_json = '''
10
+ {
11
+ "date": "2026-06-25T11:05:52+1000",
12
+ "dirty": false,
13
+ "error": null,
14
+ "full-revisionid": "1e88a763b5dea9ec2589f39ffcd71332a3c6f709",
15
+ "version": "0.7.3"
16
+ }
17
+ ''' # END VERSION_JSON
18
+
19
+
20
+ def get_versions():
21
+ return json.loads(version_json)
@@ -0,0 +1 @@
1
+ """Top-level client objects."""
hpcpy/client/base.py ADDED
@@ -0,0 +1,445 @@
1
+ """Base client object."""
2
+
3
+ from hpcpy.utilities import shell, interpolate_file_template, get_logger, ensure_list
4
+ from hpcpy.job import Job
5
+ import hpcpy.constants as hc
6
+ from random import choice
7
+ from string import ascii_uppercase
8
+ import os
9
+ from datetime import datetime, timedelta
10
+ from pandas import to_timedelta
11
+ from typing import Union
12
+
13
+
14
+ class BaseClient:
15
+ """A base class from which all others inherit.
16
+
17
+ Parameters
18
+ ----------
19
+ cmd_templates : dict
20
+ Dictionary of command templates.
21
+ directive_templates : dict
22
+ Dictionary of directive templates.
23
+ statuses : list
24
+ List of statuses.
25
+ status_attribute : str
26
+ Attribute to use for status lookup.
27
+ job_script_expiry : str, optional
28
+ Job script expiry interval, by default "1H"
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ cmd_templates,
34
+ directive_templates,
35
+ statuses,
36
+ status_attribute,
37
+ job_script_expiry="1H",
38
+ ):
39
+ # Set the command templates etc.
40
+ self.cmd_templates = cmd_templates
41
+ self.job_script_expiry = job_script_expiry
42
+ self.statuses = statuses
43
+ self.status_attribute = status_attribute
44
+ self.directive_templates = directive_templates
45
+
46
+ # Set up a shared logger
47
+ self._logger = get_logger()
48
+
49
+ def _clean_rendered_job_scripts(self, force=False) -> None:
50
+ """Clean the rendered job scripts from the JOB_SCRIPT_DIR.
51
+
52
+ Parameters
53
+ ----------
54
+ force : bool, optional
55
+ Clean regardless of expiry, by default False
56
+ """
57
+ # Disable option
58
+ if self.job_script_expiry is None and force is False:
59
+ return
60
+
61
+ self._logger.debug(f"Cleaning rendered jobscripts (force={force}).")
62
+
63
+ # List the rendered files.
64
+ rendered_job_scripts = self.list_rendered_job_scripts()
65
+
66
+ # Work out the threshold
67
+ now = datetime.now()
68
+ threshold = now - to_timedelta(self.job_script_expiry).to_pytimedelta()
69
+
70
+ for rjs in rendered_job_scripts:
71
+
72
+ # Ensure actually a file
73
+ if not os.path.isfile(rjs):
74
+ continue
75
+
76
+ # Get the modified time of the file, check threshold and delete
77
+ mod_time = datetime.fromtimestamp(os.path.getmtime(rjs))
78
+
79
+ # Check if the file has expired
80
+ if mod_time <= threshold or force:
81
+ self._logger.debug(f"Removing {rjs}")
82
+ os.remove(rjs)
83
+
84
+ def list_rendered_job_scripts(self) -> list:
85
+ """List the rendered job scripts in the JOB_SCRIPT_DIR.
86
+
87
+ Returns
88
+ -------
89
+ list
90
+ List of paths to job scripts.
91
+ """
92
+ return [hc.JOB_SCRIPT_DIR / rjs for rjs in os.listdir(hc.JOB_SCRIPT_DIR)]
93
+
94
+ def submit(
95
+ self,
96
+ job_script,
97
+ directives=list(),
98
+ render=False,
99
+ dry_run=False,
100
+ env=dict(),
101
+ **context,
102
+ ) -> Union[Job, str]:
103
+ """Submit the job script.
104
+
105
+ Parameters
106
+ ----------
107
+ job_script : path-like
108
+ Path to the job script or template if render=True
109
+ render : bool
110
+ Use the job_script as a template and render **context into it.
111
+ directives : list
112
+ List of directives to add to the command.
113
+ dry_run : bool
114
+ Return the command that would have been executed.
115
+ env : dict
116
+ Append the specified dictionary to the execution environment
117
+ **context :
118
+ Additional key/value pairs interpolated into the command and job script.
119
+
120
+ Returns
121
+ -------
122
+ Union[Job, str]
123
+ Submitted job object, or the command to execute (if dry_run=True).
124
+
125
+ """
126
+ if render:
127
+ self._logger.debug("Rendering job script.")
128
+ _job_script = self._render_job_script(job_script, **context)
129
+ else:
130
+ _job_script = job_script
131
+
132
+ self._logger.debug(f"Using job script as {_job_script}")
133
+
134
+ # Add the directives to the interpolation context (will return blank string if nothing there)
135
+ _directives = self._render_directives(directives)
136
+ context["directives"] = _directives
137
+ self._logger.debug(f"Directives rendered as {directives}")
138
+
139
+ # Add the job script to the context
140
+ context["job_script"] = _job_script
141
+
142
+ # Assemble the submit command
143
+ cmd = self.cmd_templates["submit"].format(**context)
144
+ self._logger.debug("Command rendered as:")
145
+ self._logger.debug(cmd)
146
+
147
+ # Just return the command string for the user without submitting
148
+ if dry_run:
149
+ self._logger.debug("dry_run requested, returning submission command.")
150
+ return cmd
151
+
152
+ # Attach the user environment to the call
153
+ self._logger.debug("Updating user environment.")
154
+ _env = os.environ
155
+ _env.update(env)
156
+
157
+ # Submit
158
+ self._logger.debug("Submitting to the shell.")
159
+ result = self._shell(cmd, env=_env)
160
+
161
+ # Job ID is the last part of the returned string for either client
162
+ job_id = result.split()[-1]
163
+
164
+ # Return the job object, but switch off auto_update so it doesn't attempt to get status
165
+ return Job(job_id, client=None, auto_update=False)
166
+
167
+ def status(self, job_id):
168
+ """Check the status of a job.
169
+
170
+ Parameters
171
+ ----------
172
+ job_id : str
173
+ Job ID.
174
+
175
+ Returns
176
+ -------
177
+ str
178
+ Command response text.
179
+ """
180
+ cmd = self.cmd_templates["status"].format(job_id=job_id)
181
+ result = self._shell(cmd)
182
+ return result
183
+
184
+ def delete(self, job_id):
185
+ """Delete/cancel a job.
186
+
187
+ Parameters
188
+ ----------
189
+ job_id : str
190
+ Job ID.
191
+
192
+ Returns
193
+ -------
194
+ str
195
+ Command response text.
196
+ """
197
+ cmd = self.cmd_templates["delete"].format(job_id=job_id)
198
+ result = self._shell(cmd)
199
+ return result
200
+
201
+ def is_queued(self, job_id):
202
+ """Check if the job is queued.
203
+
204
+ Parameters
205
+ ----------
206
+ job_id : str
207
+ Job ID.
208
+
209
+ Returns
210
+ -------
211
+ bool
212
+ True if queued, False otherwise.
213
+ """
214
+ return self.status(job_id) == hc.STATUS_QUEUED
215
+
216
+ def is_running(self, job_id):
217
+ """Check if the job is running.
218
+
219
+ Parameters
220
+ ----------
221
+ job_id : str
222
+ Job ID.
223
+
224
+ Returns
225
+ -------
226
+ bool
227
+ True if running, False otherwise.
228
+ """
229
+ return self.status(job_id) == hc.STATUS_RUNNING
230
+
231
+ def _shell(self, cmd, decode=True, env=None):
232
+ """Run the shell utility for the given command.
233
+
234
+ Parameters
235
+ ----------
236
+ cmd : str
237
+ Command to run.
238
+ decode : bool
239
+ Automatically decode response with utf-8, defaults to True
240
+ env : dict, optional
241
+ Add environment variables to the command.
242
+
243
+ Raises
244
+ ------
245
+ hpcpy.exceptions.ShellException :
246
+ When the underlying shell call fails.
247
+
248
+ Returns
249
+ -------
250
+ str
251
+ Result from the underlying called command.
252
+ """
253
+ result = shell(cmd, env=env)
254
+
255
+ if decode:
256
+ result = result.stdout.decode("utf8").strip()
257
+
258
+ return result
259
+
260
+ def _get_job_script_filename(self, filepath, hash_length=8) -> str:
261
+ """Generate a script filename with a random suffix.
262
+
263
+ Parameters
264
+ ----------
265
+ filepath : str
266
+ Path to the file.
267
+ hash_length : int, optional
268
+ Length of the random hash, by default 8
269
+
270
+ Returns
271
+ -------
272
+ str
273
+ Hash-suffixed filename.
274
+ """
275
+ filename, ext = os.path.splitext(filepath)
276
+ filename = os.path.basename(filename)
277
+ _hash = "".join(choice(ascii_uppercase) for i in range(hash_length))
278
+ return f"{filename}_{_hash}{ext}"
279
+
280
+ def _render_job_script(self, template, **context):
281
+ """Render a job script.
282
+
283
+ Parameters
284
+ ----------
285
+ template : str, path-like.
286
+ Path to the template.
287
+ **context :
288
+ Key/value pairs to be interpolated into the script.
289
+
290
+ Returns
291
+ -------
292
+ str
293
+ Path to the rendered job script.
294
+ """
295
+ # Render the template
296
+ _rendered = interpolate_file_template(template, **context)
297
+
298
+ # Generate the output filepath
299
+ os.makedirs(hc.JOB_SCRIPT_DIR, exist_ok=True)
300
+ output_filename = self._get_job_script_filename(template)
301
+ output_filepath = hc.JOB_SCRIPT_DIR / output_filename
302
+
303
+ # Write it out
304
+ with open(output_filepath, "w") as fo:
305
+ fo.write(_rendered)
306
+
307
+ return output_filepath
308
+
309
+ def _render_directives(self, directives):
310
+ """Render the directives into a single string for command interpolation.
311
+
312
+ Parameters
313
+ ----------
314
+ directives : list
315
+ List of scheduler-compliant directives. One per item.
316
+
317
+ Returns
318
+ -------
319
+ str
320
+ Rendered directives, or blank string.
321
+ """
322
+ # Render blank directives if empty
323
+ if not directives:
324
+ return ""
325
+
326
+ return " " + " ".join(directives)
327
+
328
+ def hold(self, job_id):
329
+ """Hold a job.
330
+
331
+ Parameters
332
+ ----------
333
+ job_id : str
334
+ Job ID.
335
+
336
+ Returns
337
+ -------
338
+ str
339
+ Command response text.
340
+ """
341
+ cmd = self.cmd_templates["hold"].format(job_id=job_id)
342
+ result = self._shell(cmd)
343
+ return result
344
+
345
+ def release(self, job_id):
346
+ """Release a job.
347
+
348
+ Parameters
349
+ ----------
350
+ job_id : str
351
+ Job ID.
352
+
353
+ Returns
354
+ -------
355
+ str
356
+ Command response text.
357
+ """
358
+ cmd = self.cmd_templates["release"].format(job_id=job_id)
359
+ result = self._shell(cmd)
360
+ return result
361
+
362
+ def _assemble_delay_directive(self, delay, delay_directive_fmt) -> str:
363
+ """Assemble the delay directive for the scheduler.
364
+
365
+ Parameters
366
+ ----------
367
+ delay : datetime or timedelta
368
+ Either a specific datetime or delta from now.
369
+ delay_directive_fmt : str
370
+ Delay directive format.
371
+
372
+ Returns
373
+ -------
374
+ str
375
+ Formatted delay directive for the current scheduler.
376
+
377
+ Raises
378
+ ------
379
+ ValueError
380
+ When the delay argument is incorrect or puts the job in the past.
381
+ """
382
+ current_time = datetime.now()
383
+ delay_str = None
384
+
385
+ if isinstance(delay, datetime) and delay > current_time:
386
+ delay_str = delay.strftime(delay_directive_fmt)
387
+
388
+ elif isinstance(delay, timedelta) and (current_time + delay) > current_time:
389
+ delay_str = (current_time + delay).strftime(delay_directive_fmt)
390
+ else:
391
+ raise ValueError(
392
+ "Job submission delay argument either incorrect or puts the job in the past."
393
+ )
394
+
395
+ return self.directive_templates["delay"].format(delay_str=delay_str)
396
+
397
+ def _interpolate_directive(self, directives, key, **kwargs):
398
+ """Interpolate a directive into the keyed template and add to the list.
399
+
400
+ Parameters
401
+ ----------
402
+ directives : list
403
+ Directives
404
+ key : str
405
+ Key to the template to interpolate into.
406
+ **kwargs :
407
+ Key/value pairs to use in interpolation.
408
+
409
+ Returns
410
+ -------
411
+ list
412
+ Updated directives list.
413
+ """
414
+ directives.append(self.directive_templates[key].format(**kwargs))
415
+ return directives
416
+
417
+ def _normalise_depends_on(self, jobs: Union[str, Job, list]) -> list:
418
+ """Normalise the jobs supplied by a depends_on argument into strings.
419
+
420
+ Parameters
421
+ ----------
422
+ jobs : Union[str, Job, list]
423
+ Job ID, Job object or a list containing either.
424
+
425
+ Returns
426
+ -------
427
+ list
428
+ List of Job IDs
429
+
430
+ Raises
431
+ ------
432
+ TypeError
433
+ When any of the objects is neither a str or a Job object.
434
+ """
435
+ normalised = list()
436
+ for ix, _job in enumerate(ensure_list(jobs)):
437
+
438
+ if isinstance(_job, str):
439
+ normalised.append(_job)
440
+ elif isinstance(_job, Job):
441
+ normalised.append(_job.id)
442
+ else:
443
+ raise TypeError(f"Object at index {ix} is neither a str or Job object.")
444
+
445
+ return normalised
@@ -0,0 +1,38 @@
1
+ """Client Factory."""
2
+
3
+ from hpcpy.client.direct import DirectClient
4
+ from hpcpy.client.pbs import PBSClient
5
+ from hpcpy.client.slurm import SlurmClient
6
+ from hpcpy.utilities import shell
7
+ from typing import Union
8
+
9
+
10
+ class ClientFactory:
11
+ """An object that agnostically selects a scheduler."""
12
+
13
+ def get_client(
14
+ self, *args, **kwargs
15
+ ) -> Union[PBSClient, SlurmClient, DirectClient]:
16
+ """Get a client object based on what kind of scheduler we are using.
17
+
18
+ Falls back to DirectClient when no scheduler can be detected.
19
+
20
+ Arguments:
21
+ ----------
22
+ **kwargs
23
+ Arguments for the specific client.
24
+
25
+ Returns
26
+ -------
27
+ Union[PBSClient, SlurmClient, DirectClient]
28
+ Client object suitable for the detected scheduler.
29
+ """
30
+ clients = dict(qsub=PBSClient, sbatch=SlurmClient)
31
+
32
+ # Loop through the clients in order, looking for a valid scheduler
33
+ for cmd, client in clients.items():
34
+ if shell(f"which {cmd}", check=False).returncode == 0:
35
+ return client(*args, **kwargs)
36
+
37
+ # No scheduler found; fall back to direct execution
38
+ return DirectClient(*args, **kwargs)