lsst-ctrl-bps 29.2025.2900__tar.gz → 29.2025.3100__tar.gz

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.
Files changed (73) hide show
  1. {lsst_ctrl_bps-29.2025.2900/python/lsst_ctrl_bps.egg-info → lsst_ctrl_bps-29.2025.3100}/PKG-INFO +1 -1
  2. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/doc/lsst.ctrl.bps/quickstart.rst +53 -16
  3. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/construct.py +108 -5
  4. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/transform.py +1 -7
  5. lsst_ctrl_bps-29.2025.3100/python/lsst/ctrl/bps/version.py +2 -0
  6. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100/python/lsst_ctrl_bps.egg-info}/PKG-INFO +1 -1
  7. lsst_ctrl_bps-29.2025.3100/tests/test_construct.py +371 -0
  8. lsst_ctrl_bps-29.2025.2900/python/lsst/ctrl/bps/version.py +0 -2
  9. lsst_ctrl_bps-29.2025.2900/tests/test_construct.py +0 -122
  10. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/COPYRIGHT +0 -0
  11. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/LICENSE +0 -0
  12. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/MANIFEST.in +0 -0
  13. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/README.md +0 -0
  14. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/bsd_license.txt +0 -0
  15. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/doc/lsst.ctrl.bps/CHANGES.rst +0 -0
  16. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/doc/lsst.ctrl.bps/index.rst +0 -0
  17. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/gpl-v3.0.txt +0 -0
  18. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/pyproject.toml +0 -0
  19. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/__init__.py +0 -0
  20. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/__init__.py +0 -0
  21. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/__init__.py +0 -0
  22. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/_exceptions.py +0 -0
  23. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/bps_config.py +0 -0
  24. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/bps_draw.py +0 -0
  25. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/bps_reports.py +0 -0
  26. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/bps_utils.py +0 -0
  27. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cancel.py +0 -0
  28. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/__init__.py +0 -0
  29. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/bps.py +0 -0
  30. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/cmd/__init__.py +0 -0
  31. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/cmd/commands.py +0 -0
  32. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/opt/__init__.py +0 -0
  33. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/opt/arguments.py +0 -0
  34. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/opt/option_groups.py +0 -0
  35. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/cli/opt/options.py +0 -0
  36. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/clustered_quantum_graph.py +0 -0
  37. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/constants.py +0 -0
  38. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/drivers.py +0 -0
  39. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/etc/bps_defaults.yaml +0 -0
  40. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/generic_workflow.py +0 -0
  41. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/initialize.py +0 -0
  42. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/ping.py +0 -0
  43. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/pre_transform.py +0 -0
  44. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/prepare.py +0 -0
  45. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/quantum_clustering_funcs.py +0 -0
  46. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/report.py +0 -0
  47. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/restart.py +0 -0
  48. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/status.py +0 -0
  49. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/submit.py +0 -0
  50. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/tests/config_test_utils.py +0 -0
  51. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/tests/gw_test_utils.py +0 -0
  52. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst/ctrl/bps/wms_service.py +0 -0
  53. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst_ctrl_bps.egg-info/SOURCES.txt +0 -0
  54. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst_ctrl_bps.egg-info/dependency_links.txt +0 -0
  55. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst_ctrl_bps.egg-info/entry_points.txt +0 -0
  56. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst_ctrl_bps.egg-info/requires.txt +0 -0
  57. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst_ctrl_bps.egg-info/top_level.txt +0 -0
  58. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/python/lsst_ctrl_bps.egg-info/zip-safe +0 -0
  59. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/setup.cfg +0 -0
  60. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_bps_utils.py +0 -0
  61. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_bpsconfig.py +0 -0
  62. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_cli_commands.py +0 -0
  63. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_clustered_quantum_graph.py +0 -0
  64. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_drivers.py +0 -0
  65. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_generic_workflow.py +0 -0
  66. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_initialize.py +0 -0
  67. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_ping.py +0 -0
  68. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_pre_transform.py +0 -0
  69. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_quantum_clustering_funcs.py +0 -0
  70. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_report.py +0 -0
  71. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_status.py +0 -0
  72. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_transform.py +0 -0
  73. {lsst_ctrl_bps-29.2025.2900 → lsst_ctrl_bps-29.2025.3100}/tests/test_wms_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-ctrl-bps
3
- Version: 29.2025.2900
3
+ Version: 29.2025.3100
4
4
  Summary: Pluggable execution of workflow graphs from Rubin pipelines.
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License: BSD 3-Clause License
@@ -265,7 +265,7 @@ arguments, e.g.:
265
265
  .. code-block:: yaml
266
266
 
267
267
  customJob:
268
- executable: "${HOME}/scripts/sleep.sh"
268
+ executable: "${HOME}/scripts/do_stuff.sh"
269
269
  arguments: "2"
270
270
 
271
271
  # Uncomment settings below to disable automatic memory scaling and retries
@@ -276,16 +276,62 @@ arguments, e.g.:
276
276
 
277
277
  where ``executable`` specifies the path to the executable to run and
278
278
  ``arguments`` is a list of arguments to be supplied to the executable as part
279
- of the command line.
279
+ of the command line. If your executable does not take any command line
280
+ arguments set ``arguments`` to an empty string.
280
281
 
281
282
  .. note::
282
283
 
283
- If your executable does not take any command line arguments set
284
- ``arguments`` to an empty string.
284
+ The script specified by ``customJob.executable`` is copied to the run's
285
+ submit directory and this copy (not the original script) is being submitted
286
+ for execution. As a result, making any changes to the original script after
287
+ the run has been submitted will have no effect even if the run is still
288
+ in the WMS work queue waiting for execution.
289
+
290
+ If the script requires any input files that should be transferred to the
291
+ execution site as well and/or produces output files that should be brought back
292
+ specify them as follows:
293
+
294
+ .. code-block:: yaml
295
+
296
+ customJob:
297
+ executable: "${HOME}/scripts/do_stuff.sh"
298
+ arguments: "-o {outfile} {infile}"
299
+ inputs:
300
+ infile: path/to/input/file
301
+ outputs:
302
+ outfile: path/to/output/file
285
303
 
286
- This config file will instruct BPS to create a special single-job *workflow* to
287
- run your script. That workflow will be submitted for execution as any other
288
- workflow.
304
+ # Uncomment settings below to disable automatic memory scaling and retries
305
+ # which BPS enables by default.
306
+ #
307
+ # memoryMultiplier: 1
308
+ # numberOfRetries: 1
309
+
310
+ The paths in ``inputs`` specify files as they are accessed on the submit site.
311
+ They can be absolute or relative to the current working directory as the run is
312
+ submitted. The input files will be copied to the run's submit directory.
313
+ These copies (not the original files) will be submitted along with the script's
314
+ copy for execution. During the execution BPS will transfer these copies into a
315
+ single flat directory -- job's scratch directory on the execute machine.
316
+
317
+ The paths in ``outputs`` specifies the paths on the submit site the output
318
+ files will be copied to after job's completion. As input paths they can be
319
+ either absolute or relative. However, on the execution site, the script is
320
+ expected to write all its output files directly to job's scratch directory
321
+ (assumed to be the current working directory when the job starts unless stated
322
+ otherwise in the WMS-specific documentation).
323
+
324
+ As a result, both input and output files base names *must* be unique.
325
+
326
+ .. note::
327
+
328
+ Currently, BPS itself doesn't verify if the file declared in ``outputs`` was
329
+ produced by the script. Whether a missing output file will be considered an
330
+ error depends entirely on WMS in use.
331
+
332
+ The config files shown above will instruct BPS to create a special single-job
333
+ *workflow* to run your script. That workflow will be submitted for execution
334
+ as any other workflow.
289
335
 
290
336
  As a result, the submission process for a custom script looks quite similar
291
337
  to the submission process of regular payload jobs (i.e. jobs running
@@ -305,15 +351,6 @@ There are few things you need to keep in mind though:
305
351
  instructions exist in the submit YAML. If you need the quantum graph, use
306
352
  ``bps submit``.
307
353
 
308
- #. At the moment, the mechanism does not support transferring files other than
309
- executable.
310
-
311
- #. The script specified by ``customJob.executable`` is copied to the run's
312
- submit directory and this copy (not the original script) is being submitted
313
- for execution. As a result, making any changes to the original script after
314
- the run has been submitted will have no effect even if the run is still in
315
- the WMS work queue waiting for execution.
316
-
317
354
  #. Some BPS plugins may require inclusion of plugin-specific settings for this
318
355
  mechanism to work. Consult the documentation of the plugin you use for
319
356
  details.
@@ -31,9 +31,16 @@ __all__ = ["construct"]
31
31
 
32
32
  import logging
33
33
  import shutil
34
+ from collections.abc import Callable
34
35
  from pathlib import Path
35
36
 
36
- from lsst.ctrl.bps import BpsConfig, GenericWorkflow, GenericWorkflowExec, GenericWorkflowJob
37
+ from lsst.ctrl.bps import (
38
+ BpsConfig,
39
+ GenericWorkflow,
40
+ GenericWorkflowExec,
41
+ GenericWorkflowFile,
42
+ GenericWorkflowJob,
43
+ )
37
44
  from lsst.ctrl.bps.transform import _get_job_values
38
45
 
39
46
  _LOG = logging.getLogger(__name__)
@@ -73,7 +80,7 @@ def create_custom_workflow(config: BpsConfig) -> tuple[GenericWorkflow, BpsConfi
73
80
  generic_workflow_config : `lsst.ctrl.BpsConfig`
74
81
  Configuration to accompany created generic workflow.
75
82
  """
76
- gwjob = create_custom_job(config)
83
+ gwjob, inputs, outputs = create_custom_job(config)
77
84
 
78
85
  _, name = config.search("uniqProcName", opt={"required": True})
79
86
  generic_workflow = GenericWorkflow(name)
@@ -90,6 +97,10 @@ def create_custom_workflow(config: BpsConfig) -> tuple[GenericWorkflow, BpsConfi
90
97
  "bps_runsite": config["computeSite"],
91
98
  }
92
99
  )
100
+ if inputs:
101
+ generic_workflow.add_job_inputs(gwjob.name, inputs)
102
+ if outputs:
103
+ generic_workflow.add_job_outputs(gwjob.name, outputs)
93
104
 
94
105
  generic_workflow_config = BpsConfig(config)
95
106
  generic_workflow_config["workflowName"] = config["uniqProcName"]
@@ -98,7 +109,9 @@ def create_custom_workflow(config: BpsConfig) -> tuple[GenericWorkflow, BpsConfi
98
109
  return generic_workflow, generic_workflow_config
99
110
 
100
111
 
101
- def create_custom_job(config: BpsConfig) -> GenericWorkflowJob:
112
+ def create_custom_job(
113
+ config: BpsConfig,
114
+ ) -> tuple[GenericWorkflowJob, list[GenericWorkflowFile], list[GenericWorkflowFile]]:
102
115
  """Create a job that will run a custom command or script.
103
116
 
104
117
  Parameters
@@ -110,6 +123,10 @@ def create_custom_job(config: BpsConfig) -> GenericWorkflowJob:
110
123
  -------
111
124
  job : `lsst.ctrl.bps.GenericWorkflowJob`
112
125
  A custom job responsible for running the command.
126
+ inputs : `list` [`lsst.ctrl.bps.GenericWorkflowFile`]
127
+ List of job's input files, empty if the job has no input files.
128
+ outputs : `list` [`lsst.ctrl.bps.GenericWorkflowFile`]
129
+ List of job's output files, empty if the job has no output files.
113
130
  """
114
131
  prefix = Path(config["submitPath"])
115
132
  job_label = "customJob"
@@ -135,6 +152,92 @@ def create_custom_job(config: BpsConfig) -> GenericWorkflowJob:
135
152
  job.executable = GenericWorkflowExec(
136
153
  name=script_name, src_uri=str(prefix / script_name), transfer_executable=True
137
154
  )
138
- job.arguments = config[f".{job_label}.arguments"]
155
+ _, job.arguments = config.search("arguments", opt=search_opts | {"replaceVars": False})
139
156
 
140
- return job
157
+ inputs = []
158
+ found, mapping = config.search("inputs", opt=search_opts)
159
+ if found:
160
+ inputs = create_job_files(mapping, prefix, path_creator=create_input_path)
161
+
162
+ outputs = []
163
+ found, mapping = config.search("outputs", opt=search_opts)
164
+ if found:
165
+ outputs = create_job_files(mapping, prefix, path_creator=create_output_path)
166
+
167
+ for gwfile in inputs + outputs:
168
+ job.arguments = job.arguments.replace(f"{{{gwfile.name}}}", f"<FILE:{gwfile.name}>")
169
+
170
+ return job, inputs, outputs
171
+
172
+
173
+ def create_job_files(
174
+ file_specs: BpsConfig, prefix: str | Path, path_creator: Callable[[Path, Path], Path]
175
+ ) -> list[GenericWorkflowFile]:
176
+ """Create files for a job.
177
+
178
+ Parameters
179
+ ----------
180
+ file_specs : `lsst.ctrl.bps.BpsConfig`
181
+ The mapping between file keys and file paths.
182
+ prefix : `str` | `pathlib.Path`
183
+ The root directory to which the files will be written.
184
+ path_creator : `Callable` [[`Path`, `Path`], `Path`]
185
+ File category that determines actions that need to be taken during
186
+ file creation.
187
+
188
+ Returns
189
+ -------
190
+ gwfiles : `list` [`lsst.ctrl.bps.GenericWorkflowFile`]
191
+ List of files created for the job.
192
+ """
193
+ prefix = Path(prefix)
194
+
195
+ gwfiles = []
196
+ for key, path in file_specs.items():
197
+ src = Path(path)
198
+ dest = path_creator(src, prefix)
199
+ gwfiles.append(GenericWorkflowFile(name=key, src_uri=str(dest), wms_transfer=True))
200
+ return gwfiles
201
+
202
+
203
+ def create_input_path(path: Path, prefix: Path) -> Path:
204
+ """Process an input path.
205
+
206
+ Parameters
207
+ ----------
208
+ path : `pathlib.Path`
209
+ The input path.
210
+ prefix : `pathlib.Path`
211
+ The root directory to which the file will be written.
212
+
213
+ Raises
214
+ ------
215
+ ValueError
216
+ Raised if the input path does not exist or is a directory.
217
+ """
218
+ if path.exists():
219
+ if path.is_dir():
220
+ raise ValueError(f"input path '{path} is a directory, must be file")
221
+ else:
222
+ raise ValueError(f"input path '{path}' does not exist")
223
+ dest = prefix / path.name
224
+ shutil.copy2(path, dest)
225
+ return dest
226
+
227
+
228
+ def create_output_path(path: Path, prefix: Path) -> Path:
229
+ """Process an output path.
230
+
231
+ Parameters
232
+ ----------
233
+ path : `pathlib.Path`
234
+ The output path.
235
+ prefix : `pathlib.Path`
236
+ The root directory to which the file will be written.
237
+ """
238
+ if path.is_absolute():
239
+ dest = path
240
+ else:
241
+ dest = prefix / path
242
+ dest.parent.mkdir(parents=True, exist_ok=True)
243
+ return dest
@@ -316,13 +316,7 @@ def _fill_arguments(use_shared, generic_workflow, arguments, cmdvals):
316
316
  # Have shared filesystems and jobs can share file.
317
317
  uri = gwfile.src_uri
318
318
  else:
319
- # Taking advantage of inside knowledge. Not future-proof.
320
- # Temporary fix until have job wrapper that pulls files
321
- # within job.
322
- if gwfile.name == "butlerConfig" and os.path.splitext(gwfile.src_uri)[1] != ".yaml":
323
- uri = "butler.yaml"
324
- else:
325
- uri = os.path.basename(gwfile.src_uri)
319
+ uri = os.path.basename(gwfile.src_uri)
326
320
  else: # Using push transfer
327
321
  uri = os.path.basename(gwfile.src_uri)
328
322
 
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "29.2025.3100"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-ctrl-bps
3
- Version: 29.2025.2900
3
+ Version: 29.2025.3100
4
4
  Summary: Pluggable execution of workflow graphs from Rubin pipelines.
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License: BSD 3-Clause License
@@ -0,0 +1,371 @@
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
+
28
+ """Unit tests for the methods in construct.py."""
29
+
30
+ import os
31
+ import tempfile
32
+ import unittest
33
+ from pathlib import Path
34
+ from unittest.mock import patch
35
+
36
+ from lsst.ctrl.bps import BpsConfig, GenericWorkflowFile, GenericWorkflowJob
37
+ from lsst.ctrl.bps.construct import (
38
+ construct,
39
+ create_custom_job,
40
+ create_custom_workflow,
41
+ create_input_path,
42
+ create_job_files,
43
+ create_output_path,
44
+ )
45
+
46
+
47
+ class ConstructTestCase(unittest.TestCase):
48
+ """Tests for the main construct function."""
49
+
50
+ def setUp(self):
51
+ self.script = tempfile.NamedTemporaryFile(prefix="foo", suffix=".sh")
52
+ self.submit_dir = tempfile.TemporaryDirectory()
53
+ self.config = BpsConfig(
54
+ {
55
+ "submitPath": self.submit_dir.name,
56
+ "uniqProcName": "test_workflow",
57
+ "project": "test_project",
58
+ "campaign": "test_campaign",
59
+ "operator": "test_operator",
60
+ "payloadName": "test_payload",
61
+ "computeSite": "test_site",
62
+ "customJob": {
63
+ "executable": self.script.name,
64
+ "arguments": "test_arg",
65
+ },
66
+ },
67
+ defaults={},
68
+ )
69
+
70
+ def tearDown(self):
71
+ self.script.close()
72
+ self.submit_dir.cleanup()
73
+
74
+ def testConstructSuccess(self):
75
+ """Test that construct returns a workflow and config."""
76
+ workflow, config = construct(self.config)
77
+
78
+ self.assertIsNotNone(workflow)
79
+ self.assertIsNotNone(config)
80
+ self.assertEqual(workflow.name, "test_workflow")
81
+ self.assertEqual(config["workflowName"], "test_workflow")
82
+
83
+
84
+ class CreateCustomWorkflowTestCase(unittest.TestCase):
85
+ """Tests for creating a custom workflow."""
86
+
87
+ def setUp(self):
88
+ self.script = tempfile.NamedTemporaryFile(prefix="bar", suffix=".sh")
89
+ self.submit_dir = tempfile.TemporaryDirectory()
90
+ self.config = BpsConfig(
91
+ {
92
+ "submitPath": self.submit_dir.name,
93
+ "customJob": {
94
+ "executable": self.script.name,
95
+ "arguments": "arg",
96
+ },
97
+ "project": "dev",
98
+ "campaign": "test",
99
+ "operator": "tester",
100
+ "payloadName": "custom/workflow",
101
+ "computeCloud": "test_cloud",
102
+ "computeSite": "test_site",
103
+ },
104
+ defaults={},
105
+ )
106
+
107
+ def tearDown(self):
108
+ self.script.close()
109
+ self.submit_dir.cleanup()
110
+
111
+ def testSuccess(self):
112
+ self.config["uniqProcName"] = self.config["submitPath"]
113
+
114
+ workflow, config = create_custom_workflow(self.config)
115
+
116
+ self.assertEqual(workflow.name, self.config["uniqProcName"])
117
+ self.assertEqual(workflow.job_counts, {"customJob": 1})
118
+ self.assertTrue(workflow.run_attrs["bps_isjob"])
119
+ self.assertTrue(workflow.run_attrs["bps_iscustom"])
120
+ self.assertEqual(workflow.run_attrs["bps_project"], "dev")
121
+ self.assertEqual(workflow.run_attrs["bps_campaign"], "test")
122
+ self.assertEqual(workflow.run_attrs["bps_operator"], "tester")
123
+ self.assertEqual(workflow.run_attrs["bps_payload"], "custom/workflow")
124
+ self.assertEqual(workflow.run_attrs["bps_run"], workflow.name)
125
+ self.assertEqual(workflow.run_attrs["bps_runsite"], "test_site")
126
+ self.assertEqual(config["workflowName"], self.config["uniqProcName"])
127
+ self.assertEqual(config["workflowPath"], self.config["submitPath"])
128
+
129
+ def testEmptyInputs(self):
130
+ """Test workflow creation when job has no inputs files."""
131
+ with patch("lsst.ctrl.bps.construct.create_custom_job") as mock_create:
132
+ self.config["uniqProcName"] = "test_custom"
133
+
134
+ job = GenericWorkflowJob(name="test_job", label="test_job")
135
+ gwfile = GenericWorkflowFile(name="test_output", src_uri="test_output")
136
+ mock_create.return_value = (job, [], [gwfile])
137
+
138
+ workflow, config = create_custom_workflow(self.config)
139
+
140
+ self.assertEqual(len(workflow.get_job_inputs(job.name)), 0)
141
+ self.assertGreater(len(workflow.get_job_outputs(job.name)), 0)
142
+
143
+ def testEmptyOutputs(self):
144
+ """Test workflow creation when job has no output files."""
145
+ with patch("lsst.ctrl.bps.construct.create_custom_job") as mock_create:
146
+ self.config["uniqProcName"] = "test_custom"
147
+
148
+ job = GenericWorkflowJob(name="test_job", label="test_job")
149
+ gwfile = GenericWorkflowFile(name="test_input", src_uri="test_input")
150
+ mock_create.return_value = (job, [gwfile], [])
151
+
152
+ workflow, config = create_custom_workflow(self.config)
153
+
154
+ self.assertGreater(len(workflow.get_job_inputs(job.name)), 0)
155
+ self.assertEqual(len(workflow.get_job_outputs(job.name)), 0)
156
+
157
+ def testEmptyInputsAndOutputs(self):
158
+ """Test workflow creation when job has no input nor output files."""
159
+ with patch("lsst.ctrl.bps.construct.create_custom_job") as mock_create:
160
+ self.config["uniqProcName"] = "test_custom"
161
+
162
+ job = GenericWorkflowJob(name="test_job", label="test_job")
163
+ mock_create.return_value = (job, [], [])
164
+
165
+ workflow, config = create_custom_workflow(self.config)
166
+
167
+ self.assertEqual(len(workflow.get_job_inputs(job.name)), 0)
168
+ self.assertEqual(len(workflow.get_job_outputs(job.name)), 0)
169
+
170
+
171
+ class CreateCustomJobTestCase(unittest.TestCase):
172
+ """Tests for creating a custom job."""
173
+
174
+ def setUp(self):
175
+ self.script = tempfile.NamedTemporaryFile(prefix="foo", suffix=".sh")
176
+ self.submit_dir = tempfile.TemporaryDirectory()
177
+ self.config = BpsConfig(
178
+ {
179
+ "submitPath": self.submit_dir.name,
180
+ "computeCloud": "test_cloud",
181
+ "computeSite": "test_site",
182
+ "customJob": {
183
+ "executable": self.script.name,
184
+ "arguments": "arg",
185
+ },
186
+ },
187
+ defaults={},
188
+ )
189
+
190
+ def tearDown(self):
191
+ self.script.close()
192
+ self.submit_dir.cleanup()
193
+
194
+ def testJobCreationNoFilesSuccess(self):
195
+ """Test successful creation of a custom job."""
196
+ script_file = self.script.name
197
+ script_name = Path(script_file).name
198
+
199
+ job, inputs, outputs = create_custom_job(self.config)
200
+
201
+ self.assertEqual(job.name, script_name)
202
+ self.assertEqual(job.label, "customJob")
203
+ self.assertEqual(job.compute_cloud, "test_cloud")
204
+ self.assertEqual(job.compute_site, "test_site")
205
+ self.assertEqual(job.executable.name, script_name)
206
+ self.assertEqual(job.executable.src_uri, f"{self.submit_dir.name}/{script_name}")
207
+ self.assertTrue(job.executable.transfer_executable)
208
+ self.assertTrue(Path(f"{self.submit_dir.name}/{script_name}").exists())
209
+ self.assertEqual(job.arguments, "arg")
210
+ self.assertEqual(inputs, [])
211
+ self.assertEqual(outputs, [])
212
+
213
+ def testJobCreationWithFilesSuccess(self):
214
+ """Test custom job creation with input and output files."""
215
+ # Create a temporary input file
216
+ input_file = tempfile.NamedTemporaryFile(prefix="input", suffix=".txt", delete=False)
217
+ input_file.write(b"test input data")
218
+ input_file.close()
219
+
220
+ self.config[".customJob.arguments"] = "--input {input1} --output {output1}"
221
+ self.config[".customJob.inputs.input1"] = input_file.name
222
+ self.config[".customJob.outputs.output1"] = "output.txt"
223
+
224
+ try:
225
+ job, inputs, outputs = create_custom_job(self.config)
226
+
227
+ self.assertEqual(len(inputs), 1)
228
+ self.assertEqual(len(outputs), 1)
229
+ self.assertEqual(inputs[0].name, "input1")
230
+ self.assertEqual(outputs[0].name, "output1")
231
+ self.assertIn("<FILE:input1>", job.arguments)
232
+ self.assertIn("<FILE:output1>", job.arguments)
233
+ finally:
234
+ os.unlink(input_file.name)
235
+
236
+ def testJobCreationMissingExecutable(self):
237
+ """Test custom job creation fails with missing executable."""
238
+ self.config[".customJob.executable"] = "/nonexistent/script.sh"
239
+
240
+ with self.assertRaises(FileNotFoundError):
241
+ create_custom_job(self.config)
242
+
243
+
244
+ class CreateJobFilesTestCase(unittest.TestCase):
245
+ """Tests for create_job_files function."""
246
+
247
+ def setUp(self):
248
+ self.temp_dir = tempfile.TemporaryDirectory()
249
+ self.prefix = Path(self.temp_dir.name)
250
+
251
+ def tearDown(self):
252
+ self.temp_dir.cleanup()
253
+
254
+ def testJobFileCreationNoFiles(self):
255
+ """Test create_job_files with empty file specs."""
256
+ config = BpsConfig({"inputs": {}})
257
+ _, filespecs = config.search("inputs")
258
+ files = create_job_files(filespecs, self.prefix, lambda path, prefix: prefix / path.name)
259
+
260
+ self.assertEqual(files, [])
261
+
262
+ def testJobFileCreationWithFiles(self):
263
+ """Test create_job_files with file specifications."""
264
+ config = BpsConfig(
265
+ {
266
+ "inputs": {
267
+ "file1": "/path/to/file1.txt",
268
+ "file2": "/path/to/file2.txt",
269
+ }
270
+ }
271
+ )
272
+ _, filespecs = config.search("inputs")
273
+ files = create_job_files(filespecs, self.prefix, lambda path, prefix: prefix / path.name)
274
+
275
+ self.assertEqual(len(files), 2)
276
+ self.assertEqual(files[0].name, "file1")
277
+ self.assertEqual(files[1].name, "file2")
278
+ self.assertTrue(files[0].wms_transfer)
279
+ self.assertTrue(files[1].wms_transfer)
280
+
281
+
282
+ class CreateInputPathTestCase(unittest.TestCase):
283
+ """Tests for create_input_path function."""
284
+
285
+ def setUp(self):
286
+ self.temp_dir = tempfile.TemporaryDirectory()
287
+ self.prefix = Path(self.temp_dir.name)
288
+
289
+ # Create a test input file
290
+ self.input_file = tempfile.NamedTemporaryFile(prefix="input_", suffix=".txt", delete=False)
291
+ self.input_file.write(b"test content")
292
+ self.input_file.close()
293
+
294
+ def tearDown(self):
295
+ self.temp_dir.cleanup()
296
+
297
+ def testInputPathCreationSuccess(self):
298
+ """Test successful input path creation."""
299
+ input_path = Path(self.input_file.name)
300
+ result_path = create_input_path(input_path, self.prefix)
301
+
302
+ expected_path = self.prefix / input_path.name
303
+ self.assertEqual(result_path, expected_path)
304
+ self.assertTrue(result_path.exists())
305
+
306
+ # Verify file content was copied
307
+ with open(result_path) as f:
308
+ content = f.read()
309
+ self.assertEqual(content, "test content")
310
+
311
+ def testInputPathCreationFileIsMissing(self):
312
+ """Test input path creation fails if file does not exist."""
313
+ nonexistent_path = Path("/nonexistent/file.txt")
314
+
315
+ with self.assertRaisesRegex(ValueError, "does not exist"):
316
+ create_input_path(nonexistent_path, self.prefix)
317
+
318
+ def testInputPathCreationFileIsDirectory(self):
319
+ """Test input path creation fails if path is a directory."""
320
+ dir_path = Path(self.temp_dir.name)
321
+
322
+ with self.assertRaisesRegex(ValueError, "is a directory"):
323
+ create_input_path(dir_path, self.prefix)
324
+
325
+ def testInputPathCreationPermissionError(self):
326
+ """Test input path creation with permission denied."""
327
+ with patch("shutil.copy2", side_effect=PermissionError("Permission denied")):
328
+ with self.assertRaises(PermissionError):
329
+ create_input_path(Path(self.input_file.name), self.prefix)
330
+
331
+
332
+ class CreateOutputPathTestCase(unittest.TestCase):
333
+ """Tests for create_output_path function."""
334
+
335
+ def setUp(self):
336
+ self.temp_dir = tempfile.TemporaryDirectory()
337
+ self.prefix = Path(self.temp_dir.name)
338
+
339
+ def tearDown(self):
340
+ self.temp_dir.cleanup()
341
+
342
+ def testOutputPathCreationRelativePathNew(self):
343
+ """Test output path creation."""
344
+ output_path = Path("foo/bar.txt")
345
+ result_path = create_output_path(output_path, self.prefix)
346
+ expected_path = self.prefix / "foo/bar.txt"
347
+
348
+ self.assertEqual(result_path, expected_path)
349
+ self.assertTrue(result_path.parent.exists())
350
+
351
+ def testOutputPathCreationRelativePathParentExits(self):
352
+ """Test output path creation when directory already exists."""
353
+ # Create the directory first
354
+ subdir = self.prefix / "foo"
355
+ subdir.mkdir()
356
+
357
+ output_path = Path("foo/bar.txt")
358
+ result_path = create_output_path(output_path, self.prefix)
359
+ expected_path = self.prefix / "foo/bar.txt"
360
+
361
+ self.assertEqual(result_path, expected_path)
362
+ self.assertTrue(result_path.parent.exists())
363
+
364
+ def testOutputPathCreationAbsolutePath(self):
365
+ """Test that absolute output paths are handled properly."""
366
+ output_path = self.prefix / "foo/bar.txt"
367
+ expected_path = output_path
368
+ result_path = create_output_path(output_path, self.prefix)
369
+
370
+ self.assertEqual(result_path, expected_path)
371
+ self.assertTrue(result_path.parent.exists())
@@ -1,2 +0,0 @@
1
- __all__ = ["__version__"]
2
- __version__ = "29.2025.2900"
@@ -1,122 +0,0 @@
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
-
28
- """Unit tests for the methods in construct.py."""
29
-
30
- import tempfile
31
- import unittest
32
- from pathlib import Path
33
-
34
- from lsst.ctrl.bps import BpsConfig
35
- from lsst.ctrl.bps.construct import create_custom_job, create_custom_workflow
36
-
37
-
38
- class CreateCustomJobTestCase(unittest.TestCase):
39
- """Tests for creating a custom job."""
40
-
41
- def setUp(self):
42
- self.script = tempfile.NamedTemporaryFile(prefix="foo", suffix=".sh")
43
- self.submit_dir = tempfile.TemporaryDirectory()
44
- self.config = BpsConfig(
45
- {
46
- "submitPath": self.submit_dir.name,
47
- "computeCloud": "testcloud",
48
- "computeSite": "testsite",
49
- "customJob": {
50
- "executable": self.script.name,
51
- "arguments": "arg",
52
- },
53
- },
54
- defaults={},
55
- )
56
-
57
- def tearDown(self):
58
- self.script.close()
59
- self.submit_dir.cleanup()
60
-
61
- def testSuccess(self):
62
- script_file = self.script.name
63
- script_name = Path(script_file).name
64
-
65
- job = create_custom_job(self.config)
66
-
67
- self.assertEqual(job.name, script_name)
68
- self.assertEqual(job.label, "customJob")
69
- self.assertEqual(job.compute_cloud, "testcloud")
70
- self.assertEqual(job.compute_site, "testsite")
71
- self.assertEqual(job.executable.name, script_name)
72
- self.assertEqual(job.executable.src_uri, f"{self.submit_dir.name}/{script_name}")
73
- self.assertTrue(job.executable.transfer_executable)
74
- self.assertTrue(Path(f"{self.submit_dir.name}/{script_name}").exists())
75
- self.assertEqual(job.arguments, "arg")
76
-
77
-
78
- class CreateCustomWorkflowTestSuite(unittest.TestCase):
79
- """Tests for creating a custom workflow."""
80
-
81
- def setUp(self):
82
- self.script = tempfile.NamedTemporaryFile(prefix="bar", suffix=".sh")
83
- self.submit_dir = tempfile.TemporaryDirectory()
84
- self.config = BpsConfig(
85
- {
86
- "submitPath": self.submit_dir.name,
87
- "customJob": {
88
- "executable": self.script.name,
89
- "arguments": "arg",
90
- },
91
- "project": "dev",
92
- "campaign": "test",
93
- "operator": "tester",
94
- "payloadName": "custom/workflow",
95
- "computeCloud": "testcloud",
96
- "computeSite": "testsite",
97
- },
98
- defaults={},
99
- )
100
-
101
- def tearDown(self):
102
- self.script.close()
103
- self.submit_dir.cleanup()
104
-
105
- def testSuccess(self):
106
- self.config["uniqProcName"] = self.config["submitPath"]
107
-
108
- workflow, config = create_custom_workflow(self.config)
109
-
110
- self.assertEqual(workflow.name, self.config["uniqProcName"])
111
- self.assertEqual(workflow.job_counts, {"customJob": 1})
112
- self.assertTrue(workflow.run_attrs["bps_isjob"])
113
- self.assertTrue(workflow.run_attrs["bps_iscustom"])
114
- self.assertEqual(workflow.run_attrs["bps_project"], "dev")
115
- self.assertEqual(workflow.run_attrs["bps_campaign"], "test")
116
- self.assertEqual(workflow.run_attrs["bps_operator"], "tester")
117
- self.assertEqual(workflow.run_attrs["bps_payload"], "custom/workflow")
118
- self.assertEqual(workflow.run_attrs["bps_run"], workflow.name)
119
- self.assertEqual(workflow.run_attrs["bps_runsite"], "testsite")
120
-
121
- self.assertEqual(config["workflowName"], self.config["uniqProcName"])
122
- self.assertEqual(config["workflowPath"], self.config["submitPath"])