falconry 0.1.2__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.
falconry/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .manager import manager # NOQA
2
+ from .job import job # NOQA
3
+ from . import translate # NOQA
4
+ from . import cli # NOQA
5
+ from .schedd_wrapper import ScheddWrapper # NOQA
falconry/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ import sys
2
+ import select
3
+ import logging
4
+ from enum import Enum
5
+ from typing import Dict, Optional, Tuple
6
+
7
+ log = logging.getLogger('falconry')
8
+
9
+
10
+ # helper class to define the status
11
+ class InputState(Enum):
12
+ UNKNOWN = -1
13
+ SUCCESS = 0
14
+ TIMEOUT = 1
15
+
16
+
17
+ def input_checker(
18
+ validOptions: Dict[str, str],
19
+ timeout: int = 60,
20
+ message: str = "Following options available:",
21
+ silent: bool = False,
22
+ ) -> Tuple[InputState, Optional[str]]:
23
+ """Helper function to get input from user
24
+
25
+ Arguments:
26
+ validOptions (Dict[str, str]): dictionary of valid options
27
+ timeout (int, optional): timeout in seconds. Defaults to 60.
28
+ message (str, optional): message to print before options.
29
+ Defaults to "Following options available:".
30
+ silent (bool, optional): silent mode. Defaults to False.
31
+
32
+ Returns:
33
+ Tuple[InputState, Optional[str]]: returns the state of the input
34
+ """
35
+ if message != "" and not silent:
36
+ log.info(message)
37
+ for opt, desc in validOptions.items():
38
+ if desc != "" and not silent:
39
+ log.info(f"{opt} - {desc}")
40
+
41
+ i, o, e = select.select([sys.stdin], [], [], timeout)
42
+ if i:
43
+ inp = sys.stdin.readline().strip()
44
+ if inp in validOptions.keys():
45
+ return InputState.SUCCESS, inp
46
+
47
+ if not silent:
48
+ log.info(f"Unknown state {inp}!")
49
+ return InputState.UNKNOWN, inp
50
+
51
+ else:
52
+ if not silent:
53
+ log.info("Timed out ...")
54
+ return InputState.TIMEOUT, None
falconry/job.py ADDED
@@ -0,0 +1,383 @@
1
+ import htcondor
2
+ import os
3
+ import logging
4
+
5
+ from typing import List, Dict, Any
6
+
7
+ from . import translate
8
+ from .schedd_wrapper import ScheddWrapper
9
+
10
+ log = logging.getLogger('falconry')
11
+
12
+
13
+ class job:
14
+ """Submits and holds a single job and all relevant information
15
+
16
+ The schedd can be imported as:
17
+
18
+ .. code-block:: python
19
+
20
+ from falconry import ScheddWrapper
21
+ schedd = ScheddWrapper()
22
+
23
+ or from the manager:
24
+
25
+ .. code-block:: python
26
+
27
+ from falconry import manager
28
+ mgr = manager.Manager(mgrDir, mgrMsg)
29
+ schedd = mgr.schedd
30
+
31
+ Currently planning to keep single job per clusterID,
32
+ since group submittion would significantly complicate resubmitting.
33
+ HTCondor does not seem to allow for re-submittion of single ProcId,
34
+ so one would have to first connect specific arguments to specific ProcIds
35
+ and then resubmit individual jobs anyway.
36
+
37
+
38
+ Arguments:
39
+ name (str): name of the job for easy identification
40
+ schedd (ScheddWrapper): HTCondor schedd wrapper
41
+ """
42
+
43
+ def __init__(self, name: str, schedd: ScheddWrapper) -> None:
44
+
45
+ # first, define HTCondor schedd wrapper
46
+ self.schedd = schedd
47
+
48
+ # name of the job for easy identification
49
+ self.name = name
50
+
51
+ # since we will be resubmitting, job IDs are kept as a list
52
+ self.clusterIDs: List[str] = []
53
+
54
+ # add a decoration to the job to hold dependencies
55
+ self.dependencies: List["job"] = []
56
+
57
+ # configuration of the jobs
58
+ self.config: Dict[str, str] = {}
59
+
60
+ # to setup initial state (done/submitted and so on)
61
+ self.reset()
62
+
63
+ def set_simple(self, exe: str, logPath: str):
64
+ """Sets up a simple job with only executable and a path to log files
65
+
66
+ Arguments:
67
+ exe (str): path to the executable
68
+ logPath (str): path to the log files
69
+ """
70
+
71
+ # htcondor defines job as a dict
72
+ cfg = {
73
+ "executable": exe,
74
+ "output": logPath + "/" + self.name + "/$(ClusterId).out",
75
+ "error": logPath + "/" + self.name + "/$(ClusterId).err",
76
+ "log": logPath + "/" + self.name + "/$(ClusterId).log",
77
+ }
78
+ self.config = cfg
79
+
80
+ # create the directory for the log
81
+ logDir = os.getcwd() + "/" + logPath + "/" + self.name + "/"
82
+ if not os.path.exists(logDir):
83
+ os.makedirs(logDir)
84
+
85
+ # setup flags:
86
+ self.reset()
87
+
88
+ def save(self) -> Dict[str, Any]:
89
+ """Returns a dictionary containing all relevant job information
90
+ to be saved to a file.
91
+
92
+ Returns:
93
+ Dict[str, Any]: dictionary containing job information
94
+ """
95
+ # first rewrite dependencies using names
96
+ depNames = [j.name for j in self.dependencies]
97
+ jobDict = {
98
+ "clusterIDs": self.clusterIDs,
99
+ "config": self.config,
100
+ "depNames": depNames,
101
+ "done": "false",
102
+ }
103
+ # to test if job is done takes long time
104
+ # because log file needs to be checked
105
+ # so its best to save this status
106
+ if self.done:
107
+ jobDict["done"] = "true"
108
+ return jobDict
109
+
110
+ def load(self, jobDict: Dict[str, Any]) -> None:
111
+ """Loads a job from a dictionary created using the save function.
112
+
113
+ Arguments:
114
+ jobDict (Dict[str, Any]): dictionary containing job information
115
+ """
116
+
117
+ # TODO: define proper "jobDict checker"
118
+ if "clusterIDs" not in jobDict.keys() and "config" not in jobDict.keys():
119
+ log.error("Job dictionary in a wrong form")
120
+ raise SystemError
121
+
122
+ # the htcondor version of the configuration
123
+ self.config = jobDict["config"]
124
+
125
+ # setup flags:
126
+ self.reset()
127
+ # tmp backwards compatibility
128
+ if "done" in jobDict and jobDict["done"] == "true":
129
+ log.debug("Job is already done")
130
+ self.done = True
131
+
132
+ # set cluster IDs
133
+ self.clusterIDs = jobDict["clusterIDs"]
134
+ # if not empty, the job has been already submitted at least once
135
+ if len(self.clusterIDs):
136
+ self.htjob = htcondor.Submit(self.config)
137
+ self.clusterID = self.clusterIDs[-1]
138
+ self.logFile = self.config["log"].replace(
139
+ "$(ClusterId)", str(self.clusterID)
140
+ )
141
+ self.outFile = self.config["output"].replace(
142
+ "$(ClusterId)", str(self.clusterID)
143
+ )
144
+ self.errFile = self.config["error"].replace(
145
+ "$(ClusterId)", str(self.clusterID)
146
+ )
147
+ self.submitted = True
148
+
149
+ def reset(self) -> None:
150
+ """Resets job flags"""
151
+ self.submitted = False
152
+ self.skipped = False
153
+ self.failed = False
154
+ self.done = False
155
+
156
+ def add_job_dependency(self, *args: "job") -> None:
157
+ """Add dependencies to the job.
158
+
159
+ Arguments:
160
+ *args (List["job"]): list of jobs
161
+ """
162
+ self.dependencies.extend(list(args))
163
+
164
+ # submit the job
165
+ def submit(self, force: bool = False) -> None:
166
+ """Submits the job to HTCondor if either the job is not submitted,
167
+ the force flag is set or the job failed.
168
+
169
+ Arguments:
170
+ force (bool, optional): force submission. Defaults to False.
171
+ """
172
+ # TODO: raise error if problem
173
+
174
+ # force: for cases when the job status was checked
175
+ # e.g. when retrying
176
+ # this is can save a lot of time because
177
+ # failed job required the log file to be read.
178
+ # Should not be used by users, only internally.
179
+
180
+ # first check if job was not submitted before:
181
+ if not force and self.clusterIDs != []:
182
+ status = self.get_status()
183
+ if status == 12 or status < 0 or status == 10:
184
+ log.info("Job %s failed and will be resubmitted.", self.name)
185
+ else:
186
+ log.info(
187
+ "The job is %s, not submitting", translate.statusMessage[status]
188
+ )
189
+ return
190
+ else:
191
+ # the htcondor version of the configuration
192
+ self.htjob = htcondor.Submit(self.config)
193
+
194
+ # Of course the submit has different capitalization here ...
195
+ self.submit_result = self.schedd.submit(self.htjob)
196
+ self.clusterID = self.submit_result.cluster()
197
+
198
+ self.clusterIDs.append(self.clusterID)
199
+ log.info("Submitting job %s with id %s", self.name, self.clusterID)
200
+ log.debug(self.config)
201
+ self.logFile = self.config["log"].replace("$(ClusterId)", str(self.clusterID))
202
+ self.outFile = self.config["output"].replace(
203
+ "$(ClusterId)", str(self.clusterID)
204
+ )
205
+ self.errFile = self.config["error"].replace("$(ClusterId)", str(self.clusterID))
206
+
207
+ # reset job properties
208
+ self.reset()
209
+ self.submitted = True
210
+
211
+ def release(self) -> bool:
212
+ """Releases held job"""
213
+ if self.clusterIDs == []:
214
+ return False
215
+ self.schedd.act(
216
+ htcondor.JobAction.Release, "ClusterId == " + str(self.clusterID)
217
+ )
218
+ log.info("Releasing job %s with id %s", self.name, self.clusterID)
219
+ return True
220
+
221
+ def remove(self) -> bool:
222
+ """Removes the job from HTCondor"""
223
+ if self.clusterIDs == []:
224
+ return False
225
+ self.schedd.act(
226
+ htcondor.JobAction.Remove, "ClusterId == " + str(self.clusterID)
227
+ )
228
+ log.info("Removing job %s with id %s", self.name, self.clusterID)
229
+ return True
230
+
231
+ def get_info(self) -> Dict[str, Any]:
232
+ """Returns information about the job
233
+
234
+ Returns:
235
+ Dict[str, Any]: dictionary containing job information
236
+ """
237
+ # check if job has an ID
238
+ if self.clusterIDs == []:
239
+ log.error("Trying to list info for a job which was not submitted")
240
+ raise SystemError
241
+
242
+ constr = "ClusterId == " + str(self.clusterID)
243
+ # get all job info of running job
244
+ ads = self.schedd.query(constraint=constr)
245
+
246
+ # if the job finished, query will be empty and we have to use history
247
+ # because condor is stupid, it returns and iterator (?),
248
+ # so just returning first element
249
+ # TODO: add more to projection
250
+ if ads == []:
251
+ for ad in self.schedd.history(constraint=constr, projection=["JobStatus"]):
252
+ return ad
253
+
254
+ # check if only one job was returned
255
+ if len(ads) != 1:
256
+ # empty job is probably job finished a long ago
257
+ # return specific code -999 and let get_status function
258
+ # sort the rest from the log files
259
+ if ads == []:
260
+ return {"JobStatus": -999}
261
+ else:
262
+ log.error(
263
+ "HTCondor returned more than one jobs for given ID, this should not happen!"
264
+ )
265
+ log.error("Job %s with id %u", self.name, self.clusterID)
266
+ print(ads)
267
+ raise SystemError
268
+
269
+ return ads[0] # we take only single job, so return onl the first eleement
270
+
271
+ def get_status(self) -> int:
272
+ """Returns status of the job, as defined in translate.py
273
+
274
+ Returns:
275
+ int: status of the job
276
+ """
277
+
278
+ # First check if the job is skipped or not even submitted
279
+ if self.skipped:
280
+ return 8
281
+ elif self.done:
282
+ return 4
283
+ elif self.clusterIDs == []: # job was not even submitted
284
+ return 9
285
+ elif not os.path.isfile(self.logFile):
286
+ return 10
287
+
288
+ status_log = self._get_status_log()
289
+ if status_log != 0: # 0 for unknown so try from condor
290
+ return status_log
291
+
292
+ cndr_status = self._get_status_condor()
293
+ # If job is incomplete, simply return the status:
294
+ if cndr_status != 4 and cndr_status != -999:
295
+ return cndr_status
296
+
297
+ log.error("Unknown output of job %s!", self.name)
298
+ return 0
299
+
300
+ def _get_status_condor(self) -> int:
301
+ """Returns status of the job, as defined in condor
302
+
303
+ Returns:
304
+ int: status of the job
305
+ """
306
+ return self.get_info()["JobStatus"]
307
+
308
+ def _get_status_log(self) -> int:
309
+ """Gets status from the log file
310
+
311
+ Returns:
312
+ int: status of the job
313
+ """
314
+ # Check log file to determine if job finished with an error
315
+ with open(self.logFile, 'r') as fl:
316
+ search = fl.read()
317
+
318
+ # User abortion is special case
319
+ if "Job was aborted by the user" in search:
320
+ # I think this is the same as 3 but need to check
321
+ return 12
322
+
323
+ # Sometimes `removed` is not properly saved
324
+ # (probably when continuing after long time?)
325
+ # so here alternative way
326
+ if "SYSTEM_PERIODIC_REMOVE" in search or "Job was aborted" in search:
327
+ return 3
328
+
329
+ # Otherwise check `"Job terminated"`. If the log does not contain it
330
+ # its unknown state
331
+ if "Job terminated" not in search:
332
+ return 0
333
+
334
+ # Evaluate `"Job terminated"`
335
+ searchSplit = search.split("\n")
336
+ for line in searchSplit:
337
+ if "Normal termination (return value" in line:
338
+ line = line.rstrip() # remove '\n' at end of line
339
+ status = int(line.split("value")[1].strip()[:-1])
340
+
341
+ if status == 0:
342
+ self.done = True
343
+ return 4 # success
344
+
345
+ log.debug(f"Job failed {status}")
346
+ self.failed = True
347
+ # Positive values reserved for falconry states,
348
+ # so return as negative
349
+ return -status
350
+ return 11 # no "Normal termination for Job terminated"
351
+
352
+ def set_custom(self, dict: Dict[str, str]) -> None:
353
+ """Sets custom configuration for the job from a dictionary
354
+
355
+ Arguments:
356
+ dict (Dict[str, str]): dictionary containing job configuration
357
+ """
358
+ for key, item in dict.items():
359
+ self.config[key] = item
360
+
361
+ def set_time(self, runTime: int, useRequestRuntime: bool = False) -> None:
362
+ """Sets time limit for the job.
363
+
364
+ For some clusters (DESY), RequestRuntime is used instead of MaxRuntime,
365
+ to use it set useRequestRuntime to `True`.
366
+
367
+ Arguments:
368
+ runTime (int): time limit in seconds
369
+ useRequestRuntime (bool, optional): use RequestRuntime option. Defaults to False.
370
+ """
371
+ self.config["+MaxRuntime"] = str(runTime)
372
+ # RequestRuntime seems to be DESY specific option and does not work
373
+ # e.g. in Prague (jobs get held), so this option is false by default
374
+ if useRequestRuntime:
375
+ self.config["+RequestRuntime"] = str(runTime)
376
+
377
+ def set_arguments(self, args: str) -> None:
378
+ """Sets arguments for the job
379
+
380
+ Arguments:
381
+ args (str): arguments for the job
382
+ """
383
+ self.config["arguments"] = args
falconry/manager.py ADDED
@@ -0,0 +1,624 @@
1
+ import logging
2
+ import json
3
+ import ijson
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import traceback
8
+ import datetime
9
+ import select
10
+ from time import sleep
11
+
12
+ from typing import Dict, Any, Tuple, Optional
13
+
14
+ from .job import job
15
+ from . import translate
16
+ from . import cli
17
+ from .schedd_wrapper import ScheddWrapper
18
+
19
+ log = logging.getLogger('falconry')
20
+
21
+
22
+ class counter:
23
+ # just holds few variables used in status print
24
+ def __init__(self):
25
+ self.waiting = 0
26
+ self.notSub = 0
27
+ self.idle = 0
28
+ self.run = 0
29
+ self.failed = 0
30
+ self.done = 0
31
+ self.skipped = 0
32
+ self.removed = 0
33
+ self.held = 0
34
+
35
+ def __eq__(self, other):
36
+ return self.__dict__ == other.__dict__
37
+
38
+
39
+ class manager:
40
+ """Manager holds all jobs and periodically checks their status.
41
+
42
+ It also take care of dependent jobs,
43
+ submitting jobs when all dependencies are satisfied.
44
+ These are handled as decorations of the job.
45
+
46
+ Arguments:
47
+ mgrDir (str): directory where the manager stores the jobs
48
+ mgrMsg (str): message to be saved in the save file
49
+ maxJobIdle (int): maximum number of idle jobs
50
+ """
51
+
52
+ reservedNames = ["Message", "Command"]
53
+
54
+ def __init__(self, mgrDir: str, mgrMsg: str = "", maxJobIdle: int = -1):
55
+ log.info("MONITOR: INIT")
56
+
57
+ # Initialize the manager, maily getting the htcondor schedd
58
+ self.schedd = ScheddWrapper()
59
+
60
+ # job collection
61
+ self.jobs: Dict[str, job] = {}
62
+
63
+ # now create a directory where the info about jobs will be save
64
+ if not os.path.exists(mgrDir):
65
+ os.makedirs(mgrDir)
66
+ self.dir = mgrDir
67
+ self.saveFileName = self.dir + "/data.json"
68
+ self.mgrMsg = mgrMsg
69
+ self.command = " ".join(sys.argv)
70
+
71
+ self.maxJobIdle = maxJobIdle
72
+ self.curJobIdle = 0
73
+
74
+ # check if save file already exists
75
+ def check_savefile_status(self) -> Tuple[bool, Optional[str]]:
76
+ """Checks if the save file already exists. If it does, asks the user
77
+ whether to load existing jobs or start new ones.
78
+
79
+ Returns:
80
+ Tuple[bool, Optional[str]]: (True, 'l') if load, (True, 'n') if new,
81
+ (False, None) if error
82
+ """
83
+ if os.path.exists(self.saveFileName):
84
+ log.warning(f"Manager directory {self.dir} already exists!")
85
+ state, var = cli.input_checker(
86
+ {"l": "Load existing jobs", "n": "Start new jobs"}
87
+ )
88
+
89
+ # Simplify the output for user interface
90
+ # both unknown/timeout have the same result
91
+ if state == cli.InputState.SUCCESS:
92
+ return True, var
93
+ return False, var
94
+ return True, "n" # automatically assume new
95
+
96
+ def ask_for_message(self):
97
+ """Asks user for a message to be saved in the save file for bookkeeping."""
98
+
99
+ log.info("Enter a message to be saved in the save file " "for bookkeeping.")
100
+ i, o, e = select.select([sys.stdin], [], [], 60)
101
+ if i:
102
+ self.mgrMsg = sys.stdin.readline().strip()
103
+
104
+ def add_job(self, j: job, update: bool = False):
105
+ """Adds a job to the manager. If the job already exists and `update` is
106
+ `True`, it will be updated.
107
+
108
+ Arguments:
109
+ j (job): job to be added
110
+ update (bool, optional): whether to update the job if it already
111
+ exists. Defaults to False.
112
+ """
113
+ # some reserved names, to simplify saving later
114
+ if j.name in manager.reservedNames:
115
+ log.error("Name %s is reserved! Exiting ...", j.name)
116
+ raise SystemExit
117
+
118
+ # first check if the jobs already exists
119
+ if j.name in self.jobs.keys():
120
+ if not update:
121
+ log.error("Job %s already exists! Exiting ...", j.name)
122
+ raise SystemExit
123
+ else:
124
+ log.info(f"Updating job {j.name}.")
125
+
126
+ self.jobs[j.name] = j
127
+
128
+ def save(self, quiet: bool = False):
129
+ """Saves the current status of the jobs to a json file.
130
+
131
+ If `quiet` is `True`, it will not print any messages and
132
+ will not make a time-stamped copy of the save file.
133
+
134
+ Arguments:
135
+ quiet (bool, optional): whether to print messages. Defaults to False.
136
+ """
137
+ if not quiet:
138
+ log.info("Saving current status of jobs")
139
+ output: Dict[str, Any] = {
140
+ "Message": self.mgrMsg,
141
+ "Command": self.command,
142
+ }
143
+ for name, j in self.jobs.items():
144
+ output[name] = j.save()
145
+
146
+ # save with a timestamp as a suffix, create sym link
147
+ current_time = datetime.datetime.now().strftime("%Y%m%d_%H%M_%S")
148
+ fileLatest = f"{self.saveFileName}.latest"
149
+ fileSuf = f"{self.saveFileName}.{current_time}" # only if not quiet
150
+
151
+ with open(fileLatest, "w") as f:
152
+ json.dump(output, f, indent=2)
153
+ if not quiet:
154
+ log.info("Success! Making copy with time-stamp.")
155
+ if not os.path.exists(fileSuf):
156
+ shutil.copyfile(fileLatest, fileSuf)
157
+ else:
158
+ raise FileExistsError(
159
+ f"Destination file {fileSuf} already exists. "
160
+ "This should not be possible."
161
+ )
162
+
163
+ # not necessary to remove, but maybe better to be sure its not broken
164
+ if os.path.exists(self.saveFileName):
165
+ os.remove(self.saveFileName)
166
+ os.symlink(fileLatest.split("/")[-1], self.saveFileName)
167
+
168
+ def load(self, retryFailed: bool = False):
169
+ """Loads the saved status of the jobs from a json file
170
+ provided by the user.
171
+
172
+ Arguments:
173
+ retryFailed (bool, optional): whether to retry the failed jobs.
174
+ Defaults to False.
175
+ """
176
+ log.info("Loading past status of jobs")
177
+ with open(self.dir + "/data.json", "r") as f:
178
+ depNames = {}
179
+ for name, jobDict in ijson.kvitems(f, ""):
180
+ if name in manager.reservedNames:
181
+ continue
182
+ log.debug("Loading job %s", name)
183
+
184
+ # create a job
185
+ j = job(name, self.schedd)
186
+ j.load(jobDict)
187
+
188
+ # add it to the manager
189
+ self.add_job(j, update=True)
190
+
191
+ # decorate the list of names of the dependencies
192
+ depNames[j.name] = jobDict["depNames"]
193
+
194
+ # Now that jobs are defined, dependencies can be recreated
195
+ # also resubmit jobs which failed
196
+ for j in self.jobs.values():
197
+ dependencies = [self.jobs[name] for name in depNames[j.name]]
198
+ j.add_job_dependency(*dependencies)
199
+
200
+ # Retry failed jobs
201
+ # Since this changes the status and submits
202
+ # jobs, add safequard in case of crash
203
+ # to save up-to-date state
204
+ if retryFailed:
205
+ try:
206
+ for j in self.jobs.values():
207
+ self._check_resubmit(j, True)
208
+ except KeyboardInterrupt:
209
+ log.error("Manager interrupted with keyboard!")
210
+ log.error("Saving and exitting ...")
211
+ self.save()
212
+ self.print_failed()
213
+ sys.exit(0)
214
+ except Exception:
215
+ log.error("Error ocurred when running manager!")
216
+ traceback.print_exc(file=sys.stdout)
217
+ self.save()
218
+ self.print_failed()
219
+ sys.exit(1)
220
+
221
+ # print names of all failed jobs
222
+ def print_running(self, printLogs: bool = False):
223
+ log.info("Printing running jobs:")
224
+ for name, j in self.jobs.items():
225
+ if j.get_status() == 2:
226
+ log.info("%s (id %u)", name, j.clusterID)
227
+ if printLogs:
228
+ log.info(f"log: {j.logFile}")
229
+ log.info(f"out: {j.outFile}")
230
+ log.info(f"err: {j.errFile}")
231
+
232
+ # print names of all failed jobs
233
+ def print_failed(self, printLogs: bool = False):
234
+ """Prints names of all failed jobs.
235
+
236
+ Arguments:
237
+ printLogs (bool, optional): whether to print paths to logs.
238
+ Defaults to False.
239
+ """
240
+ log.info("Printing failed jobs:")
241
+ for name, j in self.jobs.items():
242
+ if j.get_status() < 0:
243
+ log.info("%s (id %u)", name, j.clusterID)
244
+ if printLogs:
245
+ log.info(f"log: {j.logFile}")
246
+ log.info(f"out: {j.outFile}")
247
+ log.info(f"err: {j.errFile}")
248
+ # TODO: maybe separate failed and removed?
249
+ log.info("Printing removed jobs:")
250
+ for name, j in self.jobs.items():
251
+ if j.get_status() == 3:
252
+ log.info("%s (id %u)", name, j.clusterID)
253
+ if printLogs:
254
+ log.info(f"log: {j.logFile}")
255
+ log.info(f"out: {j.outFile}")
256
+ log.info(f"err: {j.errFile}")
257
+
258
+ def _check_dependence(self):
259
+ """Checks if all dependencies of a job are done. If so, it will submit
260
+ the job. If any of the dependencies failed, it will add the job to the
261
+ skipped list.
262
+ """
263
+
264
+ # TODO: consider if not submitted jobs in a special list
265
+ for name, j in self.jobs.items():
266
+ # only check jobs which are neither submitted nor skipped
267
+ if j.submitted or j.skipped:
268
+ continue
269
+
270
+ # if ready submit, single not done dependency leads to isReady=False
271
+ isReady = True
272
+ for tarJob in j.dependencies:
273
+ # if any job is not done, do not submit
274
+ if tarJob.done:
275
+ continue
276
+
277
+ isReady = False
278
+
279
+ if tarJob.skipped or tarJob.failed:
280
+ log.error(
281
+ f"Job {name} depends on job {tarJob.name} which either failed or was skipped! Skipping ..."
282
+ )
283
+ j.skipped = True
284
+
285
+ status = tarJob.get_status()
286
+ if status == 3:
287
+ log.error(
288
+ f"Job {name} depends on job {tarJob.name} which is {translate.statusMessage[status]}! Skipping ..."
289
+ )
290
+ j.skipped = True
291
+
292
+ break
293
+
294
+ if isReady:
295
+ # Check if we did not reach maximum number of submitted jobs
296
+ if self.maxJobIdle != -1 and self.curJobIdle > self.maxJobIdle:
297
+ break # break because it does not make sense to check any other jobs now
298
+ j.submit()
299
+ self.curJobIdle += 1 # Add the jobs as a idle for now
300
+
301
+ def _check_resubmit(self, j: job, retryFailed: bool = False):
302
+ """Checks if a job should be resubmitted due to some known problems.
303
+
304
+ Arguments:
305
+ j (job): job to check
306
+ retryFailed (bool, optional): whether to also retry failed jobs.
307
+ Defaults to False.
308
+ """
309
+ status = j.get_status()
310
+ if status > 0:
311
+ log.debug("Job %s has status %s", j.name, translate.statusMessage[status])
312
+ if status == 12:
313
+ log.warning(
314
+ f"Error! Job {j.name} (id {j.clusterID}) failed due to condor, rerunning"
315
+ )
316
+ j.submit(force=True)
317
+ elif retryFailed and status < 0:
318
+ log.warning(
319
+ f"Error! Job {j.name} (id {j.clusterID}) failed and will be retried, rerunning"
320
+ )
321
+ j.submit(force=True)
322
+ elif retryFailed and status == 3:
323
+ log.warning(
324
+ f"Error! Job {j.name} (id {j.clusterID}) was removed and will be retried, rerunning"
325
+ )
326
+ j.submit(force=True)
327
+ elif retryFailed and j.submitted and (status == 9 or status == 10):
328
+ log.warning(
329
+ f"Error! Job {j.name} was not submitted succesfully (probably...), rerunning"
330
+ )
331
+ j.submit(force=True)
332
+
333
+ elif retryFailed and j.skipped:
334
+ log.warning(
335
+ f"Error! Job {j.name} was skipped and will be retried, rerunning"
336
+ )
337
+ j.skipped = False
338
+
339
+ def _count_jobs(self, c: counter):
340
+ """Counts the number of jobs with different status.
341
+ Resubmits jobs which failed due to condor problems.
342
+
343
+ Arguments:
344
+ c (counter): counter object to count the jobs
345
+ """
346
+
347
+ maxLength = 0
348
+ for name, j in self.jobs.items():
349
+ printStr = f"Checking {name}\t\t\t\t\t\t\r"
350
+ if len(printStr) > maxLength:
351
+ maxLength = len(printStr)
352
+ print(printStr, end='')
353
+
354
+ self._count_job(c, j)
355
+
356
+ print(" " * maxLength + "\r", flush=True, end='')
357
+
358
+ def _count_job(self, c: counter, j: job):
359
+ """Updates the counter object with the status of a single job.
360
+ Also resubmits jobs which failed due to condor problems.
361
+
362
+ Arguments:
363
+ c (counter): counter object to update
364
+ j (job): job to check
365
+ """
366
+
367
+ # first check if job is not submitted, skipped or done
368
+ if j.skipped:
369
+ c.skipped += 1
370
+ return
371
+ if not j.submitted:
372
+ c.waiting += 1
373
+ return
374
+ if j.done:
375
+ c.done += 1
376
+ return
377
+
378
+ # resubmit job which failed due to condor problems
379
+ self._check_resubmit(j)
380
+
381
+ # count job with different status
382
+ status = j.get_status()
383
+ if status == 9 or status == 10:
384
+ c.notSub += 1
385
+ elif status == 1:
386
+ c.idle += 1
387
+ elif status == 2:
388
+ c.run += 1
389
+ elif status < 0:
390
+ c.failed += 1
391
+ elif status == 4:
392
+ c.done += 1
393
+ elif status == 5:
394
+ c.held += 1
395
+ elif status == 3:
396
+ c.removed += 1
397
+
398
+ def _start_cli(self, sleep_time: int = 60):
399
+ """Starts the manager, iteratively checking status of jobs.
400
+
401
+ Arguments:
402
+ sleep_time (int, optional): time to sleep between checks.
403
+ Defaults to 60.
404
+ """
405
+ # TODO: maybe add flag to save for each check? or every n-th check?
406
+
407
+ log.info("MONITOR: START")
408
+
409
+ c = counter()
410
+ event_counter = 0
411
+ while True:
412
+
413
+ log.info(
414
+ f"|-Checking status of jobs [{datetime.datetime.now()}]----------------|",
415
+ )
416
+
417
+ cOld = c
418
+ c = counter()
419
+ self._count_jobs(c)
420
+
421
+ # if no job is waiting nor running, finish the manager
422
+ if not (c.waiting + c.notSub + c.idle + c.run > 0):
423
+ break
424
+
425
+ # only printout if something changed:
426
+ if c != cOld:
427
+ sleep(0.2) # the printing sometimes breaks here, adding delay helps...
428
+ log.info(
429
+ "| nsub: {0:>4} | hold: {1:>5} | fail: {2:>6} | rem: {3:>6} | skip: {4:>5} |".format(
430
+ c.notSub, c.held, c.failed, c.removed, c.skipped
431
+ )
432
+ )
433
+ log.info(
434
+ "| wait: {0:>6} | idle: {1:>4} | RUN: {2:>5} | DONE: {3:>6} | TOT: {4:>6} |".format(
435
+ c.waiting, c.idle, c.run, c.done, len(self.jobs)
436
+ )
437
+ )
438
+
439
+ # Update current idle of jobs managed by manager.
440
+ # All new jobs submitted jobs in `check_dependence`
441
+ # will increase this number, that why we create different
442
+ # variable than `c.idle`
443
+ self.curJobIdle = c.idle
444
+
445
+ # checking dependencies and submitting ready jobs
446
+ self._check_dependence()
447
+ self.save(quiet=True)
448
+
449
+ # instead of sleeping wait for input
450
+ log.info(
451
+ "|-Enter 'h' to show all commands, e.g. to resubmit or show failed jobs|"
452
+ )
453
+
454
+ # save with timestamp every 30 events
455
+ # most important for first event when first
456
+ # batch of jobs is defined
457
+ if event_counter % 30 == 0:
458
+ self.save()
459
+ event_counter += 1
460
+
461
+ self._cli_interface(sleep_time)
462
+
463
+ log.info("MONITOR: FINISHED")
464
+
465
+ def _cli_interface(self, sleep_time: int = 60):
466
+ """CLI interface for the manager.
467
+
468
+ Arguments:
469
+ sleep_time (int, optional): time to sleep between checks.
470
+ Defaults to 60.
471
+ """
472
+ print('>>>> ', end='', flush=True)
473
+ state, var = cli.input_checker(
474
+ {
475
+ "h": "",
476
+ "s": "",
477
+ "f": "",
478
+ "x": "",
479
+ "ff": "",
480
+ "retry all": "",
481
+ "r": "",
482
+ "rr": "",
483
+ },
484
+ silent=True,
485
+ timeout=sleep_time,
486
+ )
487
+ if state == cli.InputState.TIMEOUT:
488
+ print('\r \r', end='', flush=True)
489
+ elif state == cli.InputState.SUCCESS:
490
+ if var == "f":
491
+ self.print_failed()
492
+ elif var == "s":
493
+ self.save()
494
+ elif var == "h":
495
+ log.info(
496
+ "|-Enter 'f' to show failed jobs, 'ff' to also show log paths----------|"
497
+ )
498
+ log.info(
499
+ "|-Enter 'r' to show running jobs, 'rr' to also show log paths---------|"
500
+ )
501
+ log.info(
502
+ "|-Enter 'x' to exit, 's' to save or 'retry all' to retry all failed---|"
503
+ )
504
+ self._cli_interface(sleep_time)
505
+ elif var == "ff":
506
+ self.print_failed(True)
507
+ elif var == "r":
508
+ self.print_running()
509
+ elif var == "rr":
510
+ self.print_running(True)
511
+ elif var == "x":
512
+ log.info("MONITOR: EXITING")
513
+ return
514
+ elif var == "retry all":
515
+ for j in self.jobs.values():
516
+ self._check_resubmit(j, True)
517
+
518
+ def _start_gui(self, sleepTime: int = 60):
519
+ """Starts the manager with GUI, iteratively checking status of jobs.
520
+
521
+ This is only experimental!
522
+
523
+ Arguments:
524
+ sleepTime (int, optional): time to sleep between checks.
525
+ Defaults to 60.
526
+ """
527
+
528
+ log.warning("GUI version is only experimental!")
529
+ import tkinter as tk
530
+
531
+ window = tk.Tk()
532
+ window.title("Falconry monitor")
533
+ frm_counter = tk.Frame()
534
+
535
+ def quick_label(name: str, x: int, y: int = 0):
536
+ lbl = tk.Label(master=frm_counter, width=10, text=name)
537
+ lbl.grid(row=y, column=x)
538
+ return lbl
539
+
540
+ quick_label("Not sub.:", 0)
541
+ quick_label("Idle:", 1)
542
+ quick_label("Running:", 2)
543
+ quick_label("Failed:", 3)
544
+ quick_label("Done:", 4)
545
+ quick_label("Waiting:", 5)
546
+ quick_label("Skipped:", 6)
547
+ quick_label("Removed:", 7)
548
+ labels = {}
549
+ labels["ns"] = quick_label("0", 0, 1)
550
+ labels["i"] = quick_label("0", 1, 1)
551
+ labels["r"] = quick_label("0", 2, 1)
552
+ labels["f"] = quick_label("0", 3, 1)
553
+ labels["d"] = quick_label("0", 4, 1)
554
+ labels["w"] = quick_label("0", 5, 1)
555
+ labels["s"] = quick_label("0", 6, 1)
556
+ labels["rm"] = quick_label("0", 1)
557
+
558
+ frm_counter.grid(row=0, column=0)
559
+
560
+ def tk_count():
561
+ c = counter()
562
+ self._count_jobs(c)
563
+ labels["ns"]["text"] = f"{c.notSub}"
564
+ labels["i"]["text"] = f"{c.idle}"
565
+ labels["r"]["text"] = f"{c.run}"
566
+ labels["f"]["text"] = f"{c.failed}"
567
+ labels["d"]["text"] = f"{c.done}"
568
+ labels["w"]["text"] = f"{c.waiting}"
569
+ labels["s"]["text"] = f"{c.skipped}"
570
+ labels["rm"]["text"] = f"{c.removed}"
571
+
572
+ # if no job is waiting nor running, finish the manager
573
+ # TODO: add condition (close on finish)
574
+ # if not (c.waiting + c.notSub + c.idle + c.run > 0):
575
+ # window.destroy()
576
+
577
+ # checking dependencies and submitting ready jobs
578
+ self._check_dependence()
579
+
580
+ window.after(1000 * sleepTime, tk_count)
581
+
582
+ tk_count()
583
+ log.info("MONITOR: START")
584
+ window.mainloop()
585
+ log.info("MONITOR: FINISHED")
586
+
587
+ def start(self, sleepTime: int = 60, gui: bool = False):
588
+ """Starts the manager, iteratively checking status of jobs.
589
+
590
+ Makes sure to save the current state of jobs
591
+ in case of interupt or crash.
592
+
593
+ Arguments:
594
+ sleepTime (int, optional): time to sleep between checks.
595
+ Defaults to 60.
596
+ gui (bool, optional): whether to use GUI. Defaults to False.
597
+ GUI is experimental!
598
+ """
599
+ try:
600
+ if gui:
601
+ self._start_gui(sleepTime)
602
+ else:
603
+ self._start_cli(sleepTime)
604
+ except KeyboardInterrupt:
605
+ log.error("Manager interrupted with keyboard!")
606
+ log.error("Saving and exitting ...")
607
+ self.save()
608
+ self.print_failed()
609
+ sys.exit(0)
610
+ except Exception as e:
611
+ log.error("Error ocurred when running manager!")
612
+ log.error(str(e))
613
+ self.save()
614
+ self.print_failed()
615
+ sys.exit(1)
616
+
617
+ def start_safe(self, sleepTime: int = 60, gui: bool = False):
618
+ """Deprecated! Use `start` instead!"""
619
+ log.warning(
620
+ "IMPORTANT! `start_safe` is now renamed as `start`. "
621
+ "Change your scripts as `start_safe` will be removed "
622
+ "in next version!"
623
+ )
624
+ self.start(sleepTime, gui)
@@ -0,0 +1,47 @@
1
+ import htcondor # for submitting jobs, querying HTCondor daemons, etc.
2
+ import logging
3
+ import functools
4
+ from typing import Callable, Any
5
+ import time
6
+
7
+ log = logging.getLogger('falconry')
8
+
9
+
10
+ class ScheddWrapper:
11
+ """Wrapper to allow reload of schedd"""
12
+ def __init__(self) -> None:
13
+ self.schedd = htcondor.Schedd()
14
+
15
+ # Here the typing did not work properly ...
16
+ def schedd_check(func: Callable[["ScheddWrapper"], Any]): # type: ignore
17
+ @functools.wraps(func)
18
+ def wrapper(self, *args, **kwargs):
19
+ try:
20
+ return func(self, *args, **kwargs)
21
+ except htcondor.HTCondorIOError:
22
+ log.warning("Possible problem with scheduler, waiting a bit and reloading schedd ...")
23
+ time.sleep(60)
24
+ self.schedd = htcondor.Schedd()
25
+ return func(self, *args, **kwargs)
26
+ return wrapper
27
+
28
+ """Reimplementing all the used functions"""
29
+ @schedd_check
30
+ def transaction(self):
31
+ return self.schedd.transaction()
32
+
33
+ @schedd_check
34
+ def act(self, *args, **kwargs):
35
+ return self.schedd.act(*args, **kwargs)
36
+
37
+ @schedd_check
38
+ def query(self, *args, **kwargs):
39
+ return self.schedd.query(*args, **kwargs)
40
+
41
+ @schedd_check
42
+ def history(self, *args, **kwargs):
43
+ return self.schedd.history(*args, **kwargs)
44
+
45
+ @schedd_check
46
+ def submit(self, *args, **kwargs):
47
+ return self.schedd.submit(*args, **kwargs)
falconry/translate.py ADDED
@@ -0,0 +1,17 @@
1
+
2
+ # Translation of the Job status
3
+ statusMessage = {
4
+ 0: "UNKNOWN",
5
+ 1: "IDLE",
6
+ 2: "RUNNING",
7
+ 3: "REMOVED",
8
+ 4: "COMPLETE",
9
+ 5: "HELD",
10
+ 6: "TRANSPORTING",
11
+ 7: "SUSPENDED",
12
+ 8: "SKIPPED",
13
+ 9: "NOT SUBMITTED",
14
+ 10: "LOG FILE MISSING",
15
+ 11: "ABNORMAL TERMINATION",
16
+ 12: "ABORTED BY USER"
17
+ }
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: falconry
3
+ Version: 0.1.2
4
+ Summary: A lightweight python package to create and manage your HTCondor jobs.
5
+ Project-URL: Documentation, https://falconry.readthedocs.io/en/stable/
6
+ Project-URL: Repository, https://github.com/fnechans/falconry
7
+ Project-URL: Issues, https://github.com/fnechans/falconry/issues
8
+ Author-email: Filip Nechansky <filip.nechansky@protonmail.com>
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.8
19
+ Requires-Dist: htcondor>=24.2.1
20
+ Requires-Dist: ijson>=3.3.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # falconry
24
+
25
+ ![Python package](https://github.com/fnechans/falconry/workflows/Python%20package/badge.svg)
26
+ [![Documentation Status](https://readthedocs.org/projects/falconry/badge/?version=latest)](https://falconry.readthedocs.io/en/latest/?badge=latest)
27
+
28
+ ## Introduction
29
+
30
+ Falconry is lightweight python package to create and manage your [HTCondor](https://github.com/htcondor/) jobs.
31
+ It handles things like job submission, dependent jobs, and job status checking. It periodically saves progress,
32
+ so even if you disconnect or htcondor crashes, you can continue where you left off.
33
+
34
+ Detailed documentation can be found on [ReadTheDocs](https://falconry.readthedocs.io/en/latest/index.html). You can also check `example.py` for an example of usage. Package has to be first installed using pip as described in section on [installation](#installation-using-pip).
35
+
36
+ ## Instalation using pip
37
+
38
+ Falconry can be installed using pip:
39
+
40
+ $ pip3 install falconry
41
+
42
+ ## Installation from source
43
+
44
+ To install falconry, simply call following in the repository directory:
45
+
46
+ $ pip3 install --user -e .
47
+
48
+ Then you can include the package in your project simply by adding:
49
+
50
+ import falconry
51
+
52
+ ### Installing python3 API for HTCondor
53
+
54
+ The package requires htcondor API to run. One can simply do:
55
+
56
+ $ python3 -m pip install --user -r requirements.txt
57
+
58
+ though it might be better to install in virtual environment.
@@ -0,0 +1,10 @@
1
+ falconry/__init__.py,sha256=LlVXOIZn1vOA46jO4Xi96W3qxWAdOK5qiAjid_IknCo,174
2
+ falconry/cli.py,sha256=7dMrRzn3maF82pCovpO7I4jyhyFzqWG7HWBKu0kfxJE,1520
3
+ falconry/job.py,sha256=OU5dncDrFp8pB3YA-PAp7NvwALqm6ha-vbVOvKw4hC8,13033
4
+ falconry/manager.py,sha256=rIn9DoUb43NMxTVMW6-J6xWxuwHrhGW6IZ1Cul3E-N4,21831
5
+ falconry/schedd_wrapper.py,sha256=Y9oWnPcmbdDDhLeaeGQX907qt9r7l11z31e6G3GnjA4,1461
6
+ falconry/translate.py,sha256=3eHbHA9dW0HapNRJkcl28EZ61XXfOxdtcCmrIBZA0LM,327
7
+ falconry-0.1.2.dist-info/METADATA,sha256=-JYe85EGHa1aNvLCzjzzgNB2ZZIYd2GXenk49fBRZtU,2329
8
+ falconry-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ falconry-0.1.2.dist-info/licenses/LICENSE,sha256=SGozOSvKOihd2xzAhxloeYpU10cr7daz69N73JTtIs8,1071
10
+ falconry-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Filip Nechansky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.