Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2@file
3@brief Parse a file *.yml* and convert it into a set of actions.
4"""
5import os
6import re
7from ..texthelper.templating import apply_template
8from ..filehelper import read_content_ufs
9from .yaml_helper_yaml import yaml_load
10from .jenkins_helper import get_platform
13_jenkins_split = "JENKINS_SPLIT"
16def pickname(*args):
17 """
18 Picks the first string non null in the list.
20 @param l list of string
21 @return string
22 """
23 for s in args:
24 s = s.strip()
25 if s:
26 return s
27 raise ValueError( # pragma: no cover
28 "Unable to find a non empty string in {0}".format(args))
31def load_yaml(file_or_buffer, context=None, engine="jinja2", platform=None):
32 """
33 Loads a :epkg:`yml` file (*.yml*).
35 @param file_or_buffer string or physical file or url
36 @param context variables to replace in the configuration
37 @param engine see @see fn apply_template
38 @param platform to join path differently based on the OS
39 @return see `PyYAML <http://pyyaml.org/wiki/PyYAMLDocumentation>`_
40 """
41 def replace(val, rep, into):
42 if val is None:
43 return val
44 return val.replace(rep, into)
45 content, source = read_content_ufs(file_or_buffer, add_source=True)
47 def ospathjoinp(*args, **kwargs):
48 p = kwargs.get('platform', platform)
49 return ospathjoin(*args, platform=p)
51 if context is None:
52 context = dict(replace=replace, ospathjoin=ospathjoinp,
53 pickname=pickname)
54 else:
55 fs = [("replace", replace), ("ospathjoin", ospathjoinp),
56 ("pickname", pickname)]
57 if any(_[0] not in context for _ in fs):
58 context = context.copy()
59 for k, f in fs:
60 if k not in context:
61 context[k] = f
62 if not isinstance(context, dict):
63 raise TypeError( # pragma: no cover
64 "context must be a dictionary not {}.".format(type(context)))
65 if "project_name" not in context:
66 project_name = infer_project_name(file_or_buffer, source)
67 else:
68 project_name = context["project_name"]
69 if project_name.endswith("__"):
70 raise ValueError( # pragma: no cover
71 "project_name is wrong, it cannot end by '__': '{0}'"
72 "".format(project_name))
73 if "project_name" not in context and project_name is not None:
74 context["project_name"] = project_name
76 if ("root_path" not in context or
77 not context["root_path"].endswith(project_name)):
78 context = context.copy()
79 context["root_path"] = ospathjoin(
80 context.get("root_path", ""), project_name, platform=platform)
82 if "root_path" in context:
83 if platform is None:
84 platform = get_platform(platform)
85 if platform.startswith("win"):
86 addition = "set current={0}\\%NAME_JENKINS%".format(
87 context["root_path"])
88 else:
89 addition = "export current={0}/$NAME_JENKINS".format(
90 context["root_path"])
91 content = "automatedsetup:\n - {0}\n{1}".format(addition, content)
93 content = apply_template(content, context, engine)
94 try:
95 return yaml_load(content), project_name
96 except Exception as e: # pragma: no cover
97 raise SyntaxError(
98 "Unable to parse content\n{0}".format(content)) from e
101def evaluate_condition(cond, variables=None):
102 """
103 Evaluates a condition inserted in a :epkg:`yml` file.
105 @param cond (str) condition
106 @param variables (dict|None) dictionary
107 @return boolean
109 Example of a condition::
111 [ ${PYTHON} == "C:\\Python370_x64" ]
112 """
113 if variables is not None:
114 for k, v in variables.items():
115 rep = "${%s}" % k
116 vv = '"%s"' % v
117 cond = cond.replace(rep, vv)
118 cond = cond.replace(rep.upper(), vv)
119 cond = cond.strip()
120 if cond.startswith("[") and cond.endswith("]"):
121 e = eval(cond)
122 return all(e)
123 try:
124 return eval(cond)
125 except SyntaxError as e:
126 raise SyntaxError(
127 "Unable to interpret '{0}'\nvariables: {1}".format(cond, variables)) from e
130def interpret_instruction(inst, variables=None):
131 """
132 Interprets an instruction with if statement.
134 @param inst (str) instruction
135 @param variables (dict|None)
136 @return (str|None)
138 Example of a statement::
140 - if [ ${PYTHON} == "C:\\\\Python391_x64" ] then python setup.py build_sphinx fi
142 Another example::
144 - if [ ${VERSION} == "3.9" and ${DIST} == "std" ]
145 then
146 --CMD=$PYINT -u scikit-learn/bench_plot_polynomial_features_partial_fit.py;;
147 --NAME=SKL_POLYF_PF;;
148 fi
150 In this second syntax, lines must end with ``;;``.
151 If an instruction cannot be interpreted, it is left
152 left unchanged as the function assumes it can only be solved
153 in a bash script.
155 Switch to ``;;`` instead of ``;`` as a instruction separator
156 for conditional instructions.
157 """
158 if isinstance(inst, list):
159 res = [interpret_instruction(_, variables) for _ in inst]
160 if any(res):
161 return [_ for _ in res if _ is not None]
162 return None
163 if isinstance(inst, tuple):
164 if len(inst) != 2 or inst[1] is None:
165 raise ValueError( # pragma: no cover
166 "Unable to interpret '{}'.".format(inst))
167 return (inst[0], interpret_instruction(inst[1], variables))
168 if isinstance(inst, dict):
169 return inst
170 if isinstance(inst, (int, float)):
171 return inst
173 inst = inst.replace("\n", " ")
174 exp = re.compile("^ *if +(.*) +then +(.*)( +else +(.*))? +fi *$")
175 find = exp.search(inst)
176 if find:
177 gr = find.groups()
178 try:
179 e = evaluate_condition(gr[0], variables)
180 except SyntaxError:
181 # We assume the condition is a linux condition.
182 return inst
183 g = gr[1] if e else gr[3]
184 return None if g is None else interpret_instruction(g, variables)
186 if inst.startswith('--'):
187 # one format like --CMD=...; --NAME==...;
188 exp = re.compile("--([a-zA-Z]+?)=(.+?);;")
189 find = exp.findall(inst)
190 if find:
191 inst = {k.strip(): v.strip() for k, v in find}
192 inst = {k: (None if not v or len(v) == 0 else v)
193 for k, v in inst.items()}
194 return inst
195 return inst
196 return inst
199def enumerate_convert_yaml_into_instructions(obj, variables=None, add_environ=True):
200 """
201 Converts a :epkg:`yml` file into sequences of instructions,
202 conditions are interpreted.
204 @param obj yaml objects (@see fn load_yaml)
205 @param variables additional variables to be used
206 @param add_environ add environment variables available, does not
207 overwrite existing variables
208 when the job is generated
209 @return list of tuple(instructions, variables)
211 The function expects the following list
212 of steps in this order:
214 * *automatedsetup*: added by this module
215 * *language*: should be python
216 * *python*: list of interpreters (multiplies jobs)
217 * *virtualenv*: name of the virtual environment
218 * *install*: list of installation steps in the virtual environment
219 * *before_script*: list of steps to run
220 * *script*: list of script to run (multiplies jobs)
221 * *after_script*: list of steps to run
222 * *documentation*: documentation to run after the
224 Each step *multiplies jobs* creates a sequence of jobs and a :epkg:`Jenkins` job.
225 """
226 if variables is None:
227 def_variables = {}
228 else:
229 def_variables = variables.copy()
230 if 'Python37' in def_variables and 'Python38' not in def_variables:
231 raise RuntimeError( # pragma: no cover
232 "Key 'Python38' is missing in {}.".format(def_variables))
233 if add_environ:
234 for k, v in os.environ.items():
235 if k not in def_variables:
236 def_variables[k] = v
237 sequences = []
238 count = {}
239 steps = ["automatedsetup", "language", "python", "virtualenv", "install",
240 "before_script", "script", "after_script",
241 "documentation"]
242 for key in steps:
243 value = obj.get(key, None)
244 if key == "language":
245 if value != "python":
246 raise NotImplementedError( # pragma: no cover
247 "language must be python")
248 continue # pragma: no cover
249 if value is not None:
250 if key in {'python', 'script'} and not isinstance(value, list):
251 value = [value]
252 count[key] = len(value)
253 sequences.append((key, value))
255 for k in obj:
256 if k not in steps:
257 raise ValueError(
258 "Unexpected key '{0}' found in yaml file. Expect:\n{1}".format(k, "\n".join(steps)))
260 # multiplications
261 i_python = 0
262 i_script = 0
263 notstop = True
264 while notstop:
265 seq = []
266 add = True
267 variables = def_variables.copy()
268 for key, value in sequences:
269 if key == "python":
270 value = value[i_python]
271 if isinstance(value, dict):
272 if 'PATH' not in value:
273 raise KeyError( # pragma: no cover
274 "The dictionary should include key 'path': {0}"
275 "".format(value))
276 for k, v in sorted(value.items()):
277 if k != 'PATH':
278 variables[k] = v
279 seq.append(('INFO', (k, v)))
280 value = value["PATH"]
281 elif key == "script":
282 value = interpret_instruction(value[i_script], variables)
283 if isinstance(value, dict):
284 for k, v in sorted(value.items()):
285 if k not in ('CMD', 'CMDPY'):
286 seq.append(('INFO', (k, v)))
287 variables[k] = v
289 i_script += 1
290 if i_script >= count['script']:
291 i_script = 0
292 i_python += 1
293 if i_python >= count['python']:
294 notstop = False
295 if value is not None and value != 'None':
296 seq.append((key, value))
297 variables[key] = value
298 else:
299 add = False
300 if add:
301 r = interpret_instruction(seq, variables)
302 if r is not None:
303 yield r, variables
306def ospathjoin(*args, **kwargs):
307 """
308 Simple ``o.path.join`` for a specific platform.
310 @param args list of paths
311 @param kwargs additional parameters, among them,
312 *platform* (win32 or ...)
313 @return path
314 """
315 def build_value(*args, **kwargs):
316 platform = kwargs.get('platform', None)
317 if platform is None:
318 return os.path.join(*args)
319 elif platform.startswith("win"):
320 return "\\".join(args)
321 return "/".join(args)
323 value = build_value(*args, **kwargs)
324 if value == "/$PYINT":
325 raise RuntimeError( # pragma: no cover
326 "Impossible values {} - {}.".format(args, kwargs))
327 return value
330def ospathdirname(lp, platform=None):
331 """
332 Simple ``o.path.dirname`` for a specific platform.
334 @param lp path
335 @param platform platform
336 @return path
337 """
338 if platform is None:
339 return os.path.dirname(lp)
340 elif platform.startswith("win"):
341 return "\\".join(lp.replace("/", "\\").split("\\")[:-1])
342 return "/".join(lp.replace("\\", "/").split("/")[:-1])
345def convert_sequence_into_batch_file(seq, variables=None, platform=None):
346 """
347 Converts a sequence of instructions into a batch file.
349 @param seq sequence of instructions
350 @param variables list of variables
351 @param platform ``get_platform(platform)`` if None
352 @return (str) batch file or a list of batch file if the constant ``JENKINS_SPLIT``
353 was found in section install (this tweak is needed when the job has to be split
354 for :epkg:`Jenkins`.
355 """
356 global _jenkins_split
357 if platform is None:
358 platform = get_platform(platform)
360 iswin = platform.startswith("win")
362 if iswin:
363 error_level = "if %errorlevel% neq 0 exit /b %errorlevel%"
364 else:
365 error_level = "if [ $? -ne 0 ]; then exit $?; fi"
367 interpreter = None
368 venv_interpreter = None
369 root_project = None
370 anaconda = False
371 conda = None
372 echo = "@echo" if iswin else "echo"
374 rowsset = []
375 if iswin:
376 rowsset.append("@echo off")
377 rowsset.append("set PATH0=%PATH%")
379 def add_path_win(rows, interpreter, platform, root_project):
380 path_inter = ospathdirname(interpreter, platform)
381 if len(path_inter) == 0:
382 raise ValueError( # pragma: no cover
383 "Unable to guess interpreter path from '{0}', platform={1}"
384 "".format(interpreter, platform))
385 if iswin:
386 rows.append("set PATH={0};%PATH%".format(path_inter))
387 else:
388 rows.append("export PATH={0}:$PATH".format(path_inter))
389 if root_project is not None:
390 if iswin:
391 rows.append("set ROOTPROJECT={0}".format(root_project))
392 else:
393 rows.append("export ROOTPROJECT={0}".format(root_project))
395 rows = []
396 splits = [rows]
397 typstr = str
399 for key, value in seq:
400 if key == "automatedsetup":
401 rows.append("")
402 rows.append(echo + " AUTOMATEDSETUP")
403 rows.append("\n".join(value))
404 rows.append("")
405 elif key == "python":
406 variables["YMLPYTHON"] = value
407 if variables.get('DIST', None) == "conda":
408 rows.append(echo + " conda")
409 anaconda = True
410 interpreter = ospathjoin(
411 value, "python", platform=platform)
412 venv_interpreter = value
413 if platform.startswith("win"):
414 conda = ospathjoin(
415 value, "Scripts", "conda", platform=platform)
416 else:
417 conda = ospathjoin(
418 value, "bin", "conda", platform=platform)
419 else:
420 if iswin:
421 interpreter = ospathjoin(
422 value, "python", platform=platform)
423 else:
424 interpreter = ospathjoin(
425 value, "$PYINT", platform=platform)
426 venv_interpreter = value
427 rows.append(echo + " interpreter=" + interpreter)
429 elif key == "virtualenv":
430 if isinstance(value, list):
431 if len(value) > 2:
432 raise ValueError( # pragma: no cover
433 "Expecting one or two values for the path of the virtual environment"
434 ":\n{0}".format(value))
435 d = value[0].copy()
436 for i in range(1, len(value)):
437 d.update(value[i])
438 value = d
439 p = value["path"] if isinstance(value, dict) else value
440 rows.append("")
441 rows.append(echo + " CREATE VIRTUAL ENVIRONMENT in %s" % p)
442 if not anaconda:
443 if iswin:
444 rows.append('if not exist "{0}" mkdir "{0}"'.format(p))
445 else:
446 rows.append('if [-f {0}]; then mkdir "{0}"; fi'.format(p))
447 if anaconda:
448 pinter = ospathdirname(interpreter, platform=platform)
449 rows.append(
450 '"{0}" create -y -v -p "{1}" --clone "{2}" --offline --no-update-deps'.format(conda, p, pinter))
451 interpreter = ospathjoin(
452 p, "python", platform=platform)
453 else:
454 if iswin:
455 rows.append("set KEEPPATH=%PATH%")
456 rows.append("set PATH={0};%PATH%".format(venv_interpreter))
457 else:
458 rows.append("export KEEPPATH=$PATH")
459 rows.append(
460 "export PATH={0}:$PATH".format(venv_interpreter))
461 pat = '"{0}" -m virtualenv {1}'
462 if isinstance(value, dict):
463 system_site_packages = value.get(
464 'system_site_packages', True)
465 else:
466 system_site_packages = True
467 if system_site_packages:
468 pat += " --system-site-packages"
469 rows.append(pat.format(interpreter, p))
470 if iswin:
471 rows.append("set PATH=%KEEPPATH%")
472 interpreter = ospathjoin(
473 p, "Scripts", "python", platform=platform)
474 else:
475 rows.append("export PATH=$KEEPPATH")
476 interpreter = ospathjoin(
477 p, "bin", "python", platform=platform)
478 rows.append(error_level)
480 elif key in {"install", "before_script", "script", "after_script", "documentation"}:
481 if value is not None:
482 if isinstance(value, dict):
483 if "CMD" not in value and "CMDPY" not in value:
484 raise KeyError( # pragma: no cover
485 "A script defined by a dictionary must contain key "
486 "'{0}' or '{1}' in \n{2}".format("CMD", 'CMDPY', value))
487 if "NAME" in value:
488 if iswin:
489 rows.append("set JOB_NAME=%s" % value["NAME"])
490 else:
491 rows.append("export JOB_NAME=%s" % value["NAME"])
492 if "CMD" in value:
493 value = value["CMD"]
494 else:
495 value = evaluate_condition(
496 value["CMDPY"], variables=variables)
497 elif isinstance(value, list):
498 starter = list(rows)
499 elif isinstance(value, typstr):
500 pass
501 else:
502 raise TypeError( # pragma: no cover
503 "value must of type list, dict, not '{0}'\n{1}"
504 "".format(type(value), value))
506 rows.append("")
507 rows.append(echo + " " + key.upper())
508 add_path_win(rows, interpreter, platform, root_project)
509 if not isinstance(value, list):
510 value = [value, error_level]
511 else:
512 keep = value
513 value = []
514 for v in keep:
515 if v.startswith(_jenkins_split):
516 if "-" in v:
517 nbrem = v.split("-")[-1]
518 try:
519 nbrem = int(nbrem)
520 except ValueError: # pragma: no cover
521 raise ValueError(
522 "Unable to interpret '{0}'".format(v))
523 else:
524 nbrem = 0
525 rows.extend(value)
526 value = []
527 st = list(starter)
528 if nbrem > 0:
529 st = st[:-nbrem]
530 splits.append(st)
531 rows = splits[-1]
532 add_path_win(rows, interpreter,
533 platform, root_project)
534 else:
535 value.append(v)
536 value.append(error_level)
537 rows.extend(value)
538 elif key == 'INFO':
539 vs = '"{0}"'.format(value[1]) if isinstance(
540 value[1], str) and " " in value[1] else value[1]
541 if iswin:
542 rowsset.append("SET {0}={1}".format(value[0], vs))
543 else:
544 rowsset.append("export {0}={1}".format(value[0], vs))
545 else:
546 raise ValueError( # pragma: no cover
547 "unexpected key '{0}'".format(key))
549 splits = [rowsset + _ for _ in splits]
550 allres = []
551 for rows in splits:
552 try:
553 res = "\n".join(rows)
554 except TypeError as e: # pragma: no cover
555 raise TypeError("Unexpected type\n{0}".format(
556 "\n".join([str((type(_), _)) for _ in rows]))) from e
557 if _jenkins_split in res:
558 raise ValueError( # pragma: no cover
559 "Constant '{0}' is present in the generated script. "
560 "It can only be added to the install section."
561 "".format(_jenkins_split))
562 allres.append(res)
563 return allres if len(allres) > 1 else allres[0]
566def infer_project_name(file_or_buffer, source):
567 """
568 Infers a project name based on :epkg:`yml` file.
570 @param file_or_buffer file name
571 @param source second output of @see fn read_content_ufs
572 @return name
574 The function can infer a name for *source* in ``{'r', 'u'}``.
575 For *source* equal to ``'s'``, it returns ``'unknown_string'``.
576 """
577 if source == "r":
578 fold = os.path.dirname(file_or_buffer)
579 last = os.path.split(fold)[-1]
580 elif source == "u":
581 spl = file_or_buffer.split('/')
582 pos = -2
583 name = None
584 while len(spl) > -pos:
585 name = spl[pos]
586 if name in {'master'}:
587 pos -= 1
588 elif 'github' in name:
589 break
590 else:
591 break
592 if name is None:
593 raise ValueError( # pragma: no cover
594 "Unable to infer project name for '{0}'".format(
595 file_or_buffer))
596 return name
597 elif source == "s":
598 return "unknown_string"
599 else:
600 raise ValueError( # pragma: no cover
601 "Unexpected value for add_source: '{0}' for '{1}'".format(
602 source, file_or_buffer))
603 return last
606def enumerate_processed_yml(file_or_buffer, context=None, engine="jinja2", platform=None,
607 server=None, git_repo=None, add_environ=True, overwrite=False,
608 build_location=None, **kwargs):
609 """
610 Submits or enumerates jobs based on the content of a :epkg:`yml` file.
612 @param file_or_buffer filename or string
613 @param context variables to replace in the configuration
614 @param engine see @see fn apply_template
615 @param server see @see cl JenkinsExt
616 @param platform plaform where the job will be executed
617 @param git_repo git repository (if *server* is not None)
618 @param add_environ add environment variable before interpreting the job
619 @param overwrite overwrite the job if it already exists in Jenkins
620 @param build_location location for the build
621 @param kwargs see @see me create_job_template
622 @return enumerator for *(job, name, variables)*
624 Example of a :epkg:`yml` file
625 `.local.jenkins.win.yml <https://github.com/sdpython/pyquickhelper/blob/master/.local.jenkins.win.yml>`_.
626 A subfolder was added to the project location.
627 A scheduler can be defined as well by adding ``SCHEDULER:'* * * * *'``.
628 """
629 typstr = str
630 fLOG = kwargs.get('fLOG', None)
631 project_name = None if context is None else context.get(
632 "project_name", None)
633 obj, project_name = load_yaml(
634 file_or_buffer, context=context, platform=platform)
635 platform_set = platform or get_platform(platform)
636 for seq, var in enumerate_convert_yaml_into_instructions(obj, variables=context, add_environ=add_environ):
637 conv = convert_sequence_into_batch_file(
638 seq, variables=var, platform=platform)
640 # we extract a suffix from the command line
641 if server is not None:
642 name = "_".join([project_name, var.get('NAME', ''),
643 typstr(var.get("VERSION", '')).replace(".", ""),
644 var.get('DIST', '')])
646 if platform_set.startswith("win"):
647 if isinstance(conv, list):
648 conv = ["SET NAME_JENKINS=" +
649 name + "\n" + _ for _ in conv]
650 else:
651 conv = "SET NAME_JENKINS=" + name + "\n" + conv
652 else:
653 if isinstance(conv, list):
654 conv = ["export NAME_JENKINS=" +
655 name + "\n" + _ for _ in conv]
656 conv.append("export $(cat ~/.profile)")
657 else:
658 conv = ("export NAME_JENKINS=" + name +
659 "\nexport $(cat ~/.profile)\n" + conv)
661 import jenkins
662 try:
663 j = server.get_job_config(name) if not server._mock else None
664 except jenkins.NotFoundException: # pragma: no cover
665 j = None
666 except jenkins.JenkinsException as e: # pragma: no cover
667 from .jenkins_exceptions import JenkinsExtException
668 raise JenkinsExtException(
669 "Unable to retrieve job config for name='{0}'.".format(name)) from e
671 update_job = False
672 if j is not None:
673 if kwargs.get('update', True):
674 update_job = True
675 else:
676 if fLOG is not None: # pragma: no cover
677 fLOG("[jenkins] delete job", name)
678 server.delete_job(name)
680 if git_repo is not None and project_name not in git_repo:
681 git_repo += project_name
683 # set up location
684 if build_location is None:
685 loc = None
686 else:
687 loc = ospathjoin(build_location, project_name,
688 name, platform=platform)
690 if overwrite or j is None:
691 timeout = var.get("TIMEOUT", None)
692 scheduler = var.get("SCHEDULER", None)
693 clean_repo = var.get("CLEAN", True) in {
694 True, 1, "True", "true", "1"}
695 if timeout is not None:
696 kwargs["timeout"] = timeout
697 if scheduler is not None:
698 if "FIXED" in scheduler:
699 scheduler = scheduler.replace("FIXED", "").strip()
700 adjuster_scheduler = False
701 elif "STARTUP" in scheduler:
702 adjuster_scheduler = False
703 elif 'fixed' in scheduler.lower():
704 raise ValueError( # pragma: no cover
705 "Scheduler should contain 'FIXED' in upper case.")
706 elif 'startup' in scheduler.lower():
707 raise ValueError( # pragma: no cover
708 "Scheduler should contain 'STARTUP' in upper case.")
709 else:
710 adjuster_scheduler = True
711 kwargs["scheduler"] = scheduler
712 kwargs["adjuster_scheduler"] = adjuster_scheduler
713 yield server.create_job_template(name, script=conv, git_repo=git_repo,
714 update=update_job, location=loc,
715 clean_repo=clean_repo, **kwargs), name, var
716 else:
717 yield conv, None, var