modacor 1.0.0__py3-none-any.whl

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 (120) hide show
  1. modacor/__init__.py +30 -0
  2. modacor/dataclasses/__init__.py +0 -0
  3. modacor/dataclasses/basedata.py +973 -0
  4. modacor/dataclasses/databundle.py +23 -0
  5. modacor/dataclasses/helpers.py +45 -0
  6. modacor/dataclasses/messagehandler.py +75 -0
  7. modacor/dataclasses/process_step.py +233 -0
  8. modacor/dataclasses/process_step_describer.py +146 -0
  9. modacor/dataclasses/processing_data.py +59 -0
  10. modacor/dataclasses/trace_event.py +118 -0
  11. modacor/dataclasses/uncertainty_tools.py +132 -0
  12. modacor/dataclasses/validators.py +84 -0
  13. modacor/debug/pipeline_tracer.py +548 -0
  14. modacor/io/__init__.py +33 -0
  15. modacor/io/csv/__init__.py +0 -0
  16. modacor/io/csv/csv_sink.py +114 -0
  17. modacor/io/csv/csv_source.py +210 -0
  18. modacor/io/hdf/__init__.py +27 -0
  19. modacor/io/hdf/hdf_source.py +120 -0
  20. modacor/io/io_sink.py +41 -0
  21. modacor/io/io_sinks.py +61 -0
  22. modacor/io/io_source.py +164 -0
  23. modacor/io/io_sources.py +208 -0
  24. modacor/io/processing_path.py +113 -0
  25. modacor/io/tiled/__init__.py +16 -0
  26. modacor/io/tiled/tiled_source.py +403 -0
  27. modacor/io/yaml/__init__.py +27 -0
  28. modacor/io/yaml/yaml_source.py +116 -0
  29. modacor/modules/__init__.py +53 -0
  30. modacor/modules/base_modules/__init__.py +0 -0
  31. modacor/modules/base_modules/append_processing_data.py +329 -0
  32. modacor/modules/base_modules/append_sink.py +141 -0
  33. modacor/modules/base_modules/append_source.py +181 -0
  34. modacor/modules/base_modules/bitwise_or_masks.py +113 -0
  35. modacor/modules/base_modules/combine_uncertainties.py +120 -0
  36. modacor/modules/base_modules/combine_uncertainties_max.py +105 -0
  37. modacor/modules/base_modules/divide.py +82 -0
  38. modacor/modules/base_modules/find_scale_factor1d.py +373 -0
  39. modacor/modules/base_modules/multiply.py +77 -0
  40. modacor/modules/base_modules/multiply_databundles.py +73 -0
  41. modacor/modules/base_modules/poisson_uncertainties.py +69 -0
  42. modacor/modules/base_modules/reduce_dimensionality.py +252 -0
  43. modacor/modules/base_modules/sink_processing_data.py +80 -0
  44. modacor/modules/base_modules/subtract.py +80 -0
  45. modacor/modules/base_modules/subtract_databundles.py +67 -0
  46. modacor/modules/base_modules/units_label_update.py +66 -0
  47. modacor/modules/instrument_modules/__init__.py +0 -0
  48. modacor/modules/instrument_modules/readme.md +9 -0
  49. modacor/modules/technique_modules/__init__.py +0 -0
  50. modacor/modules/technique_modules/scattering/__init__.py +0 -0
  51. modacor/modules/technique_modules/scattering/geometry_helpers.py +114 -0
  52. modacor/modules/technique_modules/scattering/index_pixels.py +492 -0
  53. modacor/modules/technique_modules/scattering/indexed_averager.py +628 -0
  54. modacor/modules/technique_modules/scattering/pixel_coordinates_3d.py +417 -0
  55. modacor/modules/technique_modules/scattering/solid_angle_correction.py +63 -0
  56. modacor/modules/technique_modules/scattering/xs_geometry.py +571 -0
  57. modacor/modules/technique_modules/scattering/xs_geometry_from_pixel_coordinates.py +293 -0
  58. modacor/runner/__init__.py +0 -0
  59. modacor/runner/pipeline.py +749 -0
  60. modacor/runner/process_step_registry.py +224 -0
  61. modacor/tests/__init__.py +27 -0
  62. modacor/tests/dataclasses/test_basedata.py +519 -0
  63. modacor/tests/dataclasses/test_basedata_operations.py +439 -0
  64. modacor/tests/dataclasses/test_basedata_to_base_units.py +57 -0
  65. modacor/tests/dataclasses/test_process_step_describer.py +73 -0
  66. modacor/tests/dataclasses/test_processstep.py +282 -0
  67. modacor/tests/debug/test_tracing_integration.py +188 -0
  68. modacor/tests/integration/__init__.py +0 -0
  69. modacor/tests/integration/test_pipeline_run.py +238 -0
  70. modacor/tests/io/__init__.py +27 -0
  71. modacor/tests/io/csv/__init__.py +0 -0
  72. modacor/tests/io/csv/test_csv_source.py +156 -0
  73. modacor/tests/io/hdf/__init__.py +27 -0
  74. modacor/tests/io/hdf/test_hdf_source.py +92 -0
  75. modacor/tests/io/test_io_sources.py +119 -0
  76. modacor/tests/io/tiled/__init__.py +12 -0
  77. modacor/tests/io/tiled/test_tiled_source.py +120 -0
  78. modacor/tests/io/yaml/__init__.py +27 -0
  79. modacor/tests/io/yaml/static_data_example.yaml +26 -0
  80. modacor/tests/io/yaml/test_yaml_source.py +47 -0
  81. modacor/tests/modules/__init__.py +27 -0
  82. modacor/tests/modules/base_modules/__init__.py +27 -0
  83. modacor/tests/modules/base_modules/test_append_processing_data.py +219 -0
  84. modacor/tests/modules/base_modules/test_append_sink.py +76 -0
  85. modacor/tests/modules/base_modules/test_append_source.py +180 -0
  86. modacor/tests/modules/base_modules/test_bitwise_or_masks.py +264 -0
  87. modacor/tests/modules/base_modules/test_combine_uncertainties.py +105 -0
  88. modacor/tests/modules/base_modules/test_combine_uncertainties_max.py +109 -0
  89. modacor/tests/modules/base_modules/test_divide.py +140 -0
  90. modacor/tests/modules/base_modules/test_find_scale_factor1d.py +220 -0
  91. modacor/tests/modules/base_modules/test_multiply.py +113 -0
  92. modacor/tests/modules/base_modules/test_multiply_databundles.py +136 -0
  93. modacor/tests/modules/base_modules/test_poisson_uncertainties.py +61 -0
  94. modacor/tests/modules/base_modules/test_reduce_dimensionality.py +358 -0
  95. modacor/tests/modules/base_modules/test_sink_processing_data.py +119 -0
  96. modacor/tests/modules/base_modules/test_subtract.py +111 -0
  97. modacor/tests/modules/base_modules/test_subtract_databundles.py +136 -0
  98. modacor/tests/modules/base_modules/test_units_label_update.py +91 -0
  99. modacor/tests/modules/technique_modules/__init__.py +0 -0
  100. modacor/tests/modules/technique_modules/scattering/__init__.py +0 -0
  101. modacor/tests/modules/technique_modules/scattering/test_geometry_helpers.py +198 -0
  102. modacor/tests/modules/technique_modules/scattering/test_index_pixels.py +426 -0
  103. modacor/tests/modules/technique_modules/scattering/test_indexed_averaging.py +559 -0
  104. modacor/tests/modules/technique_modules/scattering/test_pixel_coordinates_3d.py +282 -0
  105. modacor/tests/modules/technique_modules/scattering/test_xs_geometry_from_pixel_coordinates.py +224 -0
  106. modacor/tests/modules/technique_modules/scattering/test_xsgeometry.py +635 -0
  107. modacor/tests/requirements.txt +12 -0
  108. modacor/tests/runner/test_pipeline.py +438 -0
  109. modacor/tests/runner/test_process_step_registry.py +65 -0
  110. modacor/tests/test_import.py +43 -0
  111. modacor/tests/test_modacor.py +17 -0
  112. modacor/tests/test_units.py +79 -0
  113. modacor/units.py +97 -0
  114. modacor-1.0.0.dist-info/METADATA +482 -0
  115. modacor-1.0.0.dist-info/RECORD +120 -0
  116. modacor-1.0.0.dist-info/WHEEL +5 -0
  117. modacor-1.0.0.dist-info/licenses/AUTHORS.md +11 -0
  118. modacor-1.0.0.dist-info/licenses/LICENSE +11 -0
  119. modacor-1.0.0.dist-info/licenses/LICENSE.txt +11 -0
  120. modacor-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,282 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # /usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+
5
+ from __future__ import annotations
6
+
7
+ __coding__ = "utf-8"
8
+ __authors__ = ["Malte Storm", "Jérome Kieffer", "Anja Hörmann", "Brian R. Pauw"] # add names to the list as appropriate
9
+ __copyright__ = "Copyright 2025, The MoDaCor team"
10
+ __date__ = "16/11/2025"
11
+ __status__ = "Development" # "Development", "Production"
12
+ # end of header and standard imports
13
+
14
+ from typing import Iterable
15
+
16
+ import numpy as np
17
+ import pytest
18
+
19
+ from modacor import ureg
20
+ from modacor.dataclasses.basedata import BaseData
21
+ from modacor.dataclasses.databundle import DataBundle
22
+ from modacor.dataclasses.process_step import ProcessStep
23
+ from modacor.dataclasses.processing_data import ProcessingData
24
+ from modacor.io import IoSources
25
+
26
+ TEST_IO_SOURCES = IoSources()
27
+
28
+ _TEST_KEYS = {
29
+ "test_str": {
30
+ "type": str,
31
+ "allow_iterable": False,
32
+ "allow_none": False,
33
+ "default": "",
34
+ },
35
+ "test_str_allow_list": {
36
+ "type": str,
37
+ "allow_iterable": True,
38
+ "allow_none": False,
39
+ "default": "",
40
+ },
41
+ "test_str_allow_none": {
42
+ "type": str,
43
+ "allow_iterable": False,
44
+ "allow_none": True,
45
+ "default": None,
46
+ },
47
+ "test_str_allow_list_none": {
48
+ "type": str,
49
+ "allow_iterable": True,
50
+ "allow_none": True,
51
+ "default": None,
52
+ },
53
+ "test_int": {
54
+ "type": int,
55
+ "allow_iterable": False,
56
+ "allow_none": False,
57
+ "default": 0,
58
+ },
59
+ "test_int_allow_list": {
60
+ "type": int,
61
+ "allow_iterable": True,
62
+ "allow_none": False,
63
+ "default": 0,
64
+ },
65
+ "test_int_allow_none": {
66
+ "type": int,
67
+ "allow_iterable": False,
68
+ "allow_none": True,
69
+ "default": None,
70
+ },
71
+ "test_int_allow_list_none": {
72
+ "type": int,
73
+ "allow_iterable": True,
74
+ "allow_none": True,
75
+ "default": None,
76
+ },
77
+ }
78
+
79
+ _TEST_VALUES = ["", "test", 1, 42, None]
80
+ _TEST_LISTS = [["", "2"], ["", 12], [12, 42], [12, None], ["b", None]]
81
+ _TEST_TUPLES = [("12", "b"), ("", 12), (12, 42), ("a", None), (12, None)]
82
+
83
+
84
+ class TESTProcessingStep(ProcessStep):
85
+ CONFIG_KEYS = {_k: _v for _k, _v in _TEST_KEYS.items()}
86
+
87
+ def calculate(self) -> dict[str, DataBundle]:
88
+ _data = self.processing_data.get("dummy_key", DataBundle())
89
+ _data["new_key"] = BaseData(signal=np.arange(100).reshape(10, 10), uncertainties={"sem": 0.0}, units=ureg.meter)
90
+ _data2 = self.processing_data.get("bundle2", DataBundle())
91
+ _data2["new_key"] = BaseData(signal=np.zeros(20), uncertainties={"sem": 0.0}, units=ureg.meter)
92
+ return {"dummy_key": _data, "bundle2": _data2}
93
+
94
+
95
+ @pytest.fixture
96
+ def class_with_config_keys(request):
97
+ _keys = request.param
98
+
99
+ class TestClass(TESTProcessingStep):
100
+ CONFIG_KEYS = {_key: _TEST_KEYS[_key] for _key in _keys}
101
+
102
+ return _keys, TestClass
103
+
104
+
105
+ @pytest.fixture
106
+ def processing_data():
107
+ data = ProcessingData()
108
+ data["bundle1"] = DataBundle()
109
+ data["bundle2"] = DataBundle()
110
+ data["bundle1"]["key1"] = BaseData(signal=np.arange(50), uncertainties={"sem": 0.0}, units=ureg.meter)
111
+ data["bundle2"]["key2"] = BaseData(signal=np.ones((10, 10)), uncertainties={"sem": 0.0}, units=ureg.meter)
112
+ return data
113
+
114
+
115
+ def test_process_step_default_config__generic():
116
+ _defaults = ProcessStep.default_config()
117
+ assert isinstance(_defaults, dict)
118
+ assert all(key in _defaults for key in ProcessStep.CONFIG_KEYS.keys())
119
+
120
+
121
+ @pytest.mark.parametrize(
122
+ "class_with_config_keys", [["test_str"], ["test_int"], ["test_str", "test_int"]], indirect=True
123
+ )
124
+ def test_process_step_default_config__specific(class_with_config_keys):
125
+ _keys, _class = class_with_config_keys
126
+ _defaults = _class.default_config()
127
+ assert isinstance(_defaults, dict)
128
+ assert all(key in _defaults for key in _keys)
129
+
130
+
131
+ @pytest.mark.parametrize("item", _TEST_VALUES + _TEST_LISTS + _TEST_TUPLES)
132
+ @pytest.mark.parametrize("class_with_config_keys", [[_k] for _k in _TEST_KEYS.keys()], indirect=True)
133
+ def test_is_process_step_dict__w_correct_key(class_with_config_keys, item):
134
+ _keys, _class = class_with_config_keys
135
+ _config = _class.CONFIG_KEYS[_keys[0]]
136
+ _test_dict = {_keys[0]: item}
137
+ if item is None:
138
+ assert _class.is_process_step_dict(None, None, _test_dict) == _config["allow_none"]
139
+ elif not _config["allow_iterable"]:
140
+ assert _class.is_process_step_dict(None, None, _test_dict) == isinstance(item, _config["type"])
141
+ elif _config["allow_iterable"]:
142
+ assert _class.is_process_step_dict(None, None, _test_dict) == (
143
+ (isinstance(item, Iterable) and not isinstance(item, str))
144
+ and all(isinstance(i, _config["type"]) for i in item)
145
+ or isinstance(item, _config["type"])
146
+ )
147
+ else:
148
+ assert False
149
+
150
+
151
+ def test_is_process_step_dict__w_wrong_key():
152
+ test_dict = ProcessStep.default_config() | {"wrong_key": "value"}
153
+ assert not ProcessStep.is_process_step_dict(None, None, test_dict)
154
+
155
+
156
+ def test_minimal_instantiation():
157
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES)
158
+ assert isinstance(ps, ProcessStep)
159
+
160
+
161
+ def test_instantiation_of_subclass():
162
+ instance = TESTProcessingStep(io_sources=TEST_IO_SOURCES)
163
+ assert all(k in instance.configuration for k in TESTProcessingStep.CONFIG_KEYS)
164
+ assert isinstance(instance, TESTProcessingStep)
165
+
166
+
167
+ def test_process_step__reset():
168
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES)
169
+ ps.produced_outputs = {"a": 1}
170
+ ps._ProcessStep__prepared = True
171
+ ps.executed = True
172
+ ps.reset()
173
+ assert ps.produced_outputs == {}
174
+ assert ps._ProcessStep__prepared is False
175
+ assert ps.executed is False
176
+
177
+
178
+ def test_normalised_processing_keys_with_single_entry():
179
+ data = ProcessingData()
180
+ data["only"] = DataBundle()
181
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES, processing_data=data)
182
+ ps.configuration["with_processing_keys"] = None
183
+ assert ps._normalised_processing_keys() == ["only"]
184
+
185
+
186
+ def test_normalised_processing_keys_with_multiple_entries_requires_explicit_keys():
187
+ data = ProcessingData()
188
+ data["a"] = DataBundle()
189
+ data["b"] = DataBundle()
190
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES, processing_data=data)
191
+ ps.configuration["with_processing_keys"] = None
192
+ with pytest.raises(ValueError):
193
+ ps._normalised_processing_keys()
194
+
195
+
196
+ def test_normalised_processing_keys_accepts_string_or_list():
197
+ data = ProcessingData()
198
+ data["a"] = DataBundle()
199
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES, processing_data=data)
200
+ ps.configuration["with_processing_keys"] = "a"
201
+ assert ps._normalised_processing_keys() == ["a"]
202
+
203
+ ps.configuration["with_processing_keys"] = ["a", "b"]
204
+ assert ps._normalised_processing_keys() == ["a", "b"]
205
+
206
+
207
+ def test_normalised_processing_keys_rejects_empty_list():
208
+ data = ProcessingData()
209
+ data["a"] = DataBundle()
210
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES, processing_data=data)
211
+ ps.configuration["with_processing_keys"] = []
212
+ with pytest.raises(ValueError):
213
+ ps._normalised_processing_keys()
214
+
215
+
216
+ def test_normalised_processing_keys_requires_processing_data():
217
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES, processing_data=None)
218
+ ps.configuration["with_processing_keys"] = ["a"]
219
+ with pytest.raises(RuntimeError):
220
+ ps._normalised_processing_keys()
221
+
222
+
223
+ @pytest.mark.parametrize("class_with_config_keys", [["test_str"]], indirect=True)
224
+ def test_modify_config__valid_key(class_with_config_keys):
225
+ instance = class_with_config_keys[1](io_sources=TEST_IO_SOURCES)
226
+ instance.modify_config_by_kwargs(test_str="new_value")
227
+ assert instance.configuration["test_str"] == "new_value"
228
+ assert not instance._ProcessStep__prepared
229
+
230
+
231
+ @pytest.mark.parametrize("class_with_config_keys", [["test_str"]], indirect=True)
232
+ def test_modify_config__by_dict(class_with_config_keys):
233
+ instance = class_with_config_keys[1](io_sources=TEST_IO_SOURCES)
234
+ instance.modify_config_by_dict({"test_str": "new_value"})
235
+ assert instance.configuration["test_str"] == "new_value"
236
+ assert not instance._ProcessStep__prepared
237
+
238
+
239
+ @pytest.mark.parametrize("class_with_config_keys", [["test_str"]], indirect=True)
240
+ def test_modify_config__invalid_key(class_with_config_keys):
241
+ instance = class_with_config_keys[1](io_sources=TEST_IO_SOURCES)
242
+ with pytest.raises(KeyError):
243
+ instance.modify_config_by_kwargs(silly_key="new_value")
244
+
245
+
246
+ def test_calculate():
247
+ ps = TESTProcessingStep(io_sources=TEST_IO_SOURCES)
248
+ ps.processing_data = ProcessingData()
249
+ _return = ps.calculate()
250
+ assert isinstance(_return, dict)
251
+
252
+
253
+ def test_calculate__abstract():
254
+ ps = ProcessStep(io_sources=TEST_IO_SOURCES)
255
+ with pytest.raises(NotImplementedError):
256
+ ps.calculate()
257
+
258
+
259
+ def test_execute(processing_data):
260
+ ps = TESTProcessingStep(io_sources=TEST_IO_SOURCES)
261
+ ps.execute(processing_data)
262
+ assert ps.executed is True
263
+ assert ps._ProcessStep__prepared is True
264
+ assert isinstance(ps.produced_outputs, dict)
265
+ assert isinstance(ps.produced_outputs["dummy_key"], DataBundle)
266
+ assert isinstance(ps.produced_outputs["bundle2"], DataBundle)
267
+ assert "dummy_key" in processing_data
268
+ assert isinstance(processing_data["bundle2"]["key2"], BaseData)
269
+ assert isinstance(processing_data["bundle2"]["new_key"], BaseData)
270
+
271
+
272
+ def test_call(processing_data):
273
+ ps = TESTProcessingStep(io_sources=TEST_IO_SOURCES)
274
+ ps(processing_data)
275
+ assert ps.executed is True
276
+ assert ps._ProcessStep__prepared is True
277
+ assert isinstance(ps.produced_outputs, dict)
278
+ assert isinstance(ps.produced_outputs["dummy_key"], DataBundle)
279
+ assert isinstance(ps.produced_outputs["bundle2"], DataBundle)
280
+ assert "dummy_key" in processing_data
281
+ assert isinstance(processing_data["bundle2"]["key2"], BaseData)
282
+ assert isinstance(processing_data["bundle2"]["new_key"], BaseData)
@@ -0,0 +1,188 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # /usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+
5
+ from __future__ import annotations
6
+
7
+ __coding__ = "utf-8"
8
+ __authors__ = ["Brian R. Pauw"] # add names to the list as appropriate
9
+ __copyright__ = "Copyright 2025, The MoDaCor team"
10
+ __date__ = "13/12/2025"
11
+ __status__ = "Development" # "Development", "Production"
12
+ # end of header and standard imports
13
+
14
+ from pathlib import Path
15
+
16
+ from modacor.dataclasses.process_step import ProcessStep
17
+ from modacor.dataclasses.process_step_describer import ProcessStepDescriber
18
+ from modacor.dataclasses.processing_data import ProcessingData
19
+ from modacor.debug.pipeline_tracer import PipelineTracer
20
+ from modacor.runner.pipeline import Pipeline
21
+
22
+
23
+ class DummyStep(ProcessStep):
24
+ documentation = ProcessStepDescriber(
25
+ calling_name="Dummy",
26
+ calling_id="dummy.step",
27
+ calling_module_path=Path(__file__),
28
+ calling_version="0",
29
+ required_data_keys=[],
30
+ )
31
+
32
+ def calculate(self):
33
+ return {} # does not modify ProcessingData
34
+
35
+
36
+ def test_tracer_emits_nothing_when_no_changes_and_no_empty_events():
37
+ tracer = PipelineTracer(
38
+ watch={"sample": ["signal"]},
39
+ record_only_on_change=True,
40
+ record_empty_step_events=False, # requires the optional field; if you did not add it, remove this line
41
+ )
42
+
43
+ step = DummyStep(io_sources=None, step_id="A")
44
+ data = ProcessingData()
45
+
46
+ tracer.after_step(step, data)
47
+ assert tracer.events == []
48
+
49
+
50
+ def test_tracer_can_emit_empty_step_events_when_enabled():
51
+ tracer = PipelineTracer(
52
+ watch={"sample": ["signal"]},
53
+ record_only_on_change=True,
54
+ record_empty_step_events=True, # requires the optional field
55
+ )
56
+
57
+ step = DummyStep(io_sources=None, step_id="A")
58
+ data = ProcessingData()
59
+
60
+ tracer.after_step(step, data)
61
+ assert len(tracer.events) == 1
62
+ assert tracer.events[0]["step_id"] == "A"
63
+ assert tracer.events[0]["changed"] == {}
64
+
65
+
66
+ def test_pipeline_attach_tracer_event_always_attaches_event_even_without_tracer_events():
67
+ step = DummyStep(io_sources=None, step_id="A")
68
+ pipeline = Pipeline.from_dict({step: []}, name="t")
69
+
70
+ tracer = PipelineTracer(watch={"sample": ["signal"]})
71
+ # No tracer.after_step() call -> tracer.events empty
72
+
73
+ ev = pipeline.attach_tracer_event(step, tracer)
74
+
75
+ assert ev.step_id == "A"
76
+ assert ev.module == "DummyStep"
77
+ assert ev.datasets == {} # no tracer payload
78
+ assert "A" in pipeline.trace_events
79
+ assert pipeline.trace_events["A"][-1] is ev
80
+
81
+
82
+ def test_pipeline_attach_tracer_event_includes_datasets_when_tracer_has_matching_event():
83
+ step = DummyStep(io_sources=None, step_id="A")
84
+ pipeline = Pipeline.from_dict({step: []}, name="t")
85
+
86
+ tracer = PipelineTracer(
87
+ watch={"sample": ["signal"]},
88
+ record_only_on_change=True,
89
+ record_empty_step_events=True, # ensure tracer produces an event even if empty
90
+ )
91
+ data = ProcessingData()
92
+
93
+ tracer.after_step(step, data)
94
+ ev = pipeline.attach_tracer_event(step, tracer)
95
+
96
+ # Event exists; datasets may still be empty because watched target doesn't exist in ProcessingData
97
+ assert ev.step_id == "A"
98
+ assert isinstance(ev.datasets, dict)
99
+
100
+
101
+ def test_pipeline_attach_tracer_event_can_embed_rendered_trace_block():
102
+ step = DummyStep(io_sources=None, step_id="A")
103
+ pipeline = Pipeline.from_dict({step: []}, name="t")
104
+
105
+ tracer = PipelineTracer(
106
+ watch={"sample": ["signal"]},
107
+ record_only_on_change=False,
108
+ )
109
+
110
+ # Ensure the tracer has at least one event to render:
111
+ tracer.after_step(step, ProcessingData())
112
+ assert tracer.events # sanity
113
+
114
+ ev = pipeline.attach_tracer_event(
115
+ step,
116
+ tracer,
117
+ include_rendered_trace=True,
118
+ include_rendered_config=True,
119
+ rendered_format="text/plain",
120
+ )
121
+
122
+ assert isinstance(ev.messages, list)
123
+ trace_msgs = [m for m in ev.messages if m.get("kind") in {"rendered_trace", "rendered_trace_error"}]
124
+ assert trace_msgs, "Expected a rendered_trace (or error) block"
125
+ m = trace_msgs[0]
126
+ assert "format" in m and "content" in m
127
+
128
+
129
+ def test_attach_tracer_event_renders_step_local_block():
130
+ step = DummyStep(io_sources=None, step_id="A")
131
+ pipeline = Pipeline.from_dict({step: []}, name="t")
132
+
133
+ tracer = PipelineTracer(
134
+ watch={"sample": ["signal"]},
135
+ record_only_on_change=False, # ensure an event is recorded even if nothing changes
136
+ )
137
+
138
+ tracer.after_step(step, ProcessingData())
139
+ ev = pipeline.attach_tracer_event(
140
+ step,
141
+ tracer,
142
+ include_rendered_trace=True,
143
+ include_rendered_config=True,
144
+ rendered_format="text/plain",
145
+ )
146
+
147
+ trace_msgs = [m for m in ev.messages if m.get("kind") in {"rendered_trace", "rendered_trace_error"}]
148
+ assert trace_msgs, "Expected a rendered_trace (or error) block"
149
+ m = trace_msgs[0]
150
+
151
+ if m.get("kind") == "rendered_trace":
152
+ assert "Step A" in m.get("content", "")
153
+
154
+
155
+ def test_attach_tracer_event_embeds_rendered_trace_and_config():
156
+ step = DummyStep(io_sources=None, step_id="A")
157
+ pipeline = Pipeline.from_dict({step: []}, name="t")
158
+
159
+ tracer = PipelineTracer(watch={"sample": ["signal"]}, record_only_on_change=False)
160
+ tracer.after_step(step, ProcessingData())
161
+
162
+ ev = pipeline.attach_tracer_event(
163
+ step,
164
+ tracer,
165
+ include_rendered_trace=True,
166
+ include_rendered_config=True,
167
+ rendered_format="text/plain",
168
+ )
169
+
170
+ kinds = {m.get("kind") for m in ev.messages}
171
+ assert "rendered_config" in kinds
172
+ assert "rendered_trace" in kinds # since record_only_on_change=False guarantees a step event exists
173
+
174
+ # step-local sanity: rendered trace header contains step id
175
+ trace_blocks = [m for m in ev.messages if m.get("kind") == "rendered_trace"]
176
+ if trace_blocks:
177
+ assert "Step A" in trace_blocks[0].get("content", "")
178
+
179
+
180
+ def test_attach_tracer_event_copies_duration():
181
+ step = DummyStep(io_sources=None, step_id="A")
182
+ pipeline = Pipeline.from_dict({step: []}, name="t")
183
+
184
+ tracer = PipelineTracer(watch={"sample": ["signal"]}, record_only_on_change=False)
185
+ tracer.after_step(step, ProcessingData(), duration_s=0.0123)
186
+
187
+ ev = pipeline.attach_tracer_event(step, tracer)
188
+ assert ev.duration_s == 0.0123
File without changes