lsst-ctrl-bps-htcondor 29.2025.4500__tar.gz → 29.2025.4600__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 (42) hide show
  1. {lsst_ctrl_bps_htcondor-29.2025.4500/python/lsst_ctrl_bps_htcondor.egg-info → lsst_ctrl_bps_htcondor-29.2025.4600}/PKG-INFO +4 -1
  2. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/doc/lsst.ctrl.bps.htcondor/userguide.rst +41 -0
  3. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/pyproject.toml +4 -1
  4. lsst_ctrl_bps_htcondor-29.2025.4600/python/lsst/ctrl/bps/htcondor/dagman_configurator.py +196 -0
  5. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/etc/htcondor_defaults.yaml +12 -0
  6. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/htcondor_service.py +12 -0
  7. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/lssthtc.py +24 -17
  8. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/prepare_utils.py +0 -2
  9. lsst_ctrl_bps_htcondor-29.2025.4600/python/lsst/ctrl/bps/htcondor/version.py +2 -0
  10. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600/python/lsst_ctrl_bps_htcondor.egg-info}/PKG-INFO +4 -1
  11. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst_ctrl_bps_htcondor.egg-info/SOURCES.txt +2 -0
  12. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst_ctrl_bps_htcondor.egg-info/requires.txt +3 -0
  13. lsst_ctrl_bps_htcondor-29.2025.4600/tests/test_dagman_configurator.py +143 -0
  14. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/tests/test_lssthtc.py +81 -28
  15. lsst_ctrl_bps_htcondor-29.2025.4500/python/lsst/ctrl/bps/htcondor/version.py +0 -2
  16. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/COPYRIGHT +0 -0
  17. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/LICENSE +0 -0
  18. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/MANIFEST.in +0 -0
  19. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/README.rst +0 -0
  20. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/bsd_license.txt +0 -0
  21. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/doc/lsst.ctrl.bps.htcondor/CHANGES.rst +0 -0
  22. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/doc/lsst.ctrl.bps.htcondor/index.rst +0 -0
  23. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/gpl-v3.0.txt +0 -0
  24. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/__init__.py +0 -0
  25. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/common_utils.py +0 -0
  26. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/etc/__init__.py +0 -0
  27. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/final_post.sh +0 -0
  28. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/handlers.py +0 -0
  29. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/htcondor_config.py +0 -0
  30. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/htcondor_workflow.py +0 -0
  31. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/provisioner.py +0 -0
  32. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst/ctrl/bps/htcondor/report_utils.py +0 -0
  33. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst_ctrl_bps_htcondor.egg-info/dependency_links.txt +0 -0
  34. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst_ctrl_bps_htcondor.egg-info/top_level.txt +0 -0
  35. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/python/lsst_ctrl_bps_htcondor.egg-info/zip-safe +0 -0
  36. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/setup.cfg +0 -0
  37. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/tests/test_common_utils.py +0 -0
  38. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/tests/test_handlers.py +0 -0
  39. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/tests/test_htcondor_service.py +0 -0
  40. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/tests/test_prepare_utils.py +0 -0
  41. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/tests/test_provisioner.py +0 -0
  42. {lsst_ctrl_bps_htcondor-29.2025.4500 → lsst_ctrl_bps_htcondor-29.2025.4600}/tests/test_report_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-ctrl-bps-htcondor
3
- Version: 29.2025.4500
3
+ Version: 29.2025.4600
4
4
  Summary: HTCondor plugin for lsst-ctrl-bps.
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License-Expression: BSD-3-Clause OR GPL-3.0-or-later
@@ -23,7 +23,10 @@ License-File: gpl-v3.0.txt
23
23
  Requires-Dist: htcondor>=8.8
24
24
  Requires-Dist: lsst-ctrl-bps
25
25
  Requires-Dist: lsst-daf-butler
26
+ Requires-Dist: lsst-pipe-base
26
27
  Requires-Dist: lsst-utils
28
+ Requires-Dist: packaging
29
+ Requires-Dist: pydantic<3.0,>=2
27
30
  Provides-Extra: test
28
31
  Requires-Dist: pytest>=3.2; extra == "test"
29
32
  Requires-Dist: pytest-openfiles>=0.5.0; extra == "test"
@@ -150,6 +150,46 @@ available in your BPS configuration file. For example:
150
150
  .. __: https://pipelines.lsst.io/v/weekly/modules/lsst.ctrl.bps/quickstart.html#bps-configuration-file
151
151
  .. __: https://pipelines.lsst.io/v/weekly/modules/lsst.ctrl.bps/quickstart.html#supported-settings
152
152
 
153
+ Configuring DAGMan
154
+ ^^^^^^^^^^^^^^^^^^
155
+
156
+ `DAGMan`_ is a `HTCondor`_ tool that allows multiple jobs to be organized in
157
+ workflows. It orchestrates the execution of jobs in a workflow to satisfy their
158
+ data dependencies. DAGman workflows are described in the DAG description files.
159
+
160
+ `HTCondor`_ has many settings that affect the operation of `DAGMan`_. Any of
161
+ these settings can be managed via the submit YAML by specifying their values
162
+ in the ``wmsConfig`` section. For example, including the lines below in your
163
+ submit YAML will instruct DAGMan to throttle the number of jobs DAGMan will
164
+ submit at once for execution to 256:
165
+
166
+ .. code-block:: YAML
167
+
168
+ wmsConfig:
169
+ DAGMAN_MAX_JOBS_IDLE: 256
170
+
171
+ A complete list of the supported settings, their descriptions, and default
172
+ values can be found `here`__.
173
+
174
+ .. note::
175
+
176
+ Make sure to select the version of the documentation that corresponds to the
177
+ version of the HTCondor you're using. Supported settings may vary between
178
+ different versions.
179
+
180
+ When customizing DAGMan's settings, make sure the value you provide has the
181
+ appropriate type. Using incorrect value type will result in an error during
182
+ the BPS submission.
183
+
184
+ The settings are for the entire workflow, so the ``wmsConfig`` section can go
185
+ at the root level or inside a ``site`` section, but not inside a ``pipetask``,
186
+ ``clusterorfinalJob`` section.
187
+
188
+ If your main workflow contains sub-workflow defined in individual DAG
189
+ description files, they will use the same configuration as the main workflow.
190
+
191
+ .. __: https://htcondor.readthedocs.io/en/latest/admin-manual/configuration-macros.html#dagman-configuration-file-entries
192
+
153
193
  .. .. _htc-plugin-authenticating:
154
194
 
155
195
  .. Authenticating
@@ -629,6 +669,7 @@ complete your run.
629
669
 
630
670
  .. __: https://developer.lsst.io/usdf/batch.html#ctrl-bps-htcondor
631
671
 
672
+ .. _DAGMan: https://htcondor.readthedocs.io/en/latest/automated-workflows/index.html#dagman-workflows
632
673
  .. _HTCondor: https://htcondor.readthedocs.io/en/latest/
633
674
  .. _Slurm: https://slurm.schedmd.com/overview.html
634
675
  .. _bps cancel: https://pipelines.lsst.io/v/weekly/modules/lsst.ctrl.bps/quickstart.html#canceling-submitted-jobs
@@ -27,7 +27,10 @@ dependencies = [
27
27
  "htcondor >= 8.8",
28
28
  "lsst-ctrl-bps",
29
29
  "lsst-daf-butler",
30
- "lsst-utils"
30
+ "lsst-pipe-base",
31
+ "lsst-utils",
32
+ "packaging",
33
+ "pydantic >= 2, < 3.0",
31
34
  ]
32
35
  dynamic = ["version"]
33
36
 
@@ -0,0 +1,196 @@
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/>.
27
+
28
+ """Module enabling configuring DAGMan via submit YAML."""
29
+
30
+ __all__ = ["DagmanConfigurator"]
31
+
32
+ import logging
33
+ import os
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import htcondor
38
+ from pydantic import AliasGenerator, ConfigDict, create_model
39
+
40
+ from lsst.ctrl.bps import BpsConfig
41
+
42
+ from .lssthtc import HTCDag
43
+
44
+ _LOG = logging.getLogger(__name__)
45
+
46
+ # Extract DAGMan configuration options with their types and default values from
47
+ # the local HTCondor configuration.
48
+ #
49
+ # Notes
50
+ # -----
51
+ # There are some DAGMan configuration options that names do not start with
52
+ # ``DAGMAN_`` (e.g., ``MAX_DAGMAN_LOG``). Hence, do not use
53
+ # ``key.startswith("DAGMAN_")``.
54
+ _fields = {key.lower(): (type(val), val) for key, val in htcondor.param.items() if "DAGMAN_" in key}
55
+
56
+ # Add some valid configuration options are not set by default by HTCondor and
57
+ # are missing from ``htcondor.param``.
58
+ #
59
+ # Notes
60
+ # -----
61
+ # A complete list of configuration options HTCondor supports can be found in
62
+ # ``src/condor_utils/param_info.in`` in
63
+ # `HTCondor GitHub repository <https://github.com/htcondor/htcondor>`_.
64
+ _fields.update(
65
+ {
66
+ "dagman_debug": (str, ""),
67
+ "dagman_node_record_info": (str, ""),
68
+ "dagman_record_machine_attrs": (str, ""),
69
+ }
70
+ )
71
+
72
+ # Dynamically create a Pydantic model encapsulating the DAGMan configuration
73
+ # options gathered above.
74
+ _DagmanOptions = create_model(
75
+ "DagmanOptions",
76
+ __config__=ConfigDict(
77
+ alias_generator=AliasGenerator(
78
+ serialization_alias=lambda name: name.upper(),
79
+ ),
80
+ extra="allow",
81
+ serialize_by_alias=True,
82
+ ),
83
+ **_fields,
84
+ )
85
+
86
+
87
+ class DagmanConfigurator:
88
+ """Class responsible for setting WMS-specific configuration options.
89
+
90
+ Parameters
91
+ ----------
92
+ config : `lsst.ctrl.bps.BpsConfig`
93
+ BPS configuration.
94
+ search_opts : `dict` [`str`, `Any`], optional
95
+ Options to use while searching the BPS configuration for values.
96
+
97
+ Raises
98
+ ------
99
+ KeyError
100
+ Raised if DAGMan configuration is missing from the BPS configuration.
101
+ """
102
+
103
+ def __init__(self, config: BpsConfig, search_opts: dict[str, Any] | None = None) -> None:
104
+ if search_opts is None:
105
+ search_opts = {}
106
+ _, site = config.search("computeSite", search_opts)
107
+ if site:
108
+ search_opts["curvals"] = {"curr_site": site}
109
+ _, wms_config = config.search("wmsConfig", search_opts)
110
+ if not wms_config:
111
+ raise KeyError("WMS-specific configuration not found")
112
+ self._options = _DagmanOptions.model_validate({key.lower(): val for key, val in wms_config.items()})
113
+ if self._options.model_extra:
114
+ unknown_opts = [key.upper() for key in self._options.model_extra]
115
+ _LOG.warning(
116
+ "The following WMS-specific config options were not recognized and will be ignored: %s.",
117
+ ", ".join(unknown_opts),
118
+ )
119
+ self.config_path: Path | None = None
120
+ self.prefix: Path | None = None
121
+
122
+ @property
123
+ def options(self) -> dict[str, Any]:
124
+ """DAGMan configuration options set via BPS (`dict` [`str`, `Any`])."""
125
+ return {
126
+ key: val
127
+ for key, val in self._options.model_dump(exclude_unset=True).items()
128
+ if key not in self._options.model_extra
129
+ }
130
+
131
+ def prepare(self, filename: os.PathLike | str, prefix: os.PathLike | str | None) -> None:
132
+ """Write WMS-specific configuration to a file.
133
+
134
+ Parameters
135
+ ----------
136
+ filename : `str`, optional
137
+ Name of the file to use when creating the DAG configuration.
138
+ prefix : `pathlib.Path` | `str`, optional
139
+ Directory in which to output the DAG configuration file. If not
140
+ provided, the script will be written to the current directory.
141
+
142
+ Raises
143
+ ------
144
+ OSError
145
+ Raised if the configuration file cannot be created.
146
+ """
147
+ if prefix:
148
+ self.prefix = Path(prefix)
149
+ self.config_path = self.prefix / filename if self.prefix else Path(filename)
150
+ try:
151
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
152
+ except OSError as exc:
153
+ _LOG.error(
154
+ "Could not write WMS-specific configuration file '%s': %s",
155
+ self.config_path,
156
+ exc.strerror,
157
+ )
158
+ raise
159
+
160
+ # Populate the DAG configuration file only with options that were
161
+ # explicitly set in the BPS configuration.
162
+ #
163
+ # Notes
164
+ # -----
165
+ # The Pydantic model we are using to represent the DAGMan configuration
166
+ # options allows for extra fields. However, it seems that
167
+ # BaseModel.model_dump() does not support excluding these fields during
168
+ # serialization at the moment (Pydantic ver. 2.12), so we have to do it
169
+ # manually.
170
+ self.config_path.write_text("\n".join(f"{key} = {val}" for key, val in self.options.items()))
171
+
172
+ def configure(self, dag: HTCDag) -> None:
173
+ """Add DAG configuration file to the workflow.
174
+
175
+ Parameters
176
+ ----------
177
+ dag : `lsst.ctrl.bps.htcondor.HTCDag`
178
+ HTCondor DAG.
179
+
180
+ Raises
181
+ ------
182
+ RuntimeError
183
+ Raised if the prepare step was omitted.
184
+
185
+ Notes
186
+ -----
187
+ The path to the DAG configuration is added as a DAG attribute named
188
+ ``bps_wms_config_path``. The stored path is relative to the prefix.
189
+ """
190
+ if self.config_path is None:
191
+ raise RuntimeError(
192
+ f"cannot add WMS-specific configuration to the workflow: file does not exist. "
193
+ f"(hint: run {type(self).__qualname__}.prepare() to create it)"
194
+ )
195
+ config_path = self.config_path.relative_to(self.prefix) if self.prefix else self.config_path
196
+ dag.add_attribs({"bps_wms_config_path": str(config_path)})
@@ -43,3 +43,15 @@ provisionResources: false
43
43
  overwriteJobFiles: true
44
44
  finalJob:
45
45
  overwriteJobFiles: false
46
+
47
+ # Define the default DAGMan configuration.
48
+ wmsConfig:
49
+ # A comma separated list of variable names to add to the DAGMan *.condor.sub
50
+ # file’s getenv option. If set to "TRUE" (a string, not a boolean!), DAGMan
51
+ # will effectively lift all environmental variables from the user's shell
52
+ # environment.
53
+ DAGMAN_MANAGER_JOB_APPEND_GETENV: "TRUE"
54
+
55
+ # A boolean flag controlling whether DAGMan should generate submit files for
56
+ # nested DAGs automatically.
57
+ DAGMAN_GENERATE_SUBDAG_SUBMITS: true
@@ -46,6 +46,7 @@ from lsst.daf.butler import Config
46
46
  from lsst.utils.timer import time_this
47
47
 
48
48
  from .common_utils import WmsIdType, _wms_id_to_cluster, _wms_id_to_dir, _wms_id_type
49
+ from .dagman_configurator import DagmanConfigurator
49
50
  from .htcondor_config import HTC_DEFAULTS_URI
50
51
  from .htcondor_workflow import HTCondorWorkflow
51
52
  from .lssthtc import (
@@ -118,6 +119,17 @@ class HTCondorService(BaseWmsService):
118
119
  provisioner.prepare("provisioningJob.bash", prefix=out_prefix)
119
120
  provisioner.provision(workflow.dag)
120
121
 
122
+ try:
123
+ configurator = DagmanConfigurator(config)
124
+ except KeyError:
125
+ _LOG.debug(
126
+ "No DAGMan-specific settings were found in BPS config; "
127
+ "skipping writing DAG-specific configuration file."
128
+ )
129
+ else:
130
+ configurator.prepare("dagman.conf", prefix=out_prefix)
131
+ configurator.configure(workflow.dag)
132
+
121
133
  with time_this(
122
134
  log=_LOG, level=logging.INFO, prefix=None, msg="Completed writing out HTCondor workflow"
123
135
  ):
@@ -725,19 +725,6 @@ def htc_create_submit_from_dag(dag_filename: str, submit_options: dict[str, Any]
725
725
  Use with HTCondor versions which support htcondor.Submit.from_dag(),
726
726
  i.e., 8.9.3 or newer.
727
727
  """
728
- # Passing do_recurse as submit_option does not seem to
729
- # override DAGMAN_GENERATE_SUBDAG_SUBMITS as manual implies.
730
- # So setting it and the other bps required setting here as
731
- # environment variables if they don't exist.
732
- var_name = "_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"
733
- if var_name not in os.environ:
734
- os.environ[var_name] = "True"
735
-
736
- if "do_recurse" in submit_options:
737
- var_name = "_CONDOR_DAGMAN_GENERATE_SUBDAG_SUBMITS"
738
- if var_name not in os.environ:
739
- os.environ[var_name] = str(submit_options["do_recurse"])
740
-
741
728
  # Config and environment variables do not seem to override -MaxIdle
742
729
  # on the .dag.condor.sub's command line (broken in some 24.0.x versions).
743
730
  # Explicitly forward them as a submit_option if either exists.
@@ -1164,7 +1151,15 @@ class HTCDag(networkx.DiGraph):
1164
1151
  self.graph["dag_filename"] = os.path.join(dag_subdir, f"{self.graph['name']}.dag")
1165
1152
  full_filename = os.path.join(submit_path, self.graph["dag_filename"])
1166
1153
  os.makedirs(os.path.dirname(full_filename), exist_ok=True)
1154
+
1155
+ try:
1156
+ dagman_config_path = Path(self.graph["attr"]["bps_wms_config_path"])
1157
+ except KeyError:
1158
+ dagman_config_path = None
1167
1159
  with open(full_filename, "w") as fh:
1160
+ if dagman_config_path is not None:
1161
+ fh.write(f"CONFIG {dag_rel_path / dagman_config_path}\n")
1162
+
1168
1163
  for name, nodeval in self.nodes().items():
1169
1164
  try:
1170
1165
  job = nodeval["data"]
@@ -1177,6 +1172,8 @@ class HTCDag(networkx.DiGraph):
1177
1172
  subdir = job.dagcmds["dir"]
1178
1173
  else:
1179
1174
  subdir = job_subdir
1175
+ if dagman_config_path is not None:
1176
+ job.subdag.add_attribs({"bps_wms_config_path": str(dagman_config_path)})
1180
1177
  job.subdag.write(submit_path, subdir, dag_subdir, "../..")
1181
1178
  fh.write(
1182
1179
  f"SUBDAG EXTERNAL {job.name} {Path(job.subdag.graph['dag_filename']).name} "
@@ -1468,7 +1465,17 @@ def count_jobs_in_single_dag(
1468
1465
  job_name_to_type: dict[str, WmsNodeType] = {}
1469
1466
  with open(filename) as fh:
1470
1467
  for line in fh:
1471
- job_name = ""
1468
+ # Skip any line that contains commands irrelevant to job counting.
1469
+ if not line.startswith(
1470
+ (
1471
+ "JOB",
1472
+ "FINAL",
1473
+ "SERVICE",
1474
+ "SUBDAG EXTERNAL",
1475
+ )
1476
+ ):
1477
+ continue
1478
+
1472
1479
  m = re.match(
1473
1480
  r"(?P<command>JOB|FINAL|SERVICE|SUBDAG EXTERNAL)\s+"
1474
1481
  r'(?P<jobname>(?P<wms>wms_)?\S+)\s+"?(?P<subfile>\S+)"?\s*'
@@ -1524,9 +1531,9 @@ def count_jobs_in_single_dag(
1524
1531
 
1525
1532
  job_name_to_label[job_name] = label
1526
1533
  job_name_to_type[job_name] = job_type
1527
- elif not line.startswith(("VARS", "PARENT", "DOT", "NODE_STATUS_FILE", "SET_JOB_ATTR", "SCRIPT")):
1528
- # Only print warning if not a line wanting to skip
1529
- # Probably means problem with regex in above match pattern.
1534
+ else:
1535
+ # The line should, but didn't match the pattern above. Probably
1536
+ # problems with regex.
1530
1537
  _LOG.warning("Unexpected skipping of dag line: %s", line)
1531
1538
 
1532
1539
  return counts, job_name_to_label, job_name_to_type
@@ -897,8 +897,6 @@ def _generic_workflow_to_htcondor_dag(
897
897
  elif gwjob.node_type == GenericWorkflowNodeType.GROUP:
898
898
  gwjob = cast(GenericWorkflowGroup, gwjob)
899
899
  htc_job = _group_to_subdag(config, gwjob, out_prefix)
900
- # In case DAGMAN_GENERATE_SUBDAG_SUBMITS is False,
901
- dag.graph["submit_options"]["do_recurse"] = True
902
900
  else:
903
901
  raise RuntimeError(f"Unsupported generic workflow node type {gwjob.node_type} ({gwjob.name})")
904
902
  _LOG.debug("Calling adding job %s %s", htc_job.name, htc_job.label)
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "29.2025.4600"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-ctrl-bps-htcondor
3
- Version: 29.2025.4500
3
+ Version: 29.2025.4600
4
4
  Summary: HTCondor plugin for lsst-ctrl-bps.
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License-Expression: BSD-3-Clause OR GPL-3.0-or-later
@@ -23,7 +23,10 @@ License-File: gpl-v3.0.txt
23
23
  Requires-Dist: htcondor>=8.8
24
24
  Requires-Dist: lsst-ctrl-bps
25
25
  Requires-Dist: lsst-daf-butler
26
+ Requires-Dist: lsst-pipe-base
26
27
  Requires-Dist: lsst-utils
28
+ Requires-Dist: packaging
29
+ Requires-Dist: pydantic<3.0,>=2
27
30
  Provides-Extra: test
28
31
  Requires-Dist: pytest>=3.2; extra == "test"
29
32
  Requires-Dist: pytest-openfiles>=0.5.0; extra == "test"
@@ -11,6 +11,7 @@ doc/lsst.ctrl.bps.htcondor/index.rst
11
11
  doc/lsst.ctrl.bps.htcondor/userguide.rst
12
12
  python/lsst/ctrl/bps/htcondor/__init__.py
13
13
  python/lsst/ctrl/bps/htcondor/common_utils.py
14
+ python/lsst/ctrl/bps/htcondor/dagman_configurator.py
14
15
  python/lsst/ctrl/bps/htcondor/final_post.sh
15
16
  python/lsst/ctrl/bps/htcondor/handlers.py
16
17
  python/lsst/ctrl/bps/htcondor/htcondor_config.py
@@ -30,6 +31,7 @@ python/lsst_ctrl_bps_htcondor.egg-info/requires.txt
30
31
  python/lsst_ctrl_bps_htcondor.egg-info/top_level.txt
31
32
  python/lsst_ctrl_bps_htcondor.egg-info/zip-safe
32
33
  tests/test_common_utils.py
34
+ tests/test_dagman_configurator.py
33
35
  tests/test_handlers.py
34
36
  tests/test_htcondor_service.py
35
37
  tests/test_lssthtc.py
@@ -1,7 +1,10 @@
1
1
  htcondor>=8.8
2
2
  lsst-ctrl-bps
3
3
  lsst-daf-butler
4
+ lsst-pipe-base
4
5
  lsst-utils
6
+ packaging
7
+ pydantic<3.0,>=2
5
8
 
6
9
  [test]
7
10
  pytest>=3.2
@@ -0,0 +1,143 @@
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/>.
27
+
28
+ """Unit tests for DagmanConfigurator class."""
29
+
30
+ import logging
31
+ import os
32
+ import tempfile
33
+ import unittest
34
+ from pathlib import Path
35
+
36
+ from pydantic import ValidationError
37
+
38
+ from lsst.ctrl.bps import BpsConfig
39
+ from lsst.ctrl.bps.htcondor import HTCDag
40
+ from lsst.ctrl.bps.htcondor.dagman_configurator import DagmanConfigurator
41
+
42
+ logger = logging.getLogger("lsst.ctrl.bps.htcondor")
43
+
44
+
45
+ class DagmanConfiguratorTestCase(unittest.TestCase):
46
+ """Unit tests for DagmanConfigurator class."""
47
+
48
+ def setUp(self):
49
+ self.config = BpsConfig(
50
+ {
51
+ "site": {
52
+ "foo": {
53
+ "wmsConfig": {"DAGMAN_USE_STRICT": 1},
54
+ },
55
+ },
56
+ "wmsConfig": {"DAGMAN_USE_STRICT": 0},
57
+ }
58
+ )
59
+
60
+ def tearDown(self):
61
+ pass
62
+
63
+ def testInitDefaultSearchOptions(self):
64
+ """Test object instantiation with default search options."""
65
+ configurator = DagmanConfigurator(self.config)
66
+ self.assertIn("DAGMAN_USE_STRICT", configurator.options)
67
+ self.assertEqual(configurator.options["DAGMAN_USE_STRICT"], 0)
68
+ self.assertIsNone(configurator.config_path)
69
+ self.assertIsNone(configurator.prefix)
70
+
71
+ def testInitCustomSearchOptions(self):
72
+ """Test object instantiation with custom search options."""
73
+ configurator = DagmanConfigurator(self.config, search_opts={"curvals": {"computeSite": "foo"}})
74
+ self.assertIn("DAGMAN_USE_STRICT", configurator.options)
75
+ self.assertEqual(configurator.options["DAGMAN_USE_STRICT"], 1)
76
+ self.assertIsNone(configurator.config_path)
77
+ self.assertIsNone(configurator.prefix)
78
+
79
+ def testInitWrongOptionType(self):
80
+ self.config[".wmsConfig.DAGMAN_USE_STRICT"] = "foo"
81
+ with self.assertRaisesRegex(ValidationError, "DAGMAN_USE_STRICT".lower()):
82
+ DagmanConfigurator(self.config)
83
+
84
+ def testInitUnsupportedDagmanOption(self):
85
+ """Test object instantiation with unsupported DAGMAN options."""
86
+ self.config[".wmsConfig.DAGMAN_UNSUPPORTED_OPTION"] = "foo"
87
+ with self.assertLogs(logger=logger, level="WARNING") as cm:
88
+ configurator = DagmanConfigurator(self.config)
89
+ self.assertIn("DAGMAN_UNSUPPORTED_OPTION", cm.output[0])
90
+ self.assertNotIn("DAGMAN_UNSUPPORTED_OPTION", configurator.options)
91
+
92
+ def testInitNoWmsConfig(self):
93
+ """Test object instantiation fails when no WMS-specific options."""
94
+ del self.config["wmsConfig"]
95
+ with self.assertRaisesRegex(KeyError, "not found"):
96
+ DagmanConfigurator(self.config)
97
+
98
+ def testPrepare(self):
99
+ """Test if the method creates the configuration file."""
100
+ configurator = DagmanConfigurator(self.config)
101
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
102
+ configurator.prepare("dagman.conf", prefix=tmpdir)
103
+ self.assertIn(Path(tmpdir), list(configurator.config_path.parents))
104
+ self.assertTrue(configurator.config_path.is_file())
105
+ self.assertEqual(configurator.config_path.read_text(), "DAGMAN_USE_STRICT = 0")
106
+
107
+ def testPrepareWithUnsupportedOption(self):
108
+ """Test if the method does not include unsupported options."""
109
+ self.config[".wmsConfig.DAGMAN_UNSUPPORTED_OPTION"] = "foo"
110
+ configurator = DagmanConfigurator(self.config)
111
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
112
+ configurator.prepare("dagman.conf", prefix=tmpdir)
113
+ self.assertIn(Path(tmpdir), list(configurator.config_path.parents))
114
+ self.assertTrue(configurator.config_path.is_file())
115
+ self.assertEqual(configurator.config_path.read_text(), "DAGMAN_USE_STRICT = 0")
116
+
117
+ def testPrepareConfigWriteFailure(self):
118
+ """Test if the method raises when it can't create the configuration."""
119
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
120
+ os.chmod(tmpdir, 0o500)
121
+
122
+ configurator = DagmanConfigurator(self.config)
123
+ with self.assertLogs(logger=logger, level="ERROR") as cm, self.assertRaises(OSError):
124
+ configurator.prepare("dagman.conf", f"{tmpdir}/subdir")
125
+ self.assertIn("Could not write", cm.output[0])
126
+
127
+ os.chmod(tmpdir, 0o700)
128
+
129
+ def testConfigure(self):
130
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
131
+ dag = HTCDag(name="test_configure")
132
+ configurator = DagmanConfigurator(self.config)
133
+ configurator.prepare("dagman.conf", prefix=tmpdir)
134
+ configurator.configure(dag)
135
+ self.assertIn("bps_wms_config_path", dag.graph["attr"])
136
+ self.assertEqual(dag.graph["attr"]["bps_wms_config_path"], "dagman.conf")
137
+
138
+ def testConfigureIfNotPrepared(self):
139
+ """Test if the method raises when prepare step was skipped."""
140
+ dag = HTCDag(name="test_configure_not_prepared")
141
+ configurator = DagmanConfigurator(self.config)
142
+ with self.assertRaisesRegex(RuntimeError, "file does not exist"):
143
+ configurator.configure(dag)
@@ -38,7 +38,9 @@ from shutil import copy2, copytree, ignore_patterns, rmtree, which
38
38
 
39
39
  import htcondor
40
40
 
41
- from lsst.ctrl.bps.htcondor import lssthtc
41
+ from lsst.ctrl.bps import BpsConfig
42
+ from lsst.ctrl.bps.htcondor import dagman_configurator, htcondor_config, lssthtc
43
+ from lsst.daf.butler import Config
42
44
  from lsst.utils.tests import temporaryDirectory
43
45
 
44
46
  logger = logging.getLogger("lsst.ctrl.bps.htcondor")
@@ -1190,7 +1192,6 @@ class HtcCreateSubmitFromDagTestCase(unittest.TestCase):
1190
1192
  dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag"
1191
1193
  submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {})
1192
1194
  self.assertIn("-MaxIdle 42", submit["arguments"])
1193
- self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower())
1194
1195
 
1195
1196
  @unittest.mock.patch.dict(os.environ, {})
1196
1197
  def testMaxIdleGiven(self):
@@ -1199,7 +1200,6 @@ class HtcCreateSubmitFromDagTestCase(unittest.TestCase):
1199
1200
  dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag"
1200
1201
  submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"MaxIdle": 37})
1201
1202
  self.assertIn("-MaxIdle 37", submit["arguments"])
1202
- self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower())
1203
1203
 
1204
1204
  @unittest.mock.patch.dict(os.environ, {})
1205
1205
  def testNoMaxJobsIdle(self):
@@ -1215,37 +1215,90 @@ class HtcCreateSubmitFromDagTestCase(unittest.TestCase):
1215
1215
  with unittest.mock.patch("htcondor.param") as mock_param:
1216
1216
  mock_param.__contains__.return_value = False
1217
1217
  _ = lssthtc.htc_create_submit_from_dag(str(dag_filename), {})
1218
- self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower())
1219
1218
  submit_mock.assert_called_once_with(str(dag_filename), {})
1220
1219
 
1221
- @unittest.mock.patch.dict(os.environ, {})
1222
- def testDoRecurseGivenWithNoEnv(self):
1223
- with temporaryDirectory() as tmp_dir:
1224
- copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir)
1225
- dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag"
1226
- submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"do_recurse": True})
1227
- self.assertIn("-do_recurse", submit["arguments"])
1228
- self.assertEqual("true", os.environ["_CONDOR_DAGMAN_GENERATE_SUBDAG_SUBMITS"].lower())
1229
- self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower())
1230
1220
 
1231
- @unittest.mock.patch.dict(os.environ, {"_CONDOR_DAGMAN_GENERATE_SUBDAG_SUBMITS": "False"})
1232
- def testDoRecurseGivenWithEnv(self):
1221
+ class HtcDagTestCase(unittest.TestCase):
1222
+ """Test for HTCDag class."""
1223
+
1224
+ def setUp(self):
1225
+ job = lssthtc.HTCJob(name="test_job")
1226
+ job.add_job_cmds(
1227
+ {
1228
+ "executable": "/usr/bin/echo",
1229
+ "arguments": "foo",
1230
+ "output": "test_job.$(Cluster).out",
1231
+ "error": "test_job.$(Cluster).out",
1232
+ "log": "test_job.$(Cluster).log",
1233
+ }
1234
+ )
1235
+ job.subfile = f"{job.name}.sub"
1236
+
1237
+ self.dag = lssthtc.HTCDag(name="test_workflow")
1238
+ self.dag.add_job(job)
1239
+
1240
+ self.subfile_expected = [
1241
+ "executable=/usr/bin/echo\n",
1242
+ "arguments=foo\n",
1243
+ "output=test_job.$(Cluster).out\n",
1244
+ "error=test_job.$(Cluster).out\n",
1245
+ "log=test_job.$(Cluster).log\n",
1246
+ "queue\n",
1247
+ ]
1248
+
1249
+ def tearDown(self):
1250
+ pass
1251
+
1252
+ def testWriteWithDagConfig(self):
1233
1253
  with temporaryDirectory() as tmp_dir:
1234
- copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir)
1235
- dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag"
1236
- submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"do_recurse": True})
1237
- self.assertIn("-do_recurse", submit["arguments"])
1238
- self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower())
1239
- self.assertEqual("false", os.environ["_CONDOR_DAGMAN_GENERATE_SUBDAG_SUBMITS"].lower())
1254
+ config = BpsConfig(Config(htcondor_config.HTC_DEFAULTS_URI))
1255
+ job = self.dag.nodes["test_job"]["data"]
1256
+ wms_config_filename = "dagman.conf"
1257
+ wms_configurator = dagman_configurator.DagmanConfigurator(config)
1258
+ wms_configurator.prepare(wms_config_filename, prefix=tmp_dir)
1259
+ wms_configurator.configure(self.dag)
1260
+ dagfile_expected = [
1261
+ f"CONFIG {wms_config_filename}\n",
1262
+ f'JOB {job.name} "{job.subfile}"\n',
1263
+ f"DOT {self.dag.name}.dot\n",
1264
+ f"NODE_STATUS_FILE {self.dag.name}.node_status\n",
1265
+ f'SET_JOB_ATTR bps_wms_config_path= "{wms_config_filename}"\n',
1266
+ ]
1267
+
1268
+ self.dag.write(tmp_dir, "", "")
1240
1269
 
1241
- @unittest.mock.patch.dict(os.environ, {"_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV": "*_DIR"})
1242
- def testGetEnvOverridden(self):
1270
+ self.assertIn("submit_path", self.dag.graph)
1271
+ self.assertEqual(self.dag.graph["submit_path"], tmp_dir)
1272
+ self.assertIn("dag_filename", self.dag.graph)
1273
+ self.assertEqual(self.dag.graph["dag_filename"], f"{self.dag.graph['name']}.dag")
1274
+ with open(os.path.join(tmp_dir, self.dag.graph["dag_filename"]), encoding="utf-8") as f:
1275
+ dagfile_actual = f.readlines()
1276
+ self.assertEqual(dagfile_actual, dagfile_expected)
1277
+ with open(os.path.join(tmp_dir, job.subfile), encoding="utf-8") as f:
1278
+ subfile_actual = f.readlines()
1279
+ self.assertEqual(subfile_actual, self.subfile_expected)
1280
+
1281
+ def testWriteWithoutDagConfig(self):
1243
1282
  with temporaryDirectory() as tmp_dir:
1244
- copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir)
1245
- dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag"
1246
- submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"do_recurse": True})
1247
- self.assertIn("-do_recurse", submit["arguments"])
1248
- self.assertEqual(os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"], "*_DIR")
1283
+ job = self.dag.nodes["test_job"]["data"]
1284
+ dagfile_expected = [
1285
+ f'JOB {job.name} "{job.subfile}"\n',
1286
+ f"DOT {self.dag.name}.dot\n",
1287
+ f"NODE_STATUS_FILE {self.dag.name}.node_status\n",
1288
+ ]
1289
+
1290
+ self.dag.write(tmp_dir, "", "")
1291
+
1292
+ self.assertIn("submit_path", self.dag.graph)
1293
+ self.assertEqual(self.dag.graph["submit_path"], tmp_dir)
1294
+ self.assertIn("dag_filename", self.dag.graph)
1295
+ self.assertEqual(self.dag.graph["dag_filename"], f"{self.dag.graph['name']}.dag")
1296
+ with open(os.path.join(tmp_dir, self.dag.graph["dag_filename"]), encoding="utf-8") as f:
1297
+ dagfile_actual = f.readlines()
1298
+ self.assertEqual(dagfile_actual, dagfile_expected)
1299
+ with open(os.path.join(tmp_dir, job.subfile), encoding="utf-8") as f:
1300
+ subfile_actual = f.readlines()
1301
+ self.assertEqual(subfile_actual, self.subfile_expected)
1249
1302
 
1250
1303
 
1251
1304
  if __name__ == "__main__":
@@ -1,2 +0,0 @@
1
- __all__ = ["__version__"]
2
- __version__ = "29.2025.4500"