Coverage for python / lsst / ctrl / bps / transform.py: 8%
323 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 00:53 -0700
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-10 00:53 -0700
1# This file is part of ctrl_bps.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <https://www.gnu.org/licenses/>.
28"""Driver for the transformation of a QuantumGraph into a generic workflow."""
30import copy
31import dataclasses
32import logging
33import math
34import os
35import re
37from lsst.ctrl.bps import ClusteredQuantumGraph
38from lsst.pipe.base import QuantumGraph
39from lsst.utils.logging import VERBOSE
40from lsst.utils.timer import timeMethod
42from . import (
43 DEFAULT_MEM_RETRIES,
44 BpsConfig,
45 GenericWorkflow,
46 GenericWorkflowExec,
47 GenericWorkflowFile,
48 GenericWorkflowJob,
49)
50from .bps_utils import WhenToSaveQuantumGraphs, create_job_quantum_graph_filename, save_qg_subgraph
52# All available job attributes.
53_ATTRS_ALL = frozenset([field.name for field in dataclasses.fields(GenericWorkflowJob)])
55# Job attributes that need to be set to their maximal value in the cluster.
56_ATTRS_MAX = frozenset(
57 {
58 "memory_multiplier",
59 "number_of_retries",
60 "request_cpus",
61 "request_memory",
62 "request_memory_max",
63 }
64)
66# Job attributes that need to be set to sum of their values in the cluster.
67_ATTRS_SUM = frozenset(
68 {
69 "request_disk",
70 "request_walltime",
71 }
72)
74# Job attributes do not fall into a specific category
75_ATTRS_MISC = frozenset(
76 {
77 "label", # taskDef labels aren't same in job and may not match job label
78 "cmdvals",
79 "profile",
80 "attrs",
81 }
82)
84# Attributes that need to be the same for each quanta in the cluster.
85_ATTRS_UNIVERSAL = frozenset(_ATTRS_ALL - (_ATTRS_MAX | _ATTRS_MISC | _ATTRS_SUM))
87_LOG = logging.getLogger(__name__)
90@timeMethod(logger=_LOG, logLevel=VERBOSE)
91def transform(
92 config: BpsConfig, cqgraph: ClusteredQuantumGraph, prefix: str
93) -> tuple[GenericWorkflow, BpsConfig]:
94 """Transform a ClusteredQuantumGraph to a GenericWorkflow.
96 Parameters
97 ----------
98 config : `lsst.ctrl.bps.BpsConfig`
99 BPS configuration.
100 cqgraph : `lsst.ctrl.bps.ClusteredQuantumGraph`
101 A clustered quantum graph to transform into a generic workflow.
102 prefix : `str`
103 Root path for any output files.
105 Returns
106 -------
107 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
108 The generic workflow transformed from the clustered quantum graph.
109 generic_workflow_config : `lsst.ctrl.bps.BpsConfig`
110 Configuration to accompany GenericWorkflow.
111 """
112 if cqgraph.name is not None:
113 name = cqgraph.name
114 else:
115 _, name = config.search("uniqProcName", opt={"required": True})
117 generic_workflow = create_generic_workflow(config, cqgraph, name, prefix)
118 generic_workflow_config = create_generic_workflow_config(config, prefix)
120 return generic_workflow, generic_workflow_config
123def add_workflow_init_nodes(config, qgraph, generic_workflow):
124 """Add nodes to workflow graph that perform initialization steps.
126 Assumes that all of the initialization should be executed prior to any
127 of the current workflow.
129 Parameters
130 ----------
131 config : `lsst.ctrl.bps.BpsConfig`
132 BPS configuration.
133 qgraph : `lsst.pipe.base.graph.QuantumGraph`
134 The quantum graph the generic workflow represents.
135 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
136 Generic workflow to which the initialization steps should be added.
137 """
138 # Create a workflow graph that will have task and file nodes necessary for
139 # initializing the pipeline execution
140 init_workflow = create_init_workflow(config, qgraph, generic_workflow.get_file("runQgraphFile"))
141 _LOG.debug("init_workflow nodes = %s", init_workflow.nodes())
142 generic_workflow.add_workflow_source(init_workflow)
145def create_init_workflow(
146 config: BpsConfig, qgraph: QuantumGraph, qgraph_gwfile: GenericWorkflowFile
147) -> GenericWorkflow:
148 """Create workflow for running initialization job(s).
150 Parameters
151 ----------
152 config : `lsst.ctrl.bps.BpsConfig`
153 BPS configuration.
154 qgraph : `lsst.pipe.base.graph.QuantumGraph`
155 The quantum graph the generic workflow represents.
156 qgraph_gwfile : `lsst.ctrl.bps.GenericWorkflowFile`
157 File object for the full run QuantumGraph file.
159 Returns
160 -------
161 init_workflow : `lsst.ctrl.bps.GenericWorkflow`
162 GenericWorkflow consisting of job(s) to initialize workflow.
163 """
164 _LOG.debug("creating init subgraph")
165 _LOG.debug("creating init task input(s)")
166 search_opt = {
167 "curvals": {"curr_pipetask": "pipetaskInit"},
168 "replaceVars": False,
169 "expandEnvVars": False,
170 "replaceEnvVars": True,
171 "required": False,
172 }
173 found, value = config.search("computeSite", opt=search_opt)
174 if found:
175 search_opt["curvals"]["curr_site"] = value
176 found, value = config.search("computeCloud", opt=search_opt)
177 if found:
178 search_opt["curvals"]["curr_cloud"] = value
180 init_workflow = GenericWorkflow("init")
181 init_workflow.add_file(qgraph_gwfile)
183 # create job for executing --init-only
184 gwjob = GenericWorkflowJob("pipetaskInit", "pipetaskInit")
186 job_values = _get_job_values(config, search_opt, "runQuantumCommand")
187 job_values["name"] = "pipetaskInit"
188 job_values["label"] = "pipetaskInit"
190 # Adjust job attributes values if necessary.
191 _handle_job_values(job_values, gwjob)
193 init_workflow.add_job(gwjob)
194 init_workflow.add_job_inputs(gwjob.name, [qgraph_gwfile])
195 _enhance_command(config, init_workflow, gwjob, {})
197 return init_workflow
200def _enhance_command(config, generic_workflow, gwjob, cached_job_values):
201 """Enhance command line with env and file placeholders
202 and gather command line values.
204 Parameters
205 ----------
206 config : `lsst.ctrl.bps.BpsConfig`
207 BPS configuration.
208 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
209 Generic workflow that contains the job.
210 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
211 Generic workflow job to which the updated executable, arguments,
212 and values should be saved.
213 cached_job_values : `dict` [`str`, dict[`str`, `~typing.Any`]]
214 Cached values common across jobs with same label. Updated if values
215 aren't already saved for given gwjob's label.
216 """
217 _LOG.debug("gwjob given to _enhance_command: %s", gwjob)
219 curvals = {
220 "curr_pipetask": gwjob.label,
221 "curr_cluster": gwjob.label,
222 "jobName": gwjob.name,
223 "jobLabel": gwjob.label,
224 }
225 for key, value in gwjob.tags.items():
226 curvals[key] = value
228 search_opt = {
229 "curvals": curvals,
230 "replaceVars": False,
231 "expandEnvVars": False,
232 "replaceEnvVars": True,
233 "required": False,
234 }
236 if gwjob.label not in cached_job_values:
237 cached_job_values[gwjob.label] = {}
238 # Allowing whenSaveJobQgraph and useLazyCommands per pipetask label.
239 key = "whenSaveJobQgraph"
240 _, when_save = config.search(key, opt=search_opt)
241 cached_job_values[gwjob.label][key] = WhenToSaveQuantumGraphs[when_save.upper()]
243 key = "useLazyCommands"
244 search_opt["default"] = True
245 _, cached_job_values[gwjob.label][key] = config.search(key, opt=search_opt)
246 del search_opt["default"]
248 # Change qgraph variable to match whether using run or per-job qgraph
249 # Note: these are lookup keys, not actual physical filenames.
250 if cached_job_values[gwjob.label]["whenSaveJobQgraph"] == WhenToSaveQuantumGraphs.NEVER:
251 gwjob.arguments = gwjob.arguments.replace("{qgraphFile}", "{runQgraphFile}")
252 elif gwjob.name == "pipetaskInit":
253 gwjob.arguments = gwjob.arguments.replace("{qgraphFile}", "{runQgraphFile}")
254 else: # Needed unique file keys for per-job QuantumGraphs
255 gwjob.arguments = gwjob.arguments.replace("{qgraphFile}", f"{{qgraphFile_{gwjob.name}}}")
257 # Replace files with special placeholders
258 for gwfile in generic_workflow.get_job_inputs(gwjob.name):
259 gwjob.arguments = gwjob.arguments.replace(f"{{{gwfile.name}}}", f"<FILE:{gwfile.name}>")
260 for gwfile in generic_workflow.get_job_outputs(gwjob.name):
261 gwjob.arguments = gwjob.arguments.replace(f"{{{gwfile.name}}}", f"<FILE:{gwfile.name}>")
263 # Replace wms variables with wms placeholders.
264 gwjob.arguments = re.sub(
265 r"{wms([^}]+)}", lambda x: f"<WMS:{x[1][0].lower() + x[1][1:]}>", gwjob.arguments
266 )
268 # To make yaml-specified paths easier to read, remove empty subdirs (//)
269 gwjob.arguments = re.sub(r"//+", "/", gwjob.arguments)
271 # Save dict of other values needed to complete command line.
272 # (Be careful to not replace env variables as they may
273 # be different in compute job.)
274 search_opt["replaceVars"] = True
275 for key in re.findall(r"{([^}]+)}", gwjob.arguments):
276 if key in gwjob.cmdvals:
277 continue
278 elif key in cached_job_values[gwjob.label]:
279 gwjob.cmdvals[key] = cached_job_values[gwjob.label][key]
280 else:
281 _, gwjob.cmdvals[key] = config.search(key, opt=search_opt)
283 # backwards compatibility
284 if not cached_job_values[gwjob.label]["useLazyCommands"]:
285 if "bpsUseShared" not in cached_job_values[gwjob.label]:
286 key = "bpsUseShared"
287 search_opt["default"] = True
288 _, cached_job_values[gwjob.label][key] = config.search(key, opt=search_opt)
289 del search_opt["default"]
291 gwjob.arguments = _fill_arguments(
292 cached_job_values[gwjob.label]["bpsUseShared"], generic_workflow, gwjob.arguments, gwjob.cmdvals
293 )
296def _fill_arguments(use_shared, generic_workflow, arguments, cmdvals):
297 """Replace placeholders in command line string in job.
299 Parameters
300 ----------
301 use_shared : `bool`
302 Whether using shared filesystem.
303 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
304 Generic workflow containing the job.
305 arguments : `str`
306 String containing placeholders.
307 cmdvals : `dict` [`str`, `~typing.Any`]
308 Any command line values that can be used to replace placeholders.
310 Returns
311 -------
312 arguments : `str`
313 Command line with FILE and ENV placeholders replaced.
314 """
315 # Replace file placeholders
316 for file_key in re.findall(r"<FILE:([^>]+)>", arguments):
317 gwfile = generic_workflow.get_file(file_key)
318 if not gwfile.wms_transfer:
319 # Must assume full URI if in command line and told WMS is not
320 # responsible for transferring file.
321 uri = gwfile.src_uri
322 elif use_shared:
323 if gwfile.job_shared:
324 # Have shared filesystems and jobs can share file.
325 uri = gwfile.src_uri
326 else:
327 uri = os.path.basename(gwfile.src_uri)
328 else: # Using push transfer
329 uri = os.path.basename(gwfile.src_uri)
331 arguments = arguments.replace(f"<FILE:{file_key}>", uri)
333 # Replace env placeholder with submit-side values
334 arguments = re.sub(r"<ENV:([^>]+)>", r"$\1", arguments)
335 arguments = os.path.expandvars(arguments)
337 # Replace remaining vars
338 arguments = arguments.format(**cmdvals)
340 return arguments
343def _get_qgraph_gwfile(config, save_qgraph_per_job, gwjob, run_qgraph_file, prefix):
344 """Get qgraph location to be used by job.
346 Parameters
347 ----------
348 config : `lsst.ctrl.bps.BpsConfig`
349 Bps configuration.
350 save_qgraph_per_job : `lsst.ctrl.bps.bps_utils.WhenToSaveQuantumGraphs`
351 What submission stage to save per-job qgraph files (or NEVER)
352 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
353 Job for which determining QuantumGraph file.
354 run_qgraph_file : `lsst.ctrl.bps.GenericWorkflowFile`
355 File representation of the full run QuantumGraph.
356 prefix : `str`
357 Path prefix for any files written.
359 Returns
360 -------
361 gwfile : `lsst.ctrl.bps.GenericWorkflowFile`
362 Representation of butler location (may not include filename).
363 """
364 qgraph_gwfile = None
365 if save_qgraph_per_job != WhenToSaveQuantumGraphs.NEVER:
366 qgraph_gwfile = GenericWorkflowFile(
367 f"qgraphFile_{gwjob.name}",
368 src_uri=create_job_quantum_graph_filename(config, gwjob, prefix),
369 wms_transfer=True,
370 job_access_remote=True,
371 job_shared=True,
372 )
373 else:
374 qgraph_gwfile = run_qgraph_file
376 return qgraph_gwfile
379def _get_job_values(config, search_opt, cmd_line_key):
380 """Gather generic workflow job values from the bps config.
382 Parameters
383 ----------
384 config : `lsst.ctrl.bps.BpsConfig`
385 Bps configuration.
386 search_opt : `dict` [`str`, `~typing.Any`]
387 Search options to be used when searching config.
388 cmd_line_key : `str` or None
389 Which command line key to search for (e.g., "runQuantumCommand").
391 Returns
392 -------
393 job_values : `dict` [ `str`, `~typing.Any` ]`
394 A mapping between job attributes and their values.
395 """
396 _LOG.debug("cmd_line_key=%s, search_opt=%s", cmd_line_key, search_opt)
398 # Create a dummy job to easily access the default values.
399 default_gwjob = GenericWorkflowJob("default_job", "default_label")
401 job_values = {}
402 for attr in _ATTRS_ALL:
403 # Variable names in yaml are camel case instead of snake case.
404 yaml_name = re.sub(r"_(\S)", lambda match: match.group(1).upper(), attr)
405 found, value = config.search(yaml_name, opt=search_opt)
406 if found:
407 job_values[attr] = value
408 else:
409 job_values[attr] = getattr(default_gwjob, attr)
411 # Need to replace all config variables in environment values
412 # While replacing variables, convert to plain dict
413 if job_values["environment"]:
414 old_searchobj = search_opt.get("searchobj", None)
415 old_replace_vars = search_opt.get("replaceVars", None)
416 job_env = job_values["environment"]
417 search_opt["searchobj"] = job_env
418 search_opt["replaceVars"] = True
419 job_values["environment"] = {}
420 for name in job_env:
421 job_values["environment"][name] = str(config.search(name, search_opt)[1])
422 if old_searchobj is None:
423 del search_opt["searchobj"]
424 else:
425 search_opt["searchobj"] = old_searchobj
426 if old_replace_vars is None:
427 del search_opt["replaceVars"]
428 else:
429 search_opt["replaceVars"] = old_replace_vars
431 # If the automatic memory scaling is enabled (i.e. the memory multiplier
432 # is set and it is a positive number greater than 1.0), adjust number
433 # of retries when necessary. If the memory multiplier is invalid, disable
434 # automatic memory scaling.
435 if job_values["memory_multiplier"] is not None:
436 if math.ceil(float(job_values["memory_multiplier"])) > 1:
437 if job_values["number_of_retries"] is None:
438 job_values["number_of_retries"] = DEFAULT_MEM_RETRIES
439 else:
440 job_values["memory_multiplier"] = None
442 if cmd_line_key:
443 found, cmdline = config.search(cmd_line_key, opt=search_opt)
444 # Make sure cmdline isn't None as that could be sent in as a
445 # default value in search_opt.
446 if found and cmdline:
447 cmd, args = cmdline.split(" ", 1)
448 job_values["executable"] = GenericWorkflowExec(os.path.basename(cmd), cmd, False)
449 if args:
450 job_values["arguments"] = args
452 return job_values
455def _handle_job_values(quantum_job_values, gwjob, attributes=_ATTRS_ALL):
456 """Set the job attributes in the cluster to their correct values.
458 Parameters
459 ----------
460 quantum_job_values : `dict` [`str`, Any]
461 Job values for running single Quantum.
462 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
463 Generic workflow job in which to store the universal values.
464 attributes : `~collections.abc.Iterable` [`str`], optional
465 Job attributes to be set in the job following different rules.
466 The default value is _ATTRS_ALL.
467 """
468 _LOG.debug("Call to _handle_job_values")
469 _handle_job_values_universal(quantum_job_values, gwjob, attributes)
470 _handle_job_values_max(quantum_job_values, gwjob, attributes)
471 _handle_job_values_sum(quantum_job_values, gwjob, attributes)
474def _handle_job_values_universal(quantum_job_values, gwjob, attributes=_ATTRS_UNIVERSAL):
475 """Handle job attributes that must have the same value for every quantum
476 in the cluster.
478 Parameters
479 ----------
480 quantum_job_values : `dict` [`str`, Any]
481 Job values for running single Quantum.
482 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
483 Generic workflow job in which to store the universal values.
484 attributes : `~collections.abc.Iterable` [`str`], optional
485 Job attributes to be set in the job following different rules.
486 The default value is _ATTRS_UNIVERSAL.
487 """
488 for attr in _ATTRS_UNIVERSAL & set(attributes):
489 _LOG.debug(
490 "Handling job %s (job=%s, quantum=%s)",
491 attr,
492 getattr(gwjob, attr),
493 quantum_job_values.get(attr, "MISSING"),
494 )
495 current_value = getattr(gwjob, attr)
496 try:
497 quantum_value = quantum_job_values[attr]
498 except KeyError:
499 continue
500 else:
501 if not current_value:
502 setattr(gwjob, attr, quantum_value)
503 elif current_value != quantum_value:
504 _LOG.error(
505 "Inconsistent value for %s in Cluster %s Quantum Number %s\n"
506 "Current cluster value: %s\n"
507 "Quantum value: %s",
508 attr,
509 gwjob.name,
510 quantum_job_values.get("qgraphNodeId", "MISSING"),
511 current_value,
512 quantum_value,
513 )
514 raise RuntimeError(f"Inconsistent value for {attr} in cluster {gwjob.name}.")
517def _handle_job_values_max(quantum_job_values, gwjob, attributes=_ATTRS_MAX):
518 """Handle job attributes that should be set to their maximum value in
519 the in cluster.
521 Parameters
522 ----------
523 quantum_job_values : `dict` [`str`, `~typing.Any`]
524 Job values for running single Quantum.
525 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
526 Generic workflow job in which to store the aggregate values.
527 attributes : `~collections.abc.Iterable` [`str`], optional
528 Job attributes to be set in the job following different rules.
529 The default value is _ATTR_MAX.
530 """
531 for attr in _ATTRS_MAX & set(attributes):
532 current_value = getattr(gwjob, attr)
533 try:
534 quantum_value = quantum_job_values[attr]
535 except KeyError:
536 continue
537 else:
538 needs_update = False
539 if current_value is None:
540 if quantum_value is not None:
541 needs_update = True
542 else:
543 if quantum_value is not None and current_value < quantum_value:
544 needs_update = True
545 if needs_update:
546 setattr(gwjob, attr, quantum_value)
548 # When updating memory requirements for a job, check if memory
549 # autoscaling is enabled. If it is, always use the memory
550 # multiplier and the number of retries which comes with the
551 # quantum.
552 #
553 # Note that as a result, the quantum with the biggest memory
554 # requirements will determine whether the memory autoscaling
555 # will be enabled (or disabled) depending on the value of its
556 # memory multiplier.
557 if attr == "request_memory":
558 gwjob.memory_multiplier = quantum_job_values["memory_multiplier"]
559 if gwjob.memory_multiplier is not None:
560 gwjob.number_of_retries = quantum_job_values["number_of_retries"]
563def _handle_job_values_sum(quantum_job_values, gwjob, attributes=_ATTRS_SUM):
564 """Handle job attributes that are the sum of their values in the cluster.
566 Parameters
567 ----------
568 quantum_job_values : `dict` [`str`, `~typing.Any`]
569 Job values for running single Quantum.
570 gwjob : `lsst.ctrl.bps.GenericWorkflowJob`
571 Generic workflow job in which to store the aggregate values.
572 attributes : `~collections.abc.Iterable` [`str`], optional
573 Job attributes to be set in the job following different rules.
574 The default value is _ATTRS_SUM.
575 """
576 for attr in _ATTRS_SUM & set(attributes):
577 current_value = getattr(gwjob, attr)
578 if not current_value:
579 setattr(gwjob, attr, quantum_job_values[attr])
580 else:
581 setattr(gwjob, attr, current_value + quantum_job_values[attr])
584def create_generic_workflow(
585 config: BpsConfig, cqgraph: ClusteredQuantumGraph, name: str, prefix: str
586) -> GenericWorkflow:
587 """Create a generic workflow from a ClusteredQuantumGraph such that it
588 has information needed for WMS (e.g., command lines).
590 Parameters
591 ----------
592 config : `lsst.ctrl.bps.BpsConfig`
593 BPS configuration.
594 cqgraph : `lsst.ctrl.bps.ClusteredQuantumGraph`
595 ClusteredQuantumGraph for running a specific pipeline on a specific
596 payload.
597 name : `str`
598 Name for the workflow (typically unique).
599 prefix : `str`
600 Root path for any output files.
602 Returns
603 -------
604 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
605 Generic workflow for the given ClusteredQuantumGraph + config.
606 """
607 # Determine whether saving per-job QuantumGraph files in the loop.
608 _, when_save = config.search("whenSaveJobQgraph", {"default": WhenToSaveQuantumGraphs.TRANSFORM.name})
609 save_qgraph_per_job = WhenToSaveQuantumGraphs[when_save.upper()]
611 search_opt = {"replaceVars": False, "expandEnvVars": False, "replaceEnvVars": True, "required": False}
613 generic_workflow = GenericWorkflow(name)
615 # Save full run QuantumGraph for use by jobs
616 generic_workflow.add_file(
617 GenericWorkflowFile(
618 "runQgraphFile",
619 src_uri=config["runQgraphFile"],
620 wms_transfer=True,
621 job_access_remote=True,
622 job_shared=True,
623 )
624 )
626 # Cache pipetask specific or more generic job values to minimize number
627 # on config searches.
628 cached_job_values = {}
629 cached_pipetask_values = {}
631 for cluster in cqgraph.clusters():
632 _LOG.debug("Loop over clusters: %s, %s", cluster, type(cluster))
633 _LOG.debug(
634 "cqgraph: name=%s, len=%s, label=%s, ids=%s",
635 cluster.name,
636 len(cluster.qgraph_node_ids),
637 cluster.label,
638 cluster.qgraph_node_ids,
639 )
641 gwjob = GenericWorkflowJob(cluster.name, cluster.label)
643 # First get job values from cluster or cluster config
644 search_opt["curvals"] = {"curr_cluster": cluster.label}
645 found, value = config.search("computeSite", opt=search_opt)
646 if found:
647 search_opt["curvals"]["curr_site"] = value
648 found, value = config.search("computeCloud", opt=search_opt)
649 if found:
650 search_opt["curvals"]["curr_cloud"] = value
652 # If some config values are set for this cluster
653 if cluster.label not in cached_job_values:
654 _LOG.debug("config['cluster'][%s] = %s", cluster.label, config["cluster"][cluster.label])
655 cached_job_values[cluster.label] = {}
657 # Allowing whenSaveJobQgraph and useLazyCommands per cluster label.
658 key = "whenSaveJobQgraph"
659 _, when_save = config.search(key, opt=search_opt)
660 cached_job_values[cluster.label][key] = WhenToSaveQuantumGraphs[when_save.upper()]
662 key = "useLazyCommands"
663 search_opt["default"] = True
664 _, cached_job_values[cluster.label][key] = config.search(key, opt=search_opt)
665 del search_opt["default"]
667 if cluster.label in config["cluster"]:
668 # Don't want to get global defaults here so only look in
669 # cluster section.
670 cached_job_values[cluster.label].update(
671 _get_job_values(config["cluster"][cluster.label], search_opt, "runQuantumCommand")
672 )
673 cluster_job_values = copy.copy(cached_job_values[cluster.label])
675 cluster_job_values["name"] = cluster.name
676 cluster_job_values["label"] = cluster.label
677 cluster_job_values["quanta_counts"] = cluster.quanta_counts
678 cluster_job_values["tags"] = cluster.tags
679 _LOG.debug("cluster_job_values = %s", cluster_job_values)
680 _handle_job_values(cluster_job_values, gwjob, cluster_job_values.keys())
682 # For purposes of whether to continue searching for a value is whether
683 # the value evaluates to False.
684 unset_attributes = {attr for attr in _ATTRS_ALL if not getattr(gwjob, attr)}
686 _LOG.debug("unset_attributes=%s", unset_attributes)
687 _LOG.debug("set=%s", _ATTRS_ALL - unset_attributes)
689 # For job info not defined at cluster level, attempt to get job info
690 # either common or aggregate for all Quanta in cluster.
691 for node_id in iter(cluster.qgraph_node_ids):
692 _LOG.debug("node_id=%s", node_id)
693 quantum_info = cqgraph.get_quantum_info(node_id)
695 task_label = quantum_info["task_label"]
696 if task_label not in cached_pipetask_values:
697 search_opt["curvals"]["curr_pipetask"] = task_label
698 cached_pipetask_values[task_label] = _get_job_values(config, search_opt, "runQuantumCommand")
699 _handle_job_values(cached_pipetask_values[task_label], gwjob, unset_attributes)
701 # Update job with workflow attribute and profile values.
702 qgraph_gwfile = _get_qgraph_gwfile(
703 config, save_qgraph_per_job, gwjob, generic_workflow.get_file("runQgraphFile"), prefix
704 )
706 generic_workflow.add_job(gwjob)
707 generic_workflow.add_job_inputs(gwjob.name, [qgraph_gwfile])
709 gwjob.cmdvals["qgraphNodeId"] = ",".join(
710 sorted([f"{node_id}" for node_id in cluster.qgraph_node_ids])
711 )
712 _enhance_command(config, generic_workflow, gwjob, cached_job_values)
714 # If writing per-job QuantumGraph files during TRANSFORM stage,
715 # write it now while in memory.
716 if save_qgraph_per_job == WhenToSaveQuantumGraphs.TRANSFORM:
717 save_qg_subgraph(cqgraph.qgraph, qgraph_gwfile.src_uri, cluster.qgraph_node_ids)
719 # Create job dependencies.
720 for parent in cqgraph.clusters():
721 for child in cqgraph.successors(parent):
722 generic_workflow.add_job_relationships(parent.name, child.name)
724 # Add initial workflow.
725 if config.get("runInit", "{default: False}"):
726 add_workflow_init_nodes(config, cqgraph.qgraph, generic_workflow)
728 generic_workflow.run_attrs.update(
729 {
730 "bps_isjob": "True",
731 "bps_project": config["project"],
732 "bps_campaign": config["campaign"],
733 "bps_run": generic_workflow.name,
734 "bps_operator": config["operator"],
735 "bps_payload": config["payloadName"],
736 "bps_runsite": config["computeSite"],
737 }
738 )
740 # Add final job
741 add_final_job(config, generic_workflow, prefix)
743 if "ordering" in config:
744 generic_workflow.add_special_job_ordering(config["ordering"])
746 return generic_workflow
749def create_generic_workflow_config(config, prefix):
750 """Create generic workflow configuration.
752 Parameters
753 ----------
754 config : `lsst.ctrl.bps.BpsConfig`
755 Bps configuration.
756 prefix : `str`
757 Root path for any output files.
759 Returns
760 -------
761 generic_workflow_config : `lsst.ctrl.bps.BpsConfig`
762 Configuration accompanying the GenericWorkflow.
763 """
764 generic_workflow_config = BpsConfig(config)
765 generic_workflow_config["workflowName"] = config["uniqProcName"]
766 generic_workflow_config["workflowPath"] = prefix
767 return generic_workflow_config
770def add_final_job(config: BpsConfig, generic_workflow: GenericWorkflow, prefix: str) -> None:
771 """Add final workflow job depending upon configuration.
773 Depending on configuration, the final job will be added as a special job
774 which will always run regardless of the exit status of the workflow or
775 a regular sink node which will only run if the workflow execution finished
776 with no errors.
778 Parameters
779 ----------
780 config : `lsst.ctrl.bps.BpsConfig`
781 Bps configuration.
782 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
783 Generic workflow to which attributes should be added.
784 prefix : `str`
785 Directory in which to output final script.
786 """
787 _, when_run = config.search(".finalJob.whenRun")
788 if when_run.upper() != "NEVER":
789 gwjob = create_final_job(config, generic_workflow, prefix)
790 if when_run.upper() == "ALWAYS":
791 generic_workflow.add_final(gwjob)
792 elif when_run.upper() == "SUCCESS":
793 add_final_job_as_sink(generic_workflow, gwjob)
794 else:
795 raise ValueError(f"Invalid value for finalJob.whenRun: {when_run}")
798def create_final_job(config: BpsConfig, generic_workflow: GenericWorkflow, prefix: str) -> GenericWorkflowJob:
799 """Create the final workflow job.
801 Parameters
802 ----------
803 config : `lsst.ctrl.bps.BpsConfig`
804 Bps configuration.
805 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
806 Generic workflow to which attributes should be added.
807 prefix : `str`
808 Directory in which to output final script.
810 Returns
811 -------
812 final_job : `lsst.ctrl.bps.GenericWorkflowJob`
813 Final workflow job.
814 """
815 job_name = "finalJob"
816 gwjob = GenericWorkflowJob(job_name, job_name)
818 search_opt = {"searchobj": config[job_name], "curvals": {}, "default": None}
819 found, value = config.search("computeSite", opt=search_opt)
820 if found:
821 search_opt["curvals"]["curr_site"] = value
822 found, value = config.search("computeCloud", opt=search_opt)
823 if found:
824 search_opt["curvals"]["curr_cloud"] = value
826 # Set job attributes based on the values find in the config excluding
827 # the ones in the _ATTRS_MISC group. The attributes in this group are
828 # somewhat "special":
829 # * HTCondor plugin, which uses 'attrs' and 'profile', has its own
830 # mechanism for setting them,
831 # * 'cmdvals' is being set internally, not via config.
832 job_values = _get_job_values(config, search_opt, None)
833 for attr in _ATTRS_ALL - _ATTRS_MISC:
834 if not getattr(gwjob, attr) and job_values.get(attr, None):
835 setattr(gwjob, attr, job_values[attr])
837 # Create script and add command line to job.
838 gwjob.executable, gwjob.arguments = create_final_command(config, prefix)
840 # Determine inputs from command line.
841 for file_key in re.findall(r"<FILE:([^>]+)>", gwjob.arguments):
842 gwfile = generic_workflow.get_file(file_key)
843 generic_workflow.add_job_inputs(gwjob.name, gwfile)
845 _enhance_command(config, generic_workflow, gwjob, {})
846 return gwjob
849def create_final_command(config: BpsConfig, prefix: str) -> tuple[GenericWorkflowExec, str]:
850 """Create the command and shell script for the final job.
852 Parameters
853 ----------
854 config : `lsst.ctrl.bps.BpsConfig`
855 Bps configuration.
856 prefix : `str`
857 Directory in which to output final script.
859 Returns
860 -------
861 executable : `lsst.ctrl.bps.GenericWorkflowExec`
862 Executable object for the final script.
863 arguments : `str`
864 Command line needed to call the final script.
866 Raises
867 ------
868 RuntimeError if no commands found.
869 """
870 search_opt = {
871 "replaceVars": True,
872 "skipNames": ["butlerConfig", "qgraphFile"],
873 "replaceEnvVars": False,
874 "expandEnvVars": False,
875 "searchobj": config["finalJob"],
876 }
878 script_file = os.path.join(prefix, "final_job.bash")
879 with open(script_file, "w", encoding="utf8") as fh:
880 print("#!/bin/bash\n", file=fh)
881 print("set -e", file=fh)
882 print("set -x", file=fh)
884 print("qgraphFile=$1", file=fh)
885 print("butlerConfig=$2", file=fh)
887 command_len = 0 # Make sure at least write one actual command
888 i = 1
889 found, command = config.search(f"command{i}", opt=search_opt)
890 while found:
891 # The files will be args to script, so change to shell vars
892 command = command.replace("{qgraphFile}", "${qgraphFile}")
893 command = command.replace("{butlerConfig}", "${butlerConfig}")
895 print(command, file=fh)
896 command_len += len(command.strip())
898 # Search for next command
899 i += 1
900 found, command = config.search(f"command{i}", opt=search_opt)
901 if command_len == 0:
902 raise RuntimeError(
903 "No finalJob commands were found. Use NEVER for finalJob.whenRun to turn off finalJob"
904 )
905 os.chmod(script_file, 0o755)
906 executable = GenericWorkflowExec(os.path.basename(script_file), script_file, True)
908 _, orig_butler = config.search("butlerConfig")
909 return executable, f"<FILE:runQgraphFile> {orig_butler}"
912def add_final_job_as_sink(generic_workflow, final_job):
913 """Add final job as the single sink for the workflow.
915 Parameters
916 ----------
917 generic_workflow : `lsst.ctrl.bps.GenericWorkflow`
918 Generic workflow to which attributes should be added.
919 final_job : `lsst.ctrl.bps.GenericWorkflowJob`
920 Job to add as new sink node depending upon all previous sink nodes.
921 """
922 # Find sink nodes of generic workflow graph.
923 gw_sinks = [n for n in generic_workflow if generic_workflow.out_degree(n) == 0]
924 _LOG.debug("gw_sinks = %s", gw_sinks)
926 generic_workflow.add_job(final_job)
927 generic_workflow.add_job_relationships(gw_sinks, final_job.name)