Coverage for tests / test_prepare_utils.py: 26%
226 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-12 01:19 -0800
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-12 01:19 -0800
1# This file is part of ctrl_bps_htcondor.
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"""Unit tests for prepare utility functions."""
30import logging
31import os
32import unittest
34from lsst.ctrl.bps import (
35 BPS_DEFAULTS,
36 BPS_SEARCH_ORDER,
37 BpsConfig,
38 GenericWorkflowExec,
39 GenericWorkflowFile,
40 GenericWorkflowJob,
41)
42from lsst.ctrl.bps.htcondor import prepare_utils
43from lsst.ctrl.bps.tests.gw_test_utils import make_3_label_workflow, make_3_label_workflow_groups_sort
45logger = logging.getLogger("lsst.ctrl.bps.htcondor")
47TESTDIR = os.path.abspath(os.path.dirname(__file__))
50class TranslateJobCmdsTestCase(unittest.TestCase):
51 """Test _translate_job_cmds method."""
53 def setUp(self):
54 self.gw_exec = GenericWorkflowExec("test_exec", "/dummy/dir/pipetask")
55 self.cached_vals = {"profile": {}, "bpsUseShared": True, "memoryLimit": 32768}
57 def testRetryUnlessNone(self):
58 gwjob = GenericWorkflowJob("retryUnless", "label1", executable=self.gw_exec)
59 gwjob.retry_unless_exit = None
60 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
61 self.assertNotIn("retry_until", htc_commands)
63 def testRetryUnlessInt(self):
64 gwjob = GenericWorkflowJob("retryUnlessInt", "label1", executable=self.gw_exec)
65 gwjob.retry_unless_exit = 3
66 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
67 self.assertEqual(int(htc_commands["retry_until"]), gwjob.retry_unless_exit)
69 def testRetryUnlessList(self):
70 gwjob = GenericWorkflowJob("retryUnlessList", "label1", executable=self.gw_exec)
71 gwjob.retry_unless_exit = [1, 2]
72 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
73 self.assertEqual(htc_commands["retry_until"], "member(ExitCode, {1,2})")
75 def testRetryUnlessBad(self):
76 gwjob = GenericWorkflowJob("retryUnlessBad", "label1", executable=self.gw_exec)
77 gwjob.retry_unless_exit = "1,2,3"
78 with self.assertRaises(ValueError) as cm:
79 _ = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
80 self.assertIn("retryUnlessExit", str(cm.exception))
82 def testEnvironmentBasic(self):
83 gwjob = GenericWorkflowJob("jobEnvironment", "label1", executable=self.gw_exec)
84 gwjob.environment = {"TEST_INT": 1, "TEST_STR": "TWO"}
85 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
86 self.assertEqual(htc_commands["environment"], "TEST_INT=1 TEST_STR='TWO'")
88 def testEnvironmentSpaces(self):
89 gwjob = GenericWorkflowJob("jobEnvironment", "label1", executable=self.gw_exec)
90 gwjob.environment = {"TEST_SPACES": "spacey value"}
91 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
92 self.assertEqual(htc_commands["environment"], "TEST_SPACES='spacey value'")
94 def testEnvironmentSingleQuotes(self):
95 gwjob = GenericWorkflowJob("jobEnvironment", "label1", executable=self.gw_exec)
96 gwjob.environment = {"TEST_SINGLE_QUOTES": "spacey 'quoted' value"}
97 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
98 self.assertEqual(htc_commands["environment"], "TEST_SINGLE_QUOTES='spacey ''quoted'' value'")
100 def testEnvironmentDoubleQuotes(self):
101 gwjob = GenericWorkflowJob("jobEnvironment", "label1", executable=self.gw_exec)
102 gwjob.environment = {"TEST_DOUBLE_QUOTES": 'spacey "double" value'}
103 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
104 self.assertEqual(htc_commands["environment"], """TEST_DOUBLE_QUOTES='spacey ""double"" value'""")
106 def testEnvironmentWithEnvVars(self):
107 gwjob = GenericWorkflowJob("jobEnvironment", "label1", executable=self.gw_exec)
108 gwjob.environment = {"TEST_ENV_VAR": "<ENV:CTRL_BPS_DIR>/tests"}
109 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
110 self.assertEqual(htc_commands["environment"], "TEST_ENV_VAR='$ENV(CTRL_BPS_DIR)/tests'")
112 def testPeriodicRelease(self):
113 gwjob = GenericWorkflowJob("periodicRelease", "label1", executable=self.gw_exec)
114 gwjob.request_memory = 2048
115 gwjob.memory_multiplier = 2
116 gwjob.number_of_retries = 3
117 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
118 release = (
119 "JobStatus == 5 && NumJobStarts <= JobMaxRetries && "
120 "(HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 || "
121 "HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34) && "
122 "min({int(2048 * pow(2, NumJobStarts - 1)), 32768}) < 32768"
123 )
124 self.assertEqual(htc_commands["periodic_release"], release)
126 def testPeriodicRemoveNoRetries(self):
127 gwjob = GenericWorkflowJob("periodicRelease", "label1", executable=self.gw_exec)
128 gwjob.request_memory = 2048
129 gwjob.memory_multiplier = 1
130 gwjob.number_of_retries = 0
131 htc_commands = prepare_utils._translate_job_cmds(self.cached_vals, None, gwjob)
132 remove = "JobStatus == 5 && (NumJobStarts > JobMaxRetries)"
133 self.assertEqual(htc_commands["periodic_remove"], remove)
134 self.assertEqual(htc_commands["max_retries"], 0)
137class GroupToSubdagTestCase(unittest.TestCase):
138 """Test _group_to_subdag function."""
140 def testBlocking(self):
141 gw = make_3_label_workflow_groups_sort("test1", True)
142 gwjob = gw.get_job("group_order1_10001")
143 config = BpsConfig(
144 {},
145 search_order=BPS_SEARCH_ORDER,
146 defaults=BPS_DEFAULTS,
147 )
149 htc_job = prepare_utils._group_to_subdag(config, gwjob, "the_prefix")
150 self.assertEqual(len(htc_job.subdag), len(gwjob))
153class GatherSiteValuesTestCase(unittest.TestCase):
154 """Test _gather_site_values function."""
156 def testAllThere(self):
157 config = BpsConfig(
158 {},
159 search_order=BPS_SEARCH_ORDER,
160 defaults=BPS_DEFAULTS,
161 )
162 compute_site = "notThere"
163 results = prepare_utils._gather_site_values(config, compute_site)
164 self.assertEqual(results["memoryLimit"], BPS_DEFAULTS["memoryLimit"])
166 def testNotSpecified(self):
167 config = BpsConfig(
168 {},
169 search_order=BPS_SEARCH_ORDER,
170 defaults=BPS_DEFAULTS,
171 wms_service_class_fqn="lsst.ctrl.bps.htcondor.HTCondorService",
172 )
173 compute_site = "notThere"
174 results = prepare_utils._gather_site_values(config, compute_site)
175 self.assertEqual(results["memoryLimit"], BPS_DEFAULTS["memoryLimit"])
178class GatherLabelValuesTestCase(unittest.TestCase):
179 """Test _gather_labels_values function."""
181 def testClusterLabel(self):
182 # Test cluster value overrides pipetask.
183 label = "label1"
184 config = BpsConfig(
185 {
186 "cluster": {
187 "label1": {
188 "releaseExpr": "cluster_val",
189 "overwriteJobFiles": False,
190 "profile": {"condor": {"prof_val1": 3}},
191 }
192 },
193 "pipetask": {"label1": {"releaseExpr": "pipetask_val"}},
194 },
195 search_order=BPS_SEARCH_ORDER,
196 defaults=BPS_DEFAULTS,
197 wms_service_class_fqn="lsst.ctrl.bps.htcondor.HTCondorService",
198 )
199 results = prepare_utils._gather_label_values(config, label)
200 self.assertEqual(
201 results,
202 {
203 "attrs": {},
204 "profile": {"prof_val1": 3},
205 "releaseExpr": "cluster_val",
206 "overwriteJobFiles": False,
207 },
208 )
210 def testPipetaskLabel(self):
211 label = "label1"
212 config = BpsConfig(
213 {
214 "pipetask": {
215 "label1": {
216 "releaseExpr": "pipetask_val",
217 "overwriteJobFiles": False,
218 "profile": {"condor": {"prof_val1": 3}},
219 }
220 }
221 },
222 search_order=BPS_SEARCH_ORDER,
223 defaults=BPS_DEFAULTS,
224 wms_service_class_fqn="lsst.ctrl.bps.htcondor.HTCondorService",
225 )
226 results = prepare_utils._gather_label_values(config, label)
227 self.assertEqual(
228 results,
229 {
230 "attrs": {},
231 "profile": {"prof_val1": 3},
232 "releaseExpr": "pipetask_val",
233 "overwriteJobFiles": False,
234 },
235 )
237 def testNoSection(self):
238 label = "notThere"
239 config = BpsConfig(
240 {},
241 search_order=BPS_SEARCH_ORDER,
242 defaults=BPS_DEFAULTS,
243 wms_service_class_fqn="lsst.ctrl.bps.htcondor.HTCondorService",
244 )
245 results = prepare_utils._gather_label_values(config, label)
246 self.assertEqual(results, {"attrs": {}, "profile": {}, "overwriteJobFiles": True})
248 def testNoOverwriteSpecified(self):
249 label = "notthere"
250 config = BpsConfig(
251 {},
252 search_order=BPS_SEARCH_ORDER,
253 defaults={},
254 wms_service_class_fqn="lsst.ctrl.bps.htcondor.HTCondorService",
255 )
256 results = prepare_utils._gather_label_values(config, label)
257 self.assertEqual(results, {"attrs": {}, "profile": {}, "overwriteJobFiles": True})
259 def testFinalJob(self):
260 label = "finalJob"
261 config = BpsConfig(
262 {"finalJob": {"profile": {"condor": {"prof_val2": 6, "+attr_val1": 5}}}},
263 search_order=BPS_SEARCH_ORDER,
264 defaults=BPS_DEFAULTS,
265 wms_service_class_fqn="lsst.ctrl.bps.htcondor.HTCondorService",
266 )
267 results = prepare_utils._gather_label_values(config, label)
268 self.assertEqual(
269 results, {"attrs": {"attr_val1": 5}, "profile": {"prof_val2": 6}, "overwriteJobFiles": False}
270 )
273class CreateCheckJobTestCase(unittest.TestCase):
274 """Test _create_check_job function."""
276 def testSuccess(self):
277 group_job_name = "group_order1_val1a"
278 job_label = "order1"
279 job = prepare_utils._create_check_job(group_job_name, job_label)
280 self.assertIn(group_job_name, job.name)
281 self.assertEqual(job.label, job_label)
282 self.assertIn("check_group_status.sub", job.subfile)
285class CreatePeriodicReleaseExprTestCase(unittest.TestCase):
286 """Test _create_periodic_release_expr function."""
288 def testNoReleaseExpr(self):
289 results = prepare_utils._create_periodic_release_expr(2048, 1, 32768, "")
290 self.assertEqual(results, "")
292 def testMultiplierNone(self):
293 results = prepare_utils._create_periodic_release_expr(2048, None, 32768, "")
294 self.assertEqual(results, "")
296 def testJustMemoryReleaseExpr(self):
297 self.maxDiff = None # so test error shows entire strings
298 results = prepare_utils._create_periodic_release_expr(2048, 2, 32768, "")
299 truth = (
300 "JobStatus == 5 && NumJobStarts <= JobMaxRetries && "
301 "(HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 || "
302 "HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34) && "
303 "min({int(2048 * pow(2, NumJobStarts - 1)), 32768}) < 32768"
304 )
305 self.assertEqual(results, truth)
307 def testJustUserReleaseExpr(self):
308 results = prepare_utils._create_periodic_release_expr(2048, 1, 32768, "True")
309 truth = "JobStatus == 5 && NumJobStarts <= JobMaxRetries && HoldReasonCode =!= 1 && True"
310 self.assertEqual(results, truth)
312 def testJustUserReleaseExprMultiplierNone(self):
313 results = prepare_utils._create_periodic_release_expr(2048, None, 32768, "True")
314 truth = "JobStatus == 5 && NumJobStarts <= JobMaxRetries && HoldReasonCode =!= 1 && True"
315 self.assertEqual(results, truth)
317 def testMemoryAndUserReleaseExpr(self):
318 self.maxDiff = None # so test error shows entire strings
319 results = prepare_utils._create_periodic_release_expr(2048, 2, 32768, "True")
320 truth = (
321 "JobStatus == 5 && NumJobStarts <= JobMaxRetries && "
322 "((HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 || "
323 "HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34) && "
324 "min({int(2048 * pow(2, NumJobStarts - 1)), 32768}) < 32768 || "
325 "HoldReasonCode =!= 1 && True)"
326 )
327 self.assertEqual(results, truth)
330class CreatePeriodicRemoveExprTestCase(unittest.TestCase):
331 """Test _create_periodic_release_expr function."""
333 def testBasicRemoveExpr(self):
334 """Function assumes only called if max_retries >= 0."""
335 results = prepare_utils._create_periodic_remove_expr(2048, 1, 32768)
336 truth = "JobStatus == 5 && (NumJobStarts > JobMaxRetries)"
337 self.assertEqual(results, truth)
339 def testBasicRemoveExprMultiplierNone(self):
340 """Function assumes only called if max_retries >= 0."""
341 results = prepare_utils._create_periodic_remove_expr(2048, None, 32768)
342 truth = "JobStatus == 5 && (NumJobStarts > JobMaxRetries)"
343 self.assertEqual(results, truth)
345 def testMemoryRemoveExpr(self):
346 self.maxDiff = None # so test error shows entire strings
347 results = prepare_utils._create_periodic_remove_expr(2048, 2, 32768)
348 truth = (
349 "JobStatus == 5 && (NumJobStarts > JobMaxRetries || "
350 "((HoldReasonCode =?= 34 && HoldReasonSubCode =?= 0 || "
351 "HoldReasonCode =?= 3 && HoldReasonSubCode =?= 34) && "
352 "min({int(2048 * pow(2, NumJobStarts - 1)), 32768}) == 32768))"
353 )
354 self.assertEqual(results, truth)
357class HandleJobOutputsTestCase(unittest.TestCase):
358 """Test _handle_job_outputs function."""
360 def setUp(self):
361 self.job_name = "test_job"
362 self.out_prefix = "/test/prefix"
364 def tearDown(self):
365 pass
367 def testNoOutputsSharedFilesystem(self):
368 """Test with shared filesystem and no outputs."""
369 mock_workflow = unittest.mock.Mock()
370 mock_workflow.get_job_outputs.return_value = []
372 result = prepare_utils._handle_job_outputs(mock_workflow, self.job_name, True, self.out_prefix)
374 self.assertEqual(result, {"transfer_output_files": '""'})
376 def testWithOutputsSharedFilesystem(self):
377 """Test with shared filesystem and outputs present (still empty)."""
378 mock_workflow = unittest.mock.Mock()
379 mock_workflow.get_job_outputs.return_value = [
380 GenericWorkflowFile(name="output.txt", src_uri="/path/to/output.txt")
381 ]
383 result = prepare_utils._handle_job_outputs(mock_workflow, self.job_name, True, self.out_prefix)
385 self.assertEqual(result, {"transfer_output_files": '""'})
387 def testNoOutputsNoSharedFilesystem(self):
388 """Test without shared filesystem and no outputs."""
389 mock_workflow = unittest.mock.Mock()
390 mock_workflow.get_job_outputs.return_value = []
392 result = prepare_utils._handle_job_outputs(mock_workflow, self.job_name, False, self.out_prefix)
394 self.assertEqual(result, {"transfer_output_files": '""'})
396 def testWithAnOutputNoSharedFilesystem(self):
397 """Test without shared filesystem and single output file."""
398 mock_workflow = unittest.mock.Mock()
399 mock_workflow.get_job_outputs.return_value = [
400 GenericWorkflowFile(name="output.txt", src_uri="/path/to/output.txt")
401 ]
403 result = prepare_utils._handle_job_outputs(mock_workflow, self.job_name, False, self.out_prefix)
405 expected = {
406 "transfer_output_files": "output.txt",
407 "transfer_output_remaps": '"output.txt=/path/to/output.txt"',
408 }
409 self.assertEqual(result, expected)
411 def testWithOutputsNoSharedFilesystem(self):
412 """Test without shared filesystem and multiple output files."""
413 mock_workflow = unittest.mock.Mock()
414 mock_workflow.get_job_outputs.return_value = [
415 GenericWorkflowFile(name="output1.txt", src_uri="/path/output1.txt"),
416 GenericWorkflowFile(name="output2.txt", src_uri="/another/path/output2.txt"),
417 ]
419 result = prepare_utils._handle_job_outputs(mock_workflow, self.job_name, False, self.out_prefix)
421 expected = {
422 "transfer_output_files": "output1.txt,output2.txt",
423 "transfer_output_remaps": '"output1.txt=/path/output1.txt;output2.txt=/another/path/output2.txt"',
424 }
425 self.assertEqual(result, expected)
427 @unittest.mock.patch("lsst.ctrl.bps.htcondor.prepare_utils._LOG")
428 def testLogging(self, mock_log):
429 mock_workflow = unittest.mock.Mock()
430 mock_workflow.get_job_outputs.return_value = [
431 GenericWorkflowFile(name="output.txt", src_uri="/path/to/output.txt")
432 ]
434 prepare_utils._handle_job_outputs(mock_workflow, self.job_name, False, self.out_prefix)
436 self.assertTrue(mock_log.debug.called)
437 debug_calls = mock_log.debug.call_args_list
438 self.assertTrue(any("src_uri=" in str(call) for call in debug_calls))
439 self.assertTrue(any("transfer_output_files=" in str(call) for call in debug_calls))
440 self.assertTrue(any("transfer_output_remaps=" in str(call) for call in debug_calls))
443class CreateJobTestCase(unittest.TestCase):
444 """Test _create_job function."""
446 def setUp(self):
447 self.generic_workflow = make_3_label_workflow("test1", True)
449 def testNoOverwrite(self):
450 template = "{label}/{tract}/{patch}/{band}/{subfilter}/{physical_filter}/{visit}/{exposure}"
451 cached_values = {
452 "bpsUseShared": True,
453 "overwriteJobFiles": False,
454 "memoryLimit": 491520,
455 "profile": {},
456 "attrs": {},
457 }
458 gwjob = self.generic_workflow.get_final()
459 out_prefix = "submit"
460 htc_job = prepare_utils._create_job(template, cached_values, self.generic_workflow, gwjob, out_prefix)
461 self.assertEqual(htc_job.name, gwjob.name)
462 self.assertEqual(htc_job.label, gwjob.label)
463 self.assertIn("NumJobStarts", htc_job.cmds["output"])
464 self.assertIn("NumJobStarts", htc_job.cmds["error"])
465 self.assertNotIn("NumJobStarts", htc_job.cmds["log"])
466 self.assertTrue(htc_job.cmds["error"].endswith(".out"))
467 self.assertTrue(htc_job.cmds["output"].endswith(".out"))
468 self.assertTrue(htc_job.cmds["log"].endswith(".log"))
471if __name__ == "__main__":
472 unittest.main()