idds-prompt 2.6.12__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.
@@ -0,0 +1,14 @@
1
+ Copyright 2019
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ https://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
14
+
@@ -0,0 +1,8 @@
1
+ include README.md
2
+ include LICENSE.rst
3
+ recursive-include etc/idds *.template
4
+ recursive-include etc/idds/rest *template
5
+ recursive-include tools/prompt/env *.yml
6
+ graft bin
7
+ prune build
8
+ prune dist
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: idds-prompt
3
+ Version: 2.6.12
4
+ Summary: intelligent Distributed Dispatch and Scheduling Service (iDDS) - Prompt Package
5
+ Author-email: PanDA Team <atlas-adc-panda@cern.ch>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Documentation, https://github.com/HSF/iDDS/wiki
8
+ Project-URL: Source, https://github.com/HSF/iDDS
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Natural Language :: English
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE.rst
19
+ Requires-Dist: stomp.py
20
+ Requires-Dist: idds-common>=2.2.0
21
+ Dynamic: license-file
22
+
23
+ idds-workflow
24
+ ====
25
+
26
+ idds-prompt subpackage implements prompt processing based on ActiveMQ.
@@ -0,0 +1,4 @@
1
+ idds-workflow
2
+ ====
3
+
4
+ idds-prompt subpackage implements prompt processing based on ActiveMQ.
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # You may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ # http://www.apache.org/licenses/LICENSE-2.0OA
7
+ #
8
+ # Authors:
9
+ # - Wen Guan, <wen.guan@cern.ch>, 2024 - 2025
10
+
11
+
12
+ """
13
+ Run workflow.
14
+ """
15
+
16
+ from __future__ import print_function
17
+
18
+ import argparse
19
+
20
+ # import argcomplete
21
+ import logging
22
+ import os
23
+ import sys
24
+ import time
25
+ import traceback
26
+
27
+
28
+ from idds.common.utils import setup_logging
29
+ from idds.common.version import release_version
30
+ from idds.prompt.transformer import Transformer
31
+
32
+ setup_logging(__name__, stream=sys.stdout)
33
+
34
+
35
+ def run_prompt(args):
36
+ logging.info(f"run_prompt called with args: {str(args)}")
37
+
38
+ transformer = Transformer(run_id=args.run_id, workdir=args.workdir, namespace=args.namespace, idle_timeout=args.idle_timeout)
39
+ logging.info("Initializing transformer brokers")
40
+ ret = transformer.run()
41
+ logging.info("Transformer run returned: %s" % str(ret))
42
+ return ret
43
+
44
+
45
+ def get_parser():
46
+ """
47
+ Return the argparse parser.
48
+ """
49
+ oparser = argparse.ArgumentParser(prog=os.path.basename(sys.argv[0]), add_help=True)
50
+
51
+ # common items
52
+ oparser.add_argument(
53
+ "--version", action="version", version="%(prog)s " + release_version
54
+ )
55
+ oparser.add_argument(
56
+ "--verbose",
57
+ "-v",
58
+ default=False,
59
+ action="store_true",
60
+ help="Print more verbose output.",
61
+ )
62
+ oparser.add_argument(
63
+ "--run_id", type=int, required=True, help="Run ID for the prompt run."
64
+ )
65
+ oparser.add_argument(
66
+ "--workdir", type=str, required=False, default=None, help="Working directory for the prompt run."
67
+ )
68
+ oparser.add_argument(
69
+ "--namespace", type=str, required=False, default=None, help="Namespace for the prompt run."
70
+ )
71
+
72
+ oparser.add_argument(
73
+ "--idle_timeout",
74
+ type=int,
75
+ default=600,
76
+ help="Idle timeout in seconds to stop the transformer if no messages are received.",
77
+ )
78
+
79
+ return oparser
80
+
81
+
82
+ if __name__ == "__main__":
83
+ arguments = sys.argv[1:]
84
+
85
+ oparser = get_parser()
86
+ # argcomplete.autocomplete(oparser)
87
+
88
+ args, unknown = oparser.parse_known_args(arguments)
89
+
90
+ try:
91
+ if args.verbose:
92
+ logging.getLogger().setLevel(logging.DEBUG)
93
+ start_time = time.time()
94
+ logging.info("Starting prompt with arguments: {0}".format(arguments))
95
+ logging.info("Starting at: %-0.4f" % start_time)
96
+
97
+ exit_code = run_prompt(args)
98
+
99
+ end_time = time.time()
100
+ logging.info("Finished at: %-0.4f" % end_time)
101
+ logging.info("Completed in %-0.4f sec." % (end_time - start_time))
102
+ sys.exit(exit_code)
103
+ except Exception as error:
104
+ logging.error("Strange error: {0}".format(error))
105
+ logging.error(traceback.format_exc())
106
+ end_time = time.time()
107
+ logging.info("Failed at: %-0.4f" % end_time)
108
+ logging.info("Failed in %-0.4f sec." % (end_time - start_time))
109
+ sys.exit(-1)
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # You may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ # http://www.apache.org/licenses/LICENSE-2.0OA
7
+ #
8
+ # Authors:
9
+ # - Wen Guan, <wen.guan@cern.ch>, 2023
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # You may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ # http://www.apache.org/licenses/LICENSE-2.0OA
7
+ #
8
+ # Authors:
9
+ # - Wen Guan, <wen.guan@cern.ch>, 2019
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env python
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # You may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Authors:
9
+ # - Wen Guan, <wen.guan@cern.ch>, 2025
10
+
11
+
12
+ import logging
13
+ import os
14
+ import random
15
+ import socket
16
+ import threading
17
+ import time
18
+ import traceback
19
+ import stomp
20
+ import uuid
21
+
22
+ from idds.common.plugin.plugin_base import PluginBase
23
+ from idds.common.utils import setup_logging, json_dumps, json_loads
24
+
25
+
26
+ setup_logging(__name__)
27
+ # Allow enabling stomp debug via environment variable for diagnostics
28
+ if os.environ.get("STOMP_DEBUG") in ("1", "true", "True"):
29
+ logging.getLogger("stomp").setLevel(logging.DEBUG)
30
+ else:
31
+ logging.getLogger("stomp").setLevel(logging.CRITICAL)
32
+
33
+
34
+ class MessagingListener(stomp.ConnectionListener):
35
+ """
36
+ Messaging Listener
37
+ """
38
+
39
+ def __init__(
40
+ self, broker, handler, handler_kwargs, conn, logger=None, subscriber=None, namespace=None
41
+ ):
42
+ super(MessagingListener, self).__init__()
43
+ self.__broker = broker
44
+ self.handler = handler
45
+ self.handler_kwargs = handler_kwargs
46
+ self.conn = conn
47
+ self.subscriber = subscriber
48
+ self.namespace = namespace
49
+
50
+ if logger:
51
+ self.logger = logger
52
+ else:
53
+ self.logger = logging.getLogger(self.__class__.__name__)
54
+
55
+ def on_error(self, frame):
56
+ self.logger.error("[broker] [%s]: headers:%s, body: %s", self.__broker, frame.headers, frame.body)
57
+
58
+ def on_disconnected(self):
59
+ self.logger.warning("STOMP connection disconnected (server or transport).")
60
+ if self.subscriber is not None:
61
+ try:
62
+ self.subscriber.fail()
63
+ self.subscriber.monitor()
64
+ except Exception:
65
+ pass
66
+
67
+ def on_connected(self, headers, body=None):
68
+ try:
69
+ hdrs = headers.headers if hasattr(headers, "headers") else headers
70
+ self.logger.info("STOMP CONNECTED from broker %s: %s", self.__broker, hdrs)
71
+ except Exception:
72
+ self.logger.info("STOMP CONNECTED from broker %s (failed to extract headers)", self.__broker)
73
+
74
+ def on_heartbeat_timeout(self):
75
+ self.logger.warning("STOMP heartbeat timeout.")
76
+ if self.subscriber is not None:
77
+ self.subscriber.fail()
78
+
79
+ def on_message(self, frame):
80
+ self.logger.debug(
81
+ f"[broker] [{self.__broker}]: headers: {frame.headers}, body: {frame.body}"
82
+ )
83
+ headers = frame.headers
84
+ try:
85
+ if self.subscriber is not None:
86
+ try:
87
+ self.subscriber.is_processing_message = True
88
+ except Exception:
89
+ pass
90
+
91
+ self.handler(headers, json_loads(frame.body), self.handler_kwargs)
92
+ if self.subscriber is None or self.subscriber.ack != "auto":
93
+ self.conn.ack(frame.headers["message-id"])
94
+ except Exception as ex:
95
+ self.logger.error(f"Failed to handle message: {ex}", exc_info=True)
96
+ if self.subscriber is not None:
97
+ self.subscriber.fail()
98
+
99
+ if self.subscriber is None or self.subscriber.ack != "auto":
100
+ try:
101
+ self.conn.nack(frame.headers["message-id"])
102
+ except Exception:
103
+ self.logger.exception("nack failed")
104
+
105
+ if self.subscriber is not None:
106
+ try:
107
+ self.subscriber.last_message_at = time.time()
108
+ self.subscriber.is_processing_message = False
109
+ except Exception:
110
+ pass
111
+
112
+
113
+ class BaseActiveMQ(PluginBase):
114
+ def __init__(
115
+ self, name="BaseActiveMQ", namespace=None, logger=None, broker=None, lifetime=3600, **kwargs
116
+ ):
117
+ super(BaseActiveMQ, self).__init__(
118
+ name=name, logger=logger, broker=broker, **kwargs
119
+ )
120
+
121
+ self.logger = logger
122
+ self.setup_logger(self.logger)
123
+ self.namespace = namespace
124
+
125
+ self.has_connection_failures = False
126
+ self.start_at = None
127
+ self.lifetime = lifetime
128
+
129
+ self.name = name
130
+ self.broker = broker
131
+
132
+ internal_id = str(uuid.uuid4())[:8]
133
+ self.hostname = socket.getfqdn().split(".")[0]
134
+ self.internal_id = f"{self.namespace}.{self.name}.{self.hostname}.{internal_id}"
135
+
136
+ if not hasattr(self, "timetolive"):
137
+ self.timetolive = 12 * 3600 * 1000 # milliseconds
138
+ else:
139
+ self.timetolive = int(self.timetolive)
140
+
141
+ self.conns = []
142
+ self.graceful_stop = threading.Event()
143
+
144
+ def setup_logger(self, logger):
145
+ if logger:
146
+ self.logger = logger
147
+ else:
148
+ logger_name = self.name if self.name else f"{self.__class__.__name__}"
149
+ self.logger = logging.getLogger(logger_name)
150
+
151
+ def get_logger(self):
152
+ return self.logger
153
+
154
+ def stop(self):
155
+ self.graceful_stop.set()
156
+ self.disconnect(self.conns)
157
+
158
+ def connect_to_messaging_brokers(self, sender=True, resolve_addresses=False):
159
+ if self.conns:
160
+ self.disconnect(self.conns)
161
+
162
+ # Support both "broker" (singular, idds.cfg format) and "brokers" keys
163
+ brokers = self.broker.get("brokers") or self.broker.get("broker")
164
+ if brokers is None:
165
+ raise KeyError("broker config must contain 'broker' or 'brokers' key")
166
+ if not isinstance(brokers, (list, tuple)):
167
+ brokers = [b.strip() for b in brokers.split(",")]
168
+
169
+ broker_addresses = []
170
+ for b in brokers:
171
+ try:
172
+ host, port_str = b.strip().rsplit(":", 1)
173
+ port = int(port_str)
174
+
175
+ if not resolve_addresses:
176
+ broker_addresses.append((host, port))
177
+ continue
178
+
179
+ addrinfos = socket.getaddrinfo(
180
+ host, 0, socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP
181
+ )
182
+ seen = set()
183
+ for addrinfo in addrinfos:
184
+ pair = (addrinfo[4][0], port)
185
+ if pair not in seen:
186
+ seen.add(pair)
187
+ broker_addresses.append(pair)
188
+ except socket.gaierror as error:
189
+ self.logger.error("Cannot resolve hostname %s: %s" % (b, str(error)))
190
+
191
+ self.logger.info(
192
+ "Broker addresses for channel %s with brokers %s: %s"
193
+ % (self.name, brokers, broker_addresses)
194
+ )
195
+
196
+ use_ssl = self.broker.get("use_ssl", False)
197
+
198
+ conns = []
199
+ for broker_host, broker_port in broker_addresses:
200
+ conn = stomp.Connection12(
201
+ host_and_ports=[(broker_host, broker_port)],
202
+ keepalive=True,
203
+ try_loopback_connect=False,
204
+ auto_content_length=False,
205
+ heartbeats=(50000, 50000),
206
+ )
207
+ if use_ssl:
208
+ conn.set_ssl(
209
+ for_hosts=[(broker_host, broker_port)],
210
+ key_file=self.broker.get("ssl_key_file"),
211
+ cert_file=self.broker.get("ssl_cert_file"),
212
+ ca_certs=self.broker.get("ssl_ca_certs"),
213
+ )
214
+ conns.append(conn)
215
+ self.conns = conns
216
+
217
+ self.has_connection_failures = False
218
+
219
+ return self.conns
220
+
221
+ def disconnect(self, conns):
222
+ for conn in conns:
223
+ try:
224
+ if conn.is_connected():
225
+ conn.disconnect()
226
+ except Exception:
227
+ pass
228
+
229
+ def fail(self):
230
+ self.has_connection_failures = True
231
+
232
+ def get_connection(self):
233
+ try:
234
+ if self.conns:
235
+ conn = random.sample(self.conns, 1)[0]
236
+ if not conn.is_connected():
237
+ conn.connect(
238
+ self.broker["username"],
239
+ self.broker["password"],
240
+ wait=True,
241
+ heartbeats=(30000, 30000),
242
+ headers={"client-id": self.internal_id, "heart-beat": "30000,30000"},
243
+ )
244
+ return conn
245
+ except Exception as error:
246
+ self.logger.error(
247
+ "Failed to connect to message broker (will re-resolve brokers): %s" % str(error)
248
+ )
249
+
250
+ self.disconnect(self.conns)
251
+
252
+ try:
253
+ self.conns = self.connect_to_messaging_brokers(sender=True)
254
+ conn = random.sample(self.conns, 1)[0]
255
+ if not conn.is_connected():
256
+ conn.connect(
257
+ self.broker["username"],
258
+ self.broker["password"],
259
+ wait=True,
260
+ heartbeats=(30000, 30000),
261
+ headers={"client-id": self.internal_id, "heart-beat": "30000,30000"},
262
+ )
263
+ return conn
264
+ except Exception as error:
265
+ self.logger.error(
266
+ "Failed to connect to message broker (will re-resolve brokers): %s" % str(error)
267
+ )
268
+
269
+ self.fail()
270
+ return None
271
+
272
+ def monitor(self):
273
+ try:
274
+ if (
275
+ not self.conns
276
+ or not self.start_at
277
+ or self.has_connection_failures
278
+ or self.start_at + self.lifetime < time.time()
279
+ ):
280
+ self.conns = self.connect_to_messaging_brokers(sender=True)
281
+ self.start_at = time.time()
282
+ except Exception as error:
283
+ self.logger.error(
284
+ "Setup connections throws an exception: %s, %s"
285
+ % (error, traceback.format_exc())
286
+ )
287
+
288
+
289
+ class Publisher(BaseActiveMQ):
290
+ def __init__(
291
+ self,
292
+ name="Publisher",
293
+ instance=None,
294
+ logger=None,
295
+ broker=None,
296
+ lifetime=3600,
297
+ broadcast=False,
298
+ **kwargs,
299
+ ):
300
+ super(Publisher, self).__init__(
301
+ name=name, instance=instance, logger=logger, broker=broker, lifetime=lifetime, **kwargs
302
+ )
303
+ self.broadcast = broadcast
304
+
305
+ def publish(self, msg, headers=None):
306
+ """
307
+ Publish a message to the broker.
308
+
309
+ :param msg: Message dictionary to publish (should contain 'msg_type' and 'run_id')
310
+ :param headers: Optional headers dictionary (overrides defaults)
311
+ """
312
+ namespace = getattr(self, "namespace", None)
313
+ msg_type = msg.get("msg_type", "unknown")
314
+ run_id = msg.get("run_id", "unknown")
315
+
316
+ self.logger.debug(f"Publishing message: msg_type={msg_type}, run_id={run_id}")
317
+
318
+ conn = self.get_connection()
319
+ if not conn:
320
+ self.logger.error(
321
+ f"No connection available to send message: msg_type={msg_type}, run_id={run_id}"
322
+ )
323
+ return
324
+
325
+ send_headers = {
326
+ "persistent": "true",
327
+ "ttl": self.timetolive,
328
+ "vo": "eic",
329
+ "msg_type": str(msg_type).lower(),
330
+ "run_id": run_id,
331
+ "client-id": self.internal_id,
332
+ }
333
+ if namespace is not None:
334
+ send_headers["namespace"] = namespace
335
+
336
+ # Caller-provided headers take precedence
337
+ if headers:
338
+ send_headers.update(headers)
339
+
340
+ try:
341
+ conn.send(
342
+ body=json_dumps(msg),
343
+ destination=self.broker["destination"],
344
+ id=self.internal_id,
345
+ ack="auto",
346
+ headers=send_headers,
347
+ )
348
+ self.logger.debug(
349
+ f"Message published successfully: msg_type={msg_type}, run_id={run_id}, destination={self.broker['destination']}"
350
+ )
351
+ except Exception as ex:
352
+ self.logger.error(
353
+ f"Failed to publish message: msg_type={msg_type}, run_id={run_id}, destination={self.broker['destination']}, error={ex}",
354
+ exc_info=True,
355
+ )
356
+
357
+
358
+ class Subscriber(BaseActiveMQ):
359
+ def __init__(
360
+ self,
361
+ name="Subscriber",
362
+ namespace=None,
363
+ logger=None,
364
+ broker=None,
365
+ lifetime=1800,
366
+ handler=None,
367
+ handler_kwargs=None,
368
+ selector=None,
369
+ ack="auto",
370
+ **kwargs,
371
+ ):
372
+ super(Subscriber, self).__init__(
373
+ name=name, namespace=namespace, logger=logger, broker=broker, lifetime=lifetime, **kwargs
374
+ )
375
+ self.listener = None
376
+ self.handler = handler
377
+ self.handler_kwargs = handler_kwargs if handler_kwargs else {}
378
+ self.selector = selector
379
+ self.ack = ack
380
+ self.last_message_at = time.time()
381
+ self.idle_seconds = int(kwargs.get("idle_seconds", 5))
382
+ self.is_processing_message = False
383
+
384
+ def get_listener(self, broker, conn):
385
+ if self.listener is None:
386
+ self.listener = MessagingListener(
387
+ broker,
388
+ namespace=self.namespace,
389
+ handler=self.handler,
390
+ handler_kwargs=self.handler_kwargs,
391
+ conn=conn,
392
+ logger=self.logger,
393
+ subscriber=self,
394
+ )
395
+ else:
396
+ # Update conn reference so ack/nack uses the live connection
397
+ self.listener.conn = conn
398
+ return self.listener
399
+
400
+ def subscribe_conn(self, conn):
401
+ try:
402
+ broker_info = conn.transport._Transport__host_and_ports[0][0]
403
+ except Exception:
404
+ broker_info = str(conn)
405
+ self.logger.info(f"connecting to: {broker_info}")
406
+ conn.set_listener("message-receiver", self.get_listener(broker_info, conn=conn))
407
+ conn.connect(
408
+ self.broker["username"],
409
+ self.broker["password"],
410
+ wait=True,
411
+ heartbeats=(30000, 30000),
412
+ headers={"client-id": self.internal_id, "heart-beat": "30000,30000"},
413
+ )
414
+
415
+ if self.namespace is not None:
416
+ if self.selector:
417
+ selector = f"namespace='{self.namespace}' AND ({self.selector})"
418
+ else:
419
+ selector = f"namespace='{self.namespace}'"
420
+ else:
421
+ selector = self.selector or None
422
+
423
+ self.last_message_at = time.time()
424
+
425
+ sub_headers = {}
426
+ if selector:
427
+ sub_headers["selector"] = selector
428
+
429
+ conn.subscribe(
430
+ destination=self.broker["destination"],
431
+ id=f"{self.internal_id}",
432
+ ack=self.ack,
433
+ headers=sub_headers,
434
+ )
435
+ self.logger.info(
436
+ f"Subscribed to {self.broker['destination']} with selector: {selector} on broker {broker_info}, ack mode: {self.ack}, headers: {sub_headers}"
437
+ )
438
+
439
+ def subscribe(self):
440
+ if not self.conns:
441
+ self.conns = self.connect_to_messaging_brokers()
442
+
443
+ for conn in self.conns:
444
+ if not conn.is_connected():
445
+ self.subscribe_conn(conn)
446
+
447
+ def is_idle(self, idle_seconds=None):
448
+ """Return True if no message has been received for at least idle_seconds."""
449
+ if self.is_processing_message:
450
+ return False
451
+ if idle_seconds is None:
452
+ idle_seconds = self.idle_seconds
453
+ return (time.time() - getattr(self, "last_message_at", 0)) > float(idle_seconds)
454
+
455
+ def wait_for_idle(self, timeout=None, poll_interval=0.5):
456
+ """Block until the subscriber is idle or timeout (seconds) elapses.
457
+
458
+ Returns True if idle was reached, False if timeout occurred.
459
+ """
460
+ start = time.time()
461
+ while True:
462
+ if self.is_idle():
463
+ return True
464
+ if timeout is not None and (time.time() - start) >= timeout:
465
+ return False
466
+ time.sleep(poll_interval)
467
+
468
+ def monitor(self):
469
+ try:
470
+ if (
471
+ not self.conns
472
+ or not self.start_at
473
+ or self.has_connection_failures
474
+ or self.start_at + self.lifetime < time.time()
475
+ ):
476
+ self.subscribe()
477
+ self.start_at = time.time()
478
+ except Exception as error:
479
+ self.logger.error(
480
+ "Setup connections throws an exception: %s, %s"
481
+ % (error, traceback.format_exc())
482
+ )
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # You may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ # http://www.apache.org/licenses/LICENSE-2.0OA
7
+ #
8
+ # Authors:
9
+ # - Wen Guan, <wen.guan@cern.ch>, 2025