gstlal 1.13.0
Loading...
Searching...
No Matches
__init__.py
1# Copyright (C) 2020 Patrick Godwin (patrick.godwin@ligo.org)
2#
3# This program is free software; you can redistribute it and/or modify it
4# under the terms of the GNU General Public License as published by the
5# Free Software Foundation; either version 2 of the License, or (at your
6# option) any later version.
7#
8# This program is distributed in the hope that it will be useful, but
9# WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
11# Public License for more details.
12#
13# You should have received a copy of the GNU General Public License along
14# with this program; if not, write to the Free Software Foundation, Inc.,
15# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16
17
18from collections import defaultdict
19from collections.abc import Iterable
20from dataclasses import dataclass
21import os
22import re
23from typing import List, Optional, Tuple, Union
24
25import htcondor
26from htcondor import dags
27import pluggy
28
29from gstlal import plugins
30
31
32_PROTECTED_CONDOR_VARS = {"input", "output", "rootdir"}
33
34
36 _has_layers = False
37
38 def __init__(self, config, *args, **kwargs):
39 super().__init__(*args, **kwargs)
40 self.config = config
41 self._node_layers = {}
42 self._layers = {}
43 self._provides = {}
44
45 # register layers to DAG if needed
46 if not self._has_layers:
47 for layer_name, layer in self._get_registered_layers().items():
48 self.register_layer(layer_name)(layer)
49
50 def attach(self, layer):
51 key = layer.name
52 if key in self._layers:
53 return KeyError(f"{key} layer already added to DAG")
54 self._layers[layer.name] = layer
55
56 # determine parent-child relationships and connect accordingly
57 all_edges = defaultdict(set)
58 if layer.has_dependencies:
59 # determine edges
60 for child_idx, node in enumerate(layer.nodes):
61 for input_ in node.requires:
62 if input_ in self._provides:
63 parent_name, parent_idx = self._provides[input_]
64 all_edges[parent_name].add((parent_idx, child_idx))
65
66 if not all_edges:
67 self._node_layers[key] = self.layer(**layer.config())
68
69 # determine edge type and connect
70 for num, (parent, edges) in enumerate(all_edges.items()):
71 edge = self._get_edge_type(parent, layer.name, all_edges[parent])
72 if num == 0:
73 self._node_layers[key] = self._node_layers[parent].child_layer(
74 **layer.config(),
75 edge=edge
76 )
77 else:
78 self._node_layers[key].add_parents(self._node_layers[parent], edge=edge)
79
80 else:
81 self._node_layers[key] = self.layer(**layer.config())
82
83 # register any data products the layer provides
84 for idx, node in enumerate(layer.nodes):
85 for output in node.provides:
86 self._provides[output] = (key, idx)
87
88 def create_log_dir(self, log_dir="logs"):
89 os.makedirs(log_dir, exist_ok=True)
90
91 def write_dag(self, filename, path=None, **kwargs):
92 write_dag(self, dag_file_name=filename, dag_dir=path, **kwargs)
93
94 def write_script(self, filename, path=None, formatter=None):
95 if path:
96 filename = os.path.join(path, filename)
97 if not formatter:
98 formatter = HexFormatter()
99
100 # write script
101 with open(filename, "w") as f:
102 # traverse DAG in breadth-first order
103 for layer in self.walk(dags.WalkOrder("BREADTH")):
104 # grab relevant submit args, format $(arg) to {arg}
105 executable = layer.submit_description['executable']
106 args = layer.submit_description['arguments']
107 args = re.sub(r"\$\‍(((\w+?))\‍)", r"{\1}", args)
108
109 # evaluate vars for each node in layer, write to disk
110 for idx, node_vars in enumerate(layer.vars):
111 node_name = formatter.generate(layer.name, idx)
112 print(f"# Job {node_name}", file=f)
113 print(executable + " " + args.format(**node_vars) + "\n", file=f)
114
115 @classmethod
116 def register_layer(cls, layer_name):
117 """Register a layer to the DAG, making it callable.
118 """
119 def register(func):
120 def wrapped(self, *args, **kwargs):
121 return func(self.config, self, *args, **kwargs)
122 setattr(cls, layer_name, wrapped)
123 return register
124
125 def _get_edge_type(self, parent_name, child_name, edges):
126 parent = self._layers[parent_name]
127 child = self._layers[child_name]
128 edges = sorted(list(edges))
129
130 # check special cases, defaulting to explicit edge connections via indices
131 if len(edges) == (len(parent.nodes) + len(child.nodes)):
132 return dags.ManyToMany()
133
134 elif (len(parent.nodes) == len(child.nodes)
135 and all([parent_idx == child_idx for parent_idx, child_idx in edges])):
136 return dags.OneToOne()
137
138 else:
139 return EdgeConnector(edges)
140
141 @classmethod
143 """Get all registered DAG layers.
144 """
145 # set up plugin manager
146 manager = pluggy.PluginManager("gstlal")
147 manager.add_hookspecs(plugins)
148
149 # load layers
150 from gstlal.dags.layers import io, psd
151 manager.register(io)
152 manager.register(psd)
153
154 # add all registered plugins to registry
155 registered = {}
156 for plugin_name in manager.hook.layers():
157 for name, layer in plugin_name.items():
158 registered[name] = layer
159
160 return registered
161
162
163class HexFormatter(dags.SimpleFormatter):
164 """A hex-based node formatter that produces names like LayerName_000C.
165
166 """
167 def __init__(self, offset: int = 0):
168 self.separator = "."
169 self.index_format = "{:05X}"
170 self.offset = offset
171
172 def parse(self, node_name: str) -> Tuple[str, int]:
173 layer, index = node_name.split(self.separator)
174 index = int(index, 16)
175 return layer, index - self.offset
176
177
178class EdgeConnector(dags.BaseEdge):
179 """This edge connects individual nodes in layers given an explicit mapping.
180
181 """
182 def __init__(self, indices):
183 self.indices = indices
184
185 def get_edges(self, parent, child, join_factory):
186 for parent_idx, child_idx in self.indices:
187 yield (parent_idx,), (child_idx,)
188
189
190def write_dag(dag, dag_dir=None, formatter=None, **kwargs):
191 if not formatter:
192 formatter = HexFormatter()
193 if not dag_dir:
194 dag_dir = os.getcwd()
195 return htcondor.dags.write_dag(dag, dag_dir, node_name_formatter=formatter, **kwargs)
196
197
198@dataclass
200 """Defines a command-line argument (positional).
201
202 This provides some extra functionality over defining command line
203 argument explicitly, in addition to some extra parameters which
204 sets how condor interprets how to handle them within the DAG
205 and within submit descriptions.
206
207 Parameters
208 ----------
209 name
210 The option name. Since this is a positional argument, it is not
211 used explicitly in the command, but is needed to define
212 variable names within jobs.
213 argument
214 The positional argument value(s) used in a command.
215 track
216 Whether to track files defined here and used externally within
217 jobs to determine parent-child relationships when nodes specify
218 this option as an input or output. On by default.
219 remap
220 Whether to allow remapping of output files being transferred.
221 If set, output files will be moved to their target directories
222 after files are transferred back. This is done to avoid issues
223 where the target directories are available on the submit node
224 but not on the exectute node. On by default.
225 suppress
226 Whether to hide this option. Used externally within jobs to
227 determine whether to define job arguments. This is typically used
228 when you want to track file I/O used by a job but isn't directly
229 specified in their commands. Off by default.
230 suppress_with_remap
231 Same as suppress but allowing transfer remaps to still occur.
232 Used when you want to track file output which is not directly
233 specified in their command but whose file locations changed
234 compared to their inputs. Off by default.
235
236 Examples
237 ________
238 >>> Argument("command", "run").vars()
239 'run'
240
241 >>> files = ["input_1.txt", "input_2.txt"]
242 >>> Argument("input-files", files).vars()
243 'input_1.txt input_2.txt'
244
245 """
246 name: str
247 argument: Union[int, float, str, List]
248 track: Optional[bool] = True
249 remap: Optional[bool] = True
250 suppress: Optional[bool] = False
251 suppress_with_remap: Optional[bool] = False
252
253 def __post_init__(self):
254 # check against list of protected condor names/characters,
255 # rename condor variables name to avoid issues
256 self.condor_name = self.name.replace("-", "_")
257 if self.condor_name in _PROTECTED_CONDOR_VARS:
258 self.condor_name += "_"
259
260 if isinstance(self.argument, str) or not isinstance(self.argument, Iterable):
261 self.argument = [self.argument]
262 self.argument = [str(arg) for arg in self.argument]
263
264 # set options that control other options
265 if self.suppress:
266 self.remap = False
267 elif self.suppress_with_remap:
268 self.suppress = True
269
270 @property
271 def arg_basename(self):
272 return [os.path.basename(arg) for arg in self.argument]
273
274 def vars(self, basename=False):
275 if callable(basename):
276 # if basename is a function, determine whether the argument's
277 # basename should be used based on calling basename(argument)
278 args = []
279 for arg in self.argument:
280 if basename(arg):
281 args.append(os.path.basename(arg))
282 else:
283 args.append(arg)
284 return " ".join(args)
285 elif basename:
286 return " ".join(self.arg_basename)
287 else:
288 return " ".join(self.argument)
289
290 def files(self, basename=False):
291 return ",".join(self.arg_basename) if basename else ",".join(self.argument)
292
293 def remaps(self):
294 return ";".join([f"{base}={arg}" for base, arg in zip(self.arg_basename, self.argument) if base != arg])
295
296
297@dataclass
298class Option:
299 """Defines a command-line option (long form).
300
301 This provides some extra functionality over defining command line
302 options explicitly, in addition to some extra parameters which
303 sets how condor interprets how to handle them within the DAG
304 and within submit descriptions.
305
306 Parameters
307 ----------
308 name
309 The option name to be used in a command.
310 argument
311 The argument value(s) used in a command.
312 track
313 Whether to track files defined here and used externally within
314 jobs to determine parent-child relationships when nodes specify
315 this option as an input or output. On by default.
316 remap
317 Whether to allow remapping of output files being transferred.
318 If set, output files will be moved to their target directories
319 after files are transferred back. This is done to avoid issues
320 where the target directories are available on the submit node
321 but not on the exectute node. On by default.
322 suppress
323 Whether to hide this option. Used externally within jobs to
324 determine whether to define job arguments. This is typically used
325 when you want to track file I/O used by a job but isn't directly
326 specified in their commands. Off by default.
327 suppress_with_remap
328 Same as suppress but allowing transfer remaps to still occur.
329 Used when you want to track file output which is not directly
330 specified in their command but whose file locations changed
331 compared to their inputs. Off by default.
332
333 Examples
334 ________
335 >>> Option("verbose").vars()
336 '--verbose'
337
338 >>> Option("input-type", "file").vars()
339 '--input-type file'
340
341 >>> Option("ifos", ["H1", "L1", "V1"]).vars()
342 '--ifos H1 --ifos L1 --ifos V1'
343
344 """
345 name: str
346 argument: Optional[Union[int, float, str, List]] = None
347 track: Optional[bool] = True
348 remap: Optional[bool] = True
349 suppress: Optional[bool] = False
350 suppress_with_remap: Optional[bool] = False
351
352 def __post_init__(self):
353 # check against list of protected condor names/characters,
354 # rename condor variables name to avoid issues
355 self.condor_name = self.name.replace("-", "_")
356 if self.condor_name in _PROTECTED_CONDOR_VARS:
357 self.condor_name += "_"
358
359 if self.argument is not None:
360 if isinstance(self.argument, str) or not isinstance(self.argument, Iterable):
361 self.argument = [self.argument]
362 self.argument = [str(arg) for arg in self.argument]
363
364 # set options that control other options
365 if self.suppress:
366 self.remap = False
367 elif self.suppress_with_remap:
368 self.suppress = True
369
370 @property
371 def arg_basename(self):
372 return [os.path.basename(arg) for arg in self.argument]
373
374 def vars(self, basename=False):
375 if self.argument is None:
376 return f"--{self.name}"
377 elif callable(basename):
378 # if basename is a function, determine whether the argument's
379 # basename should be used based on calling basename(argument)
380 args = []
381 for arg in self.argument:
382 if basename(arg):
383 args.append(f"--{self.name} {os.path.basename(arg)}")
384 else:
385 args.append(f"--{self.name} {arg}")
386 return " ".join(args)
387 elif basename:
388 return " ".join([f"--{self.name} {arg}" for arg in self.arg_basename])
389 else:
390 return " ".join([f"--{self.name} {arg}" for arg in self.argument])
391
392 def files(self, basename=False):
393 return ",".join(self.arg_basename) if basename else ",".join(self.argument)
394
395 def remaps(self):
396 return ";".join([f"{base}={arg}" for base, arg in zip(self.arg_basename, self.argument) if base != arg])
Optional suppress_with_remap
Definition __init__.py:251
_get_registered_layers(cls)
Definition __init__.py:142
register_layer(cls, layer_name)
Definition __init__.py:116
_get_edge_type(self, parent_name, child_name, edges)
Definition __init__.py:125
dict _node_layers
Definition __init__.py:41
Optional argument
Definition __init__.py:346
Optional suppress_with_remap
Definition __init__.py:350
Optional suppress
Definition __init__.py:349