pipeforge 1.0.0__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.
@@ -0,0 +1,654 @@
1
+ # vim: colorcolumn=101 textwidth=100
2
+
3
+ import os
4
+ import re
5
+ import json
6
+ import copy
7
+
8
+ import toml # Not included in python's standard lib (ie. need to be "pip install"ed)
9
+
10
+ from .utils import log # Local module
11
+
12
+
13
+
14
+ ####################################################################################################
15
+ # Auxiliary functions
16
+ ####################################################################################################
17
+
18
+ def _find_line(file_path, regex):
19
+ """
20
+ Find the first line in a file that matches the provided regular expression.
21
+
22
+ @param file_path: Path to the file to search
23
+ @param regex : Regular expression to search for. Note that if "regex" is a list, the function
24
+ will search for the first line that matches the first regex, then the next
25
+ line (starting from the just found line) that matches the second regex, and so
26
+ on. The return value will be the line number where the last regex was found.
27
+
28
+ @return: The line number where the first match was found. If no match was found, return -1
29
+ """
30
+
31
+ if not isinstance(regex, list):
32
+ regex = [regex]
33
+
34
+ with open(file_path) as f:
35
+ n = 0
36
+ for line_number, line in enumerate(f):
37
+ if re.search(regex[n], line):
38
+ if n == len(regex) - 1:
39
+ return line_number+1
40
+ else:
41
+ n += 1
42
+ else:
43
+ return -1
44
+
45
+
46
+
47
+ ####################################################################################################
48
+ # API
49
+ ####################################################################################################
50
+
51
+ class FileLoader:
52
+
53
+ def __init__(self, path_to_file, validation_mode=False):
54
+
55
+ self._validation_mode = validation_mode
56
+
57
+ extension = os.path.splitext(path_to_file)[1]
58
+
59
+ log("DEBUG", "")
60
+ log("DEBUG", f"Pipeline file read from disk ({path_to_file}):")
61
+ log("DEBUG", open(path_to_file).readlines())
62
+
63
+ if extension == ".json":
64
+ self._pipeline_data = json.load(open(path_to_file))
65
+
66
+ elif extension == ".toml":
67
+ self._pipeline_data = toml.load(path_to_file)
68
+
69
+ else:
70
+ # Try to figure out whether this is json or toml
71
+ try:
72
+ self._pipeline_data = json.load(open(path_to_file))
73
+ except Exception as e1:
74
+ try:
75
+ self._pipeline_data = toml.load(path_to_file)
76
+ except Exception as e2:
77
+ log("ERROR", "")
78
+ log("ERROR", f"The provided file ({path_to_file}) does not seem to contain valid JSON nor TOML data!")
79
+ log("ERROR", "")
80
+ log("ERROR", f"json loader: {e1}")
81
+ log("ERROR", "")
82
+ log("ERROR", f"toml loader: {e2}")
83
+ log("ERROR", "")
84
+ raise ValueError("Invalid file format")
85
+
86
+ log("DEBUG", "")
87
+ log("DEBUG", "Pipeline data once parsed into memory structures:")
88
+ log("DEBUG", self._pipeline_data)
89
+
90
+ self._file = path_to_file
91
+
92
+ if validation_mode:
93
+ self._file_str = ""
94
+ else:
95
+ self._file_str = f" ({self._file})"
96
+
97
+ self._expand_syntactic_sugar() # This will also fill "self._dependencies"
98
+ self._validate()
99
+
100
+
101
+ def _calculate_dependencies(self, pipeline_name):
102
+
103
+ for p in self._pipeline_data["pipelines"]:
104
+ if p["name"] == pipeline_name:
105
+ pipeline = p
106
+ break
107
+ else:
108
+ log("ERROR", "")
109
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, re.escape(pipeline_name))})")
110
+ log("ERROR", f"Pipeline {pipeline_name} does not exist!")
111
+ log("ERROR", "")
112
+ raise ValueError("Malformed pipeline definition file")
113
+
114
+ job_pointers = {} # Direct access to each job object by name
115
+ job_dependencies = {} # List of other jobs a particular job depends on
116
+
117
+ for job in pipeline["jobs"]:
118
+ if job["name"] in job_pointers.keys():
119
+ log("ERROR", "")
120
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'name.*' + re.escape(job['name'])])}")
121
+ log("ERROR", f"Job <{job['name']}> is defined more than once in pipeline <{pipeline_name}>")
122
+ log("ERROR", "")
123
+ raise ValueError("Malformed pipeline definition file")
124
+
125
+ job_pointers [job["name"]] = job
126
+ job_dependencies[job["name"]] = []
127
+
128
+ for job in pipeline["jobs"]:
129
+ if "input" in job.keys():
130
+ for param, value in job["input"].items():
131
+ if not isinstance(value, str):
132
+ continue
133
+ for ref in re.findall("@{.*?}", value):
134
+ if "::" in ref[2:-1]:
135
+ # Reference to a parameter from another job
136
+ #
137
+ job_ref, param_ref = ref[2:-1].split("::")
138
+
139
+ if job_ref not in job_pointers.keys():
140
+ log("ERROR", "")
141
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)])}")
142
+ log("ERROR", f"Job <{job['name']}> is referencing a non existing job (<{job_ref}>) in its input parameters section")
143
+ log("ERROR", "")
144
+ raise ValueError("Malformed pipeline definition file")
145
+
146
+ if "output" not in job_pointers[job_ref] or \
147
+ param_ref not in job_pointers[job_ref]["output"].keys():
148
+
149
+ log("ERROR", "")
150
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)+'::'+re.escape(param_ref)])}")
151
+ log("ERROR", f"Job <{job['name']}> is referencing a non existing parameter (<{param_ref}>) from job <{job_ref}>")
152
+ log("ERROR", "")
153
+ raise ValueError("Malformed pipeline definition file")
154
+ else:
155
+ # Reference to the job itself (this is used to indicate that the current
156
+ # job must not start until the referenced one has finished)
157
+ #
158
+ job_ref = ref[2:-1]
159
+
160
+ if job_ref not in job_pointers.keys():
161
+ log("ERROR", "")
162
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)])}")
163
+ log("ERROR", f"Job <{job['name']}> is referencing a non existing job (<{job_ref}>) in its input parameters section")
164
+ log("ERROR", "")
165
+ raise ValueError("Malformed pipeline definition file")
166
+
167
+ if job_ref not in job_dependencies[job["name"]]:
168
+ job_dependencies[job["name"]].append(job_ref)
169
+
170
+ # Check for circular dependency
171
+ #
172
+ if job_ref in job_dependencies.keys() and \
173
+ job["name"] in job_dependencies[job_ref]:
174
+ log("ERROR", "")
175
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(job_ref)])}")
176
+ log("ERROR", f"Job <{job['name']}> depends on job <{job_ref}> which in turn depends on job <{job['name']}> (circular dependency)")
177
+ log("ERROR", "")
178
+ raise ValueError("Malformed pipeline definition file")
179
+
180
+
181
+ return job_dependencies
182
+
183
+
184
+ def _expand_syntactic_sugar(self):
185
+
186
+ # If there is no "pipelines" entry, create one with name = "main" and add all jobs to it
187
+ #
188
+ if "pipelines" not in self._pipeline_data:
189
+ if "jobs" not in self._pipeline_data:
190
+ log("ERROR", "")
191
+ log("ERROR", f"Malformed pipeline definition file{self._file_str}")
192
+ log("ERROR", "No jobs defined")
193
+ log("ERROR", "")
194
+ raise ValueError("Malformed pipeline definition file")
195
+
196
+ self._pipeline_data["pipelines"] = [{
197
+ "name" : "main",
198
+ "jobs" : copy.deepcopy(self._pipeline_data["jobs"])
199
+ }]
200
+ del self._pipeline_data["jobs"]
201
+
202
+
203
+ # Make sure all pipelines have a name and a jobs list
204
+ #
205
+ for pipeline in self._pipeline_data["pipelines"]:
206
+ if "name" not in pipeline or "jobs" not in pipeline:
207
+ log("ERROR", "")
208
+ log("ERROR", f"Malformed pipeline definition file{self._file_str}")
209
+ log("ERROR", "All pipelines must have a name and a list of jobs")
210
+ log("ERROR", "")
211
+ raise ValueError("Malformed pipeline definition file ")
212
+
213
+ if type(pipeline["jobs"]) is not list:
214
+ log("ERROR", "")
215
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(pipeline['name']),'jobs'])}")
216
+ log("ERROR", "The jobs entry of a pipeline must be a list")
217
+ log("ERROR", "")
218
+ raise ValueError("Malformed pipeline definition file")
219
+
220
+
221
+ # Replace "${PIPEFORGE__...}" references with values extracted from the environment
222
+ #
223
+ if self._validation_mode:
224
+ # In validation mode we don't care about environment variables. No need to process them.
225
+ #
226
+ pass
227
+
228
+ else:
229
+ try:
230
+ if "meta" in self._pipeline_data:
231
+ raw_meta = json.dumps(self._pipeline_data["meta"])
232
+
233
+ for ref in re.findall(r"\${PIPEFORGE__.*?}", raw_meta):
234
+ resolved_ref = os.getenv(ref[2:-1])
235
+
236
+ if not resolved_ref:
237
+ log("ERROR", "")
238
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['meta',re.escape(ref)])}")
239
+ log("ERROR", f"'meta' section is referencing a non existing pipeline manager variable (<{ref}>)")
240
+ log("ERROR", "")
241
+ raise ValueError("Malformed pipeline definition file")
242
+
243
+ raw_meta = raw_meta.replace(ref, os.getenv(ref[2:-1]))
244
+
245
+ self._pipeline_data["meta"] = json.loads(raw_meta)
246
+ except Exception:
247
+ # We don't really care about the "meta" section
248
+ pass
249
+
250
+ for pipeline in self._pipeline_data["pipelines"]:
251
+ for job in pipeline["jobs"]:
252
+ if "input" in job.keys():
253
+ for param, value in job["input"].items():
254
+ for ref in re.findall(r"\${PIPEFORGE__.*?}", value):
255
+ resolved_ref = os.getenv(ref[2:-1])
256
+
257
+ if not resolved_ref:
258
+ log("ERROR", "")
259
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'input',re.escape(ref)])}")
260
+ log("ERROR", f"Job <{job['name']}> is referencing a non existing pipeline manager variable (<{ref}>) in its input parameters section")
261
+ log("ERROR", "")
262
+ raise ValueError("Malformed pipeline definition file")
263
+
264
+ job["input"][param] = job["input"][param].replace(ref, resolved_ref)
265
+
266
+ log("DEBUG", "")
267
+ log("DEBUG", "Pipeline data after \"PIPEFORGE__...\" environment variables substitution:")
268
+ log("DEBUG", self._pipeline_data)
269
+
270
+
271
+ # Inject global parameters into each of the jobs
272
+ #
273
+ def _add_not_already_present_dictionary_entries(source, target):
274
+ #
275
+ # Insert (key,values) from dictionary "source" into dictionary "target", but only those
276
+ # which do not already exist in target.
277
+ #
278
+ # Some of the keys of "source" can contain nested dictionaried (of arbitrary depth). In
279
+ # these cases, if the key already exists in "target", this function will "descend" into
280
+ # the new dictionaries and start the process all over.
281
+
282
+ for param, value in source.items():
283
+
284
+ if param not in target.keys():
285
+ target[param] = copy.deepcopy(value)
286
+ continue
287
+
288
+ if isinstance(value, str) and isinstance(target[param], str):
289
+ # Don't update an already existing value
290
+ continue
291
+
292
+ if isinstance(value, dict) and isinstance(target[param], dict):
293
+ _add_not_already_present_dictionary_entries(value, target[param])
294
+ continue
295
+
296
+ log("ERROR", "")
297
+ log("ERROR", f"Malformed pipeline definition file{self._file_str}")
298
+ log("ERROR", f"Incompatible types when applying global substitution on parameter {param}:")
299
+ log("ERROR", f"{type(value)} != {type(target[param])}")
300
+ log("ERROR", "")
301
+ raise ValueError("Malformed pipeline definition file")
302
+
303
+ if "global" in self._pipeline_data:
304
+ for pipeline in self._pipeline_data["pipelines"]:
305
+ for job in pipeline["jobs"]:
306
+ _add_not_already_present_dictionary_entries(self._pipeline_data["global"], job)
307
+
308
+ del self._pipeline_data["global"]
309
+
310
+ log("DEBUG", "")
311
+ log("DEBUG", "Pipeline data after globals injection:")
312
+ log("DEBUG", self._pipeline_data)
313
+
314
+
315
+ # Process the "run_always" "fake" parameter
316
+ #
317
+ if "config" in self._pipeline_data and "run_always" in self._pipeline_data["config"]:
318
+
319
+ jobs_always = re.findall("@{.*?}", self._pipeline_data["config"]["run_always"])
320
+ jobs_always = [x[2:-1] for x in jobs_always]
321
+
322
+ for pipeline in self._pipeline_data["pipelines"]:
323
+ if set([x["name"] for x in pipeline["jobs"]]) & set(jobs_always):
324
+ # There is at least one job that must always run thus:
325
+ #
326
+ # 1. Change the "on_failure" property of all jobs in this pipeline to
327
+ # "continue"
328
+ #
329
+ # 2. Change the "on_input_err" property of all jobs in this pipeline:
330
+ # ...to "skip" for jobs not in the jobs_always list
331
+ # ...to "run" for jobs not in jobs_always list
332
+
333
+ for job in pipeline["jobs"]:
334
+ job["on_failure"] = "continue"
335
+ job["on_input_err"] = "skip"
336
+
337
+ if job["name"] in jobs_always:
338
+ job["on_input_err"] = "run"
339
+
340
+ log("DEBUG", "")
341
+ log("DEBUG", "Pipeline data after 'run_always' expansion:")
342
+ log("DEBUG", self._pipeline_data)
343
+
344
+
345
+ # Resolve dependencies
346
+ #
347
+ self._dependencies = {}
348
+ for pipeline in self._pipeline_data["pipelines"]:
349
+ self._dependencies[pipeline["name"]] = self._calculate_dependencies(pipeline["name"])
350
+
351
+
352
+ # Create new pipelines for "retrigger without" on_failure conditions.
353
+ #
354
+ auto_pipeline = ["auto_pipeline_", 0]
355
+ for pipeline in self._pipeline_data["pipelines"]:
356
+ for job in pipeline["jobs"]:
357
+ if "on_failure" not in job.keys():
358
+ log("ERROR", "")
359
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name'])])}")
360
+ log("ERROR", f"Job <{job['name']}> does not have an <on_failure> property")
361
+ log("ERROR", "")
362
+ raise ValueError("Malformed pipeline definition file")
363
+
364
+ if job["on_failure"].startswith("retrigger without"):
365
+
366
+ # Create new pipeline for when this job fails
367
+
368
+ auto_pipeline = [auto_pipeline[0], auto_pipeline[1] + 1]
369
+ new_pipeline = {
370
+ "name" : auto_pipeline[0] + str(auto_pipeline[1]),
371
+ "jobs" : []
372
+ }
373
+
374
+ self._dependencies[new_pipeline["name"]] = self._dependencies[pipeline["name"]]
375
+
376
+ skipped_jobs = re.findall("@{([^}]*)}", job["on_failure"])
377
+
378
+ job["on_failure"] = f"trigger pipeline : {new_pipeline['name']}"
379
+
380
+ # Add to the new pipeline a copy of all the jobs in the current pipeline that do
381
+ # not appear in the skipped_jobs list:
382
+ #
383
+ for job2 in pipeline["jobs"]:
384
+ if job2["name"] not in skipped_jobs:
385
+ new_job = copy.deepcopy(job2)
386
+
387
+ # If one of the jobs to add depends on a skipped_job, replace the input
388
+ # parameter value for a reference to the status of the jobs the
389
+ # skipped_job depends on.
390
+ #
391
+ if "input" in new_job.keys():
392
+ for input_param, input_value in new_job["input"].items():
393
+ for skipped in skipped_jobs:
394
+
395
+ def scan_dependencies(replacement, deps):
396
+ for dep in deps:
397
+ if dep not in skipped:
398
+ replacement.append("@{" + dep + "}")
399
+ else:
400
+ scan_dependencies(
401
+ replacement,
402
+ self._dependencies[pipeline["name"]][dep])
403
+
404
+ replacement = []
405
+ scan_dependencies(
406
+ replacement,
407
+ self._dependencies[pipeline["name"]][skipped])
408
+
409
+ for x in re.findall(
410
+ "(@{"+re.escape(skipped)+" *(::)*[^}]*})",
411
+ input_value):
412
+
413
+ input_value = input_value.replace(
414
+ x[0],
415
+ "!!" + "+".join(replacement) + "!!")
416
+
417
+ new_job["input"][input_param] = input_value
418
+
419
+ new_pipeline["jobs"].append(new_job)
420
+
421
+ self._pipeline_data["pipelines"].append(new_pipeline)
422
+
423
+ log("DEBUG", "")
424
+ log("DEBUG", "Pipeline data after implicit pipelines creation:")
425
+ log("DEBUG", self._pipeline_data)
426
+
427
+
428
+ # Resolve dependencies of newly created pipelines
429
+ #
430
+ for i in range(auto_pipeline[1]):
431
+ new_pipeline_name = auto_pipeline[0] + str(i+1)
432
+ self._dependencies[new_pipeline_name] = self._calculate_dependencies(new_pipeline_name)
433
+
434
+
435
+ def _validate(self):
436
+
437
+ # Make sure there is a pipeline called "main"
438
+ #
439
+ if "pipelines" not in self._pipeline_data or \
440
+ "main" not in [pipeline["name"] for pipeline in self._pipeline_data["pipelines"]]:
441
+ log("ERROR", "")
442
+ log("ERROR", f"Malformed pipeline definition file{self._file_str}")
443
+ log("ERROR", "Could not find a pipeline with name = 'main'")
444
+ log("ERROR", "")
445
+ raise ValueError("Malformed pipeline definition file")
446
+
447
+
448
+ # Make sure the name of pipelines is unique
449
+ #
450
+ all_pipeline_names = [pipeline["name"] for pipeline in self._pipeline_data["pipelines"]]
451
+ if len(all_pipeline_names) != len(set(all_pipeline_names)):
452
+ repeated = list(set([x for x in all_pipeline_names if all_pipeline_names.count(x) > 1]))[0]
453
+ log("ERROR", "")
454
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(repeated),'name.*' + re.escape(repeated)])}")
455
+ log("ERROR", "Pipeline names must be unique")
456
+ log("ERROR", "")
457
+ raise ValueError("Malformed pipeline definition file")
458
+
459
+
460
+ # Make sure the name of jobs is unique within a pipeline
461
+ #
462
+ for pipeline in self._pipeline_data["pipelines"]:
463
+ all_pipeline_jobs_names = [job["name"] for job in pipeline["jobs"]]
464
+ if len(all_pipeline_jobs_names) != len(set(all_pipeline_jobs_names)):
465
+ repeated = list(set([x for x in all_pipeline_jobs_names if all_pipeline_jobs_names.count(x) > 1]))[0]
466
+ log("ERROR", "")
467
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(pipeline['name']),'name.*' + re.escape(repeated),'name.*' + re.escape(repeated)])}")
468
+ log("ERROR", f"Job names within pipeline <{pipeline['name']}> must be unique")
469
+ log("ERROR", "")
470
+ raise ValueError("Malformed pipeline definition file")
471
+
472
+
473
+ # Make sure jobs only include mandatory fields and that their value is a string.
474
+ #
475
+ for pipeline in self._pipeline_data["pipelines"]:
476
+ for job in pipeline["jobs"]:
477
+ for field in job.keys():
478
+ if field in ["input", "output"]:
479
+ pass
480
+ elif field in ["name", "script", "runner", "detached", "timeout", "retries", "on_failure", "on_input_err"]:
481
+ if not isinstance(job[field],str):
482
+ log("ERROR", "")
483
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(field)])}")
484
+ log("ERROR", f"Job <{job['name']}> is not defining field <{field}> as a string")
485
+ log("ERROR", "")
486
+ raise ValueError("Malformed pipeline definition file")
487
+ else:
488
+ log("ERROR", "")
489
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(field)])}")
490
+ log("ERROR", f"Job <{job['name']}> is defining an invalid field: <{field}>")
491
+ log("ERROR", "")
492
+ raise ValueError("Malformed pipeline definition file")
493
+
494
+
495
+ # Make sure all input and output job parameters (if present) are strings.
496
+ # Make sure all output job parameters (if present) are set to "?"
497
+ #
498
+ for pipeline in self._pipeline_data["pipelines"]:
499
+ for job in pipeline["jobs"]:
500
+ if "input" in job.keys():
501
+ for param, value in job["input"].items():
502
+ if not isinstance(value, str):
503
+ log("ERROR", "")
504
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(param)])}")
505
+ log("ERROR", f"Job <{job['name']}> is providing a non-string value to input parameter <{param}>")
506
+ log("ERROR", "")
507
+ raise ValueError("Malformed pipeline definition file")
508
+ if "output" in job.keys():
509
+ for param, value in job["output"].items():
510
+ if not isinstance(value, str) or value != "?":
511
+ log("ERROR", "")
512
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(param)])}")
513
+ log("ERROR", f"Job <{job['name']}> output parameter <{param}> must be set to special string \"?\" (question mark). This is true for all output parameters.")
514
+ log("ERROR", "")
515
+ raise ValueError("Malformed pipeline definition file")
516
+
517
+
518
+ # Make sure all output job parameters (if present) are being used as input parameters
519
+ # somewhere. This is not an error, just a warning.
520
+ #
521
+ for pipeline in self._pipeline_data["pipelines"]:
522
+ for job in pipeline["jobs"]:
523
+ if "output" in job.keys():
524
+ for param, value in job["output"].items():
525
+ if not any([f"@{{{job['name']}::{param}}}" in y for x in pipeline["jobs"] for y in x["input"].values()]):
526
+
527
+ if self._validation_mode:
528
+ loglevel = "NORMAL"
529
+ else:
530
+ loglevel = "DEBUG"
531
+
532
+ log(loglevel, "WARNING:")
533
+ log(loglevel, f"WARNING: Possibly malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),re.escape(param)])}")
534
+ log(loglevel, f"WARNING: Job <{job['name']}> output parameter <{param}> is not being used as input parameter in any other job")
535
+ log(loglevel, "WARNING:")
536
+
537
+ else:
538
+ # Check for direct references to the job status.
539
+ #
540
+ if not any([f"@{{{job['name']}}}" in y for x in pipeline["jobs"] for y in x["input"].values()]):
541
+
542
+ if self._validation_mode:
543
+ loglevel = "NORMAL"
544
+ else:
545
+ loglevel = "DEBUG"
546
+
547
+ log(loglevel, "WARNING:")
548
+ log(loglevel, f"WARNING: Possibly malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name'])])}")
549
+ log(loglevel, f"WARNING: Job <{job['name']}> is not being used as input for any other job")
550
+ log(loglevel, "WARNING:")
551
+
552
+
553
+ # Make sure that "detached" is either "true" or "false".
554
+ # In the former case, make sure "timeout", "retries" and "on_failure" are all set to special
555
+ # string "N/A".
556
+ # In the latter case, make sure one of the other valid values are being used.
557
+ #
558
+ for pipeline in self._pipeline_data["pipelines"]:
559
+ for job in pipeline["jobs"]:
560
+
561
+ if job["detached"] == "true":
562
+ if job["timeout"] != "N/A" or job["retries"] != "N/A" or job["on_failure"] != "N/A":
563
+ log("ERROR", "")
564
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'detached'])}")
565
+ log("ERROR", f"Job <{job['name']}> has property <detached> set to \"true\". When this happens, properties <timeout>, <retries> and <on_failure> *must* be set to special string \"N/A\".")
566
+ log("ERROR", "")
567
+ raise ValueError("Malformed pipeline definition file")
568
+
569
+ elif job["detached"] == "false":
570
+
571
+ # Check <timeout> property
572
+ #
573
+ error = False
574
+
575
+ if not error:
576
+ try:
577
+ number, word = job["timeout"].split()
578
+ except Exception:
579
+ error = True
580
+
581
+ if not error:
582
+ try:
583
+ _ = int(number)
584
+ except Exception:
585
+ error = True
586
+
587
+ if not error:
588
+ if word not in ["hour", "hours", "minute", "minutes", "second", "seconds"]:
589
+ error = True
590
+
591
+ if error:
592
+ log("ERROR", "")
593
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'timeout'])}")
594
+ log("ERROR", f"Job <{job['name']}> property <timeout> value is not valid ({job['timeout']}). It must be a number followed by either \"second\", \"seconds\", \"minute\", \"minutes\", \"hour\" or \"hours\" (examples: \"5 minutes\", \"1 hour\", ...)")
595
+ log("ERROR", "")
596
+ raise ValueError("Malformed pipeline definition file")
597
+
598
+ # Check <retries> property
599
+ #
600
+ try:
601
+ _ = int(job["retries"])
602
+ except Exception:
603
+ log("ERROR", "")
604
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'retries'])}")
605
+ log("ERROR", f"Job <{job['name']}> property <retries> value is not valid ({job['retries']}). It must be a string containing a valid integer number")
606
+ log("ERROR", "")
607
+ raise ValueError("Malformed pipeline definition file")
608
+
609
+ # Check <on_failure> property
610
+ #
611
+ if job["on_failure"] in ["stop pipeline", "continue", "restart pipeline"]:
612
+ pass
613
+ elif job["on_failure"].startswith("restart from") and \
614
+ job["on_failure"].split(":")[1].strip() in [x["name"] for x in
615
+ self.pipeline["jobs"]]:
616
+ pass
617
+ elif job["on_failure"].startswith("trigger pipeline") and \
618
+ job["on_failure"].split(":")[1].strip() in [x["name"] for x in
619
+ self._pipeline_data["pipelines"]]:
620
+ pass
621
+ elif job["on_failure"].startswith("retrigger without"):
622
+ pass
623
+ else:
624
+ log("ERROR", "")
625
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'on_failure'])}")
626
+ log("ERROR", f"Job <{job['name']}> property <on_failure> value is not valid ({job['on_failure']}). It must one of these strings: \"stop pipeline\", \"continue\", \"restart pipeline\", \"restart from : <job name>\", \"trigger pipeline : <pipeline name>\", \"retrigger without : <job_1>, <job_2>, ...\"")
627
+ log("ERROR", "")
628
+ raise ValueError("Malformed pipeline definition file")
629
+
630
+ # Check <on_input_err> property
631
+ #
632
+ if job["on_input_err"] in ["run", "fail", "succeed", "skip"]:
633
+ pass
634
+ else:
635
+ log("ERROR", "")
636
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'on_input_err'])}")
637
+ log("ERROR", f"Job <{job['name']}> property <on_input_err> value is not valid ({job['on_input_err']}). It must one of these strings: \"run\", \"fail\", \"succeed\", \"skip\"")
638
+ log("ERROR", "")
639
+ raise ValueError("Malformed pipeline definition file")
640
+
641
+ else:
642
+ log("ERROR", "")
643
+ log("ERROR", f"Malformed pipeline definition file{self._file_str} @ line {_find_line(self._file, ['name.*' + re.escape(job['name']),'detached'])}")
644
+ log("ERROR", f"Job <{job['name']}> must set param <detached> to string \"true\" or \"false\". Other values are not valid.")
645
+ log("ERROR", "")
646
+ raise ValueError("Malformed pipeline definition file")
647
+
648
+
649
+ def get_pipeline(self):
650
+ return self._pipeline_data
651
+
652
+ def get_dependencies(self):
653
+ return self._dependencies
654
+