lsst-ctrl-bps 29.2025.3700__tar.gz → 29.2025.3900__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.3700/python/lsst_ctrl_bps.egg-info → lsst_ctrl_bps-29.2025.3900}/PKG-INFO +1 -1
  2. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/bps_reports.py +131 -43
  3. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/report.py +38 -19
  4. lsst_ctrl_bps-29.2025.3900/python/lsst/ctrl/bps/version.py +2 -0
  5. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900/python/lsst_ctrl_bps.egg-info}/PKG-INFO +1 -1
  6. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst_ctrl_bps.egg-info/SOURCES.txt +1 -0
  7. lsst_ctrl_bps-29.2025.3700/tests/test_report.py → lsst_ctrl_bps-29.2025.3900/tests/test_bps_reports.py +118 -39
  8. lsst_ctrl_bps-29.2025.3900/tests/test_report.py +72 -0
  9. lsst_ctrl_bps-29.2025.3700/python/lsst/ctrl/bps/version.py +0 -2
  10. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/COPYRIGHT +0 -0
  11. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/LICENSE +0 -0
  12. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/MANIFEST.in +0 -0
  13. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/README.md +0 -0
  14. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/bsd_license.txt +0 -0
  15. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/doc/lsst.ctrl.bps/CHANGES.rst +0 -0
  16. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/doc/lsst.ctrl.bps/index.rst +0 -0
  17. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/doc/lsst.ctrl.bps/quickstart.rst +0 -0
  18. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/gpl-v3.0.txt +0 -0
  19. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/pyproject.toml +0 -0
  20. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/__init__.py +0 -0
  21. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/__init__.py +0 -0
  22. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/__init__.py +0 -0
  23. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/_exceptions.py +0 -0
  24. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/bps_config.py +0 -0
  25. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/bps_draw.py +0 -0
  26. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/bps_utils.py +0 -0
  27. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cancel.py +0 -0
  28. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/__init__.py +0 -0
  29. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/bps.py +0 -0
  30. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/cmd/__init__.py +0 -0
  31. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/cmd/commands.py +0 -0
  32. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/opt/__init__.py +0 -0
  33. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/opt/arguments.py +0 -0
  34. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/opt/option_groups.py +0 -0
  35. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/cli/opt/options.py +0 -0
  36. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/clustered_quantum_graph.py +0 -0
  37. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/constants.py +0 -0
  38. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/construct.py +0 -0
  39. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/drivers.py +0 -0
  40. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/etc/bps_defaults.yaml +0 -0
  41. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/generic_workflow.py +0 -0
  42. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/initialize.py +0 -0
  43. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/ping.py +0 -0
  44. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/pre_transform.py +0 -0
  45. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/prepare.py +0 -0
  46. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/quantum_clustering_funcs.py +0 -0
  47. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/restart.py +0 -0
  48. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/status.py +0 -0
  49. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/submit.py +0 -0
  50. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/tests/config_test_utils.py +0 -0
  51. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/tests/gw_test_utils.py +0 -0
  52. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/transform.py +0 -0
  53. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst/ctrl/bps/wms_service.py +0 -0
  54. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst_ctrl_bps.egg-info/dependency_links.txt +0 -0
  55. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst_ctrl_bps.egg-info/entry_points.txt +0 -0
  56. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst_ctrl_bps.egg-info/requires.txt +0 -0
  57. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst_ctrl_bps.egg-info/top_level.txt +0 -0
  58. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/python/lsst_ctrl_bps.egg-info/zip-safe +0 -0
  59. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/setup.cfg +0 -0
  60. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_bps_utils.py +0 -0
  61. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_bpsconfig.py +0 -0
  62. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_cli_commands.py +0 -0
  63. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_clustered_quantum_graph.py +0 -0
  64. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_construct.py +0 -0
  65. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_drivers.py +0 -0
  66. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_generic_workflow.py +0 -0
  67. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_initialize.py +0 -0
  68. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_ping.py +0 -0
  69. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_pre_transform.py +0 -0
  70. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_quantum_clustering_funcs.py +0 -0
  71. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_status.py +0 -0
  72. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/tests/test_transform.py +0 -0
  73. {lsst_ctrl_bps-29.2025.3700 → lsst_ctrl_bps-29.2025.3900}/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.3700
3
+ Version: 29.2025.3900
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
@@ -27,7 +27,14 @@
27
27
 
28
28
  """Classes and functions used in reporting run status."""
29
29
 
30
- __all__ = ["BaseRunReport", "DetailedRunReport", "ExitCodesReport", "SummaryRunReport", "compile_job_summary"]
30
+ __all__ = [
31
+ "BaseRunReport",
32
+ "DetailedRunReport",
33
+ "ExitCodesReport",
34
+ "SummaryRunReport",
35
+ "compile_code_summary",
36
+ "compile_job_summary",
37
+ ]
31
38
 
32
39
  import abc
33
40
  import logging
@@ -55,7 +62,7 @@ class BaseRunReport(abc.ABC):
55
62
 
56
63
  def __eq__(self, other):
57
64
  if isinstance(other, BaseRunReport):
58
- return all(self._table == other._table)
65
+ return self._table.pformat() == other._table.pformat()
59
66
  return False
60
67
 
61
68
  def __len__(self):
@@ -195,7 +202,7 @@ class DetailedRunReport(BaseRunReport):
195
202
  job_summary = run_report.job_summary
196
203
  if job_summary is None:
197
204
  id_ = run_report.global_wms_id if use_global_id else run_report.wms_id
198
- self._msg = f"WARNING: Job summary for run '{id_}' not available, report maybe incomplete."
205
+ self._msg = f"WARNING: Job summary for run '{id_}' not available, report may be incomplete."
199
206
  return
200
207
 
201
208
  if by_label_expected:
@@ -231,44 +238,60 @@ class ExitCodesReport(BaseRunReport):
231
238
  error handling from the wms service.
232
239
  """
233
240
 
234
- def add(self, run_report, use_global_id=False):
241
+ def add(self, run_report: WmsRunReport, use_global_id: bool = False) -> None:
235
242
  # Docstring inherited from the base class.
236
243
 
237
- # Use label ordering from the run summary as it should reflect
238
- # the ordering of the pipetasks in the pipeline.
244
+ exit_code_summary = run_report.exit_code_summary
245
+ if not exit_code_summary:
246
+ id_ = run_report.global_wms_id if use_global_id else run_report.wms_id
247
+ self._msg = f"WARNING: Exit code summary for run '{id_}' not available, report may be incomplete."
248
+ return
249
+
250
+ warnings = []
251
+
252
+ # If available, use label ordering from the run summary as it should
253
+ # reflect the ordering of the pipetasks in the pipeline.
239
254
  labels = []
240
255
  if run_report.run_summary:
241
256
  for part in run_report.run_summary.split(";"):
242
257
  label, _ = part.split(":")
243
258
  labels.append(label)
244
- else:
245
- id_ = run_report.global_wms_id if use_global_id else run_report.wms_id
246
- self._msg = f"WARNING: Job summary for run '{id_}' not available, report maybe incomplete."
247
- return
259
+ if not labels:
260
+ labels = sorted(exit_code_summary)
261
+ warnings.append("WARNING: Could not determine order of pipeline, instead sorted alphabetically.")
248
262
 
249
263
  # Payload (e.g. pipetask) error codes:
250
264
  # * 1: general failure,
251
265
  # * 2: command line error (e.g. unknown command and/or option).
252
266
  pyld_error_codes = {1, 2}
253
267
 
254
- exit_code_summary = run_report.exit_code_summary
268
+ missing_labels = set()
255
269
  for label in labels:
256
- exit_codes = exit_code_summary[label]
257
-
258
- pyld_errors = [code for code in exit_codes if code in pyld_error_codes]
259
- pyld_error_count = len(pyld_errors)
260
- pyld_error_summary = (
261
- ", ".join(sorted(str(code) for code in set(pyld_errors))) if pyld_errors else "None"
262
- )
263
-
264
- infra_errors = [code for code in exit_codes if code not in pyld_error_codes]
265
- infra_error_count = len(infra_errors)
266
- infra_error_summary = (
267
- ", ".join(sorted(str(code) for code in set(infra_errors))) if infra_errors else "None"
270
+ try:
271
+ exit_codes = exit_code_summary[label]
272
+ except KeyError:
273
+ missing_labels.add(label)
274
+ else:
275
+ pyld_errors = [code for code in exit_codes if code in pyld_error_codes]
276
+ pyld_error_count = len(pyld_errors)
277
+ pyld_error_summary = (
278
+ ", ".join(sorted(str(code) for code in set(pyld_errors))) if pyld_errors else "None"
279
+ )
280
+
281
+ infra_errors = [code for code in exit_codes if code not in pyld_error_codes]
282
+ infra_error_count = len(infra_errors)
283
+ infra_error_summary = (
284
+ ", ".join(sorted(str(code) for code in set(infra_errors))) if infra_errors else "None"
285
+ )
286
+
287
+ run = [label, pyld_error_count, pyld_error_summary, infra_error_count, infra_error_summary]
288
+ self._table.add_row(run)
289
+ if missing_labels:
290
+ warnings.append(
291
+ f"WARNING: Exit code summary was not available for job labels: {', '.join(missing_labels)}"
268
292
  )
269
-
270
- run = [label, pyld_error_count, pyld_error_summary, infra_error_count, infra_error_summary]
271
- self._table.add_row(run)
293
+ if warnings:
294
+ self._msg = "\n".join(warnings)
272
295
 
273
296
  def __str__(self):
274
297
  alignments = ["<"] + [">"] * (len(self._table.colnames) - 1)
@@ -276,7 +299,7 @@ class ExitCodesReport(BaseRunReport):
276
299
  return str("\n".join(lines))
277
300
 
278
301
 
279
- def compile_job_summary(report: WmsRunReport) -> None:
302
+ def compile_job_summary(report: WmsRunReport) -> list[str]:
280
303
  """Add a job summary to the run report if necessary.
281
304
 
282
305
  If the job summary is not provided, the function will attempt to compile
@@ -289,24 +312,89 @@ def compile_job_summary(report: WmsRunReport) -> None:
289
312
  report : `lsst.ctrl.bps.WmsRunReport`
290
313
  Information about a single run.
291
314
 
292
- Raises
293
- ------
294
- ValueError
295
- Raised if the job summary *and* information about individual jobs
296
- is not available.
315
+ Returns
316
+ -------
317
+ warnings : `list` [`str`]
318
+ List of messages describing any non-critical issues encountered during
319
+ processing. Empty if none.
297
320
  """
321
+ warnings: list[str] = []
322
+
323
+ # If the job summary already exists, exit early.
298
324
  if report.job_summary:
299
- return
300
- if not report.jobs:
301
- raise ValueError("job summary cannot be compiled: information about individual jobs not available.")
302
- job_summary = {}
303
- by_label = group_jobs_by_label(report.jobs)
304
- for label, job_group in by_label.items():
305
- by_label_state = group_jobs_by_state(job_group)
306
- _LOG.debug("by_label_state = %s", by_label_state)
307
- counts = {state: len(jobs) for state, jobs in by_label_state.items()}
308
- job_summary[label] = counts
309
- report.job_summary = job_summary
325
+ return warnings
326
+
327
+ if report.jobs:
328
+ job_summary = {}
329
+ by_label = group_jobs_by_label(report.jobs)
330
+ for label, job_group in by_label.items():
331
+ by_label_state = group_jobs_by_state(job_group)
332
+ _LOG.debug("by_label_state = %s", by_label_state)
333
+ counts = {state: len(jobs) for state, jobs in by_label_state.items()}
334
+ job_summary[label] = counts
335
+ report.job_summary = job_summary
336
+ else:
337
+ warnings.append("information about individual jobs not available")
338
+
339
+ return warnings
340
+
341
+
342
+ def compile_code_summary(report: WmsRunReport) -> list[str]:
343
+ """Add missing entries to the exit code summary if necessary.
344
+
345
+ A WMS plugin may exclude job labels for which there are no failures from
346
+ the exit code summary. The function will attempt to use the job summary,
347
+ if available, to add missing entries for these labels.
348
+
349
+ Parameters
350
+ ----------
351
+ report : `lsst.ctrl.bps.WmsRunReport`
352
+ Information about a single run.
353
+
354
+ Returns
355
+ -------
356
+ warnings : `list` [`str`]
357
+ List of messages describing any non-critical issues encountered during
358
+ processing. Empty if none.
359
+ """
360
+ warnings: list[str] = []
361
+
362
+ # If the job summary is not available, exit early.
363
+ if not report.job_summary:
364
+ return warnings
365
+
366
+ # A shallow copy is enough here because we won't be modifying the existing
367
+ # entries, only adding new ones if necessary.
368
+ exit_code_summary = dict(report.exit_code_summary) if report.exit_code_summary else {}
369
+
370
+ # Use the job summary to add the entries for labels with no failures
371
+ # *without* modifying already existing entries.
372
+ failure_summary = {label: states[WmsStates.FAILED] for label, states in report.job_summary.items()}
373
+ for label, count in failure_summary.items():
374
+ if count == 0:
375
+ exit_code_summary.setdefault(label, [])
376
+
377
+ # Check if there are any discrepancies between the data in the exit code
378
+ # summary and the job summary.
379
+ code_summary_labels = set(exit_code_summary)
380
+ failure_summary_labels = set(failure_summary)
381
+ mismatches = {
382
+ label
383
+ for label in failure_summary_labels & code_summary_labels
384
+ if len(exit_code_summary[label]) != failure_summary[label]
385
+ }
386
+ if mismatches:
387
+ warnings.append(
388
+ f"number of exit codes differs from number of failures for job labels: {', '.join(mismatches)}"
389
+ )
390
+ missing = failure_summary_labels - code_summary_labels
391
+ if missing:
392
+ warnings.append(f"exit codes not available for job labels: {', '.join(missing)}")
393
+
394
+ if exit_code_summary:
395
+ report.exit_code_summary = exit_code_summary
396
+
397
+ return warnings
310
398
 
311
399
 
312
400
  def group_jobs_by_state(jobs):
@@ -38,12 +38,18 @@ import sys
38
38
  from collections.abc import Callable, Sequence
39
39
  from typing import TextIO
40
40
 
41
- from lsst.utils import doImport
42
-
43
- from .bps_reports import DetailedRunReport, ExitCodesReport, SummaryRunReport, compile_job_summary
44
- from .wms_service import WmsRunReport, WmsStates
45
-
46
- BPS_POSTPROCESSORS = (compile_job_summary,)
41
+ from lsst.utils import doImportType
42
+
43
+ from .bps_reports import (
44
+ DetailedRunReport,
45
+ ExitCodesReport,
46
+ SummaryRunReport,
47
+ compile_code_summary,
48
+ compile_job_summary,
49
+ )
50
+ from .wms_service import BaseWmsService, WmsRunReport, WmsStates
51
+
52
+ BPS_POSTPROCESSORS = (compile_job_summary, compile_code_summary)
47
53
  """Postprocessors for massaging run reports
48
54
  (`tuple` [`Callable` [[`WmsRunReport`], None]).
49
55
  """
@@ -111,7 +117,7 @@ def display_report(
111
117
 
112
118
  run_report.add(run, use_global_id=is_global)
113
119
  if run_report.message:
114
- print(run_report.message, file=file)
120
+ messages.append(run_report.message)
115
121
 
116
122
  print(run_brief, file=file)
117
123
  print("\n", file=file)
@@ -132,6 +138,8 @@ def display_report(
132
138
  ]
133
139
  run_exits_report = ExitCodesReport(fields)
134
140
  run_exits_report.add(run, use_global_id=is_global)
141
+ if run_exits_report.message:
142
+ messages.append(run_exits_report.message)
135
143
  print("\n", file=file)
136
144
  print(run_exits_report, file=file)
137
145
  run_exits_report.clear()
@@ -145,12 +153,13 @@ def display_report(
145
153
  print(run_brief, file=file)
146
154
 
147
155
  if messages:
148
- print("\n".join(messages), file=file)
156
+ uniques = list(dict.fromkeys(messages))
157
+ print("\n".join(uniques), file=file)
149
158
  print("\n", file=file)
150
159
 
151
160
 
152
161
  def retrieve_report(
153
- wms_service: str,
162
+ wms_service_fqn: str,
154
163
  *,
155
164
  run_id: str | None = None,
156
165
  user: str | None = None,
@@ -163,7 +172,7 @@ def retrieve_report(
163
172
 
164
173
  Parameters
165
174
  ----------
166
- wms_service : `str`
175
+ wms_service_fqn : `str`
167
176
  Name of the WMS service class.
168
177
  run_id : `str`, optional
169
178
  A run id the report will be restricted to.
@@ -196,11 +205,21 @@ def retrieve_report(
196
205
  messages : `list` [`str`]
197
206
  Errors that happened during report retrieval and/or processing.
198
207
  Empty if no issues were encountered.
208
+
209
+ Raises
210
+ ------
211
+ TypeError
212
+ Raised if the WMS service class is not a subclass of BaseWmsService.
199
213
  """
200
- messages = []
214
+ messages: list[str] = []
201
215
 
202
- wms_service_class = doImport(wms_service)
216
+ wms_service_class = doImportType(wms_service_fqn)
217
+ if not issubclass(wms_service_class, BaseWmsService):
218
+ raise TypeError(
219
+ f"Invalid WMS service class '{wms_service_fqn}'; must be a subclass of BaseWmsService"
220
+ )
203
221
  wms_service = wms_service_class({})
222
+
204
223
  reports, message = wms_service.report(
205
224
  wms_workflow_id=run_id, user=user, hist=hist, pass_thru=pass_thru, is_global=is_global
206
225
  )
@@ -210,12 +229,12 @@ def retrieve_report(
210
229
  if postprocessors:
211
230
  for report in reports:
212
231
  for postprocessor in postprocessors:
213
- try:
214
- postprocessor(report)
215
- except Exception as exc:
216
- messages.append(
217
- f"Postprocessing error for '{report.wms_id}': {str(exc)} "
218
- f"(origin: {postprocessor.__name__})"
219
- )
232
+ if warnings := postprocessor(report):
233
+ for warning in warnings:
234
+ messages.append(
235
+ f"WARNING: Report may be incomplete. "
236
+ f"There was an issue with report postprocessing for '{report.wms_id}': "
237
+ f"{warning} (origin: {postprocessor.__name__})"
238
+ )
220
239
 
221
240
  return reports, messages
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "29.2025.3900"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-ctrl-bps
3
- Version: 29.2025.3700
3
+ Version: 29.2025.3900
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
@@ -53,6 +53,7 @@ python/lsst_ctrl_bps.egg-info/entry_points.txt
53
53
  python/lsst_ctrl_bps.egg-info/requires.txt
54
54
  python/lsst_ctrl_bps.egg-info/top_level.txt
55
55
  python/lsst_ctrl_bps.egg-info/zip-safe
56
+ tests/test_bps_reports.py
56
57
  tests/test_bps_utils.py
57
58
  tests/test_bpsconfig.py
58
59
  tests/test_cli_commands.py
@@ -42,9 +42,9 @@ from lsst.ctrl.bps import (
42
42
  WmsJobReport,
43
43
  WmsRunReport,
44
44
  WmsStates,
45
+ compile_code_summary,
45
46
  compile_job_summary,
46
47
  )
47
- from lsst.ctrl.bps.report import retrieve_report
48
48
 
49
49
 
50
50
  class FakeRunReport(BaseRunReport):
@@ -335,23 +335,30 @@ class ExitCodesReportTestCase(unittest.TestCase):
335
335
 
336
336
  self.actual = ExitCodesReport(self.fields)
337
337
 
338
- def testAddWithJobSummary(self):
339
- """Test adding a run with a job summary."""
340
- self.run.jobs = None
338
+ def testAddSuccess(self):
339
+ """Test adding a run successfully."""
341
340
  self.actual.add(self.run)
341
+
342
+ self.assertEqual(len(self.actual), 2)
342
343
  self.assertEqual(self.actual, self.expected)
343
344
 
344
- def testAddWithJobs(self):
345
- """Test adding a run with a job info, but not job summary."""
346
- self.run.job_summary = None
345
+ def testAddFailure(self):
346
+ """Test adding a run unsuccessfully."""
347
+ self.run.job_summary = {}
348
+ self.run.exit_code_summary = {}
349
+
347
350
  self.actual.add(self.run)
348
- self.assertEqual(self.actual, self.expected)
351
+
352
+ self.assertEqual(len(self.actual), 0)
353
+ self.assertRegex(self.actual.message, r"^WARNING.*report.*incomplete")
349
354
 
350
355
  def testAddWithoutRunSummary(self):
351
356
  """Test adding a run without a run summary."""
352
357
  self.run.run_summary = None
358
+
353
359
  self.actual.add(self.run)
354
- self.assertRegex(self.actual.message, r"^WARNING.*incomplete")
360
+
361
+ self.assertRegex(self.actual.message, r"^WARNING.*sorted alphabetically")
355
362
 
356
363
 
357
364
  class CompileJobSummaryTestCase(unittest.TestCase):
@@ -364,57 +371,129 @@ class CompileJobSummaryTestCase(unittest.TestCase):
364
371
  pass
365
372
 
366
373
  def testSummaryExists(self):
367
- """Test if existing report is not altered."""
374
+ """Test if the existing report is not altered."""
368
375
  # Create a report with a "fake" job summary, i.e., a summary which
369
376
  # differs from the one which would be compiled from the information
370
377
  # about individual jobs.
371
- expected = dataclasses.replace(self.report)
372
- expected.job_summary = {"foo": {state: 1 if state == WmsStates.FAILED else 0 for state in WmsStates}}
373
-
378
+ expected = dataclasses.replace(
379
+ self.report,
380
+ job_summary={"foo": {state: 1 if state == WmsStates.FAILED else 0 for state in WmsStates}},
381
+ )
374
382
  result = dataclasses.replace(expected)
375
- compile_job_summary(result)
383
+
384
+ messages = compile_job_summary(result)
385
+
376
386
  self.assertEqual(result, expected)
387
+ self.assertFalse(messages)
377
388
 
378
389
  def testSummaryMissing(self):
379
- """Test if the summary will be compiled if necessary."""
380
- result = dataclasses.replace(self.report)
381
- result.job_summary = None
382
- compile_job_summary(result)
390
+ """Test if the summary is compiled if necessary."""
391
+ result = dataclasses.replace(self.report, job_summary=None)
392
+
393
+ messages = compile_job_summary(result)
394
+
383
395
  self.assertEqual(result, self.report)
396
+ self.assertFalse(messages)
384
397
 
385
398
  def testCompilationError(self):
386
- """Test if error is raised if the summary cannot be compiled."""
387
- self.report.job_summary = None
388
- self.report.jobs = None
389
- with self.assertRaises(ValueError):
390
- compile_job_summary(self.report)
399
+ """Test if a warning is issued if the summary cannot be compiled."""
400
+ result = dataclasses.replace(self.report, jobs=None, job_summary=None)
401
+
402
+ messages = compile_job_summary(result)
403
+
404
+ self.assertEqual(len(messages), 1)
405
+ self.assertRegex(messages[0], r"information.*not available")
391
406
 
392
407
 
393
- class RetrieveReportTestCase(unittest.TestCase):
394
- """Test report retrieval."""
408
+ class CompileCodeSummaryTestCase(unittest.TestCase):
409
+ """Test compiling a code summary."""
395
410
 
396
411
  def setUp(self):
397
- self.report = dataclasses.replace(TEST_REPORT)
412
+ self.report = WmsRunReport(
413
+ wms_id="1.0",
414
+ global_wms_id="foo#1.0",
415
+ path="/path/to/run",
416
+ label="label",
417
+ run="run",
418
+ project="dev",
419
+ campaign="testing",
420
+ payload="test",
421
+ operator="tester",
422
+ run_summary="foo:1;bar:1;baz:1",
423
+ state=WmsStates.RUNNING,
424
+ jobs=[
425
+ WmsJobReport(wms_id="1.0", name="", label="foo", state=WmsStates.SUCCEEDED),
426
+ WmsJobReport(wms_id="2.0", name="", label="bar", state=WmsStates.FAILED),
427
+ WmsJobReport(wms_id="3.0", name="", label="baz", state=WmsStates.RUNNING),
428
+ ],
429
+ total_number_jobs=3,
430
+ job_state_counts={
431
+ state: 1 if state in {WmsStates.SUCCEEDED, WmsStates.FAILED, WmsStates.RUNNING} else 0
432
+ for state in WmsStates
433
+ },
434
+ job_summary={
435
+ "foo": {state: 1 if state == WmsStates.SUCCEEDED else 0 for state in WmsStates},
436
+ "bar": {state: 1 if state == WmsStates.FAILED else 0 for state in WmsStates},
437
+ "baz": {state: 1 if state == WmsStates.RUNNING else 0 for state in WmsStates},
438
+ },
439
+ exit_code_summary={"foo": [], "bar": [1], "baz": []},
440
+ )
398
441
 
399
442
  def tearDown(self):
400
443
  pass
401
444
 
402
- def testRetrievalPostprocessingSuccessful(self):
403
- """Test retrieving a report successfully."""
404
- reports, messages = retrieve_report(
405
- "wms_test_utils.WmsServiceSuccess", run_id="1.0", postprocessors=(compile_job_summary,)
406
- )
407
- self.assertEqual(len(reports), 1)
408
- self.assertEqual(reports[0], self.report)
445
+ def testAddingMissingEntries(self):
446
+ """Test if the missing entries are added to the summary."""
447
+ result = dataclasses.replace(self.report, exit_code_summary={"bar": [1]})
448
+
449
+ messages = compile_code_summary(result)
450
+
451
+ self.assertEqual(result, self.report)
409
452
  self.assertFalse(messages)
410
453
 
411
- def testRetrievalPostprocessingFailed(self):
412
- """Test failing to retrieve a report."""
413
- report, messages = retrieve_report(
414
- "wms_test_utils.WmsServiceFailure", postprocessors=(compile_job_summary,)
415
- )
454
+ def testDetectingMismatches(self):
455
+ """Test if a mismatch between exit codes and failures is reported."""
456
+ expected = dataclasses.replace(self.report, exit_code_summary={"foo": [1], "bar": [1], "baz": []})
457
+ result = dataclasses.replace(expected)
458
+
459
+ messages = compile_code_summary(result)
460
+
461
+ self.assertEqual(result, expected)
416
462
  self.assertEqual(len(messages), 1)
417
- self.assertRegex(messages[0], "Postprocessing error")
463
+ self.assertRegex(messages[0], r"exit codes.*differs.*failures.*labels: foo")
464
+
465
+ def testDetectingOmissions(self):
466
+ """Test if a failure not reflected in exit codes is reported."""
467
+ expected = dataclasses.replace(self.report, exit_code_summary={"foo": [], "baz": []})
468
+ result = dataclasses.replace(expected)
469
+
470
+ messages = compile_code_summary(result)
471
+
472
+ self.assertEqual(result, expected)
473
+ self.assertEqual(len(messages), 1)
474
+ self.assertRegex(messages[0], r"exit codes.*not available.*labels: bar")
475
+
476
+ def testDetectingDiscrepancies(self):
477
+ """Test if multiple discrepancies are reported."""
478
+ expected = dataclasses.replace(self.report, exit_code_summary={"foo": [], "baz": [1]})
479
+ result = dataclasses.replace(expected)
480
+
481
+ messages = compile_code_summary(result)
482
+
483
+ self.assertEqual(result, expected)
484
+ self.assertEqual(len(messages), 2)
485
+ self.assertRegex(messages[0], r"exit codes.*differs.*failures.*labels: baz")
486
+ self.assertRegex(messages[1], r"exit codes.*not available.*labels: bar")
487
+
488
+ def testHandlingNoJobSummary(self):
489
+ """Test if the existing report is not altered if no job summary."""
490
+ expected = dataclasses.replace(self.report, job_summary=None)
491
+ result = dataclasses.replace(expected)
492
+
493
+ messages = compile_code_summary(result)
494
+
495
+ self.assertEqual(result, expected)
496
+ self.assertFalse(messages)
418
497
 
419
498
 
420
499
  if __name__ == "__main__":
@@ -0,0 +1,72 @@
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
+ """Tests for reporting mechanism."""
29
+
30
+ import dataclasses
31
+ import unittest
32
+
33
+ from wms_test_utils import TEST_REPORT
34
+
35
+ from lsst.ctrl.bps import compile_job_summary
36
+ from lsst.ctrl.bps.report import retrieve_report
37
+
38
+
39
+ class RetrieveReportTestCase(unittest.TestCase):
40
+ """Test report retrieval."""
41
+
42
+ def setUp(self):
43
+ self.report = dataclasses.replace(TEST_REPORT)
44
+
45
+ def tearDown(self):
46
+ pass
47
+
48
+ def testRetrievalPostprocessingSuccessful(self):
49
+ """Test retrieving a report successfully."""
50
+ reports, messages = retrieve_report(
51
+ "wms_test_utils.WmsServiceSuccess", run_id="1.0", postprocessors=(compile_job_summary,)
52
+ )
53
+ self.assertEqual(len(reports), 1)
54
+ self.assertEqual(reports[0], self.report)
55
+ self.assertFalse(messages)
56
+
57
+ def testRetrievalPostprocessingFailed(self):
58
+ """Test failing to retrieve a report."""
59
+ report, messages = retrieve_report(
60
+ "wms_test_utils.WmsServiceFailure", postprocessors=(compile_job_summary,)
61
+ )
62
+ self.assertEqual(len(messages), 1)
63
+ self.assertRegex(messages[0], "issue.*postprocessing")
64
+
65
+ def testRetrievalInvalidClass(self):
66
+ """Test retrieving a report with an invalid class."""
67
+ with self.assertRaises(TypeError):
68
+ retrieve_report("wms_test_utils.WmsServiceInvalid", run_id="1.0", postprocessors=None)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ unittest.main()
@@ -1,2 +0,0 @@
1
- __all__ = ["__version__"]
2
- __version__ = "29.2025.3700"