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 +5 -0
- falconry/cli.py +54 -0
- falconry/job.py +383 -0
- falconry/manager.py +624 -0
- falconry/schedd_wrapper.py +47 -0
- falconry/translate.py +17 -0
- falconry-0.1.2.dist-info/METADATA +58 -0
- falconry-0.1.2.dist-info/RECORD +10 -0
- falconry-0.1.2.dist-info/WHEEL +4 -0
- falconry-0.1.2.dist-info/licenses/LICENSE +21 -0
falconry/__init__.py
ADDED
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
|
+

|
|
26
|
+
[](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,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.
|