Coverage for tests/test_transform.py: 96%
193 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-16 02:14 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-16 02:14 -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/>.
27"""Unit tests of transform.py."""
29import dataclasses
30import os
31import shutil
32import tempfile
33import unittest
35from cqg_test_utils import make_test_clustered_quantum_graph
37from lsst.ctrl.bps import (
38 BPS_SEARCH_ORDER,
39 BpsConfig,
40 GenericWorkflow,
41 GenericWorkflowExec,
42 GenericWorkflowJob,
43)
44from lsst.ctrl.bps.transform import (
45 _enhance_command,
46 _get_job_values,
47 create_final_command,
48 create_generic_workflow,
49 create_generic_workflow_config,
50)
52TESTDIR = os.path.abspath(os.path.dirname(__file__))
55class TestCreateGenericWorkflowConfig(unittest.TestCase):
56 """Tests of create_generic_workflow_config."""
58 def testCreate(self):
59 """Test successful creation of the config."""
60 config = BpsConfig({"a": 1, "b": 2, "uniqProcName": "testCreate"})
61 wf_config = create_generic_workflow_config(config, "/test/create/prefix")
62 self.assertIsInstance(wf_config, BpsConfig)
63 for key in config:
64 self.assertEqual(wf_config[key], config[key])
65 self.assertEqual(wf_config["workflowName"], "testCreate")
66 self.assertEqual(wf_config["workflowPath"], "/test/create/prefix")
69class TestCreateGenericWorkflow(unittest.TestCase):
70 """Tests of create_generic_workflow."""
72 def setUp(self):
73 self.tmpdir = tempfile.mkdtemp(dir=TESTDIR)
74 self.config = BpsConfig(
75 {
76 "runInit": True,
77 "computeSite": "global",
78 "runQuantumCommand": "gexe -q {qgraphFile} --qgraph-node-id {qgraphNodeId}",
79 "clusterTemplate": "{D1}_{D2}",
80 "cluster": {
81 "cl1": {"pipetasks": "T1, T2", "dimensions": "D1, D2"},
82 "cl2": {"pipetasks": "T3, T4", "dimensions": "D1, D2"},
83 },
84 "cloud": {
85 "cloud1": {"runQuantumCommand": "c1exe -q {qgraphFile} --qgraph-node-id {qgraphNodeId}"},
86 "cloud2": {"runQuantumCommand": "c2exe -q {qgraphFile} --qgraph-node-id {qgraphNodeId}"},
87 },
88 "site": {
89 "site1": {"runQuantumCommand": "s1exe -q {qgraphFile} --qgraph-node-id {qgraphNodeId}"},
90 "site2": {"runQuantumCommand": "s2exe -q {qgraphFile} --qgraph-node-id {qgraphNodeId}"},
91 "global": {"runQuantumCommand": "s3exe -q {qgraphFile} --qgraph-node-id {qgraphNodeId}"},
92 },
93 # Needed because transform assumes they exist
94 "whenSaveJobQgraph": "NEVER",
95 "finalJob": {"whenRun": "ALWAYS", "command1": "/usr/bin/env"},
96 },
97 BPS_SEARCH_ORDER,
98 )
99 _, self.cqg = make_test_clustered_quantum_graph(self.tmpdir)
101 def tearDown(self):
102 shutil.rmtree(self.tmpdir, ignore_errors=True)
104 def testCreatingGenericWorkflowGlobal(self):
105 """Test creating a GenericWorkflow with global settings."""
106 config = BpsConfig(self.config)
107 config["computeCloud"] = "cloud1"
108 config["computeSite"] = "site2"
109 config["queue"] = "global_queue"
110 print(config)
111 workflow = create_generic_workflow(config, self.cqg, "test_gw", self.tmpdir)
112 for jname in workflow:
113 gwjob = workflow.get_job(jname)
114 print(gwjob)
115 self.assertEqual(gwjob.compute_site, "site2")
116 self.assertEqual(gwjob.compute_cloud, "cloud1")
117 self.assertEqual(gwjob.executable.src_uri, "s2exe")
118 self.assertEqual(gwjob.queue, "global_queue")
119 final = workflow.get_final()
120 self.assertEqual(final.compute_site, "site2")
121 self.assertEqual(final.compute_cloud, "cloud1")
122 self.assertEqual(final.queue, "global_queue")
124 def testCreatingQuantumGraphMixed(self):
125 """Test creating a GenericWorkflow with setting overrides."""
126 config = BpsConfig(self.config)
127 config[".cluster.cl1.computeCloud"] = "cloud2"
128 config[".cluster.cl1.computeSite"] = "notthere"
129 config[".cluster.cl2.computeSite"] = "site1"
130 config[".finalJob.queue"] = "special_final_queue"
131 config[".finalJob.computeSite"] = "special_site"
132 config[".finalJob.computeCloud"] = "special_cloud"
133 workflow = create_generic_workflow(config, self.cqg, "test_gw", self.tmpdir)
134 self.assertEqual(len(workflow) - 1, len(self.cqg)) # Don't count pipetaskInit
135 for jname in workflow:
136 gwjob = workflow.get_job(jname)
137 print(gwjob)
138 if jname.startswith("cl1"): 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 self.assertEqual(gwjob.compute_site, "notthere")
140 self.assertEqual(gwjob.compute_cloud, "cloud2")
141 self.assertEqual(gwjob.executable.src_uri, "c2exe")
142 elif jname.startswith("cl2"): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 self.assertEqual(gwjob.compute_site, "site1")
144 self.assertIsNone(gwjob.compute_cloud)
145 self.assertEqual(gwjob.executable.src_uri, "s1exe")
146 elif jname.startswith("pipetask"):
147 self.assertEqual(gwjob.compute_site, "global")
148 self.assertIsNone(gwjob.compute_cloud)
149 self.assertEqual(gwjob.executable.src_uri, "s3exe")
150 final = workflow.get_final()
151 self.assertEqual(final.compute_site, "special_site")
152 self.assertEqual(final.compute_cloud, "special_cloud")
153 self.assertEqual(final.queue, "special_final_queue")
156class TestGetJobValues(unittest.TestCase):
157 """Tests of _get_job_values."""
159 def setUp(self):
160 self.default_job = GenericWorkflowJob("default_job", "default_label")
162 def testGettingDefaults(self):
163 """Test retrieving default values."""
164 config = BpsConfig({})
165 job_values = _get_job_values(config, {}, None)
166 self.assertTrue(
167 all(
168 getattr(self.default_job, field.name) == job_values[field.name]
169 for field in dataclasses.fields(self.default_job)
170 )
171 )
173 def testEnablingMemoryScaling(self):
174 """Test enabling the memory scaling mechanism."""
175 config = BpsConfig({"memoryMultiplier": 2.0})
176 job_values = _get_job_values(config, {}, None)
177 self.assertAlmostEqual(job_values["memory_multiplier"], 2.0)
178 self.assertEqual(job_values["number_of_retries"], 5)
180 def testDisablingMemoryScaling(self):
181 """Test disabling the memory scaling mechanism."""
182 config = BpsConfig({"memoryMultiplier": 0.5})
183 job_values = _get_job_values(config, {}, None)
184 self.assertIsNone(job_values["memory_multiplier"])
186 def testRetrievingCmdLine(self):
187 """Test retrieving the command line."""
188 cmd_line_key = "runQuantum"
189 config = BpsConfig({cmd_line_key: "/path/to/foo bar.txt"})
190 job_values = _get_job_values(config, {}, cmd_line_key)
191 self.assertEqual(job_values["executable"].name, "foo")
192 self.assertEqual(job_values["executable"].src_uri, "/path/to/foo")
193 self.assertEqual(job_values["arguments"], "bar.txt")
195 def testEnvironment(self):
196 config = BpsConfig(
197 {
198 "var1": "two",
199 "environment": {"TEST_INT": 1, "TEST_BOOL": False, "TEST_SPACES": "one {var1} three"},
200 }
201 )
202 job_values = _get_job_values(config, {}, None)
203 truth = {"TEST_INT": "1", "TEST_BOOL": "False", "TEST_SPACES": "one two three"}
204 self.assertEqual(truth, job_values["environment"])
206 def testEnvironmentOptions(self):
207 config = BpsConfig(
208 {
209 "var1": "two",
210 "environment": {"TEST_INT": 1, "TEST_BOOL": False, "TEST_SPACES": "one {var1} three"},
211 "finalJob": {"requestMemory": 8096, "command1": "/usr/bin/env"},
212 }
213 )
214 search_obj = config["finalJob"]
215 search_opts = {"replaceVars": False, "searchobj": search_obj}
216 job_values = _get_job_values(config, search_opts, None)
217 truth = {"TEST_INT": "1", "TEST_BOOL": "False", "TEST_SPACES": "one two three"}
218 self.assertEqual(truth, job_values["environment"])
219 self.assertEqual(search_opts["replaceVars"], False)
220 self.assertEqual(search_opts["searchobj"]["requestMemory"], 8096)
221 self.assertEqual(job_values["request_memory"], 8096)
223 def testVarsInEnvironment(self):
224 config = BpsConfig(
225 {
226 "var1": "two",
227 "environment": {"TEST_INT": 1, "TEST_BOOL": False, "TEST_SPACES": "one {var1} <ENV:var3>"},
228 }
229 )
230 job_values = _get_job_values(config, {"replaceVars": True}, None)
231 truth = {"TEST_INT": "1", "TEST_BOOL": "False", "TEST_SPACES": "one two <ENV:var3>"}
232 self.assertEqual(truth, job_values["environment"])
235class TestCreateFinalCommand(unittest.TestCase):
236 """Tests for the create_final_command function."""
238 def setUp(self):
239 self.tmpdir = tempfile.TemporaryDirectory()
240 self.script_beginning = [
241 "#!/bin/bash\n",
242 "\n",
243 "set -e\n",
244 "set -x\n",
245 "qgraphFile=$1\n",
246 "butlerConfig=$2\n",
247 ]
249 def tearDown(self):
250 self.tmpdir.cleanup()
252 def testSingleCommand(self):
253 """Test with single final job command."""
254 config_butler = f"{self.tmpdir.name}/test_repo"
255 config = BpsConfig(
256 {
257 "var1": "42a",
258 "var2": "42b",
259 "var3": "42c",
260 "butlerConfig": config_butler,
261 "finalJob": {"command1": "/usr/bin/echo {var1} {qgraphFile} {var2} {butlerConfig} {var3}"},
262 }
263 )
264 gwf_exec, args = create_final_command(config, self.tmpdir.name)
265 self.assertEqual(args, f"<FILE:runQgraphFile> {config_butler}")
266 final_script = f"{self.tmpdir.name}/final_job.bash"
267 self.assertEqual(gwf_exec.src_uri, final_script)
268 with open(final_script) as infh:
269 lines = infh.readlines()
270 self.assertEqual(
271 lines, self.script_beginning + ["/usr/bin/echo 42a ${qgraphFile} 42b ${butlerConfig} 42c\n"]
272 )
274 def testMultipleCommands(self):
275 config_butler = f"{self.tmpdir.name}/test_repo"
276 config = BpsConfig(
277 {
278 "var1": "42a",
279 "var2": "42b",
280 "var3": "42c",
281 "butlerConfig": config_butler,
282 "finalJob": {
283 "command1": "/usr/bin/echo {var1} {qgraphFile} {var2} {butlerConfig} {var3}",
284 "command2": "/usr/bin/uptime",
285 },
286 }
287 )
288 gwf_exec, args = create_final_command(config, self.tmpdir.name)
289 self.assertEqual(args, f"<FILE:runQgraphFile> {config_butler}")
290 final_script = f"{self.tmpdir.name}/final_job.bash"
291 self.assertEqual(gwf_exec.src_uri, final_script)
292 with open(final_script) as infh:
293 lines = infh.readlines()
294 self.assertEqual(
295 lines,
296 self.script_beginning
297 + ["/usr/bin/echo 42a ${qgraphFile} 42b ${butlerConfig} 42c\n", "/usr/bin/uptime\n"],
298 )
300 def testZeroCommands(self):
301 config_butler = f"{self.tmpdir.name}/test_repo"
302 config = BpsConfig(
303 {
304 "var1": "42a",
305 "var2": "42b",
306 "var3": "42c",
307 "butlerConfig": config_butler,
308 "finalJob": {
309 "cmd1": "/usr/bin/echo {var1} {qgraphFile} {var2} {butlerConfig} {var3}",
310 "cmd2": "/usr/bin/uptime",
311 },
312 }
313 )
314 with self.assertRaisesRegex(RuntimeError, "finalJob.whenRun"):
315 _, _ = create_final_command(config, self.tmpdir.name)
317 def testWhiteSpaceOnlyCommand(self):
318 config_butler = f"{self.tmpdir.name}/test_repo"
319 config = BpsConfig(
320 {
321 "butlerConfig": config_butler,
322 "finalJob": {"command1": "", "command2": "\t \n"},
323 }
324 )
325 with self.assertRaisesRegex(RuntimeError, "finalJob.whenRun"):
326 _, _ = create_final_command(config, self.tmpdir.name)
328 def testSkipCommandUsingWhiteSpace(self):
329 config_butler = f"{self.tmpdir.name}/test_repo"
330 config = BpsConfig(
331 {
332 "var1": "42a",
333 "var2": "42b",
334 "var3": "42c",
335 "butlerConfig": config_butler,
336 "finalJob": {
337 "command1": "/usr/bin/echo {var1} {qgraphFile} {var2} {butlerConfig} {var3}",
338 "command2": "", # test skipping a command (i.e., overriding a default)
339 "command3": "/usr/bin/uptime",
340 },
341 }
342 )
343 gwf_exec, args = create_final_command(config, self.tmpdir.name)
344 self.assertEqual(args, f"<FILE:runQgraphFile> {config_butler}")
345 final_script = f"{self.tmpdir.name}/final_job.bash"
346 self.assertEqual(gwf_exec.src_uri, final_script)
347 with open(final_script) as infh:
348 lines = infh.readlines()
349 self.assertEqual(
350 lines,
351 self.script_beginning
352 + ["/usr/bin/echo 42a ${qgraphFile} 42b ${butlerConfig} 42c\n", "\n", "/usr/bin/uptime\n"],
353 )
356class TestEnhanceCommand(unittest.TestCase):
357 """Tests of _enhance_command function."""
359 def setUp(self):
360 self.gw_exec = GenericWorkflowExec("test_exec", "/dummy/dir/pipetask")
361 self.config = BpsConfig(
362 {
363 # "profile": {},
364 "bpsUseShared": True,
365 "whenSaveJobQgraph": "NEVER",
366 "useLazyCommands": True,
367 # "memoryLimit": 32768,
368 "defOpts": "--long-log --log-file {submitPath}/{jobName}.{wmsAttemptNum}.json",
369 "submitPath": "/the/path",
370 }
371 )
372 self.cached_vals = {
373 "label1": {
374 "profile": {},
375 "bpsUseShared": True,
376 "whenSaveJobQgraph": "NEVER",
377 "useLazyCommands": True,
378 "memoryLimit": 32768,
379 "key1": "val1",
380 }
381 }
383 def testAttemptNum(self):
384 # test both in arguments as well as in variables in arguments
385 gwjob = GenericWorkflowJob("job1", "label1", executable=self.gw_exec)
386 gw = GenericWorkflow("test1")
387 gw.add_job(gwjob)
389 first_args = "{defOpts} run-qbb repo test.qg --summary {submitPath}/{jobName}-summary."
390 gwjob.arguments = first_args + "{wmsAttemptNum}.json"
392 new_arguments = first_args + "<WMS:attemptNum>.json"
393 new_opts = "--long-log --log-file /the/path/job1.<WMS:attemptNum>.json"
395 _enhance_command(self.config, gw, gwjob, {})
397 self.assertEqual(gwjob.arguments, new_arguments)
398 self.assertEqual(gwjob.cmdvals["defOpts"], new_opts)
400 def testKeyCachedCmdVal(self):
401 gwjob = GenericWorkflowJob("job1", "label1", executable=self.gw_exec)
402 gw = GenericWorkflow("test1")
403 gw.add_job(gwjob)
404 gwjob.arguments = "run-qbb repo test.qg -x {key1}"
405 self.assertNotIn("key1", gwjob.cmdvals)
406 _enhance_command(self.config, gw, gwjob, self.cached_vals)
407 self.assertEqual(gwjob.cmdvals["key1"], "val1")
409 def testS3Argument(self):
410 """Make sure s3 double slashes are not getting removed."""
411 gwjob = GenericWorkflowJob("job1", "label1", executable=self.gw_exec)
412 gw = GenericWorkflow("test1")
413 gw.add_job(gwjob)
414 s3 = "s3://user1@rubin-place-users/butler-pipeline1-processing.yaml"
415 gwjob.arguments = s3
416 _enhance_command(self.config, gw, gwjob, {})
417 self.assertEqual(gwjob.arguments, s3)
420if __name__ == "__main__":
421 unittest.main()