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/client/direct.py ADDED
@@ -0,0 +1,210 @@
1
+ """Direct execution client (no scheduler)."""
2
+
3
+ import subprocess
4
+ import shlex
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Union
9
+
10
+ from hpcpy.client.base import BaseClient
11
+ from hpcpy.constants.direct import COMMANDS, DIRECTIVES, STATUSES
12
+ from hpcpy.utilities import shell
13
+ from hpcpy.job import Job
14
+ import hpcpy.constants as hc
15
+
16
+
17
+ class DirectClient(BaseClient):
18
+ """Direct execution interface for systems without a scheduler.
19
+
20
+ Runs scripts directly as local processes, using the process ID as the
21
+ job ID. Status is tracked via ps(1).
22
+
23
+ Parameters
24
+ ----------
25
+ *args
26
+ Positional arguments forwarded to the base class.
27
+ **kwargs
28
+ Keyword arguments forwarded to the base class.
29
+ """
30
+
31
+ def __init__(self, *args, **kwargs):
32
+ super().__init__(
33
+ cmd_templates=COMMANDS,
34
+ directive_templates=DIRECTIVES,
35
+ statuses=STATUSES,
36
+ status_attribute="short",
37
+ *args,
38
+ **kwargs,
39
+ )
40
+
41
+ def status(self, job_id):
42
+ """Get the status of a directly-executed process.
43
+
44
+ Parameters
45
+ ----------
46
+ job_id : str
47
+ Process ID.
48
+
49
+ Returns
50
+ -------
51
+ tuple
52
+ Generic status code and native status dictionary.
53
+ """
54
+ cmd = self.cmd_templates["status"].format(job_id=job_id)
55
+
56
+ # ps returns non-zero when the process does not exist, so use check=False
57
+ result = shell(cmd, check=False)
58
+ raw = result.stdout.decode("utf8").strip()
59
+
60
+ return self._parse_status(raw, job_id)
61
+
62
+ def submit(
63
+ self,
64
+ job_script: Union[str, Path],
65
+ directives: list = None,
66
+ render: bool = False,
67
+ dry_run: bool = False,
68
+ variables: dict = dict(),
69
+ **context,
70
+ ):
71
+ """Submit a job by running it directly as a local process.
72
+
73
+ Parameters
74
+ ----------
75
+ job_script : Union[str, Path]
76
+ Path to the script to execute.
77
+ directives : list, optional
78
+ Ignored for direct execution.
79
+ render : bool, optional
80
+ Render the job script from a template, by default False
81
+ dry_run : bool, optional
82
+ Return the command rather than executing it, by default False
83
+ variables : dict, optional
84
+ Environment variables to set for the process.
85
+ **context :
86
+ Additional key/value pairs for job script rendering.
87
+
88
+ Returns
89
+ -------
90
+ Union[Job, str]
91
+ Submitted job object, or the command string if dry_run=True.
92
+ """
93
+ # Sanitize the variables, if there are any, then create string version and add to context
94
+ variables_sanitized = self._sanitize_dict(variables)
95
+ variables_str = []
96
+ for key, value in variables_sanitized.items():
97
+ variables_str.append(f"{key}={value}")
98
+
99
+ context["variables_str"] = (
100
+ " ".join(variables_str) + " " if variables_str else ""
101
+ )
102
+
103
+ # Delegate command assembly (directive rendering, job script rendering) to the parent
104
+ cmd = super().submit(
105
+ job_script=job_script,
106
+ directives=directives if isinstance(directives, list) else [],
107
+ render=render,
108
+ dry_run=True,
109
+ variables=self._sanitize_dict(variables),
110
+ **context,
111
+ )
112
+
113
+ if dry_run:
114
+ return cmd
115
+
116
+ _env = os.environ.copy()
117
+ if isinstance(variables_sanitized, dict) and variables_sanitized:
118
+ _env.update({k: str(v) for k, v in variables_sanitized.items()})
119
+
120
+ proc = subprocess.Popen(shlex.split(cmd), env=_env)
121
+ job_id = str(proc.pid)
122
+
123
+ job = Job(job_id, client=None, auto_update=False)
124
+ job.set_client(self)
125
+ job._auto_update = True
126
+
127
+ return job
128
+
129
+ def _parse_status(self, raw, job_id):
130
+ """Extract the status from the raw ps output.
131
+
132
+ Parameters
133
+ ----------
134
+ raw : str
135
+ Raw output from ps(1). Empty string means the process has finished.
136
+ job_id : str
137
+ Process ID.
138
+
139
+ Returns
140
+ -------
141
+ tuple
142
+ Generic status code and native status dictionary.
143
+ """
144
+ # Empty output means the process no longer exists
145
+ if not raw:
146
+ return hc.STATUS_FINISHED, {"pid": job_id, "state": "FINISHED"}
147
+
148
+ # ps -o stat= returns one or more characters; the first is the main state
149
+ state_char = raw[0]
150
+ generic_status = None
151
+
152
+ for s in self.statuses:
153
+ if state_char == s.short:
154
+ generic_status = s.status
155
+ break
156
+
157
+ return generic_status, {"pid": job_id, "state": raw}
158
+
159
+ def _sanitize_dict(self, user_dict: dict) -> dict:
160
+ """Sanitize user-supplied dict for use with subprocess.run.
161
+
162
+ Parameters
163
+ ----------
164
+ user_dict : dict
165
+ Key/value pairs.
166
+
167
+ Returns
168
+ -------
169
+ dict
170
+ Sanitized version of the dict.
171
+
172
+ Raises
173
+ ------
174
+ ValueError
175
+ When a key is invalid.
176
+ When a value is invalid.
177
+ When a null byte is provided.
178
+ """
179
+ VALID_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
180
+ sanitized = dict()
181
+
182
+ for key, value in user_dict.items():
183
+
184
+ # Validate key
185
+ if not isinstance(key, str):
186
+ raise ValueError(f"Key {key!r} must be a string")
187
+
188
+ if not VALID_KEY.match(key):
189
+ raise ValueError(
190
+ f"Key {key!r} is invalid: must start with a letter or underscore "
191
+ f"and contain only alphanumeric characters and underscores"
192
+ )
193
+
194
+ # Validate and sanitize value
195
+ if not isinstance(value, (str, int, float, bool)):
196
+ raise ValueError(
197
+ f"Value for key {key!r} must be a string, int, float, or bool; "
198
+ f"got {type(value).__name__}"
199
+ )
200
+
201
+ str_value = str(value)
202
+
203
+ # Reject null bytes — they silently truncate strings in most shells
204
+ if "\x00" in str_value:
205
+ raise ValueError(f"Value for key {key!r} contains a null byte")
206
+
207
+ # Add to dict and quote for safety
208
+ sanitized[key] = shlex.quote(str_value)
209
+
210
+ return sanitized
hpcpy/client/pbs.py ADDED
@@ -0,0 +1,223 @@
1
+ """PBS implementation."""
2
+
3
+ from hpcpy.client.base import BaseClient
4
+ from hpcpy.constants.pbs import COMMANDS, DIRECTIVES, STATUSES, DELAY_DIRECTIVE_FMT
5
+ from datetime import datetime, timedelta
6
+ from typing import Union
7
+ import json
8
+ from pathlib import Path
9
+ from shlex import quote
10
+
11
+
12
+ class PBSClient(BaseClient):
13
+ """PBS interface.
14
+
15
+ Parameters
16
+ ----------
17
+ *args
18
+ Positional arguments forwarded to the base class.
19
+ **kwargs
20
+ Keyword arguments forwarded to the base class.
21
+ """
22
+
23
+ def __init__(self, *args, **kwargs):
24
+ # Set up the templates
25
+ super().__init__(
26
+ cmd_templates=COMMANDS,
27
+ directive_templates=DIRECTIVES,
28
+ statuses=STATUSES,
29
+ status_attribute="short",
30
+ *args,
31
+ **kwargs,
32
+ )
33
+
34
+ def status(self, job_id):
35
+ """Get the status of a job.
36
+
37
+ Parameters
38
+ ----------
39
+ job_id : str
40
+ Job ID.
41
+
42
+ Returns
43
+ -------
44
+ str
45
+ Generic status code.
46
+ """
47
+ # Get the raw response
48
+ raw = super().status(job_id=job_id)
49
+
50
+ # Parse the status as per this implementation
51
+ generic_status, native_full = self._parse_status(raw, job_id)
52
+
53
+ # Return the generic status
54
+ return generic_status, native_full
55
+
56
+ def _render_variables(self, variables):
57
+ """Render the variables flag for PBS.
58
+
59
+ Parameters
60
+ ----------
61
+ variables : dict
62
+ Dictionary of variables
63
+
64
+ Returns
65
+ -------
66
+ str
67
+ String formatted variables for PBS
68
+ """
69
+ formatted = list()
70
+
71
+ for k, v in variables.items():
72
+
73
+ # Fix for broken quotes #52
74
+ if isinstance(v, str) and " " in v:
75
+ v = quote(v)
76
+ line = f'"{k}={v}"'
77
+ formatted.append(line)
78
+ else:
79
+ formatted.append(f"{k}={v}")
80
+
81
+ formatted = ",".join(formatted)
82
+ return f"-v {formatted}"
83
+
84
+ def submit(
85
+ self,
86
+ job_script: Union[str, Path],
87
+ directives: list = None,
88
+ render: bool = False,
89
+ dry_run: bool = False,
90
+ depends_on: list = None,
91
+ delay: Union[datetime, timedelta] = None,
92
+ queue: str = None,
93
+ walltime: timedelta = None,
94
+ storage: list = None,
95
+ variables: dict = None,
96
+ **context,
97
+ ):
98
+ """Submit a job to the scheduler.
99
+
100
+ Parameters
101
+ ----------
102
+ job_script : Union[str, Path]
103
+ Path to the script.
104
+ directives : list, optional
105
+ List of complete directives to submit, by default list()
106
+ render : bool, optional
107
+ Render the job script from a template, by default False
108
+ dry_run : bool, optional
109
+ Return rather than executing the command, by default False
110
+ depends_on : list, optional
111
+ List of job IDs with successful exit on which this job depends, by default list()
112
+ delay: Union[datetime, timedelta]
113
+ Delay the start of this job until specific date or interval, by default None
114
+ queue: str, optional
115
+ Queue on which to submit the job, by default None
116
+ walltime: timedelta, optional
117
+ Walltime expressed as a timedelta, by default None
118
+ storage: list, optional
119
+ List of storage mounts to apply, by default None
120
+ variables: dict, optional
121
+ Key/value environment variable pairs added to the qsub command.
122
+ **context:
123
+ Additional key/value pairs to be added to command/jobscript interpolation
124
+
125
+ Returns
126
+ -------
127
+ Job : hpcpy.job.Job
128
+ Job object.
129
+ """
130
+ # Initialise the directives to an empty list
131
+ directives = directives if isinstance(directives, list) else []
132
+
133
+ # Add job depends
134
+ if depends_on:
135
+
136
+ # Normalise to a list of strs
137
+ depends_on = super()._normalise_depends_on(depends_on)
138
+
139
+ directives = self._interpolate_directive(
140
+ directives,
141
+ "depends_on",
142
+ depends_on_str=":".join(depends_on),
143
+ )
144
+
145
+ # Add delay (specified time or delta)
146
+ if delay:
147
+ delay_directive = self._assemble_delay_directive(delay, DELAY_DIRECTIVE_FMT)
148
+ directives.append(delay_directive)
149
+
150
+ # Add queue
151
+ if queue:
152
+ directives = self._interpolate_directive(directives, "queue", queue=queue)
153
+ context["queue"] = queue
154
+
155
+ # Add walltime
156
+ if walltime:
157
+ _walltime = str(walltime)
158
+ directives.append(f"-l walltime={_walltime}")
159
+ context["walltime"] = _walltime
160
+
161
+ # Add storage
162
+ if storage:
163
+ storage_str = "+".join(storage)
164
+ directives.append(f"-l storage={storage_str}")
165
+ context["storage"] = storage
166
+ context["storage_str"] = storage_str
167
+
168
+ # Add variables
169
+ if isinstance(variables, dict) and len(variables) > 0:
170
+ directives.append(self._render_variables(variables))
171
+
172
+ # Call the super
173
+ job_or_cmd = super().submit(
174
+ job_script=job_script,
175
+ directives=directives,
176
+ render=render,
177
+ dry_run=dry_run,
178
+ **context,
179
+ )
180
+
181
+ # Return the command if requested
182
+ if dry_run:
183
+ return job_or_cmd
184
+
185
+ # Point to this client from the job and switch on auto_update
186
+ job_or_cmd.set_client(self)
187
+ job_or_cmd._auto_update = True
188
+
189
+ # Return the job object
190
+ return job_or_cmd
191
+
192
+ def _parse_status(self, raw, job_id):
193
+ """Extract the status from the raw response.
194
+
195
+ Parameters
196
+ ----------
197
+ raw : str
198
+ Raw response from the scheduler.
199
+ job_id : str
200
+ Job ID required to extract status from the response.
201
+
202
+ Returns
203
+ -------
204
+ str
205
+ Status code.
206
+ """
207
+ # Convert to JSON
208
+ parsed = json.loads(raw)
209
+
210
+ # Get the status out of the job ID
211
+ native_full = parsed.get("Jobs").get(job_id)
212
+ native_status = native_full.get("job_state")
213
+
214
+ # Set the generic status attribute
215
+ generic_status = None
216
+
217
+ for s in self.statuses:
218
+ if native_status == s.short:
219
+ generic_status = s.status
220
+ break
221
+
222
+ # Return the generic status
223
+ return generic_status, native_full
hpcpy/client/slurm.py ADDED
@@ -0,0 +1,203 @@
1
+ """SLURM Client."""
2
+
3
+ from hpcpy.client.base import BaseClient
4
+ from hpcpy.constants.slurm import COMMANDS, STATUSES, DIRECTIVES, DELAY_DIRECTIVE_FMT
5
+ from datetime import datetime, timedelta
6
+ from typing import Union
7
+ import json
8
+ from pathlib import Path
9
+ import os
10
+
11
+
12
+ class SlurmClient(BaseClient):
13
+ """SLURM interface.
14
+
15
+ Parameters
16
+ ----------
17
+ *args
18
+ Positional arguments forwarded to the base class.
19
+ **kwargs
20
+ Keyword arguments forwarded to the base class.
21
+ """
22
+
23
+ def __init__(self, *args, **kwargs):
24
+ # Set up the templates
25
+ super().__init__(
26
+ cmd_templates=COMMANDS,
27
+ directive_templates=DIRECTIVES,
28
+ statuses=STATUSES,
29
+ status_attribute="short",
30
+ *args,
31
+ **kwargs,
32
+ )
33
+
34
+ def status(self, job_id):
35
+ """Get the status of a job.
36
+
37
+ Parameters
38
+ ----------
39
+ job_id : str
40
+ Job ID.
41
+
42
+ Returns
43
+ -------
44
+ str
45
+ Generic status code.
46
+ """
47
+ # Get the raw response
48
+ raw = super().status(job_id=job_id)
49
+
50
+ # Parse the status as per this implementation
51
+ generic_status, native_full = self._parse_status(raw, job_id)
52
+
53
+ # Return the generic status
54
+ return generic_status, native_full
55
+
56
+ def submit(
57
+ self,
58
+ job_script: Union[str, Path],
59
+ directives: list = None,
60
+ render: bool = False,
61
+ dry_run: bool = False,
62
+ depends_on: list = None,
63
+ delay: Union[datetime, timedelta] = None,
64
+ queue: str = None,
65
+ walltime: timedelta = None,
66
+ variables: dict = dict(),
67
+ **context,
68
+ ):
69
+ """Submit a job to the scheduler.
70
+
71
+ Parameters
72
+ ----------
73
+ job_script : Union[str, Path]
74
+ Path to the script.
75
+ directives : list, optional
76
+ List of complete directives to submit, by default list()
77
+ render : bool, optional
78
+ Render the job script from a template, by default False
79
+ dry_run : bool, optional
80
+ Return rather than executing the command, by default False
81
+ depends_on : list, optional
82
+ List of job IDs with successful exit on which this job depends, by default list()
83
+ delay: Union[datetime, timedelta]
84
+ Delay the start of this job until specific date or interval, by default None
85
+ queue: str, optional
86
+ Queue on which to submit the job, by default None
87
+ walltime: timedelta, optional
88
+ Walltime expressed as a timedelta, by default None
89
+ variables: dict, optional
90
+ Key/value environment variable pairs added to the qsub command.
91
+ **context:
92
+ Additional key/value pairs to be added to command/jobscript interpolation
93
+
94
+ Returns
95
+ -------
96
+ Job : hpcpy.job.Job
97
+ Job object.
98
+ """
99
+ # Get a logger from the parent
100
+ self._logger.debug(f"Submitting {job_script}")
101
+
102
+ # Initialise directives
103
+ directives = directives if isinstance(directives, list) else []
104
+
105
+ # Add job depends
106
+ if depends_on:
107
+
108
+ self._logger.debug("Job dependency specified.")
109
+
110
+ # Normalise depends_on to strs
111
+ depends_on = super()._normalise_depends_on(depends_on)
112
+
113
+ directives = self._interpolate_directive(
114
+ directives,
115
+ "depends_on",
116
+ depends_on_str=":".join(depends_on),
117
+ )
118
+
119
+ # Add delay (specified time or delta)
120
+ if delay:
121
+ self._logger.debug("Delay specified.")
122
+ delay_directive = self._assemble_delay_directive(delay, DELAY_DIRECTIVE_FMT)
123
+ directives.append(delay_directive)
124
+
125
+ # Add queue
126
+ if queue:
127
+ self._logger.debug(f"Queue specified ({queue})")
128
+ directives = self._interpolate_directive(directives, "queue", queue=queue)
129
+ context["queue"] = queue
130
+
131
+ # Add walltime
132
+ if walltime:
133
+ walltime_str = str(int(walltime.total_seconds() / 60.0))
134
+ self._logger.debug(f"Walltime specified ({walltime_str})")
135
+ directives = self._interpolate_directive(
136
+ directives, "walltime", walltime_str=walltime_str
137
+ )
138
+
139
+ # Update the environment with the variables (which is how Slurm does job variables)
140
+ self._logger.debug("Updating environment.")
141
+ _env = os.environ.copy()
142
+ _env.update(variables)
143
+
144
+ # Call the super submit
145
+ self._logger.debug("Submitting via parent class.")
146
+ job_or_cmd = super().submit(
147
+ job_script=job_script,
148
+ directives=directives,
149
+ render=render,
150
+ dry_run=dry_run,
151
+ env=_env,
152
+ **context,
153
+ )
154
+
155
+ # Return the command from the super
156
+ if dry_run:
157
+ self._logger.debug("Dry run requested, returning fully-formed command.")
158
+ return job_or_cmd
159
+
160
+ # Point to this client in the job object and switch on auto_update
161
+ job_or_cmd.set_client(self)
162
+ job_or_cmd._auto_update = True
163
+
164
+ # Get the job ID out of the return string
165
+ self._logger.debug(f"job_id={job_or_cmd.id}")
166
+
167
+ # Return the job object
168
+ return job_or_cmd
169
+
170
+ def _parse_status(self, raw, job_id):
171
+ """Extract the statue from the raw response.
172
+
173
+ Parameters
174
+ ----------
175
+ raw : str
176
+ Raw response from the scheduler.
177
+ job_id : str
178
+ Job ID.
179
+
180
+ Returns
181
+ -------
182
+ generic_status : str
183
+ Generic status code.
184
+ native_full : dict
185
+ Full native status dictionary.
186
+ """
187
+ # Parse the response
188
+ parsed = json.loads(raw)
189
+
190
+ # Get the status from the first job
191
+ native_full = parsed.get("jobs")[0]
192
+ native_status = native_full.get("job_state")[0]
193
+
194
+ # Set the default generic status to None
195
+ generic_status = None
196
+
197
+ # Look up the generic status
198
+ for s in self.statuses:
199
+ if native_status == s.long:
200
+ generic_status = s.status
201
+ break
202
+
203
+ return generic_status, native_full
@@ -0,0 +1,21 @@
1
+ """Common constants."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Location for rendered job scripts
6
+ JOB_SCRIPT_DIR = Path.home() / ".hpcpy" / "job_scripts"
7
+ JOB_SCRIPT_DIR.mkdir(parents=True, exist_ok=True)
8
+
9
+ # Generic Statuses (actually just PBS statuses, but used as a generic status)
10
+ STATUS_CYCLE_HARVESTING = "U"
11
+ STATUS_EXITING = "E"
12
+ STATUS_FINISHED = "F"
13
+ STATUS_HAS_SUBJOB = "B"
14
+ STATUS_HELD = "H"
15
+ STATUS_MOVED = "M"
16
+ STATUS_MOVING = "T"
17
+ STATUS_QUEUED = "Q"
18
+ STATUS_RUNNING = "R"
19
+ STATUS_SUBJOB_COMPLETED = "X"
20
+ STATUS_SUSPENDED = "S"
21
+ STATUS_WAITING = "W"
@@ -0,0 +1,55 @@
1
+ """Constants for direct execution (no scheduler)."""
2
+
3
+ from hpcpy.status import Status
4
+ import hpcpy.constants as hc
5
+
6
+ # Commands
7
+ COMMANDS = dict(
8
+ submit="{variables_str}bash {job_script}",
9
+ status="ps -p {job_id} -o stat=",
10
+ delete="kill {job_id}",
11
+ hold="kill -s STOP {job_id}",
12
+ release="kill -s CONT {job_id}",
13
+ )
14
+
15
+ # Directives (none for direct execution)
16
+ DIRECTIVES = dict()
17
+
18
+ # Statuses - mapped from ps(1) stat codes
19
+ STATUSES = [
20
+ Status("R", "RUNNING", "Process is running.", generic=hc.STATUS_RUNNING),
21
+ Status(
22
+ "S",
23
+ "SLEEPING",
24
+ "Process is sleeping (interruptible).",
25
+ generic=hc.STATUS_RUNNING,
26
+ ),
27
+ Status(
28
+ "D",
29
+ "DISK_SLEEP",
30
+ "Process is in uninterruptible disk sleep.",
31
+ generic=hc.STATUS_RUNNING,
32
+ ),
33
+ Status("I", "IDLE", "Process is idle.", generic=hc.STATUS_RUNNING),
34
+ Status(
35
+ "T", "STOPPED", "Process has been stopped by a signal.", generic=hc.STATUS_HELD
36
+ ),
37
+ Status(
38
+ "Z",
39
+ "ZOMBIE",
40
+ "Process is a zombie (awaiting reaping).",
41
+ generic=hc.STATUS_EXITING,
42
+ ),
43
+ Status(
44
+ "F",
45
+ "FINISHED",
46
+ "Job has finished, or never existed",
47
+ generic=hc.STATUS_FINISHED,
48
+ ),
49
+ Status(
50
+ "E",
51
+ "EXITING",
52
+ "Job has finished, or never existed",
53
+ generic=hc.STATUS_EXITING,
54
+ ),
55
+ ]